325 lines
12 KiB
Python
325 lines
12 KiB
Python
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("/opt/gitea-deploy/projects.json")
|
|
secret_root = Path("/opt/gitea-deploy/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",
|
|
),
|
|
}
|