"""Read-only, fixed-identity assessment of the existing RepoPulse deployment. This module is deliberately separate from generic enrollment and contains no adoption, registry-write, or container-mutation path. Its collector runs only fixed read operations in the existing deployment controller; its evaluator is pure so sanitized observations can be covered without a live Unraid host. """ from __future__ import annotations import json import re import sys from typing import Any from urllib.parse import quote from ..connectors.portainer import PortainerConnector _CONTROLLER = "gitea-deploy-control" _REPOSITORY = "jens/repopulse" _CONTAINER = "repopulse" _VOLUME = "repopulse_data" _SOURCE_DIR = "/mnt/cache/appdata/gitea-deploy-runners/sources/repopulse" _APPDATA_ROOT = "/srv/example/appdata/repopulse" _EXPECTED_TARGET = { "branch": "main", "slug": "repopulse", "source_dir": _SOURCE_DIR, "exact_source": True, "env_source": f"{_APPDATA_ROOT}/deploy.env", "compose_files": ["docker-compose.yml"], "compose_project": "repopulse", "containers": [_CONTAINER], "verify_oci_revision": False, "health_timeout": 180, "environment_keys": [], } _EXPECTED_REGISTRY_KEYS = { "branch", "slug", "source_dir", "exact_source", "env_source", "compose_files", "compose_project", "containers", "verify_oci_revision", "health_timeout", "gitea_repository_id", "gitea_webhook_secret_file", "validation", } _EXPECTED_VALIDATION = { "validation_gate_version": 1, "required": True, "contexts": ["Managed validation / full (push)"], "timeout_seconds": 1800, "poll_interval_seconds": 5, "max_poll_interval_seconds": 30, } _SHA = re.compile(r"^[0-9a-f]{40}$") _SHA256 = re.compile(r"^[0-9a-f]{64}$") _PREFLIGHT_SCRIPT = r''' import http.client import hashlib import json import os import re import stat import subprocess from pathlib import Path REPOSITORY = "jens/repopulse" CONTAINER = "repopulse" VOLUME = "repopulse_data" APPDATA_ROOT = Path("/srv/example/appdata/repopulse") SCAN_ROOT = Path("/mnt/cache/appdata/gitea-deploy-runners/sources") REGISTRY = Path("/controller-config/projects.json") HOST_REGISTRY = Path("/controller-hub-releases/controller-config/projects.json") STATE = Path("/mnt/cache/appdata/gitea-deploy-runners/state/repopulse.json") BARE_REPO = Path("/gitea-repositories/jens/repopulse.git") SHA = re.compile(r"^[0-9a-f]{40}$") def read_json(path): try: if path.is_symlink() or not path.is_file() or path.stat().st_size > 4 * 1024 * 1024: return None return json.loads(path.read_text(encoding="utf-8")) except Exception: return None def run_fixed(argv, timeout=10): try: result = subprocess.run( argv, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, timeout=timeout, check=False, env={ "PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", "HOME": "/root", "GIT_OPTIONAL_LOCKS": "0", "GIT_TERMINAL_PROMPT": "0", "GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": "/dev/null", }, ) return result.returncode, result.stdout.strip() except Exception: return -1, "" def inspect_json(kind, name): code, output = run_fixed([ "docker", "--host", "unix:///var/run/docker.sock", "inspect", "--type", kind, name ], timeout=12) if code != 0: return None try: value = json.loads(output) return value[0] if isinstance(value, list) and len(value) == 1 and isinstance(value[0], dict) else None except Exception: return None def mode_allows(path, *, uid, gid, required_bits): """Conservative POSIX mode check for a fixed non-root runtime identity.""" try: info = path.lstat() if path.is_symlink() or not info or not stat.S_ISDIR(info.st_mode): return False mode = stat.S_IMODE(info.st_mode) if info.st_uid == uid: bits = (mode >> 6) & 0o7 elif info.st_gid == gid: bits = (mode >> 3) & 0o7 else: bits = mode & 0o7 return bits & required_bits == required_bits except Exception: return False def directory_readable_as(path, *, uid, gid): """Check traversal of every parent and read/traverse rights at the leaf.""" absolute = path.absolute() chain = list(reversed(absolute.parents)) + [absolute] for item in chain[:-1]: if not mode_allows(item, uid=uid, gid=gid, required_bits=0o1): return False return mode_allows(chain[-1], uid=uid, gid=gid, required_bits=0o5) def repository_facts(): projects = read_json(REGISTRY) entry = projects.get(REPOSITORY) if isinstance(projects, dict) else None registry = {"present": isinstance(projects, dict) and REPOSITORY in projects} try: registry["alias_matches"] = ( not REGISTRY.is_symlink() and not HOST_REGISTRY.is_symlink() and REGISTRY.is_file() and HOST_REGISTRY.is_file() and os.path.samefile(REGISTRY, HOST_REGISTRY) ) except Exception: registry["alias_matches"] = False if isinstance(entry, dict): registry["entry_type"] = "object" registry["keys"] = sorted(str(key) for key in entry.keys()) registry["target"] = { key: entry.get(key) for key in ( "branch", "slug", "source_dir", "exact_source", "env_source", "compose_files", "compose_project", "containers", "verify_oci_revision", "health_timeout", ) } environment = entry.get("environment") registry["environment_keys"] = ( sorted(str(key) for key in environment) if isinstance(environment, dict) else [] if "environment" not in entry else None ) validation = entry.get("validation") registry["validation"] = validation if isinstance(validation, dict) else None registry["repository_id"] = entry.get("gitea_repository_id") registry["webhook_secret_file_matches"] = entry.get("gitea_webhook_secret_file") == "jens__repopulse" else: registry["entry_type"] = "missing" if entry is None else type(entry).__name__ state = read_json(STATE) if isinstance(state, dict): state = { key: state.get(key) for key in ( "schema_version", "repository", "revision", "live_revision", "live_status", "live_legacy_provenance", "status", ) } else: state = None live_revision = state.get("live_revision") if isinstance(state, dict) else None git = {"bare_repo_present": BARE_REPO.is_dir() and not BARE_REPO.is_symlink()} main_code, main_sha = run_fixed(["git", f"--git-dir={BARE_REPO}", "rev-parse", "refs/heads/main"]) git["main_revision"] = main_sha if main_code == 0 and SHA.fullmatch(main_sha) else None git["live_revision_resolves"] = False git["live_is_ancestor"] = False git["compose_present_on_main"] = False if isinstance(live_revision, str) and SHA.fullmatch(live_revision) and git["main_revision"]: resolve_code, resolved = run_fixed([ "git", f"--git-dir={BARE_REPO}", "rev-parse", f"{live_revision}^{{commit}}" ]) git["live_revision_resolves"] = resolve_code == 0 and resolved == live_revision if git["live_revision_resolves"]: ancestor_code, _ = run_fixed([ "git", f"--git-dir={BARE_REPO}", "merge-base", "--is-ancestor", live_revision, git["main_revision"], ]) git["live_is_ancestor"] = ancestor_code == 0 compose_code, _ = run_fixed([ "git", f"--git-dir={BARE_REPO}", "cat-file", "-e", f"{git['main_revision']}:docker-compose.yml", ]) git["compose_present_on_main"] = compose_code == 0 git["live_revision"] = live_revision if isinstance(live_revision, str) and SHA.fullmatch(live_revision) else None container = inspect_json("container", CONTAINER) runtime = None if isinstance(container, dict): config = container.get("Config") if isinstance(container.get("Config"), dict) else {} host_config = container.get("HostConfig") if isinstance(container.get("HostConfig"), dict) else {} state_payload = container.get("State") if isinstance(container.get("State"), dict) else {} labels = config.get("Labels") if isinstance(config.get("Labels"), dict) else {} mounts = [] for mount in container.get("Mounts", []): if isinstance(mount, dict) and mount.get("Destination") in ("/data", "/scan"): mounts.append({ "destination": mount.get("Destination"), "type": mount.get("Type"), "name": mount.get("Name"), "source": mount.get("Source"), "read_write": mount.get("RW"), }) envs = config.get("Env") if isinstance(config.get("Env"), list) else [] operator_token_configured = any( isinstance(value, str) and value.startswith("REPOPULSE_OPERATOR_TOKEN=") and bool(value.split("=", 1)[1]) for value in envs ) bindings = host_config.get("PortBindings") if isinstance(host_config.get("PortBindings"), dict) else {} host_ports = [] for _container_port, endpoints in bindings.items(): if not isinstance(endpoints, list): continue for endpoint in endpoints: if isinstance(endpoint, dict): ip, port = endpoint.get("HostIp"), endpoint.get("HostPort") if isinstance(port, str) and port.isdigit(): host_ports.append({"ip": (ip or "0.0.0.0")[:64] if isinstance(ip, str) else "unknown", "port": int(port)}) security_options = host_config.get("SecurityOpt") if isinstance(host_config.get("SecurityOpt"), list) else [] runtime = { "name_matches": container.get("Name") == "/repopulse", "compose_project_matches": labels.get("com.docker.compose.project") == "repopulse", "container_id_sha256": ( hashlib.sha256(container["Id"].encode("ascii")).hexdigest() if isinstance(container.get("Id"), str) and re.fullmatch(r"[0-9a-f]{64}", container["Id"]) else None ), "image_sha256": ( hashlib.sha256(config["Image"].encode("utf-8")).hexdigest() if isinstance(config.get("Image"), str) and len(config["Image"]) <= 160 else None ), "state": state_payload.get("Status") if state_payload.get("Status") in ("running", "exited", "created", "restarting", "paused", "dead") else "unknown", "health": (state_payload.get("Health") or {}).get("Status") if isinstance(state_payload.get("Health"), dict) else "missing", "image": config.get("Image") if isinstance(config.get("Image"), str) and len(config.get("Image")) <= 160 else None, "network_mode": host_config.get("NetworkMode") if isinstance(host_config.get("NetworkMode"), str) and len(host_config.get("NetworkMode")) <= 160 else "unknown", "mounts": mounts, "read_only_root": host_config.get("ReadonlyRootfs") is True, "cap_drop_all": "ALL" in (host_config.get("CapDrop") if isinstance(host_config.get("CapDrop"), list) else []), "no_new_privileges": any(isinstance(value, str) and value.casefold() == "no-new-privileges:true" for value in security_options), "host_ports": sorted(host_ports, key=lambda item: (item["ip"], item["port"])), "operator_token_configured": operator_token_configured, } volume = inspect_json("volume", VOLUME) volume_facts = None if isinstance(volume, dict): volume_facts = { "name_matches": volume.get("Name") == VOLUME, "mountpoint_matches": volume.get("Mountpoint") == "/var/lib/docker/volumes/repopulse_data/_data", } try: root_ok = APPDATA_ROOT.is_dir() and not APPDATA_ROOT.is_symlink() unexpected_entry_count = 0 if root_ok: # At most four names are needed to check the expected appdata # entries without materializing a large directory listing. with os.scandir(APPDATA_ROOT) as entries: for index, item in enumerate(entries): if item.name not in {"deploy.env", "deploy.managed.env", "data", "sources"}: unexpected_entry_count = 1 break if index >= 3: unexpected_entry_count = 1 break env_path = APPDATA_ROOT / "deploy.env" managed_env_path = APPDATA_ROOT / "deploy.managed.env" data_path = APPDATA_ROOT / "data" sources_path = APPDATA_ROOT / "sources" sources_exists = sources_path.exists() or sources_path.is_symlink() appdata = { "root_is_safe_directory": root_ok, "deploy_env_is_regular_file": env_path.is_file() and not env_path.is_symlink(), "managed_env_exists": managed_env_path.exists() or managed_env_path.is_symlink(), "managed_env_is_regular_file": managed_env_path.is_file() and not managed_env_path.is_symlink(), "managed_env_is_private": ( managed_env_path.is_file() and not managed_env_path.is_symlink() and ( os.name == "nt" or ( managed_env_path.stat().st_uid == os.geteuid() and stat.S_IMODE(managed_env_path.stat().st_mode) & 0o077 == 0 ) ) ), "target_data_exists": data_path.exists() or data_path.is_symlink(), "target_data_is_symlink": data_path.is_symlink(), "sources_exists": sources_exists, "sources_is_safe_directory": not sources_exists or (sources_path.is_dir() and not sources_path.is_symlink()), "unexpected_entry_count": unexpected_entry_count, } except Exception: appdata = None scan_access = { "runtime_uid": 10001, "runtime_gid": 10001, "root_readable": directory_readable_as(SCAN_ROOT, uid=10001, gid=10001), "repopulse_source_readable": directory_readable_as(SCAN_ROOT / "repopulse", uid=10001, gid=10001), "privatemuse_source_readable": directory_readable_as(SCAN_ROOT / "privatemuse", uid=10001, gid=10001), } health = None meta = None try: connection = http.client.HTTPConnection("127.0.0.1", 1254, timeout=5) connection.request("GET", "/health", headers={"Connection": "close"}) response = connection.getresponse() body = response.read(8192) health_payload = json.loads(body.decode("utf-8")) if response.status == 200 else None health = { "http_ok": response.status == 200, "status": health_payload.get("status") if isinstance(health_payload, dict) and health_payload.get("status") in ("ok", "degraded") else "unknown", } connection.close() connection = http.client.HTTPConnection("127.0.0.1", 1254, timeout=5) connection.request("GET", "/api/v1/meta", headers={"Connection": "close"}) response = connection.getresponse() body = response.read(65536) payload = json.loads(body.decode("utf-8")) if response.status == 200 else None connection.close() if isinstance(payload, dict): def safe_int(value): return value if isinstance(value, int) and not isinstance(value, bool) and value >= 0 else None repo_states = payload.get("repository_states") if isinstance(payload.get("repository_states"), dict) else {} storage = payload.get("storage") if isinstance(payload.get("storage"), dict) else {} recovery = payload.get("database_recovery") if isinstance(payload.get("database_recovery"), dict) else {} meta = { "http_ok": response.status == 200, "product_matches": payload.get("name") == "RepoPulse", "repository_count": safe_int(payload.get("repository_count")), "repository_states": {key: safe_int(repo_states.get(key)) for key in ("current", "stale", "queued", "scanning", "failed", "never_scanned", "archived", "unavailable")}, "queue": {key: safe_int(payload.get(name)) for key, name in (("queued", "queue_depth"), ("active", "active_jobs"), ("completed", "completed_jobs"), ("failed", "failed_jobs"))}, "storage": {key: safe_int(storage.get(key)) for key in ("database_bytes", "journal_bytes", "scan_count", "compacted_scan_count", "mirror_count", "mirror_cache_bytes", "worktree_count")}, "database_recovery_status": recovery.get("status") if recovery.get("status") in ("healthy", "acknowledged", "degraded_recovered", "unknown") else "unknown", "mutation_authentication": payload.get("mutation_authentication") if payload.get("mutation_authentication") in ("operator_token", "perimeter_required") else "unknown", } except Exception: pass safe = { "registry": registry, "state": state, "git": git, "runtime": runtime, "volume": volume_facts, "appdata": appdata, "scan_access": scan_access, "health": health, "meta": meta, } print(json.dumps(safe, sort_keys=True, separators=(",", ":"))) repository_facts() ''' def _check(code: str, passed: bool | None, detail: str) -> dict[str, str]: status = "passed" if passed is True else "blocked" if passed is False else "unknown" return {"code": code, "status": status, "detail": detail} def _unavailable_report(code: str, detail: str) -> dict[str, Any]: check = _check(code, False, detail) check["gate"] = "legacy_compatibility" return { "apiVersion": "itworx.repopulse-legacy-adoption-preflight/v1", "repository": _REPOSITORY, "status": "unavailable", "legacyCompatibility": {"status": "unavailable", "checks": [check]}, "targetAcceptance": { "status": "not_assessed", "checks": [], "externalGates": ["Verify the exact authenticated NPM HTTPS route and access list separately."], }, "readOnly": True, "adoptionAuthorized": False, "checks": [check], "evidence": {}, } def _is_nonnegative_int(value: object) -> bool: return isinstance(value, int) and not isinstance(value, bool) and value >= 0 def _matches_string_set(value: object, expected: set[str]) -> bool: return ( isinstance(value, list) and all(isinstance(item, str) for item in value) and len(value) == len(expected) and set(value) == expected ) def _same_json(left: object, right: object) -> bool: try: return json.dumps(left, sort_keys=True, separators=(",", ":"), allow_nan=False) == json.dumps( right, sort_keys=True, separators=(",", ":"), allow_nan=False ) except (TypeError, ValueError): return False def evaluate_facts(facts: object) -> dict[str, Any]: """Evaluate a sanitized snapshot; never trusts caller-provided paths or commands.""" if not isinstance(facts, dict): facts = {} legacy_checks: list[dict[str, str]] = [] acceptance_checks: list[dict[str, str]] = [] def add_check( target: list[dict[str, str]], gate: str, code: str, passed: bool | None, detail: str ) -> None: result = _check(code, passed, detail) result["gate"] = gate target.append(result) registry = facts.get("registry") if isinstance(facts.get("registry"), dict) else {} registry_target = registry.get("target") if isinstance(registry.get("target"), dict) else {} expected_registry_target = { key: value for key, value in _EXPECTED_TARGET.items() if key != "environment_keys" } registry_matches = ( registry.get("present") is True and registry.get("alias_matches") is True and registry.get("entry_type") == "object" and _matches_string_set(registry.get("keys"), _EXPECTED_REGISTRY_KEYS) and _same_json(registry_target, expected_registry_target) and registry.get("environment_keys") == [] and _same_json(registry.get("validation"), _EXPECTED_VALIDATION) and registry.get("repository_id") == 64 and registry.get("webhook_secret_file_matches") is True ) add_check(legacy_checks, "legacy_compatibility", "legacy_registry", registry_matches, "The controller registry must match the fixed jens/repopulse target and runnerless validation policy.") state = facts.get("state") if isinstance(facts.get("state"), dict) else {} live_revision = state.get("live_revision") state_matches = ( state.get("schema_version") == 3 and state.get("repository") == _REPOSITORY and isinstance(state.get("revision"), str) and state.get("revision") == live_revision and isinstance(live_revision, str) and _SHA.fullmatch(live_revision) is not None and state.get("live_status") == "healthy" and state.get("status") == "success" and state.get("live_legacy_provenance") is False ) add_check(legacy_checks, "legacy_compatibility", "controller_state", state_matches, "The current controller state must be a healthy schema v3 success for the fixed repository.") git = facts.get("git") if isinstance(facts.get("git"), dict) else {} main_revision = git.get("main_revision") git_matches = ( git.get("bare_repo_present") is True and isinstance(main_revision, str) and _SHA.fullmatch(main_revision) is not None and git.get("live_revision") == live_revision and git.get("live_revision_resolves") is True and git.get("live_is_ancestor") is True and git.get("compose_present_on_main") is True ) add_check(legacy_checks, "legacy_compatibility", "gitea_ancestry", git_matches, "The recorded live SHA must resolve in Gitea, be an ancestor of the current main SHA, and main must contain the required Compose file.") runtime = facts.get("runtime") if isinstance(facts.get("runtime"), dict) else {} runtime_identity = _check( "runtime_identity_and_health", runtime.get("name_matches") is True and runtime.get("compose_project_matches") is True and isinstance(runtime.get("container_id_sha256"), str) and _SHA256.fullmatch(runtime["container_id_sha256"]) is not None and isinstance(runtime.get("image_sha256"), str) and _SHA256.fullmatch(runtime["image_sha256"]) is not None and isinstance(runtime.get("image"), str) and runtime.get("image").split("@", 1)[0].split(":", 1)[0] == "repopulse-repopulse" and runtime.get("state") == "running" and runtime.get("health") == "healthy", "The exact repopulse container must be running and healthy under the repopulse Compose project.", ) runtime_identity["gate"] = "legacy_compatibility" legacy_checks.append(runtime_identity) mounts = runtime.get("mounts") if isinstance(runtime.get("mounts"), list) else [] data_mount = next((mount for mount in mounts if isinstance(mount, dict) and mount.get("destination") == "/data"), {}) scan_mount = next((mount for mount in mounts if isinstance(mount, dict) and mount.get("destination") == "/scan"), {}) volume = facts.get("volume") if isinstance(facts.get("volume"), dict) else {} storage_matches = ( data_mount.get("type") == "volume" and data_mount.get("name") == _VOLUME and data_mount.get("read_write") is True and scan_mount.get("type") == "bind" and scan_mount.get("source") == _SOURCE_DIR.replace("/repopulse", "") and scan_mount.get("read_write") is False and volume.get("name_matches") is True and volume.get("mountpoint_matches") is True ) add_check(legacy_checks, "legacy_compatibility", "legacy_storage_topology", storage_matches, "The legacy data volume and read-only scan source must match the audited RepoPulse mounts.") safe_runtime = ( runtime.get("read_only_root") is True and runtime.get("cap_drop_all") is True and runtime.get("no_new_privileges") is True ) add_check(acceptance_checks, "target_acceptance", "runtime_hardening", safe_runtime, "Managed cutover requires a read-only root filesystem, all capabilities dropped, and no-new-privileges; current legacy settings are reported without failing compatibility.") host_ports = runtime.get("host_ports") if isinstance(runtime.get("host_ports"), list) else [] add_check(acceptance_checks, "target_acceptance", "host_exposure", len(host_ports) == 0, "Managed cutover must remove the legacy published host port; current bindings are included in the evidence.") add_check(acceptance_checks, "target_acceptance", "managed_network", isinstance(runtime.get("network_mode"), str) and runtime.get("network_mode") not in ("host", "unknown"), "Managed cutover requires an inspected private network instead of host networking.") appdata = facts.get("appdata") if isinstance(facts.get("appdata"), dict) else {} sources_safe = ( isinstance(appdata.get("sources_exists"), bool) and appdata.get("sources_is_safe_directory") is True ) managed_env_exists = appdata.get("managed_env_exists") managed_env_is_regular_file = appdata.get("managed_env_is_regular_file") managed_env_is_private = appdata.get("managed_env_is_private") managed_env_safe = ( isinstance(managed_env_exists, bool) and isinstance(managed_env_is_regular_file, bool) and isinstance(managed_env_is_private, bool) and ( ( managed_env_exists is False and managed_env_is_regular_file is False and managed_env_is_private is False ) or ( managed_env_exists is True and managed_env_is_regular_file is True and managed_env_is_private is True ) ) ) legacy_appdata_clean = ( appdata.get("root_is_safe_directory") is True and appdata.get("deploy_env_is_regular_file") is True and sources_safe and managed_env_safe and _is_nonnegative_int(appdata.get("unexpected_entry_count")) and appdata.get("unexpected_entry_count") == 0 ) add_check(legacy_checks, "legacy_compatibility", "legacy_appdata", legacy_appdata_clean, "The legacy appdata root must retain its regular deploy.env and contain no unexpected entries; optional sources and a private managed environment file are allowed.") data_target_clear = appdata.get("target_data_exists") is False and appdata.get("target_data_is_symlink") is False add_check(acceptance_checks, "target_acceptance", "managed_data_target", data_target_clear, "The managed appdata/data target must be absent before the reviewed storage migration; an existing target is reported as a collision.") add_check( acceptance_checks, "target_acceptance", "managed_environment_file", managed_env_exists is True and managed_env_is_regular_file is True and managed_env_is_private is True, "The fixed deploy.managed.env target file must be a private regular non-symlink file; no environment values are read or returned.", ) scan_access = facts.get("scan_access") if isinstance(facts.get("scan_access"), dict) else {} scan_access_ready = ( scan_access.get("runtime_uid") == 10001 and scan_access.get("runtime_gid") == 10001 and scan_access.get("root_readable") is True and scan_access.get("repopulse_source_readable") is True and scan_access.get("privatemuse_source_readable") is True ) add_check( acceptance_checks, "target_acceptance", "scan_access", scan_access_ready, "The fixed RepoPulse and PrivateMuse scan directories must be readable and traversable by runtime UID/GID 10001, with the permission policy retained after atomic source synchronization.", ) health = facts.get("health") if isinstance(facts.get("health"), dict) else {} meta = facts.get("meta") if isinstance(facts.get("meta"), dict) else {} health_matches = health.get("http_ok") is True and health.get("status") == "ok" and meta.get("http_ok") is True and meta.get("product_matches") is True add_check(legacy_checks, "legacy_compatibility", "product_api", health_matches, "The loopback product health and metadata endpoints must return healthy RepoPulse facts.") recovery_status = meta.get("database_recovery_status") add_check(legacy_checks, "legacy_compatibility", "database_health", recovery_status in ("healthy", "acknowledged"), "The live product must report a healthy or acknowledged database recovery state.") queue = meta.get("queue") if isinstance(meta.get("queue"), dict) else {} queue_facts_present = all(_is_nonnegative_int(queue.get(key)) for key in ("queued", "active", "completed", "failed")) add_check(legacy_checks, "legacy_compatibility", "queue_quiescence", queue_facts_present and queue.get("queued") == 0 and queue.get("active") == 0, "Queue and active-job counts must be available and both zero for this legacy compatibility snapshot.") storage = meta.get("storage") if isinstance(meta.get("storage"), dict) else {} db_facts_present = all(_is_nonnegative_int(storage.get(key)) for key in ("database_bytes", "journal_bytes", "scan_count", "compacted_scan_count")) add_check(legacy_checks, "legacy_compatibility", "database_baseline", db_facts_present and storage.get("database_bytes", 0) > 0 and storage.get("scan_count", -1) >= storage.get("compacted_scan_count", 0), "The live product must provide safe aggregate SQLite size and scan-history counts.") auth_mode = meta.get("mutation_authentication") operator_token_configured = auth_mode == "operator_token" add_check(acceptance_checks, "target_acceptance", "mutation_boundary", operator_token_configured, "Managed mutation access requires an operator token or a separately verified authenticated NPM perimeter; this preflight does not inspect NPM.") legacy_status = "passed" if legacy_checks and all(item["status"] == "passed" for item in legacy_checks) else "blocked" acceptance_status = "blocked" if any(item["status"] != "passed" for item in acceptance_checks) else "external_verification_required" status = "preflight_passed" if legacy_status == "passed" else "blocked" evidence = { "main_revision": main_revision if isinstance(main_revision, str) and _SHA.fullmatch(main_revision) else None, "live_revision": live_revision if isinstance(live_revision, str) and _SHA.fullmatch(live_revision) else None, "container_state": runtime.get("state") if runtime.get("state") in ("running", "exited", "created", "restarting", "paused", "dead", "unknown") else "unknown", "container_health": runtime.get("health") if runtime.get("health") in ("healthy", "unhealthy", "starting", "missing", "unknown") else "unknown", "container_id_sha256": runtime.get("container_id_sha256") if isinstance(runtime.get("container_id_sha256"), str) and _SHA256.fullmatch(runtime["container_id_sha256"]) else None, "container_image_sha256": runtime.get("image_sha256") if isinstance(runtime.get("image_sha256"), str) and _SHA256.fullmatch(runtime["image_sha256"]) else None, "host_ports": [item for item in host_ports if isinstance(item, dict) and isinstance(item.get("port"), int) and not isinstance(item.get("port"), bool) and isinstance(item.get("ip"), str)][:16], "database": { key: storage.get(key) for key in ("database_bytes", "journal_bytes", "scan_count", "compacted_scan_count", "mirror_count", "mirror_cache_bytes", "worktree_count") if _is_nonnegative_int(storage.get(key)) }, "queue": {key: queue.get(key) for key in ("queued", "active", "completed", "failed") if _is_nonnegative_int(queue.get(key))}, "appdata_target_exists": appdata.get("target_data_exists") if isinstance(appdata.get("target_data_exists"), bool) else None, "appdata_sources_exists": appdata.get("sources_exists") if isinstance(appdata.get("sources_exists"), bool) else None, "appdata_sources_safe_directory": appdata.get("sources_is_safe_directory") if isinstance(appdata.get("sources_is_safe_directory"), bool) else None, "managed_environment_file_ready": managed_env_exists is True and managed_env_is_regular_file is True and managed_env_is_private is True, "runtime_hardening": { key: runtime.get(key) if isinstance(runtime.get(key), bool) else None for key in ("read_only_root", "cap_drop_all", "no_new_privileges") }, "network_mode": runtime.get("network_mode") if isinstance(runtime.get("network_mode"), str) else "unknown", "operator_token_configured": operator_token_configured, "scan_access_uid_10001": scan_access_ready, } checks = legacy_checks + acceptance_checks return { "apiVersion": "itworx.repopulse-legacy-adoption-preflight/v1", "repository": _REPOSITORY, "status": status, "legacyCompatibility": {"status": legacy_status, "checks": legacy_checks}, "targetAcceptance": { "status": acceptance_status, "checks": acceptance_checks, "externalGates": ["Verify the exact authenticated NPM HTTPS route and access list separately."], }, "readOnly": True, "adoptionAuthorized": False, "checks": checks, "evidence": evidence, } class RepoPulseAdoptionPreflight: """Collect fixed live facts through the controller and evaluate read-only.""" def __init__(self, portainer: PortainerConnector | None = None): self.portainer = portainer or PortainerConnector() def run(self) -> dict[str, Any]: status = self.portainer.get_container_status(_CONTROLLER) if status.get("status") != "ok": return _unavailable_report("controller", "The fixed deployment controller is unavailable.") controller = status.get("container") if isinstance(status.get("container"), dict) else {} if controller.get("name") != _CONTROLLER or controller.get("state") != "running": return _unavailable_report("controller", "The fixed deployment controller is not running.") controller_id = str(controller.get("id") or "") config = self.portainer._config() if not controller_id or not config: return _unavailable_report("controller", "Portainer deployment control is unavailable.") endpoint = quote(str(config.endpoint_id), safe="") safe_container = quote(controller_id, safe="") try: created = self.portainer._request( "POST", f"/api/endpoints/{endpoint}/docker/containers/{safe_container}/exec", json={ "AttachStdout": True, "AttachStderr": False, "Tty": True, "Cmd": ["python3", "-c", _PREFLIGHT_SCRIPT], }, ).json() exec_id = str(created.get("Id") or "") if not exec_id: raise ValueError("exec unavailable") raw_response = self.portainer._request( "POST", f"/api/endpoints/{endpoint}/docker/exec/{quote(exec_id, safe='')}/start", json={"Detach": False, "Tty": True}, timeout=60.0, ) raw = (raw_response.text or "").strip() if not raw or len(raw) > 65536: raise ValueError("preflight output unavailable") facts = json.loads(raw) except (RuntimeError, ValueError, TypeError, json.JSONDecodeError): return _unavailable_report("collector", "The fixed read-only collector failed.") return evaluate_facts(facts) def main() -> int: """CLI entry point for an operator-run, read-only preflight report.""" result = RepoPulseAdoptionPreflight().run() print(json.dumps(result, sort_keys=True, separators=(",", ":"))) return 0 if result.get("status") == "preflight_passed" else 2 if __name__ == "__main__": sys.exit(main())