"""Persistent, data-driven audit profile registry. The manifest is the source of truth; artifacts are immutable per profile version. All manifest mutations are serialized and committed with ``os.replace``. """ from __future__ import annotations import copy import hashlib import json import os import re import shutil import threading from datetime import datetime, timezone from pathlib import Path from typing import Any try: from .config_upload import normalize_json, validate_xlsx except ImportError: # application is also launched with /app on sys.path from config_upload import normalize_json, validate_xlsx class RegistryError(RuntimeError): pass class RegistryValidationError(RegistryError, ValueError): pass class RegistryNotFoundError(RegistryError, LookupError): pass _ID_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$") _ALIAS_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$") _VERSION_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") _LEGACY = ( ("windows", "Windows", "Windows_Checklist.xlsx", "windows_config.json", ("win", "winserver", "win_server")), ("centos", "CentOS", "CentOS_Checklist_NEW.xlsx", "centos_config_new.json", ("centos_linux",)), ("rhel", "RHEL (Red Hat Enterprise Linux)", "RHEL_Checklist_NEW.xlsx", "rhel_config_new.json", ("redhat", "red_hat", "red-hat")), ("ubuntu", "Ubuntu", "Ubuntu_Checklist_NEW.xlsx", "ubuntu_config_new.json", ()), ("oracle", "Oracle Linux", "Oracle_Linux_Checklist.xlsx", "oracle_linux_config.json", ("oracle_linux", "ol")), ) class RegistryService: def __init__(self, root: str | Path, legacy_config_root: str | Path | None = None): self.root = Path(root) self.legacy_config_root = Path(legacy_config_root or self.root.parent) self.manifest_path = self.root / "registry.json" self._lock = threading.RLock() @staticmethod def _id(value: str, field: str = "id") -> str: value = (value or "").strip().lower() if not _ID_RE.fullmatch(value): raise RegistryValidationError(f"{field} must match {_ID_RE.pattern}") return value @staticmethod def _version(value: str) -> str: value = (value or "").strip() if not _VERSION_RE.fullmatch(value): raise RegistryValidationError("invalid version identifier") return value @staticmethod def _alias(value: str, field: str = "alias") -> str: value = (value or "").strip().lower() if not _ALIAS_RE.fullmatch(value): raise RegistryValidationError(f"{field} is invalid") return value @staticmethod def _sha(data: bytes) -> str: return hashlib.sha256(data).hexdigest() def _empty(self) -> dict[str, Any]: return {"schema_version": 2, "revision": 0, "categories": [], "aliases": {}} def load(self) -> dict[str, Any]: if not self.manifest_path.exists(): return self._empty() try: data = json.loads(self.manifest_path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as exc: raise RegistryValidationError(f"invalid registry manifest: {exc}") from exc self._validate_manifest(data) return data def _validate_manifest(self, data: dict[str, Any]) -> None: if not isinstance(data, dict) or data.get("schema_version") != 2: raise RegistryValidationError("unsupported registry schema") seen: dict[str, set[str]] = {"category": set(), "component": set(), "profile": set()} for category in data.get("categories", []): cid = self._id(category.get("id"), "category id") if cid in seen["category"]: raise RegistryValidationError(f"duplicate category: {cid}") seen["category"].add(cid) for component in category.get("components", []): coid = self._id(component.get("id"), "component id") if coid in seen["component"]: raise RegistryValidationError(f"duplicate component: {coid}") seen["component"].add(coid) for profile in component.get("profiles", []): pid = self._id(profile.get("id"), "profile id") if pid in seen["profile"]: raise RegistryValidationError(f"duplicate profile: {pid}") seen["profile"].add(pid) versions = profile.get("versions", []) active = [v for v in versions if v.get("active")] if len(active) > 1: raise RegistryValidationError(f"multiple active versions: {pid}") def _write(self, data: dict[str, Any]) -> dict[str, Any]: self._validate_manifest(data) self.root.mkdir(parents=True, exist_ok=True) data["revision"] = int(data.get("revision", 0)) + 1 data["updated_at"] = datetime.now(timezone.utc).isoformat() temporary = self.manifest_path.with_suffix(".json.tmp") payload = json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8") + b"\n" with temporary.open("wb") as stream: stream.write(payload) stream.flush() os.fsync(stream.fileno()) os.replace(temporary, self.manifest_path) return copy.deepcopy(data) def _find(self, data: dict[str, Any], kind: str, object_id: str): for category in data["categories"]: if kind == "category" and category["id"] == object_id: return category for component in category.get("components", []): if kind == "component" and component["id"] == object_id: return component for profile in component.get("profiles", []): if kind == "profile" and profile["id"] == object_id: return profile raise RegistryNotFoundError(f"{kind} not found: {object_id}") def create_category(self, category_id: str, name: str) -> dict[str, Any]: category_id = self._id(category_id, "category id") with self._lock: data = self.load() try: self._find(data, "category", category_id) except RegistryNotFoundError: pass else: raise RegistryValidationError(f"duplicate category: {category_id}") category = {"id": category_id, "name": name.strip(), "components": []} data["categories"].append(category) self._write(data) return copy.deepcopy(category) def create_component(self, category_id: str, component_id: str, name: str) -> dict[str, Any]: component_id = self._id(component_id, "component id") with self._lock: data = self.load() try: self._find(data, "component", component_id) except RegistryNotFoundError: pass else: raise RegistryValidationError(f"duplicate component: {component_id}") category = self._find(data, "category", category_id) component = {"id": component_id, "name": name.strip(), "profiles": []} category["components"].append(component) self._write(data) return copy.deepcopy(component) def create_profile(self, component_id: str, profile_id: str, name: str, aliases=()) -> dict[str, Any]: profile_id = self._id(profile_id, "profile id") aliases = [self._alias(alias) for alias in aliases] with self._lock: data = self.load() try: self._find(data, "profile", profile_id) except RegistryNotFoundError: pass else: raise RegistryValidationError(f"duplicate profile: {profile_id}") for alias in aliases: if alias in data["aliases"] and data["aliases"][alias] != profile_id: raise RegistryValidationError(f"duplicate alias: {alias}") component = self._find(data, "component", component_id) profile = {"id": profile_id, "name": name.strip(), "aliases": aliases, "activation_history": [], "versions": []} component["profiles"].append(profile) for alias in aliases: data["aliases"][alias] = profile_id self._write(data) return copy.deepcopy(profile) def create_version(self, profile_id: str, version: str, checklist: bytes, rules: bytes, *, activate: bool = False, created_by: str = "admin") -> dict[str, Any]: profile_id, version = self._id(profile_id, "profile id"), self._version(version) try: checklist = validate_xlsx(checklist) rules = normalize_json(rules) except ValueError as exc: raise RegistryValidationError(str(exc)) from exc with self._lock: data = self.load() profile = self._find(data, "profile", profile_id) if any(item["version"] == version for item in profile["versions"]): raise RegistryValidationError(f"duplicate version: {version}") relative = Path("artifacts") / profile_id / version target = self.root / relative if target.exists(): raise RegistryValidationError(f"artifact directory already exists: {version}") temporary = self.root / "artifacts" / profile_id / f".{version}.{os.getpid()}.tmp" temporary.mkdir(parents=True, exist_ok=False) try: (temporary / "checklist.xlsx").write_bytes(checklist) (temporary / "rules.json").write_bytes(rules) target.parent.mkdir(parents=True, exist_ok=True) os.replace(temporary, target) except Exception: shutil.rmtree(temporary, ignore_errors=True) raise if activate: current = next((item["version"] for item in profile["versions"] if item.get("active")), None) if current: profile.setdefault("activation_history", []).append(current) for item in profile["versions"]: item["active"] = False item = { "version": version, "active": bool(activate), "created_at": datetime.now(timezone.utc).isoformat(), "created_by": created_by, "artifacts": {"checklist": str(relative / "checklist.xlsx").replace("\\", "/"), "rules": str(relative / "rules.json").replace("\\", "/")}, "checksums": {"checklist": self._sha(checklist), "rules": self._sha(rules)}, } profile["versions"].append(item) try: self._write(data) except Exception: shutil.rmtree(target, ignore_errors=True) raise return copy.deepcopy(item) def update_version_artifacts(self, profile_id: str, version: str, checklist: bytes | None = None, rules: bytes | None = None, *, updated_by: str = "admin") -> dict[str, Any]: """Replace artifacts for an inactive version while preserving a backup copy.""" if checklist is None and rules is None: raise RegistryValidationError("checklist or rules is required") if checklist is not None: checklist = validate_xlsx(checklist) if rules is not None: rules = normalize_json(rules) with self._lock: data = self.load() profile = self._find(data, "profile", self._id(profile_id, "profile id")) target_version = next((item for item in profile["versions"] if item["version"] == version), None) if not target_version: raise RegistryNotFoundError(f"version not found: {version}") if target_version.get("active"): raise RegistryValidationError("active versions are immutable; create a new version") updates = {"checklist": checklist, "rules": rules} for key, content in updates.items(): if content is None: continue target = self.root / target_version["artifacts"][key] temporary = target.with_suffix(target.suffix + ".tmp") backup = target.with_suffix(target.suffix + ".bak") temporary.write_bytes(content) if target.exists(): shutil.copy2(target, backup) os.replace(temporary, target) target_version["checksums"][key] = self._sha(content) target_version["updated_at"] = datetime.now(timezone.utc).isoformat() target_version["updated_by"] = updated_by self._write(data) return copy.deepcopy(target_version) def activate_version(self, profile_id: str, version: str) -> dict[str, Any]: with self._lock: data = self.load() profile = self._find(data, "profile", profile_id) target = next((v for v in profile["versions"] if v["version"] == version), None) if not target: raise RegistryNotFoundError(f"version not found: {version}") for key, relative in target["artifacts"].items(): artifact = self.root / relative if not artifact.is_file() or self._sha(artifact.read_bytes()) != target["checksums"][key]: raise RegistryValidationError(f"{key} checksum mismatch for {profile_id}:{version}") current = next((item["version"] for item in profile["versions"] if item.get("active")), None) if current and current != version: profile.setdefault("activation_history", []).append(current) for item in profile["versions"]: item["active"] = item is target target["activated_at"] = datetime.now(timezone.utc).isoformat() self._write(data) return copy.deepcopy(target) def rollback(self, profile_id: str) -> dict[str, Any]: with self._lock: data = self.load() profile = self._find(data, "profile", profile_id) history = profile.setdefault("activation_history", []) if not history: raise RegistryValidationError("no previous version available") previous_version = history.pop() previous = next((v for v in profile["versions"] if v["version"] == previous_version), None) if not previous: raise RegistryValidationError("rollback history references a missing version") for key, relative in previous["artifacts"].items(): artifact = self.root / relative if not artifact.is_file() or self._sha(artifact.read_bytes()) != previous["checksums"][key]: raise RegistryValidationError(f"{key} checksum mismatch for {profile_id}:{previous_version}") for item in profile["versions"]: item["active"] = item is previous previous["activated_at"] = datetime.now(timezone.utc).isoformat() self._write(data) return copy.deepcopy(previous) def resolve(self, *, os_type: str | None = None, profile_id: str | None = None) -> dict[str, Any]: data = self.load() if profile_id: resolved_id = self._id(profile_id, "profile id") elif os_type: alias = self._alias(os_type, "os_type") resolved_id = data["aliases"].get(alias, alias if alias.endswith("-baseline") else "") else: raise RegistryValidationError("profile_id or os_type is required") if not resolved_id: raise RegistryNotFoundError(f"unknown os_type: {os_type}") profile = self._find(data, "profile", resolved_id) version = next((v for v in profile["versions"] if v.get("active")), None) if not version: raise RegistryNotFoundError(f"no active version for profile: {resolved_id}") paths = {key: self.root / relative for key, relative in version["artifacts"].items()} for key, path in paths.items(): if not path.is_file() or self._sha(path.read_bytes()) != version["checksums"][key]: raise RegistryValidationError(f"{key} checksum mismatch for {resolved_id}:{version['version']}") return {"profile": copy.deepcopy(profile), "version": copy.deepcopy(version), "checklist_path": str(paths["checklist"]), "rules_path": str(paths["rules"])} def seed_legacy_profiles(self) -> dict[str, Any]: with self._lock: if self.manifest_path.exists(): return self.load() data = self._empty() category = {"id": "operating-systems", "name": "Operating Systems", "components": []} data["categories"].append(category) for os_id, name, checklist_name, rules_name, extra_aliases in _LEGACY: profile_id = f"{os_id}-baseline" aliases = [os_id, *extra_aliases] checklist_path = self.legacy_config_root / checklist_name rules_path = self.legacy_config_root / rules_name if not checklist_path.is_file() or not rules_path.is_file(): raise RegistryValidationError(f"legacy artifacts missing for {os_id}") checklist = validate_xlsx(checklist_path.read_bytes()) rules = normalize_json(rules_path.read_bytes()) relative = Path("artifacts") / profile_id / "legacy-1" target = self.root / relative target.mkdir(parents=True, exist_ok=True) (target / "checklist.xlsx").write_bytes(checklist) (target / "rules.json").write_bytes(rules) version = { "version": "legacy-1", "active": True, "created_at": datetime.now(timezone.utc).isoformat(), "created_by": "migration", "artifacts": {"checklist": str(relative / "checklist.xlsx").replace("\\", "/"), "rules": str(relative / "rules.json").replace("\\", "/")}, "checksums": {"checklist": self._sha(checklist), "rules": self._sha(rules)}, "legacy_sources": {"checklist": checklist_name, "rules": rules_name}, } profile = {"id": profile_id, "name": f"{name} baseline", "aliases": aliases, "activation_history": [], "versions": [version]} category["components"].append({"id": os_id, "name": name, "profiles": [profile]}) for alias in aliases: data["aliases"][alias] = profile_id return self._write(data) def public_catalog(self, active_only: bool = False) -> dict[str, Any]: """Return catalog metadata without artifact paths, checksums or legacy source paths.""" data = copy.deepcopy(self.load()) data.pop("aliases", None) for category in data["categories"]: for component in category["components"]: for profile in component["profiles"]: versions = profile.get("versions", []) if active_only: versions = [version for version in versions if version.get("active")] profile["versions"] = [ { "version": version["version"], "active": bool(version.get("active")), "created_at": version.get("created_at"), "created_by": version.get("created_by"), "activated_at": version.get("activated_at"), } for version in versions ] return data