Files
PTah f411d8f070 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.
2026-06-20 16:25:53 +10:00

309 lines
10 KiB
Python

"""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,
)