feat: agent updates from SAC, poll config, SSH/WinRM fallback (0.12.0)
Add agent update settings, pending self-update via poll, desired config per host, and automatic or manual SSH/WinRM fallback when agents do not report agent.update events.
This commit is contained in:
@@ -7,11 +7,10 @@ from sqlalchemy.orm import Session
|
||||
from app.auth.api_key import get_api_key_auth
|
||||
from app.database import get_db
|
||||
from app.services.agent_commands import (
|
||||
command_to_dict,
|
||||
complete_command,
|
||||
get_command_for_host_poll,
|
||||
resolve_host_by_agent_instance_id,
|
||||
)
|
||||
from app.services.agent_poll import build_agent_poll_payload
|
||||
|
||||
router = APIRouter(prefix="/agent", tags=["agent"])
|
||||
|
||||
@@ -23,7 +22,10 @@ class AgentCommandResultBody(BaseModel):
|
||||
|
||||
|
||||
class AgentCommandsPollResponse(BaseModel):
|
||||
commands: list[dict[str, Any]]
|
||||
config_revision: int = 0
|
||||
config: dict[str, Any] = Field(default_factory=dict)
|
||||
update: dict[str, Any] = Field(default_factory=dict)
|
||||
commands: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
@router.get("/commands", response_model=AgentCommandsPollResponse)
|
||||
@@ -35,10 +37,8 @@ def poll_agent_commands(
|
||||
host = resolve_host_by_agent_instance_id(db, agent_instance_id)
|
||||
if host is None:
|
||||
raise HTTPException(status_code=404, detail="Unknown agent_instance_id")
|
||||
pending = get_command_for_host_poll(db, host)
|
||||
return AgentCommandsPollResponse(
|
||||
commands=[command_to_dict(c, include_run_as=True) for c in pending]
|
||||
)
|
||||
payload = build_agent_poll_payload(db, host)
|
||||
return AgentCommandsPollResponse(**payload)
|
||||
|
||||
|
||||
@router.post("/commands/{command_uuid}/result")
|
||||
|
||||
@@ -11,6 +11,14 @@ from app.database import get_db
|
||||
from app.models import Event, Host
|
||||
from app.schemas.list_models import HostDetail, HostListResponse, HostSummary
|
||||
from app.services.agent_version import latest_agent_versions_by_product
|
||||
from app.services.agent_update import (
|
||||
AgentUpdateNotAllowedError,
|
||||
execute_agent_update_fallback,
|
||||
host_version_status,
|
||||
request_agent_update,
|
||||
)
|
||||
from app.services.agent_update_settings import get_effective_agent_update_config
|
||||
from app.services.agent_host_config import get_host_agent_settings, update_host_agent_config
|
||||
from app.services.host_delete import delete_host_and_related
|
||||
from app.services.linux_admin_settings import get_effective_linux_admin_config
|
||||
from app.services.win_admin_settings import get_effective_win_admin_config
|
||||
@@ -82,6 +90,8 @@ def list_hosts(
|
||||
|
||||
hb_map = max_event_time_by_host(db, HEARTBEAT_TYPE)
|
||||
report_map = max_daily_report_time_by_host(db)
|
||||
latest_map = latest_agent_versions_by_product(db)
|
||||
update_cfg = get_effective_agent_update_config(db)
|
||||
|
||||
items: list[HostSummary] = []
|
||||
for host in rows:
|
||||
@@ -107,6 +117,8 @@ def list_hosts(
|
||||
agent_status=compute_agent_status(
|
||||
last_hb, stale_minutes=settings.sac_heartbeat_stale_minutes
|
||||
),
|
||||
version_status=host_version_status(host, cfg=update_cfg, latest_map=latest_map),
|
||||
pending_agent_update=bool(host.pending_agent_update),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -131,6 +143,8 @@ def _host_detail_from_model(
|
||||
select(func.count()).select_from(Event).where(Event.host_id == host.id)
|
||||
)
|
||||
last_hb = hb_map.get(host.id)
|
||||
latest_map = latest_agent_versions_by_product(db)
|
||||
update_cfg = get_effective_agent_update_config(db)
|
||||
return HostDetail(
|
||||
id=host.id,
|
||||
hostname=host.hostname,
|
||||
@@ -147,6 +161,8 @@ def _host_detail_from_model(
|
||||
last_daily_report_at=report_map.get(host.id),
|
||||
last_inventory_at=host.inventory_updated_at,
|
||||
agent_status=compute_agent_status(last_hb, stale_minutes=settings.sac_heartbeat_stale_minutes),
|
||||
version_status=host_version_status(host, cfg=update_cfg, latest_map=latest_map),
|
||||
pending_agent_update=bool(host.pending_agent_update),
|
||||
agent_instance_id=host.agent_instance_id,
|
||||
tags=host.tags if isinstance(host.tags, list) else [],
|
||||
use_sac_mode=host.use_sac_mode,
|
||||
@@ -155,6 +171,13 @@ def _host_detail_from_model(
|
||||
created_at=host.created_at,
|
||||
ssh_admin_ok=host.ssh_admin_ok,
|
||||
ssh_admin_checked_at=host.ssh_admin_checked_at,
|
||||
pending_update_requested_at=host.pending_update_requested_at,
|
||||
pending_update_target_version=host.pending_update_target_version,
|
||||
agent_config_revision=int(host.agent_config_revision or 0),
|
||||
agent_config=get_host_agent_settings(host) or None,
|
||||
agent_update_state=host.agent_update_state,
|
||||
agent_update_last_at=host.agent_update_last_at,
|
||||
agent_update_last_error=host.agent_update_last_error,
|
||||
)
|
||||
|
||||
|
||||
@@ -202,6 +225,22 @@ class HostSshActionResponse(BaseModel):
|
||||
ssh_admin_ok: bool | None = None
|
||||
|
||||
|
||||
class HostAgentUpdateRequestResponse(BaseModel):
|
||||
status: str
|
||||
pending_agent_update: bool
|
||||
target_version: str | None = None
|
||||
agent_update_state: str | None = None
|
||||
|
||||
|
||||
class HostAgentConfigUpdate(BaseModel):
|
||||
settings: dict[str, object] = {}
|
||||
|
||||
|
||||
class HostAgentConfigResponse(BaseModel):
|
||||
config_revision: int
|
||||
settings: dict[str, object]
|
||||
|
||||
|
||||
def _ssh_action_response(
|
||||
result: SshCommandResult,
|
||||
*,
|
||||
@@ -364,6 +403,87 @@ def update_host_agent_via_ssh(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{host_id}/actions/request-agent-update", response_model=HostAgentUpdateRequestResponse)
|
||||
def post_request_agent_update(
|
||||
host_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> HostAgentUpdateRequestResponse:
|
||||
host = db.get(Host, host_id)
|
||||
if host is None:
|
||||
raise HTTPException(status_code=404, detail="Host not found")
|
||||
try:
|
||||
request_agent_update(db, host)
|
||||
except AgentUpdateNotAllowedError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
db.commit()
|
||||
return HostAgentUpdateRequestResponse(
|
||||
status="requested",
|
||||
pending_agent_update=host.pending_agent_update,
|
||||
target_version=host.pending_update_target_version,
|
||||
agent_update_state=host.agent_update_state,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{host_id}/actions/agent-update-fallback", response_model=HostSshActionResponse)
|
||||
def post_agent_update_fallback(
|
||||
host_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> HostSshActionResponse:
|
||||
host = db.get(Host, host_id)
|
||||
if host is None:
|
||||
raise HTTPException(status_code=404, detail="Host not found")
|
||||
|
||||
ok, message, version = execute_agent_update_fallback(db, host)
|
||||
db.commit()
|
||||
return HostSshActionResponse(
|
||||
ok=ok,
|
||||
message=message,
|
||||
target=host.hostname,
|
||||
product_version=version or (host.product_version if ok else None),
|
||||
ssh_admin_ok=host.ssh_admin_ok,
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{host_id}/agent-config", response_model=HostAgentConfigResponse)
|
||||
def patch_host_agent_config(
|
||||
host_id: int,
|
||||
body: HostAgentConfigUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> HostAgentConfigResponse:
|
||||
host = db.get(Host, host_id)
|
||||
if host is None:
|
||||
raise HTTPException(status_code=404, detail="Host not found")
|
||||
try:
|
||||
update_host_agent_config(db, host, body.settings)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
db.commit()
|
||||
settings = get_host_agent_settings(host)
|
||||
return HostAgentConfigResponse(
|
||||
config_revision=int(host.agent_config_revision or 0),
|
||||
settings=settings,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{host_id}/agent-config", response_model=HostAgentConfigResponse)
|
||||
def get_host_agent_config(
|
||||
host_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> HostAgentConfigResponse:
|
||||
host = db.get(Host, host_id)
|
||||
if host is None:
|
||||
raise HTTPException(status_code=404, detail="Host not found")
|
||||
settings = get_host_agent_settings(host)
|
||||
return HostAgentConfigResponse(
|
||||
config_revision=int(host.agent_config_revision or 0),
|
||||
settings=settings,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{host_id}", response_model=HostDeleteResponse)
|
||||
def delete_host(
|
||||
host_id: int,
|
||||
|
||||
@@ -45,6 +45,10 @@ from app.services.win_admin_settings import (
|
||||
get_effective_win_admin_config,
|
||||
upsert_win_admin_settings,
|
||||
)
|
||||
from app.services.agent_update_settings import (
|
||||
get_effective_agent_update_config,
|
||||
upsert_agent_update_settings,
|
||||
)
|
||||
from app.services.winrm_connect import (
|
||||
HostNotWindowsError,
|
||||
HostTargetMissingError,
|
||||
@@ -540,3 +544,71 @@ def update_linux_admin_settings(
|
||||
password_hint=_mask_secret(cfg.password),
|
||||
source=cfg.source,
|
||||
)
|
||||
|
||||
|
||||
class AgentUpdateSettingsResponse(BaseModel):
|
||||
mode: str
|
||||
fallback_enabled: bool
|
||||
fallback_after_minutes: int
|
||||
recommended_rdp_version: str | None = None
|
||||
recommended_ssh_version: str | None = None
|
||||
min_rdp_version: str | None = None
|
||||
min_ssh_version: str | None = None
|
||||
win_agent_update_script: str | None = None
|
||||
source: str = Field(description="env или db")
|
||||
|
||||
|
||||
class AgentUpdateSettingsUpdate(BaseModel):
|
||||
mode: str | None = None
|
||||
fallback_enabled: bool | None = None
|
||||
fallback_after_minutes: int | None = None
|
||||
recommended_rdp_version: str | None = None
|
||||
recommended_ssh_version: str | None = None
|
||||
min_rdp_version: str | None = None
|
||||
min_ssh_version: str | None = None
|
||||
win_agent_update_script: str | None = None
|
||||
|
||||
|
||||
def _agent_update_to_response(cfg) -> AgentUpdateSettingsResponse:
|
||||
return AgentUpdateSettingsResponse(
|
||||
mode=cfg.mode,
|
||||
fallback_enabled=cfg.fallback_enabled,
|
||||
fallback_after_minutes=cfg.fallback_after_minutes,
|
||||
recommended_rdp_version=cfg.recommended_rdp_version or None,
|
||||
recommended_ssh_version=cfg.recommended_ssh_version or None,
|
||||
min_rdp_version=cfg.min_rdp_version or None,
|
||||
min_ssh_version=cfg.min_ssh_version or None,
|
||||
win_agent_update_script=cfg.win_agent_update_script or None,
|
||||
source=cfg.source,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/agent-updates", response_model=AgentUpdateSettingsResponse)
|
||||
def get_agent_update_settings(
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> AgentUpdateSettingsResponse:
|
||||
return _agent_update_to_response(get_effective_agent_update_config(db))
|
||||
|
||||
|
||||
@router.put("/agent-updates", response_model=AgentUpdateSettingsResponse)
|
||||
def update_agent_update_settings(
|
||||
body: AgentUpdateSettingsUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> AgentUpdateSettingsResponse:
|
||||
try:
|
||||
cfg = upsert_agent_update_settings(
|
||||
db,
|
||||
mode=body.mode,
|
||||
fallback_enabled=body.fallback_enabled,
|
||||
fallback_after_minutes=body.fallback_after_minutes,
|
||||
recommended_rdp_version=body.recommended_rdp_version,
|
||||
recommended_ssh_version=body.recommended_ssh_version,
|
||||
min_rdp_version=body.min_rdp_version,
|
||||
min_ssh_version=body.min_ssh_version,
|
||||
win_agent_update_script=body.win_agent_update_script,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
return _agent_update_to_response(cfg)
|
||||
|
||||
Reference in New Issue
Block a user