from __future__ import annotations import re 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 = re.compile(r"^jens/[a-z0-9][a-z0-9_.-]{0,96}$") _BRANCH = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]{0,119}$") _CONTROLLER_ROOT = "http://198.19.0.1:1299" _PROVISION_SCRIPT = r""" import json import os from pathlib import Path import re import secrets import stat import subprocess import sys repository, branch, controller_root = sys.argv[1:4] repository_pattern = re.compile(r"^jens/[a-z0-9][a-z0-9_.-]{0,96}$") branch_pattern = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]{0,119}$") if ( not repository_pattern.fullmatch(repository) or not branch_pattern.fullmatch(branch) or ".." in branch or "//" in branch or branch.endswith("/") or controller_root != "http://198.19.0.1:1299" ): raise SystemExit(40) projects_path = Path("/controller-hub-releases/controller-config/projects.json") secret_root = Path("/controller-hub-releases/controller-config/webhook-secrets") admin_token_path = Path( "/mnt/cache/appdata/gitea-deploy-runners/secrets/runner_guard_admin_token" ) secret_name = repository.replace("/", "__") secret_path = secret_root / secret_name endpoint = f"{controller_root}/v1/gitea/push/{repository}" 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) def safe_directory(path: Path) -> None: if path.is_symlink() or not path.is_dir(): raise SystemExit(20) info = path.stat() if os.name != "nt" and info.st_uid != os.geteuid(): raise SystemExit(21) if os.name != "nt" and stat.S_IMODE(info.st_mode) & 0o077: raise SystemExit(22) safe_regular(projects_path) safe_regular(admin_token_path, private=True) safe_directory(secret_root) try: original_projects_bytes = projects_path.read_bytes() projects = json.loads(original_projects_bytes) except Exception: raise SystemExit(23) project = projects.get(repository) if isinstance(projects, dict) else None if ( not isinstance(project, dict) or project.get("branch") != branch or project.get("exact_source") is not True or not isinstance(project.get("compose_files"), list) or not project.get("compose_files") or not isinstance(project.get("containers"), list) or not project.get("containers") ): raise SystemExit(24) triplet = { "gitea_repository_id", "gitea_webhook_secret_file", "validation", } present = triplet.intersection(project) if present and present != triplet: raise SystemExit(25) already_configured = present == triplet if already_configured: repository_id = project.get("gitea_repository_id") if ( isinstance(repository_id, bool) or not isinstance(repository_id, int) or repository_id <= 0 or project.get("gitea_webhook_secret_file") != secret_name or project.get("validation") != expected_validation ): raise SystemExit(26) safe_regular(secret_path, private=True) token = admin_token_path.read_text(encoding="utf-8").strip() if not 20 <= len(token) <= 512 or any(ch in token for ch in "\r\n"): raise SystemExit(27) health = subprocess.run( [ "docker", "exec", "Gitea", "curl", "--fail", "--silent", "--show-error", "--max-time", "5", f"{controller_root}/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) owner, name = repository.split("/", 1) repo = request("GET", f"/repos/{owner}/{name}") if ( not isinstance(repo, dict) or not isinstance(repo.get("id"), int) or isinstance(repo.get("id"), bool) or str(repo.get("full_name") or "").lower() != repository or repo.get("default_branch") != branch ): raise SystemExit(32) repository_id = int(repo["id"]) if already_configured and project.get("gitea_repository_id") != repository_id: raise SystemExit(33) hooks = request("GET", f"/repos/{owner}/{name}/hooks") if not isinstance(hooks, list): raise SystemExit(34) exact = [] semantic_other = [] for hook in hooks: if not isinstance(hook, dict): continue config = hook.get("config") url = str(config.get("url") or "") if isinstance(config, dict) else "" if url == endpoint: exact.append(hook) elif url.endswith(f"/v1/gitea/push/{repository}"): semantic_other.append(hook) if len(exact) > 1 or semantic_other: raise SystemExit(35) if exact and not already_configured: # Never adopt an unknown existing hook/secret relationship. raise SystemExit(36) created_secret = False created_hook_id = None project_replaced = False try: if already_configured: secret = secret_path.read_text(encoding="utf-8").strip() else: secret = secrets.token_hex(32) temporary_secret = secret_root / f".{secret_name}.{os.getpid()}.tmp" descriptor = os.open( temporary_secret, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600, ) try: with os.fdopen(descriptor, "w", encoding="utf-8") as handle: handle.write(secret + "\n") handle.flush() os.fsync(handle.fileno()) os.replace(temporary_secret, secret_path) created_secret = True finally: try: temporary_secret.unlink(missing_ok=True) except OSError: pass project["gitea_repository_id"] = repository_id project["gitea_webhook_secret_file"] = secret_name project["validation"] = expected_validation temporary_projects = projects_path.with_name( f".{projects_path.name}.runnerless-{os.getpid()}.tmp" ) payload = ( json.dumps(projects, sort_keys=True, separators=(",", ":")) + "\n" ) descriptor = os.open( temporary_projects, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600, ) try: with os.fdopen(descriptor, "w", encoding="utf-8") as handle: handle.write(payload) handle.flush() os.fsync(handle.fileno()) os.replace(temporary_projects, projects_path) project_replaced = True directory_fd = os.open(str(projects_path.parent), os.O_RDONLY) try: os.fsync(directory_fd) finally: os.close(directory_fd) finally: try: temporary_projects.unlink(missing_ok=True) except OSError: pass if not 32 <= len(secret) <= 256 or any( ord(ch) < 33 or ord(ch) > 126 for ch in secret ): raise SystemExit(37) hook_payload = { "active": True, "branch_filter": branch, "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(38) updated = request( "PATCH", f"/repos/{owner}/{name}/hooks/{hook_id}", hook_payload, ) else: created = request( "POST", f"/repos/{owner}/{name}/hooks", {"type": "gitea", **hook_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(39) created_hook_id = hook_id updated = created if not isinstance(updated, dict): updated = request( "GET", f"/repos/{owner}/{name}/hooks/{hook_id}", ) config_out = updated.get("config") 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 "") != branch or not isinstance(config_out, dict) or config_out.get("url") != endpoint or str(config_out.get("content_type") or "").lower() != "json" or updated.get("events") != ["push"] ): raise SystemExit(41) except BaseException: rollback_failed = False if created_hook_id is not None: try: request( "DELETE", f"/repos/{owner}/{name}/hooks/{created_hook_id}", ) except BaseException: rollback_failed = True if project_replaced: try: temporary_restore = projects_path.with_name( f".{projects_path.name}.restore-{os.getpid()}.tmp" ) descriptor = os.open( temporary_restore, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600, ) with os.fdopen(descriptor, "wb") as handle: handle.write(original_projects_bytes) handle.flush() os.fsync(handle.fileno()) os.replace(temporary_restore, projects_path) except BaseException: rollback_failed = True if created_secret: try: secret_path.unlink(missing_ok=True) except OSError: rollback_failed = True if rollback_failed: raise SystemExit(42) raise print( f"runnerless_target_ready repository={repository} branch={branch} " f"hook_id={hook_id} state={'existing' if already_configured else 'provisioned'}", flush=True, ) """ class RunnerlessTargetProvisioning: """Provision runnerless delivery only for an already registered controller target.""" def __init__(self, portainer: PortainerConnector | None = None): self.portainer = portainer or PortainerConnector() def run(self, *, repository: str, branch: str) -> dict[str, Any]: repository = str(repository or "").strip().lower() branch = str(branch or "").strip() if ( not _REPOSITORY.fullmatch(repository) or not _BRANCH.fullmatch(branch) or ".." in branch or "//" in branch or branch.endswith("/") ): return { "status": "invalid", "reason": "runnerless target identity is outside the bounded contract", } 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_id = quote(str(config.endpoint_id), safe="") safe_container = quote(container_id, safe="") command = [ "python3", "-c", _PROVISION_SCRIPT, repository, branch, _CONTROLLER_ROOT, ] try: created = self.portainer._request( "POST", f"/api/endpoints/{endpoint_id}/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": "runnerless target provisioning could not start", } safe_exec = quote(exec_id, safe="") self.portainer._request( "POST", f"/api/endpoints/{endpoint_id}/docker/exec/{safe_exec}/start", json={"Detach": True, "Tty": False}, ) deadline = time.monotonic() + 90 exit_code: int | None = None while time.monotonic() < deadline: inspected = self.portainer._request( "GET", f"/api/endpoints/{endpoint_id}/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": "runnerless provisioning timed out; inspect before retrying", } if exit_code == 0: return { "status": "ready", "repository": repository, "branch": branch, "endpoint": f"{_CONTROLLER_ROOT}/v1/gitea/push/{repository}", } reasons = { 20: "required controller/Gitea path is unavailable or unsafe", 21: "required controller/Gitea path ownership is invalid", 22: "required private path permissions are too broad", 23: "deployment projects registry is invalid", 24: "repository is not an already registered exact-source controller target", 25: "runnerless target has partial controller metadata", 26: "existing runnerless target metadata differs from the fixed contract", 27: "Gitea admin credential is unavailable or invalid", 28: "Gitea cannot reach the private deployment controller", 29: "private deployment controller identity is invalid", 30: "bounded Gitea API request failed", 31: "Gitea returned invalid JSON", 32: "Gitea repository identity or default branch differs from the target", 33: "Gitea repository id differs from existing controller metadata", 34: "Gitea webhook inventory is invalid", 35: "runnerless target has ambiguous webhook endpoints", 36: "an unbound runnerless webhook already exists and cannot be adopted", 37: "runnerless secret material failed validation", 38: "existing runnerless hook id is invalid", 39: "Gitea did not return a valid hook after creation", 41: "runnerless webhook verification failed after provisioning", 42: "runnerless provisioning compensation failed; operator inspection required", 40: "runnerless provisioning contract was altered", } return { "status": "validation_failed", "reason": reasons.get( exit_code, "runnerless target provisioning failed closed", ), }