f411d8f070
SAC reads latest RDP/ssh-monitor versions from configured git repos for outdated detection, update targets, and settings test-git.
309 lines
10 KiB
Python
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,
|
|
)
|