from __future__ import annotations import time from typing import Any from urllib.parse import quote from ..connectors.portainer import PortainerConnector from ..core.redaction import redact_message _CONTROLLER = "gitea-deploy-control" _REPOSITORY = "jens/privatemuse" _ENDPOINT = "http://198.19.0.1:1299/v1/gitea/push/jens/privatemuse" # Fixed, one-target repair. The model/operator cannot supply repository, URL, # secret, token, hook id or command text. Both credentials stay inside the # existing root-owned deployment-control boundary. _REPAIR_SCRIPT = r""" import json import os from pathlib import Path import stat import subprocess import sys repository, endpoint = sys.argv[1:3] if repository != "jens/privatemuse" or endpoint != "http://198.19.0.1:1299/v1/gitea/push/jens/privatemuse": raise SystemExit(40) projects_path = Path("/controller-config/projects.json") secret_root = Path("/controller-config/webhook-secrets") secret_path = secret_root / "jens__privatemuse" admin_token_path = Path("/mnt/cache/appdata/gitea-deploy-runners/secrets/runner_guard_admin_token") 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, } def safe_regular(path: Path, *, private: bool = False) -> None: if path.is_symlink() or not path.is_file(): raise SystemExit(20) info = path.stat() if os.name != "nt" and info.st_uid != os.geteuid(): raise SystemExit(21) if private and os.name != "nt" and stat.S_IMODE(info.st_mode) & 0o077: raise SystemExit(22) safe_regular(projects_path) safe_regular(secret_path, private=True) safe_regular(admin_token_path, private=True) try: projects = json.loads(projects_path.read_text(encoding="utf-8")) except Exception: raise SystemExit(23) project = projects.get(repository) if isinstance(projects, dict) else None repository_id = project.get("gitea_repository_id") if isinstance(project, dict) else None if ( not isinstance(project, dict) or project.get("branch") != "main" or isinstance(repository_id, bool) or not isinstance(repository_id, int) or repository_id <= 0 or project.get("gitea_webhook_secret_file") != "jens__privatemuse" or project.get("validation") != expected_validation ): raise SystemExit(24) secret = secret_path.read_text(encoding="utf-8").strip() token = admin_token_path.read_text(encoding="utf-8").strip() if not 32 <= len(secret) <= 256 or any(ord(ch) < 33 or ord(ch) > 126 for ch in secret): raise SystemExit(25) if not 20 <= len(token) <= 512 or any(ch in token for ch in "\r\n"): raise SystemExit(26) # Prove the effective Gitea outbound boundary directly instead of inferring it # from app.ini syntax. This is stronger than a text parse: the Gitea container # must actually reach the fixed private controller endpoint and receive the # expected controller protocol identity. No credential is sent. health = subprocess.run( [ "docker", "exec", "Gitea", "curl", "--fail", "--silent", "--show-error", "--max-time", "5", "http://198.19.0.1:1299/health", ], text=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=10, check=False, ) if health.returncode != 0: raise SystemExit(28) try: health_payload = json.loads(health.stdout) except json.JSONDecodeError: raise SystemExit(29) if ( not isinstance(health_payload, dict) or health_payload.get("status") != "ok" or health_payload.get("protocolVersion") != "itworx-controller-request/v2" ): raise SystemExit(29) def request(method: str, path: str, payload=None): cmd = [ "docker", "exec", "-i", "Gitea", "curl", "--fail", "--silent", "--show-error", "--max-time", "15", "--request", method, "--header", f"Authorization: token {token}", "--header", "Accept: application/json", ] raw = None if payload is not None: cmd.extend(["--header", "Content-Type: application/json", "--data-binary", "@-"]) raw = json.dumps(payload, separators=(",", ":")) cmd.append(f"http://127.0.0.1:3000/api/v1{path}") result = subprocess.run( cmd, input=raw, text=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=20, check=False, ) if result.returncode != 0: raise SystemExit(30) if not result.stdout.strip(): return None try: return json.loads(result.stdout) except json.JSONDecodeError: raise SystemExit(31) repo = request("GET", "/repos/Jens/PrivateMuse") if ( not isinstance(repo, dict) or repo.get("id") != repository_id or str(repo.get("full_name") or "").lower() != repository or repo.get("default_branch") != "main" ): raise SystemExit(32) hooks = request("GET", "/repos/Jens/PrivateMuse/hooks") if not isinstance(hooks, list): raise SystemExit(33) exact = [] semantic_other = [] for hook in hooks: if not isinstance(hook, dict): continue hook_config = hook.get("config") url = str(hook_config.get("url") or "") if isinstance(hook_config, dict) else "" if url == endpoint: exact.append(hook) elif url.endswith("/v1/gitea/push/jens/privatemuse"): semantic_other.append(hook) if len(exact) > 1 or semantic_other: raise SystemExit(34) payload = { "active": True, "branch_filter": "main", "events": ["push"], "config": { "url": endpoint, "content_type": "json", "secret": secret, "insecure_ssl": "0", }, } if exact: hook_id = exact[0].get("id") if isinstance(hook_id, bool) or not isinstance(hook_id, int) or hook_id <= 0: raise SystemExit(35) updated = request("PATCH", f"/repos/Jens/PrivateMuse/hooks/{hook_id}", payload) else: created = request( "POST", "/repos/Jens/PrivateMuse/hooks", {"type": "gitea", **payload}, ) hook_id = created.get("id") if isinstance(created, dict) else None if isinstance(hook_id, bool) or not isinstance(hook_id, int) or hook_id <= 0: raise SystemExit(36) updated = created if not isinstance(updated, dict): updated = request("GET", f"/repos/Jens/PrivateMuse/hooks/{hook_id}") config_out = updated.get("config") if isinstance(updated, dict) else None events_out = updated.get("events") if isinstance(updated, dict) else None if ( not isinstance(updated, dict) or updated.get("id") != hook_id or updated.get("active") is not True or str(updated.get("branch_filter") or "") != "main" or not isinstance(config_out, dict) or config_out.get("url") != endpoint or str(config_out.get("content_type") or "").lower() != "json" or events_out != ["push"] ): raise SystemExit(37) # Never print the hook payload, token or secret. Hook id is non-sensitive # evidence that the bounded Gitea object exists. print(f"privatemuse_runnerless_webhook_ready hook_id={hook_id}", flush=True) """ class PrivateMuseWebhookRepair: """Repair only the fixed PrivateMuse runnerless repository webhook.""" 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) controller = ( dict(status.get("container") or {}) if status.get("status") == "ok" else {} ) container_id = str(controller.get("id") or "") config = self.portainer._config() if ( controller.get("name") != _CONTROLLER or controller.get("state") != "running" or not container_id or not config ): return { "status": "controller_unavailable", "reason": "deployment controller is unavailable", } endpoint = quote(str(config.endpoint_id), safe="") safe_container = quote(container_id, safe="") command = ["python3", "-c", _REPAIR_SCRIPT, _REPOSITORY, _ENDPOINT] try: created = self.portainer._request( "POST", f"/api/endpoints/{endpoint}/docker/containers/{safe_container}/exec", json={ "AttachStdout": False, "AttachStderr": False, "Tty": False, "Cmd": command, }, ).json() exec_id = str(created.get("Id") or "") if not exec_id: return { "status": "error", "reason": "PrivateMuse webhook repair could not start", } safe_exec = quote(exec_id, safe="") self.portainer._request( "POST", f"/api/endpoints/{endpoint}/docker/exec/{safe_exec}/start", json={"Detach": True, "Tty": False}, ) deadline = time.monotonic() + 60 exit_code: int | None = None while time.monotonic() < deadline: inspected = self.portainer._request( "GET", f"/api/endpoints/{endpoint}/docker/exec/{safe_exec}/json", ).json() if not bool(inspected.get("Running")): raw_exit = inspected.get("ExitCode") exit_code = int(raw_exit) if raw_exit is not None else -1 break time.sleep(0.25) except (RuntimeError, ValueError, TypeError) as exc: return {"status": "error", "reason": redact_message(str(exc))} if exit_code is None: return { "status": "outcome_unknown", "reason": "PrivateMuse webhook repair timed out; inspect before retrying", } if exit_code == 0: return { "status": "repaired", "repository": _REPOSITORY, "endpoint": _ENDPOINT, "branch": "main", "event": "push", } reasons = { 20: "required controller/Gitea file is unavailable or unsafe", 21: "required repair file is not owned by the controller user", 22: "required credential file permissions are too broad", 23: "deployment projects registry is invalid", 24: "PrivateMuse runnerless target metadata differs from the fixed contract", 25: "PrivateMuse controller webhook secret is invalid", 26: "Gitea admin credential is unavailable or invalid", 27: "reserved legacy Gitea config validation failure", 28: "Gitea cannot reach the fixed private deployment controller endpoint", 29: "Private deployment controller health identity is invalid or unavailable", 30: "bounded Gitea API request failed", 31: "Gitea returned an invalid JSON response", 32: "Gitea repository identity or default branch differs from the fixed contract", 33: "Gitea webhook inventory is invalid", 34: "PrivateMuse has ambiguous or stale runnerless webhook endpoints", 35: "existing PrivateMuse runnerless hook id is invalid", 36: "Gitea did not return a valid hook after creation", 37: "PrivateMuse runnerless hook verification failed after repair", 40: "fixed PrivateMuse webhook repair contract was altered", } return { "status": "validation_failed", "reason": reasons.get( exit_code, "PrivateMuse runnerless webhook repair failed closed", ), }