736 lines
36 KiB
Python
736 lines
36 KiB
Python
"""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())
|