ec5ec62240
Pass REPO_URL/GIT_BRANCH from agent settings over SSH, preflight git remotes on stale hosts, and stop using GitHub origin on routers.
284 lines
9.3 KiB
Python
284 lines
9.3 KiB
Python
"""Request agent self-update, ingest lifecycle, SSH/WinRM fallback."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.models import Host
|
|
from app.services.agent_update_settings import (
|
|
PRODUCT_RDP,
|
|
PRODUCT_SSH,
|
|
AgentUpdateConfig,
|
|
get_effective_agent_update_config,
|
|
)
|
|
from app.services.agent_git_release import get_git_release_versions, recommended_from_git
|
|
from app.services.agent_version import (
|
|
host_version_outdated,
|
|
latest_agent_versions_by_product,
|
|
)
|
|
from app.services.linux_admin_settings import get_effective_linux_admin_config
|
|
from app.services.ssh_connect import (
|
|
HostNotLinuxError,
|
|
HostTargetMissingError,
|
|
is_linux_host,
|
|
iter_ssh_targets,
|
|
run_ssh_monitor_update,
|
|
)
|
|
from app.services.win_admin_settings import get_effective_win_admin_config
|
|
from app.services.winrm_connect import (
|
|
HostNotWindowsError,
|
|
HostTargetMissingError as WinRmHostTargetMissingError,
|
|
is_windows_host,
|
|
iter_winrm_targets,
|
|
run_winrm_on_host_targets,
|
|
run_winrm_rdp_monitor_update,
|
|
)
|
|
|
|
AGENT_UPDATE_STARTED = "agent.update.started"
|
|
AGENT_UPDATE_SUCCESS = "agent.update.success"
|
|
AGENT_UPDATE_FAILED = "agent.update.failed"
|
|
AGENT_UPDATE_TYPES = frozenset(
|
|
{AGENT_UPDATE_STARTED, AGENT_UPDATE_SUCCESS, AGENT_UPDATE_FAILED}
|
|
)
|
|
|
|
|
|
class AgentUpdateNotAllowedError(Exception):
|
|
pass
|
|
|
|
|
|
def resolve_target_version(
|
|
db: Session,
|
|
host: Host,
|
|
cfg: AgentUpdateConfig,
|
|
*,
|
|
git_versions: dict[str, str] | None = None,
|
|
) -> str:
|
|
product = host.product or ""
|
|
if git_versions is None:
|
|
git_versions = get_git_release_versions(cfg, db=db).versions
|
|
git_target = recommended_from_git(cfg, git_versions, product)
|
|
if git_target:
|
|
return git_target
|
|
latest_map = latest_agent_versions_by_product(db)
|
|
return latest_map.get(product, "") or (host.product_version or "")
|
|
|
|
|
|
def host_version_status(
|
|
host: Host,
|
|
*,
|
|
cfg: AgentUpdateConfig,
|
|
latest_map: dict[str, str],
|
|
git_versions: dict[str, str] | None = None,
|
|
) -> str:
|
|
product = host.product or ""
|
|
if git_versions is None:
|
|
git_versions = get_git_release_versions(cfg).versions
|
|
outdated = host_version_outdated(
|
|
host.product_version,
|
|
recommended=recommended_from_git(cfg, git_versions, product),
|
|
fleet_latest=latest_map.get(product, ""),
|
|
min_version=cfg.min_for_product(product),
|
|
)
|
|
if outdated is None:
|
|
return "unknown"
|
|
return "outdated" if outdated else "ok"
|
|
|
|
|
|
def request_agent_update(db: Session, host: Host) -> Host:
|
|
cfg = get_effective_agent_update_config(db)
|
|
if not cfg.sac_managed:
|
|
raise AgentUpdateNotAllowedError(
|
|
"Agent updates from SAC are disabled (mode=gpo). Use GPO/manual update or switch to mode=sac."
|
|
)
|
|
if not host.agent_instance_id:
|
|
raise AgentUpdateNotAllowedError("Host has no agent_instance_id (agent never connected via SAC)")
|
|
|
|
target = resolve_target_version(db, host, cfg)
|
|
now = datetime.now(timezone.utc)
|
|
host.pending_agent_update = True
|
|
host.pending_update_requested_at = now
|
|
host.pending_update_target_version = target or None
|
|
host.agent_update_state = "requested"
|
|
host.agent_update_last_error = None
|
|
db.flush()
|
|
return host
|
|
|
|
|
|
def _clear_pending(host: Host) -> None:
|
|
host.pending_agent_update = False
|
|
host.pending_update_requested_at = None
|
|
host.pending_update_target_version = None
|
|
|
|
|
|
def process_agent_update_ingest(db: Session, host: Host, event_type: str, details: dict | None) -> None:
|
|
if event_type not in AGENT_UPDATE_TYPES:
|
|
return
|
|
|
|
now = datetime.now(timezone.utc)
|
|
host.agent_update_last_at = now
|
|
details = details if isinstance(details, dict) else {}
|
|
|
|
if event_type == AGENT_UPDATE_STARTED:
|
|
host.agent_update_state = "started"
|
|
return
|
|
|
|
if event_type == AGENT_UPDATE_SUCCESS:
|
|
host.agent_update_state = "success"
|
|
host.agent_update_last_error = None
|
|
_clear_pending(host)
|
|
version = details.get("product_version") or details.get("version")
|
|
if isinstance(version, str) and version.strip():
|
|
host.product_version = version.strip()
|
|
return
|
|
|
|
if event_type == AGENT_UPDATE_FAILED:
|
|
host.agent_update_state = "failed"
|
|
err = details.get("error") or details.get("message")
|
|
host.agent_update_last_error = str(err)[:2000] if err else "agent.update.failed"
|
|
_clear_pending(host)
|
|
|
|
|
|
def run_linux_agent_update_fallback(
|
|
host: Host,
|
|
cfg_linux,
|
|
*,
|
|
repo_url: str | None = None,
|
|
git_branch: str | None = None,
|
|
) -> tuple[bool, str, str | None]:
|
|
if not is_linux_host(host):
|
|
return False, "Host is not Linux", None
|
|
if not cfg_linux.configured:
|
|
return False, "Linux SSH admin is not configured", None
|
|
|
|
last_message = "SSH fallback failed"
|
|
version: str | None = None
|
|
try:
|
|
targets = iter_ssh_targets(host)
|
|
except (HostNotLinuxError, HostTargetMissingError) as exc:
|
|
return False, str(exc), None
|
|
|
|
effective_repo = (repo_url or "").strip() or None
|
|
effective_branch = (git_branch or "main").strip() or "main"
|
|
for target in targets:
|
|
result = run_ssh_monitor_update(
|
|
target=target,
|
|
user=cfg_linux.user,
|
|
password=cfg_linux.password,
|
|
repo_url=effective_repo,
|
|
git_branch=effective_branch,
|
|
)
|
|
last_message = result.message
|
|
if result.ok:
|
|
version = result.agent_version
|
|
return True, result.message, version
|
|
return False, last_message, version
|
|
|
|
|
|
def run_windows_agent_update_fallback(host: Host, cfg_win, script_path: str) -> tuple[bool, str, str | None]:
|
|
if not is_windows_host(host):
|
|
return False, "Host is not Windows", None
|
|
if not cfg_win.configured:
|
|
return False, "Windows domain admin is not configured", None
|
|
|
|
try:
|
|
targets = iter_winrm_targets(host)
|
|
except (HostNotWindowsError, WinRmHostTargetMissingError) as exc:
|
|
return False, str(exc), None
|
|
|
|
result, attempts = run_winrm_on_host_targets(
|
|
host,
|
|
user=cfg_win.user,
|
|
password=cfg_win.password,
|
|
action=lambda target: run_winrm_rdp_monitor_update(
|
|
target=target,
|
|
user=cfg_win.user,
|
|
password=cfg_win.password,
|
|
script_path=script_path,
|
|
),
|
|
)
|
|
if result is None:
|
|
return False, "WinRM fallback failed", None
|
|
message = result.message
|
|
if attempts:
|
|
message = f"{message} (пробовали: {', '.join(attempts)})"
|
|
version = None
|
|
if result.ok and result.stdout:
|
|
from app.services.ssh_connect import parse_ssh_monitor_version_text
|
|
|
|
version = parse_ssh_monitor_version_text(result.stdout)
|
|
return result.ok, message, version
|
|
|
|
|
|
def execute_agent_update_fallback(db: Session, host: Host) -> tuple[bool, str, str | None]:
|
|
cfg = get_effective_agent_update_config(db)
|
|
product = (host.product or "").strip()
|
|
|
|
if product == PRODUCT_SSH or is_linux_host(host):
|
|
linux_cfg = get_effective_linux_admin_config(db)
|
|
agent_cfg = get_effective_agent_update_config(db)
|
|
repo_url = (agent_cfg.ssh_git_repo_url or "").strip() or None
|
|
branch = (agent_cfg.git_branch or "main").strip() or "main"
|
|
ok, message, version = run_linux_agent_update_fallback(
|
|
host,
|
|
linux_cfg,
|
|
repo_url=repo_url,
|
|
git_branch=branch,
|
|
)
|
|
elif product == PRODUCT_RDP or is_windows_host(host):
|
|
win_cfg = get_effective_win_admin_config(db)
|
|
ok, message, version = run_windows_agent_update_fallback(host, win_cfg, cfg.win_agent_update_script)
|
|
else:
|
|
return False, f"Unsupported product for fallback update: {product}", None
|
|
|
|
now = datetime.now(timezone.utc)
|
|
host.agent_update_last_at = now
|
|
if ok:
|
|
host.agent_update_state = "success"
|
|
host.agent_update_last_error = None
|
|
_clear_pending(host)
|
|
if version:
|
|
host.product_version = version
|
|
if is_linux_host(host):
|
|
host.ssh_admin_ok = True
|
|
host.ssh_admin_checked_at = now
|
|
else:
|
|
host.agent_update_state = "failed"
|
|
host.agent_update_last_error = message[:2000]
|
|
|
|
db.flush()
|
|
return ok, message, version
|
|
|
|
|
|
def process_agent_update_fallbacks(db: Session) -> list[dict]:
|
|
"""Find stale pending updates and run SSH/WinRM fallback when enabled."""
|
|
cfg = get_effective_agent_update_config(db)
|
|
if not cfg.sac_managed or not cfg.fallback_enabled:
|
|
return []
|
|
|
|
cutoff = datetime.now(timezone.utc) - timedelta(minutes=cfg.fallback_after_minutes)
|
|
rows = db.scalars(
|
|
select(Host).where(
|
|
Host.pending_agent_update.is_(True),
|
|
Host.pending_update_requested_at.isnot(None),
|
|
Host.pending_update_requested_at <= cutoff,
|
|
Host.agent_update_state.in_(("requested", "started")),
|
|
)
|
|
).all()
|
|
|
|
results: list[dict] = []
|
|
for host in rows:
|
|
ok, message, version = execute_agent_update_fallback(db, host)
|
|
results.append(
|
|
{
|
|
"host_id": host.id,
|
|
"hostname": host.hostname,
|
|
"ok": ok,
|
|
"message": message,
|
|
"product_version": version,
|
|
}
|
|
)
|
|
return results
|