Initial curated Unraid-AppOps-Gateway source from 3bd6f6fee310125bfbb4d4650389a817f498724d
This commit is contained in:
commit
1dfbca08a1
528 files changed
+131054
No files matched your search
@@ -0,0 +1,531 @@
|
||||
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("/opt/gitea-deploy/projects.json")
|
||||
secret_root = Path("/opt/gitea-deploy/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",
|
||||
),
|
||||
}
|
||||
Reference in new issue
Block a user