532 lines
17 KiB
Python
532 lines
17 KiB
Python
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",
|
|
),
|
|
}
|