feat: git-based agent version reference and unified outdated badge (0.20.0)
SAC reads latest RDP/ssh-monitor versions from configured git repos for outdated detection, update targets, and settings test-git.
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
"""Fetch latest agent release versions from git repositories."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings
|
||||
from app.services.agent_update_settings import PRODUCT_RDP, PRODUCT_SSH, AgentUpdateConfig
|
||||
from app.services.agent_version import parse_agent_version
|
||||
|
||||
_SCRIPT_VERSION_RE = re.compile(r'\$ScriptVersion\s*=\s*["\']([^"\']+)["\']', re.IGNORECASE)
|
||||
_SSH_VERSION_RE = re.compile(r'^SSH_MONITOR_VERSION=["\']([^"\']+)["\']', re.MULTILINE)
|
||||
|
||||
_MEMORY_CACHE: dict[str, tuple[dict[str, str], float, str]] = {}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GitReleaseVersions:
|
||||
versions: dict[str, str]
|
||||
fetched_at: datetime | None
|
||||
from_cache: bool
|
||||
errors: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
|
||||
def normalize_git_repo_url(raw: str) -> str:
|
||||
text = (raw or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
if not text.startswith(("http://", "https://", "git@")):
|
||||
text = f"https://{text.lstrip('/')}"
|
||||
if text.startswith("git@"):
|
||||
return text
|
||||
if not text.endswith(".git"):
|
||||
text = f"{text.rstrip('/')}.git"
|
||||
return text
|
||||
|
||||
|
||||
def git_repo_url_with_auth(repo_url: str, token: str) -> str:
|
||||
normalized = normalize_git_repo_url(repo_url)
|
||||
if not token or not normalized.startswith("https://"):
|
||||
return normalized
|
||||
parsed = urlparse(normalized)
|
||||
host = parsed.netloc
|
||||
path = parsed.path or ""
|
||||
return f"https://oauth2:{token}@{host}{path}"
|
||||
|
||||
|
||||
def _cache_key(cfg: AgentUpdateConfig) -> str:
|
||||
parts = [
|
||||
cfg.rdp_git_repo_url,
|
||||
cfg.ssh_git_repo_url,
|
||||
cfg.git_branch,
|
||||
]
|
||||
return hashlib.sha256("|".join(parts).encode()).hexdigest()
|
||||
|
||||
|
||||
def _read_text(path: Path) -> str | None:
|
||||
if not path.is_file():
|
||||
return None
|
||||
return path.read_text(encoding="utf-8-sig")
|
||||
|
||||
|
||||
def parse_rdp_version_from_files(version_txt: str | None, login_monitor_ps1: str | None) -> str | None:
|
||||
if version_txt:
|
||||
first_line = version_txt.strip().splitlines()[0].strip() if version_txt.strip() else ""
|
||||
if parse_agent_version(first_line):
|
||||
return first_line
|
||||
if login_monitor_ps1:
|
||||
match = _SCRIPT_VERSION_RE.search(login_monitor_ps1)
|
||||
if match:
|
||||
candidate = match.group(1).strip()
|
||||
if parse_agent_version(candidate):
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def parse_ssh_version_from_files(ssh_monitor: str | None, version_txt: str | None) -> str | None:
|
||||
if ssh_monitor:
|
||||
match = _SSH_VERSION_RE.search(ssh_monitor)
|
||||
if match:
|
||||
candidate = match.group(1).strip()
|
||||
if parse_agent_version(candidate):
|
||||
return candidate
|
||||
if version_txt:
|
||||
first_line = version_txt.strip().splitlines()[0].strip() if version_txt.strip() else ""
|
||||
if parse_agent_version(first_line):
|
||||
return first_line
|
||||
return None
|
||||
|
||||
|
||||
def parse_product_version_from_dir(repo_dir: Path, product: str) -> str | None:
|
||||
if product == PRODUCT_RDP:
|
||||
return parse_rdp_version_from_files(
|
||||
_read_text(repo_dir / "version.txt"),
|
||||
_read_text(repo_dir / "Login_Monitor.ps1"),
|
||||
)
|
||||
if product == PRODUCT_SSH:
|
||||
return parse_ssh_version_from_files(
|
||||
_read_text(repo_dir / "ssh-monitor"),
|
||||
_read_text(repo_dir / "version.txt"),
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _run_git(args: list[str], *, cwd: Path | None = None) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
["git", *args],
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def _repo_slug(repo_url: str) -> str:
|
||||
normalized = normalize_git_repo_url(repo_url)
|
||||
name = normalized.rstrip("/").split("/")[-1]
|
||||
if name.endswith(".git"):
|
||||
name = name[:-4]
|
||||
digest = hashlib.sha256(normalized.encode()).hexdigest()[:12]
|
||||
return f"{name}-{digest}"
|
||||
|
||||
|
||||
def clone_or_update_repo(repo_url: str, branch: str, cache_dir: Path, token: str = "") -> Path:
|
||||
clone_url = git_repo_url_with_auth(repo_url, token)
|
||||
normalized = normalize_git_repo_url(repo_url)
|
||||
if not normalized:
|
||||
raise ValueError("empty repo url")
|
||||
|
||||
branch_name = (branch or "main").strip() or "main"
|
||||
target = cache_dir / _repo_slug(normalized)
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if target.is_dir() and (target / ".git").is_dir():
|
||||
fetch = _run_git(["fetch", "--depth", "1", "origin", branch_name], cwd=target)
|
||||
if fetch.returncode != 0:
|
||||
shutil.rmtree(target, ignore_errors=True)
|
||||
else:
|
||||
checkout = _run_git(["checkout", "FETCH_HEAD"], cwd=target)
|
||||
if checkout.returncode == 0:
|
||||
return target
|
||||
shutil.rmtree(target, ignore_errors=True)
|
||||
|
||||
clone = _run_git(
|
||||
["clone", "--depth", "1", "--branch", branch_name, clone_url, str(target)],
|
||||
)
|
||||
if clone.returncode != 0:
|
||||
err = (clone.stderr or clone.stdout or "git clone failed").strip()
|
||||
raise RuntimeError(err)
|
||||
return target
|
||||
|
||||
|
||||
def fetch_product_version(repo_url: str, branch: str, product: str, cache_dir: Path, token: str = "") -> str:
|
||||
repo_dir = clone_or_update_repo(repo_url, branch, cache_dir, token=token)
|
||||
version = parse_product_version_from_dir(repo_dir, product)
|
||||
if not version:
|
||||
raise RuntimeError(f"version not found in {repo_url} ({product})")
|
||||
return version
|
||||
|
||||
|
||||
def recommended_from_git(cfg: AgentUpdateConfig, git_versions: dict[str, str], product: str) -> str:
|
||||
git_version = (git_versions.get(product) or "").strip()
|
||||
if git_version:
|
||||
return git_version
|
||||
manual = cfg.recommended_for_product(product)
|
||||
return manual
|
||||
|
||||
|
||||
def _ttl_seconds() -> int:
|
||||
settings = get_settings()
|
||||
return max(60, int(settings.sac_agent_git_cache_ttl_minutes) * 60)
|
||||
|
||||
|
||||
def _cache_dir() -> Path:
|
||||
settings = get_settings()
|
||||
return Path(settings.sac_agent_git_cache_dir or "/tmp/sac-agent-repos")
|
||||
|
||||
|
||||
def _db_cache_fresh(row: UiSettings | None, cfg: AgentUpdateConfig) -> bool:
|
||||
if row is None or row.agent_git_fetched_at is None:
|
||||
return False
|
||||
fetched_at = row.agent_git_fetched_at
|
||||
if fetched_at.tzinfo is None:
|
||||
fetched_at = fetched_at.replace(tzinfo=timezone.utc)
|
||||
age = (datetime.now(timezone.utc) - fetched_at).total_seconds()
|
||||
if age > _ttl_seconds():
|
||||
return False
|
||||
if (row.agent_rdp_git_repo_url or "").strip() != cfg.rdp_git_repo_url:
|
||||
return False
|
||||
if (row.agent_ssh_git_repo_url or "").strip() != cfg.ssh_git_repo_url:
|
||||
return False
|
||||
if (row.agent_git_branch or "main").strip() != cfg.git_branch:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _versions_from_db_row(row: UiSettings) -> dict[str, str]:
|
||||
out: dict[str, str] = {}
|
||||
rdp = (row.agent_git_rdp_version or "").strip()
|
||||
ssh = (row.agent_git_ssh_version or "").strip()
|
||||
if rdp:
|
||||
out[PRODUCT_RDP] = rdp
|
||||
if ssh:
|
||||
out[PRODUCT_SSH] = ssh
|
||||
return out
|
||||
|
||||
|
||||
def _persist_db_cache(db: Session, cfg: AgentUpdateConfig, versions: dict[str, str]) -> datetime:
|
||||
now = datetime.now(timezone.utc)
|
||||
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)
|
||||
row.agent_rdp_git_repo_url = cfg.rdp_git_repo_url or None
|
||||
row.agent_ssh_git_repo_url = cfg.ssh_git_repo_url or None
|
||||
row.agent_git_branch = cfg.git_branch or "main"
|
||||
row.agent_git_rdp_version = versions.get(PRODUCT_RDP) or None
|
||||
row.agent_git_ssh_version = versions.get(PRODUCT_SSH) or None
|
||||
row.agent_git_fetched_at = now
|
||||
db.commit()
|
||||
return now
|
||||
|
||||
|
||||
def get_git_release_versions(
|
||||
cfg: AgentUpdateConfig,
|
||||
*,
|
||||
db: Session | None = None,
|
||||
force_refresh: bool = False,
|
||||
) -> GitReleaseVersions:
|
||||
key = _cache_key(cfg)
|
||||
now_ts = time.time()
|
||||
ttl = _ttl_seconds()
|
||||
|
||||
if not force_refresh:
|
||||
cached = _MEMORY_CACHE.get(key)
|
||||
if cached and now_ts - cached[1] <= ttl:
|
||||
fetched_at = datetime.fromtimestamp(cached[1], tz=timezone.utc)
|
||||
return GitReleaseVersions(versions=dict(cached[0]), fetched_at=fetched_at, from_cache=True)
|
||||
|
||||
if db is not None:
|
||||
row = db.get(UiSettings, UI_SETTINGS_ROW_ID)
|
||||
if _db_cache_fresh(row, cfg):
|
||||
versions = _versions_from_db_row(row)
|
||||
if versions:
|
||||
_MEMORY_CACHE[key] = (versions, row.agent_git_fetched_at.timestamp(), key)
|
||||
return GitReleaseVersions(
|
||||
versions=versions,
|
||||
fetched_at=row.agent_git_fetched_at,
|
||||
from_cache=True,
|
||||
)
|
||||
|
||||
settings = get_settings()
|
||||
cache_dir = _cache_dir()
|
||||
token = (settings.sac_agent_git_token or "").strip()
|
||||
branch = cfg.git_branch or "main"
|
||||
versions: dict[str, str] = {}
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
product_repo = {
|
||||
PRODUCT_RDP: cfg.rdp_git_repo_url,
|
||||
PRODUCT_SSH: cfg.ssh_git_repo_url,
|
||||
}
|
||||
for product, repo_url in product_repo.items():
|
||||
if not repo_url:
|
||||
errors[product] = "repo url not configured"
|
||||
continue
|
||||
try:
|
||||
versions[product] = fetch_product_version(repo_url, branch, product, cache_dir, token=token)
|
||||
except (OSError, RuntimeError, ValueError, subprocess.SubprocessError) as exc:
|
||||
errors[product] = str(exc).strip() or "fetch failed"
|
||||
|
||||
fetched_at: datetime | None = None
|
||||
if versions:
|
||||
fetched_at = datetime.now(timezone.utc)
|
||||
_MEMORY_CACHE[key] = (dict(versions), fetched_at.timestamp(), key)
|
||||
if db is not None:
|
||||
fetched_at = _persist_db_cache(db, cfg, versions)
|
||||
|
||||
if not versions and db is not None:
|
||||
row = db.get(UiSettings, UI_SETTINGS_ROW_ID)
|
||||
if row is not None:
|
||||
stale = _versions_from_db_row(row)
|
||||
if stale:
|
||||
return GitReleaseVersions(
|
||||
versions=stale,
|
||||
fetched_at=row.agent_git_fetched_at,
|
||||
from_cache=True,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
return GitReleaseVersions(
|
||||
versions=versions,
|
||||
fetched_at=fetched_at,
|
||||
from_cache=False,
|
||||
errors=errors,
|
||||
)
|
||||
@@ -14,10 +14,10 @@ from app.services.agent_update_settings import (
|
||||
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 (
|
||||
is_agent_version_outdated,
|
||||
host_version_outdated,
|
||||
latest_agent_versions_by_product,
|
||||
parse_agent_version,
|
||||
)
|
||||
from app.services.linux_admin_settings import get_effective_linux_admin_config
|
||||
from app.services.ssh_connect import (
|
||||
@@ -49,12 +49,21 @@ class AgentUpdateNotAllowedError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def resolve_target_version(db: Session, host: Host, cfg: AgentUpdateConfig) -> str:
|
||||
recommended = cfg.recommended_for_product(host.product or "")
|
||||
if recommended:
|
||||
return recommended
|
||||
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(host.product or "", "") or (host.product_version or "")
|
||||
return latest_map.get(product, "") or (host.product_version or "")
|
||||
|
||||
|
||||
def host_version_status(
|
||||
@@ -62,17 +71,20 @@ def host_version_status(
|
||||
*,
|
||||
cfg: AgentUpdateConfig,
|
||||
latest_map: dict[str, str],
|
||||
git_versions: dict[str, str] | None = None,
|
||||
) -> str:
|
||||
current = host.product_version
|
||||
if not current or parse_agent_version(current) is None:
|
||||
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"
|
||||
recommended = cfg.recommended_for_product(host.product or "")
|
||||
reference = recommended or latest_map.get(host.product or "")
|
||||
if not reference:
|
||||
return "unknown"
|
||||
if is_agent_version_outdated(current, reference):
|
||||
return "outdated"
|
||||
return "ok"
|
||||
return "outdated" if outdated else "ok"
|
||||
|
||||
|
||||
def request_agent_update(db: Session, host: Host) -> Host:
|
||||
|
||||
@@ -24,6 +24,9 @@ class AgentUpdateConfig:
|
||||
min_rdp_version: str
|
||||
min_ssh_version: str
|
||||
win_agent_update_script: str
|
||||
rdp_git_repo_url: str
|
||||
ssh_git_repo_url: str
|
||||
git_branch: str
|
||||
source: str # env | db
|
||||
|
||||
@property
|
||||
@@ -59,6 +62,9 @@ def _agent_update_from_env() -> AgentUpdateConfig:
|
||||
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(),
|
||||
rdp_git_repo_url=(settings.sac_agent_rdp_git_repo_url or "").strip(),
|
||||
ssh_git_repo_url=(settings.sac_agent_ssh_git_repo_url or "").strip(),
|
||||
git_branch=(settings.sac_agent_git_branch or "main").strip() or "main",
|
||||
source="env",
|
||||
)
|
||||
|
||||
@@ -74,6 +80,9 @@ def _row_has_agent_update_values(row: UiSettings) -> bool:
|
||||
bool((row.agent_min_rdp_version or "").strip()),
|
||||
bool((row.agent_min_ssh_version or "").strip()),
|
||||
bool((row.win_agent_update_script or "").strip()),
|
||||
bool((row.agent_rdp_git_repo_url or "").strip()),
|
||||
bool((row.agent_ssh_git_repo_url or "").strip()),
|
||||
(row.agent_git_branch or "main").strip() not in ("", "main"),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -116,6 +125,9 @@ def get_effective_agent_update_config(db: Session | None = None) -> AgentUpdateC
|
||||
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,
|
||||
rdp_git_repo_url=(row.agent_rdp_git_repo_url or "").strip() or env_cfg.rdp_git_repo_url,
|
||||
ssh_git_repo_url=(row.agent_ssh_git_repo_url or "").strip() or env_cfg.ssh_git_repo_url,
|
||||
git_branch=(row.agent_git_branch or env_cfg.git_branch).strip() or env_cfg.git_branch,
|
||||
source="db" if _row_has_agent_update_values(row) else env_cfg.source,
|
||||
)
|
||||
|
||||
@@ -131,6 +143,9 @@ def upsert_agent_update_settings(
|
||||
min_rdp_version: str | None = None,
|
||||
min_ssh_version: str | None = None,
|
||||
win_agent_update_script: str | None = None,
|
||||
rdp_git_repo_url: str | None = None,
|
||||
ssh_git_repo_url: str | None = None,
|
||||
git_branch: str | None = None,
|
||||
) -> AgentUpdateConfig:
|
||||
row = db.get(UiSettings, UI_SETTINGS_ROW_ID)
|
||||
if row is None:
|
||||
@@ -156,6 +171,21 @@ def upsert_agent_update_settings(
|
||||
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
|
||||
if rdp_git_repo_url is not None:
|
||||
row.agent_rdp_git_repo_url = rdp_git_repo_url.strip() or None
|
||||
row.agent_git_rdp_version = None
|
||||
row.agent_git_fetched_at = None
|
||||
if ssh_git_repo_url is not None:
|
||||
row.agent_ssh_git_repo_url = ssh_git_repo_url.strip() or None
|
||||
row.agent_git_ssh_version = None
|
||||
row.agent_git_fetched_at = None
|
||||
if git_branch is not None:
|
||||
branch = git_branch.strip() or "main"
|
||||
if branch != (row.agent_git_branch or "main"):
|
||||
row.agent_git_rdp_version = None
|
||||
row.agent_git_ssh_version = None
|
||||
row.agent_git_fetched_at = None
|
||||
row.agent_git_branch = branch
|
||||
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
|
||||
@@ -60,6 +60,75 @@ def is_agent_version_outdated(
|
||||
return lag >= lag_threshold
|
||||
|
||||
|
||||
def effective_reference_version(
|
||||
*,
|
||||
recommended: str = "",
|
||||
fleet_latest: str = "",
|
||||
min_version: str = "",
|
||||
) -> str | None:
|
||||
"""Единый эталон: max(recommended, min_version, max версия в fleet по продукту)."""
|
||||
best: ParsedAgentVersion | None = None
|
||||
best_raw: str | None = None
|
||||
for raw in (recommended, min_version, fleet_latest):
|
||||
text = (raw or "").strip()
|
||||
parsed = parse_agent_version(text)
|
||||
if parsed is None:
|
||||
continue
|
||||
if best is None or parsed > best:
|
||||
best = parsed
|
||||
best_raw = text
|
||||
return best_raw
|
||||
|
||||
|
||||
def reference_agent_versions_by_product(
|
||||
cfg,
|
||||
latest_map: dict[str, str],
|
||||
*,
|
||||
git_versions: dict[str, str] | None = None,
|
||||
products: tuple[str, ...] = ("rdp-login-monitor", "ssh-monitor"),
|
||||
) -> dict[str, str]:
|
||||
from app.services.agent_git_release import recommended_from_git
|
||||
from app.services.agent_update_settings import PRODUCT_RDP, PRODUCT_SSH
|
||||
|
||||
git_versions = git_versions or {}
|
||||
product_cfg = {
|
||||
PRODUCT_RDP: (recommended_from_git(cfg, git_versions, PRODUCT_RDP), cfg.min_for_product(PRODUCT_RDP)),
|
||||
PRODUCT_SSH: (recommended_from_git(cfg, git_versions, PRODUCT_SSH), cfg.min_for_product(PRODUCT_SSH)),
|
||||
}
|
||||
out: dict[str, str] = {}
|
||||
for product in products:
|
||||
recommended, min_version = product_cfg.get(product, ("", ""))
|
||||
ref = effective_reference_version(
|
||||
recommended=recommended,
|
||||
fleet_latest=latest_map.get(product, ""),
|
||||
min_version=min_version,
|
||||
)
|
||||
if ref:
|
||||
out[product] = ref
|
||||
return out
|
||||
|
||||
|
||||
def host_version_outdated(
|
||||
host_version: str | None,
|
||||
*,
|
||||
recommended: str = "",
|
||||
fleet_latest: str = "",
|
||||
min_version: str = "",
|
||||
lag_threshold: int = _DEFAULT_LAG_THRESHOLD,
|
||||
) -> bool | None:
|
||||
"""True=outdated, False=ok, None=unknown (нет эталона или непарсится версия хоста)."""
|
||||
if not host_version or parse_agent_version(host_version) is None:
|
||||
return None
|
||||
reference = effective_reference_version(
|
||||
recommended=recommended,
|
||||
fleet_latest=fleet_latest,
|
||||
min_version=min_version,
|
||||
)
|
||||
if not reference:
|
||||
return None
|
||||
return is_agent_version_outdated(host_version, reference, lag_threshold=lag_threshold)
|
||||
|
||||
|
||||
def latest_agent_versions_by_product(db: Session) -> dict[str, str]:
|
||||
rows = db.execute(
|
||||
select(Host.product, Host.product_version).where(
|
||||
|
||||
Reference in New Issue
Block a user