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:
2026-06-20 15:52:10 +10:00
parent 75f4c475df
commit cd2f27792d
24 changed files with 1494 additions and 15 deletions
@@ -0,0 +1,162 @@
"""Global agent update policy (DB overrides env)."""
from __future__ import annotations
from dataclasses import dataclass
from sqlalchemy.orm import Session
from app.config import get_settings
from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings
PRODUCT_RDP = "rdp-login-monitor"
PRODUCT_SSH = "ssh-monitor"
AGENT_UPDATE_MODES = frozenset({"gpo", "sac"})
@dataclass(frozen=True)
class AgentUpdateConfig:
mode: str
fallback_enabled: bool
fallback_after_minutes: int
recommended_rdp_version: str
recommended_ssh_version: str
min_rdp_version: str
min_ssh_version: str
win_agent_update_script: str
source: str # env | db
@property
def sac_managed(self) -> bool:
return self.mode == "sac"
def recommended_for_product(self, product: str) -> str:
if product == PRODUCT_RDP:
return self.recommended_rdp_version
if product == PRODUCT_SSH:
return self.recommended_ssh_version
return ""
def min_for_product(self, product: str) -> str:
if product == PRODUCT_RDP:
return self.min_rdp_version
if product == PRODUCT_SSH:
return self.min_ssh_version
return ""
def _agent_update_from_env() -> AgentUpdateConfig:
settings = get_settings()
mode = (settings.sac_agent_update_mode or "gpo").strip().lower()
if mode not in AGENT_UPDATE_MODES:
mode = "gpo"
return AgentUpdateConfig(
mode=mode,
fallback_enabled=settings.sac_agent_update_fallback_enabled,
fallback_after_minutes=max(1, int(settings.sac_agent_update_fallback_minutes)),
recommended_rdp_version=(settings.sac_agent_recommended_rdp_version or "").strip(),
recommended_ssh_version=(settings.sac_agent_recommended_ssh_version or "").strip(),
min_rdp_version=(settings.sac_agent_min_rdp_version or "").strip(),
min_ssh_version=(settings.sac_agent_min_ssh_version or "").strip(),
win_agent_update_script=(settings.sac_win_agent_update_script or "").strip(),
source="env",
)
def _row_has_agent_update_values(row: UiSettings) -> bool:
return any(
[
(row.agent_update_mode or "").strip() not in ("", "gpo"),
row.agent_update_fallback_enabled is not None,
row.agent_update_fallback_minutes not in (None, 15),
bool((row.agent_recommended_rdp_version or "").strip()),
bool((row.agent_recommended_ssh_version or "").strip()),
bool((row.agent_min_rdp_version or "").strip()),
bool((row.agent_min_ssh_version or "").strip()),
bool((row.win_agent_update_script or "").strip()),
]
)
def get_effective_agent_update_config(db: Session | None = None) -> AgentUpdateConfig:
if db is None:
from app.database import SessionLocal
session = SessionLocal()
try:
return get_effective_agent_update_config(session)
finally:
session.close()
row = db.get(UiSettings, UI_SETTINGS_ROW_ID)
env_cfg = _agent_update_from_env()
if row is None:
return env_cfg
mode = (row.agent_update_mode or env_cfg.mode).strip().lower()
if mode not in AGENT_UPDATE_MODES:
mode = env_cfg.mode
return AgentUpdateConfig(
mode=mode,
fallback_enabled=(
row.agent_update_fallback_enabled
if row.agent_update_fallback_enabled is not None
else env_cfg.fallback_enabled
),
fallback_after_minutes=max(
1,
int(row.agent_update_fallback_minutes or env_cfg.fallback_after_minutes),
),
recommended_rdp_version=(row.agent_recommended_rdp_version or "").strip()
or env_cfg.recommended_rdp_version,
recommended_ssh_version=(row.agent_recommended_ssh_version or "").strip()
or env_cfg.recommended_ssh_version,
min_rdp_version=(row.agent_min_rdp_version or "").strip() or env_cfg.min_rdp_version,
min_ssh_version=(row.agent_min_ssh_version or "").strip() or env_cfg.min_ssh_version,
win_agent_update_script=(row.win_agent_update_script or "").strip()
or env_cfg.win_agent_update_script,
source="db" if _row_has_agent_update_values(row) else env_cfg.source,
)
def upsert_agent_update_settings(
db: Session,
*,
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,
) -> AgentUpdateConfig:
row = db.get(UiSettings, UI_SETTINGS_ROW_ID)
if row is None:
row = UiSettings(id=UI_SETTINGS_ROW_ID, show_sidebar_system_stats=True)
db.add(row)
if mode is not None:
normalized = mode.strip().lower()
if normalized not in AGENT_UPDATE_MODES:
raise ValueError(f"mode must be one of: {sorted(AGENT_UPDATE_MODES)}")
row.agent_update_mode = normalized
if fallback_enabled is not None:
row.agent_update_fallback_enabled = fallback_enabled
if fallback_after_minutes is not None:
row.agent_update_fallback_minutes = max(1, int(fallback_after_minutes))
if recommended_rdp_version is not None:
row.agent_recommended_rdp_version = recommended_rdp_version.strip() or None
if recommended_ssh_version is not None:
row.agent_recommended_ssh_version = recommended_ssh_version.strip() or None
if min_rdp_version is not None:
row.agent_min_rdp_version = min_rdp_version.strip() or None
if min_ssh_version is not None:
row.agent_min_ssh_version = min_ssh_version.strip() or None
if win_agent_update_script is not None:
row.win_agent_update_script = win_agent_update_script.strip() or None
db.commit()
db.refresh(row)
return get_effective_agent_update_config(db)