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,815 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
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/portfolio"
|
||||
_LEGACY_REVISION = "e59bfb3484531883e5486ee9e10b663ed0f6764f"
|
||||
_RECOVERY_LIVE_REVISION = "52c6f2fc74a1e39ad288f1c5e987a9cd5b072d3e"
|
||||
_RECOVERY_FAILED_REVISION = "b7955d90b63860ffff8e0bd84be123aaba632043"
|
||||
_RECOVERY_FAILURE_MESSAGE = "Existing production containers require a valid healthy live deployment state"
|
||||
|
||||
_PROJECT: dict[str, object] = {
|
||||
"branch": "main",
|
||||
"slug": "portfolio",
|
||||
"source_dir": "/mnt/cache/appdata/gitea-deploy-runners/sources/portfolio",
|
||||
"exact_source": True,
|
||||
"env_source": "/srv/example/appdata/Portfolio/.env",
|
||||
"compose_files": ["docker-compose.yml"],
|
||||
"compose_project": "portfolio",
|
||||
"containers": ["Portfolio", "Portfolio-api"],
|
||||
"verify_oci_revision": False,
|
||||
"health_timeout": 180,
|
||||
"environment": {
|
||||
"GIT_COMMIT": "{revision}",
|
||||
"BUILD_ID": "build-{short_revision}",
|
||||
},
|
||||
}
|
||||
|
||||
_LEGACY_SINGLE_PROJECT: dict[str, object] = {
|
||||
"branch": "main",
|
||||
"slug": "portfolio",
|
||||
"source_dir": "/mnt/cache/appdata/gitea-deploy-runners/sources/portfolio",
|
||||
"exact_source": True,
|
||||
"compose_files": ["docker-compose.yml"],
|
||||
"compose_project": "portfolio",
|
||||
"containers": ["portfolio"],
|
||||
"verify_oci_revision": False,
|
||||
"health_timeout": 180,
|
||||
}
|
||||
|
||||
_INSPECT_SCRIPT = r'''
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
repository = sys.argv[1]
|
||||
path = Path("/opt/gitea-deploy/projects.json")
|
||||
try:
|
||||
projects = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
raise SystemExit(20)
|
||||
current = projects.get(repository)
|
||||
if current is None:
|
||||
print(json.dumps({"present": False}, sort_keys=True, separators=(",", ":")))
|
||||
raise SystemExit(0)
|
||||
if not isinstance(current, dict):
|
||||
print(json.dumps({"present": True, "type": type(current).__name__}, sort_keys=True, separators=(",", ":")))
|
||||
raise SystemExit(0)
|
||||
|
||||
safe = {"present": True}
|
||||
for key in (
|
||||
"branch",
|
||||
"slug",
|
||||
"source_dir",
|
||||
"exact_source",
|
||||
"env_source",
|
||||
"compose_files",
|
||||
"compose_project",
|
||||
"containers",
|
||||
"verify_oci_revision",
|
||||
"health_timeout",
|
||||
"deploy_script",
|
||||
"archive_deploy_script",
|
||||
"manual",
|
||||
):
|
||||
if key in current:
|
||||
safe[key] = current[key]
|
||||
environment = current.get("environment")
|
||||
if isinstance(environment, dict):
|
||||
safe["environment_keys"] = sorted(str(key) for key in environment.keys())
|
||||
live_probe = current.get("live_probe")
|
||||
if isinstance(live_probe, dict):
|
||||
safe["live_probe"] = {
|
||||
key: live_probe[key]
|
||||
for key in ("type", "container", "port", "path")
|
||||
if key in live_probe
|
||||
}
|
||||
|
||||
state_path = Path("/mnt/cache/appdata/gitea-deploy-runners/state/portfolio.json")
|
||||
if state_path.is_file() and not state_path.is_symlink():
|
||||
try:
|
||||
state = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
state = None
|
||||
if isinstance(state, dict):
|
||||
safe_state = {
|
||||
key: state[key]
|
||||
for key in (
|
||||
"schema_version",
|
||||
"repository",
|
||||
"revision",
|
||||
"live_revision",
|
||||
"live_status",
|
||||
"live_legacy_provenance",
|
||||
"status",
|
||||
"updated_at_epoch",
|
||||
)
|
||||
if key in state
|
||||
}
|
||||
last_attempt = state.get("last_attempt")
|
||||
if isinstance(last_attempt, dict):
|
||||
safe_attempt = {
|
||||
key: last_attempt[key]
|
||||
for key in ("revision", "status", "updated_at_epoch")
|
||||
if key in last_attempt
|
||||
}
|
||||
message = last_attempt.get("message")
|
||||
if isinstance(message, str):
|
||||
safe_attempt["message"] = message.replace("\r", " ").replace("\n", " ")[:768]
|
||||
safe_state["last_attempt"] = safe_attempt
|
||||
live_artifacts = state.get("live_artifacts")
|
||||
if isinstance(live_artifacts, dict):
|
||||
containers = live_artifacts.get("containers")
|
||||
if isinstance(containers, dict):
|
||||
safe_state["live_artifact_containers"] = sorted(str(key) for key in containers.keys())
|
||||
safe["state"] = safe_state
|
||||
print(json.dumps(safe, sort_keys=True, separators=(",", ":")))
|
||||
'''
|
||||
|
||||
_ENROLL_SCRIPT = r'''
|
||||
import fcntl
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
registry_path = Path("/opt/gitea-deploy/projects.json")
|
||||
host_registry_path = Path("/mnt/cache/appdata/gitea-deploy-runners/controller/projects.json")
|
||||
state_root = Path("/mnt/cache/appdata/gitea-deploy-runners/state")
|
||||
state_path = state_root / "portfolio.json"
|
||||
project_lock_path = state_root / "locks" / "portfolio.lock"
|
||||
lock_path = Path("/mnt/cache/appdata/gitea-deploy-runners/deploy.lock")
|
||||
(
|
||||
repository,
|
||||
legacy_revision,
|
||||
recovery_live_revision,
|
||||
recovery_failed_revision,
|
||||
recovery_failure_message,
|
||||
raw_project,
|
||||
raw_legacy_project,
|
||||
) = sys.argv[1:8]
|
||||
project = json.loads(raw_project)
|
||||
legacy_project = json.loads(raw_legacy_project)
|
||||
|
||||
expected_project = {
|
||||
"branch": "main",
|
||||
"slug": "portfolio",
|
||||
"source_dir": "/mnt/cache/appdata/gitea-deploy-runners/sources/portfolio",
|
||||
"exact_source": True,
|
||||
"env_source": "/srv/example/appdata/Portfolio/.env",
|
||||
"compose_files": ["docker-compose.yml"],
|
||||
"compose_project": "portfolio",
|
||||
"containers": ["Portfolio", "Portfolio-api"],
|
||||
"verify_oci_revision": False,
|
||||
"health_timeout": 180,
|
||||
"environment": {
|
||||
"GIT_COMMIT": "{revision}",
|
||||
"BUILD_ID": "build-{short_revision}",
|
||||
},
|
||||
}
|
||||
expected_legacy_project = {
|
||||
"branch": "main",
|
||||
"slug": "portfolio",
|
||||
"source_dir": "/mnt/cache/appdata/gitea-deploy-runners/sources/portfolio",
|
||||
"exact_source": True,
|
||||
"compose_files": ["docker-compose.yml"],
|
||||
"compose_project": "portfolio",
|
||||
"containers": ["portfolio"],
|
||||
"verify_oci_revision": False,
|
||||
"health_timeout": 180,
|
||||
}
|
||||
expected_legacy_revision = "e59bfb3484531883e5486ee9e10b663ed0f6764f"
|
||||
expected_recovery_live_revision = "52c6f2fc74a1e39ad288f1c5e987a9cd5b072d3e"
|
||||
expected_recovery_failed_revision = "b7955d90b63860ffff8e0bd84be123aaba632043"
|
||||
expected_recovery_failure_message = "Existing production containers require a valid healthy live deployment state"
|
||||
|
||||
if (
|
||||
repository != "jens/portfolio"
|
||||
or legacy_revision != expected_legacy_revision
|
||||
or recovery_live_revision != expected_recovery_live_revision
|
||||
or recovery_failed_revision != expected_recovery_failed_revision
|
||||
or recovery_failure_message != expected_recovery_failure_message
|
||||
or project != expected_project
|
||||
or legacy_project != expected_legacy_project
|
||||
):
|
||||
raise SystemExit(40)
|
||||
if registry_path.is_symlink() or not registry_path.is_file():
|
||||
raise SystemExit(20)
|
||||
registry_stat = registry_path.stat()
|
||||
if not stat.S_ISREG(registry_stat.st_mode):
|
||||
raise SystemExit(21)
|
||||
try:
|
||||
if (
|
||||
host_registry_path.is_symlink()
|
||||
or not host_registry_path.is_file()
|
||||
or not os.path.samefile(registry_path, host_registry_path)
|
||||
):
|
||||
raise SystemExit(36)
|
||||
except OSError:
|
||||
raise SystemExit(36)
|
||||
|
||||
env_source = Path(str(project["env_source"]))
|
||||
if env_source.is_symlink() or not env_source.is_file():
|
||||
raise SystemExit(41)
|
||||
|
||||
owner, name = repository.split("/", 1)
|
||||
bare_repo = Path("/gitea-repositories") / owner / f"{name}.git"
|
||||
if not bare_repo.is_dir():
|
||||
raise SystemExit(24)
|
||||
|
||||
def git_run(*args):
|
||||
return subprocess.run(
|
||||
["git", f"--git-dir={bare_repo}", *args],
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=10,
|
||||
check=False,
|
||||
)
|
||||
|
||||
for revision in (legacy_revision, recovery_live_revision, recovery_failed_revision):
|
||||
resolved = git_run("rev-parse", f"{revision}^{{commit}}")
|
||||
if resolved.returncode != 0 or resolved.stdout.strip() != revision:
|
||||
raise SystemExit(25)
|
||||
main = git_run("rev-parse", "refs/heads/main")
|
||||
main_revision = main.stdout.strip()
|
||||
if main.returncode != 0 or len(main_revision) != 40:
|
||||
raise SystemExit(26)
|
||||
if git_run("merge-base", "--is-ancestor", legacy_revision, main_revision).returncode != 0:
|
||||
raise SystemExit(27)
|
||||
if git_run("merge-base", "--is-ancestor", recovery_live_revision, main_revision).returncode != 0:
|
||||
raise SystemExit(27)
|
||||
if main_revision != recovery_failed_revision:
|
||||
raise SystemExit(58)
|
||||
for compose_file in project["compose_files"]:
|
||||
if git_run("cat-file", "-e", f"{main_revision}:{compose_file}").returncode != 0:
|
||||
raise SystemExit(28)
|
||||
|
||||
try:
|
||||
if state_root.is_symlink() or not state_root.is_dir():
|
||||
raise SystemExit(50)
|
||||
project_lock_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if project_lock_path.parent.is_symlink() or project_lock_path.is_symlink():
|
||||
raise SystemExit(29)
|
||||
project_lock_handle = project_lock_path.open("a+", encoding="utf-8")
|
||||
lock_handle = lock_path.open("a+", encoding="utf-8")
|
||||
except Exception:
|
||||
raise SystemExit(29)
|
||||
|
||||
with project_lock_handle as project_lock, lock_handle as lock:
|
||||
try:
|
||||
fcntl.flock(project_lock, fcntl.LOCK_EX)
|
||||
fcntl.flock(lock, fcntl.LOCK_EX)
|
||||
except Exception:
|
||||
raise SystemExit(30)
|
||||
|
||||
try:
|
||||
projects = json.loads(host_registry_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
raise SystemExit(31)
|
||||
if not isinstance(projects, dict):
|
||||
raise SystemExit(22)
|
||||
|
||||
current = projects.get(repository)
|
||||
|
||||
def legacy_script_target_matches(value):
|
||||
if not isinstance(value, dict):
|
||||
return False
|
||||
expected_keys = {
|
||||
"branch",
|
||||
"slug",
|
||||
"source_dir",
|
||||
"env_source",
|
||||
"compose_files",
|
||||
"compose_project",
|
||||
"containers",
|
||||
"deploy_script",
|
||||
"environment",
|
||||
}
|
||||
if set(value) != expected_keys:
|
||||
return False
|
||||
environment = value.get("environment")
|
||||
return (
|
||||
value.get("branch") == "main"
|
||||
and value.get("slug") == "portfolio"
|
||||
and value.get("source_dir") == "/srv/example/appdata/Portfolio/source"
|
||||
and value.get("env_source") == "/srv/example/appdata/Portfolio/.env"
|
||||
and value.get("compose_files") == ["docker-compose.yml"]
|
||||
and value.get("compose_project") == "portfolio"
|
||||
and value.get("containers") == ["Portfolio", "Portfolio-api"]
|
||||
and value.get("deploy_script") == "scripts/deploy-unraid.sh"
|
||||
and isinstance(environment, dict)
|
||||
and set(environment) == {"PORTFOLIO_APP_DIR"}
|
||||
and environment.get("PORTFOLIO_APP_DIR")
|
||||
in {"/srv/example/appdata/Portfolio", "/srv/example/appdata/portfolio"}
|
||||
)
|
||||
|
||||
if current not in (None, project, legacy_project) and not legacy_script_target_matches(current):
|
||||
raise SystemExit(23)
|
||||
|
||||
state_requires_reconcile = False
|
||||
runtime_state_requires_reconcile = False
|
||||
existing_state = None
|
||||
if state_path.exists():
|
||||
if state_path.is_symlink() or not state_path.is_file():
|
||||
raise SystemExit(42)
|
||||
try:
|
||||
existing_state = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
raise SystemExit(42)
|
||||
if not isinstance(existing_state, dict):
|
||||
raise SystemExit(42)
|
||||
if existing_state.get("schema_version") == 3:
|
||||
if existing_state.get("repository") != repository:
|
||||
raise SystemExit(43)
|
||||
if (
|
||||
current == project
|
||||
and existing_state.get("live_status") == "healthy"
|
||||
and isinstance(existing_state.get("live_revision"), str)
|
||||
and len(existing_state.get("live_revision")) == 40
|
||||
and (
|
||||
existing_state.get("live_legacy_provenance") is True
|
||||
or isinstance(existing_state.get("live_receipt"), dict)
|
||||
)
|
||||
):
|
||||
print("already-enrolled", flush=True)
|
||||
raise SystemExit(0)
|
||||
|
||||
last_attempt = existing_state.get("last_attempt")
|
||||
live_artifacts = existing_state.get("live_artifacts")
|
||||
live_receipt = existing_state.get("live_receipt")
|
||||
artifact_containers = (
|
||||
live_artifacts.get("containers")
|
||||
if isinstance(live_artifacts, dict)
|
||||
else None
|
||||
)
|
||||
if (
|
||||
current == project
|
||||
and existing_state.get("revision") == recovery_live_revision
|
||||
and existing_state.get("live_revision") == recovery_live_revision
|
||||
and existing_state.get("live_status") == "unknown"
|
||||
and existing_state.get("live_legacy_provenance") is False
|
||||
and existing_state.get("status") == "failed"
|
||||
and isinstance(last_attempt, dict)
|
||||
and last_attempt.get("revision") == recovery_failed_revision
|
||||
and last_attempt.get("status") == "failed"
|
||||
and last_attempt.get("message") == recovery_failure_message
|
||||
and isinstance(live_receipt, dict)
|
||||
and bool(live_receipt)
|
||||
and isinstance(artifact_containers, dict)
|
||||
and set(artifact_containers) == set(project["containers"])
|
||||
):
|
||||
runtime_state_requires_reconcile = True
|
||||
elif legacy_script_target_matches(current):
|
||||
state_requires_reconcile = True
|
||||
else:
|
||||
raise SystemExit(43)
|
||||
elif not (
|
||||
existing_state.get("repository") == repository
|
||||
and existing_state.get("status") == "success"
|
||||
and existing_state.get("revision") == legacy_revision
|
||||
and "schema_version" not in existing_state
|
||||
and "live_receipt" not in existing_state
|
||||
and "live_legacy_provenance" not in existing_state
|
||||
):
|
||||
if legacy_script_target_matches(current):
|
||||
state_requires_reconcile = True
|
||||
else:
|
||||
raise SystemExit(43)
|
||||
|
||||
expected_names = set(project["containers"])
|
||||
observed_result = subprocess.run(
|
||||
[
|
||||
"docker", "ps", "--all", "--filter",
|
||||
f"label=com.docker.compose.project={project['compose_project']}",
|
||||
"--format", "{{.Names}}",
|
||||
],
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=15,
|
||||
check=False,
|
||||
)
|
||||
if observed_result.returncode != 0:
|
||||
raise SystemExit(44)
|
||||
observed_names = {line.strip() for line in observed_result.stdout.splitlines() if line.strip()}
|
||||
if observed_names != expected_names:
|
||||
raise SystemExit(45)
|
||||
|
||||
for required in sorted(expected_names):
|
||||
inspected = subprocess.run(
|
||||
["docker", "inspect", required],
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=10,
|
||||
check=False,
|
||||
)
|
||||
if inspected.returncode != 0:
|
||||
raise SystemExit(46)
|
||||
try:
|
||||
container = json.loads(inspected.stdout)[0]
|
||||
except Exception:
|
||||
raise SystemExit(46)
|
||||
runtime = dict(container.get("State") or {})
|
||||
health = str(dict(runtime.get("Health") or {}).get("Status") or "none")
|
||||
config = dict(container.get("Config") or {})
|
||||
labels = dict(config.get("Labels") or {})
|
||||
revision_label = str(labels.get("tech.itworx.commit") or "")
|
||||
build_label = str(labels.get("tech.itworx.build-id") or "")
|
||||
if (
|
||||
not runtime.get("Running")
|
||||
or health in {"starting", "unhealthy"}
|
||||
or labels.get("com.docker.compose.project") != "portfolio"
|
||||
):
|
||||
raise SystemExit(46)
|
||||
if legacy_script_target_matches(current) and revision_label != legacy_revision:
|
||||
raise SystemExit(47)
|
||||
if runtime_state_requires_reconcile:
|
||||
if (
|
||||
revision_label != recovery_live_revision
|
||||
or build_label != f"build-{recovery_live_revision[:12]}"
|
||||
):
|
||||
raise SystemExit(55)
|
||||
artifacts = dict(dict(existing_state.get("live_artifacts") or {}).get("containers") or {})
|
||||
artifact = artifacts.get(required)
|
||||
if not isinstance(artifact, dict):
|
||||
raise SystemExit(55)
|
||||
if (
|
||||
str(artifact.get("image_id") or "") != str(container.get("Image") or "")
|
||||
or str(artifact.get("container_id") or "") != str(container.get("Id") or "")
|
||||
):
|
||||
raise SystemExit(55)
|
||||
|
||||
if runtime_state_requires_reconcile:
|
||||
if state_root.is_symlink() or not state_root.is_dir():
|
||||
raise SystemExit(50)
|
||||
now = int(time.time())
|
||||
state_backup = state_path.with_name(
|
||||
f"portfolio.json.runtime-reconcile-backup-{now}-{os.getpid()}"
|
||||
)
|
||||
temporary_state = state_path.with_name(
|
||||
f".{state_path.name}.{os.getpid()}.runtime-reconcile.tmp"
|
||||
)
|
||||
reconciled_state = dict(existing_state)
|
||||
reconciled_state["live_status"] = "healthy"
|
||||
reconciled_state["status"] = "success"
|
||||
reconciled_state["message"] = (
|
||||
"Verified Portfolio runtime matches recorded live artifacts; controller state reconciled"
|
||||
)
|
||||
reconciled_state["updated_at_epoch"] = now
|
||||
reconciled_state["last_attempt"] = {
|
||||
"revision": recovery_live_revision,
|
||||
"status": "success",
|
||||
"message": "Verified live Portfolio runtime state reconciliation",
|
||||
"updated_at_epoch": now,
|
||||
"metadata": {"reconciled_failed_revision": recovery_failed_revision},
|
||||
}
|
||||
try:
|
||||
mode = stat.S_IMODE(state_path.stat().st_mode)
|
||||
shutil.copy2(state_path, state_backup)
|
||||
with temporary_state.open("x", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(reconciled_state, indent=2, sort_keys=True) + "\n")
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.chmod(temporary_state, mode)
|
||||
os.replace(temporary_state, state_path)
|
||||
directory_fd = os.open(str(state_root), os.O_RDONLY)
|
||||
try:
|
||||
os.fsync(directory_fd)
|
||||
finally:
|
||||
os.close(directory_fd)
|
||||
except Exception:
|
||||
try:
|
||||
temporary_state.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
raise SystemExit(56)
|
||||
try:
|
||||
verify = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
raise SystemExit(57)
|
||||
if not (
|
||||
isinstance(verify, dict)
|
||||
and verify.get("live_revision") == recovery_live_revision
|
||||
and verify.get("live_status") == "healthy"
|
||||
and verify.get("live_legacy_provenance") is False
|
||||
and isinstance(verify.get("live_receipt"), dict)
|
||||
and bool(verify.get("live_receipt"))
|
||||
and verify.get("status") == "success"
|
||||
):
|
||||
raise SystemExit(57)
|
||||
print("runtime-state-reconciled", flush=True)
|
||||
raise SystemExit(0)
|
||||
|
||||
if state_requires_reconcile:
|
||||
if state_root.is_symlink() or not state_root.is_dir():
|
||||
raise SystemExit(50)
|
||||
state_backup = state_path.with_name(
|
||||
f"portfolio.json.verified-live-backup-{int(time.time())}-{os.getpid()}"
|
||||
)
|
||||
temporary_state = state_path.with_name(f".{state_path.name}.{os.getpid()}.reconcile.tmp")
|
||||
try:
|
||||
shutil.copy2(state_path, state_backup)
|
||||
reconciled_state = {
|
||||
"repository": repository,
|
||||
"revision": legacy_revision,
|
||||
"status": "success",
|
||||
}
|
||||
with temporary_state.open("x", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(reconciled_state, indent=2, sort_keys=True) + "\n")
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.chmod(temporary_state, 0o600)
|
||||
os.replace(temporary_state, state_path)
|
||||
directory_fd = os.open(str(state_root), os.O_RDONLY)
|
||||
try:
|
||||
os.fsync(directory_fd)
|
||||
finally:
|
||||
os.close(directory_fd)
|
||||
existing_state = reconciled_state
|
||||
except Exception:
|
||||
try:
|
||||
temporary_state.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
raise SystemExit(54)
|
||||
|
||||
if current != project:
|
||||
backup = host_registry_path.with_name(
|
||||
f"projects.json.portfolio-backup-{int(time.time())}-{os.getpid()}"
|
||||
)
|
||||
try:
|
||||
shutil.copy2(host_registry_path, backup)
|
||||
except Exception:
|
||||
raise SystemExit(32)
|
||||
updated = dict(projects)
|
||||
updated[repository] = project
|
||||
temporary = host_registry_path.with_name(f".{host_registry_path.name}.{os.getpid()}.tmp")
|
||||
payload = json.dumps(updated, indent=2, sort_keys=True) + "\n"
|
||||
try:
|
||||
with temporary.open("w", encoding="utf-8") as handle:
|
||||
handle.write(payload)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.chmod(temporary, stat.S_IMODE(registry_stat.st_mode))
|
||||
os.replace(temporary, host_registry_path)
|
||||
directory_fd = os.open(str(host_registry_path.parent), os.O_RDONLY)
|
||||
try:
|
||||
os.fsync(directory_fd)
|
||||
finally:
|
||||
os.close(directory_fd)
|
||||
except Exception:
|
||||
try:
|
||||
temporary.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
raise SystemExit(34)
|
||||
|
||||
if existing_state is None:
|
||||
state_root.mkdir(parents=True, exist_ok=True)
|
||||
if state_root.is_symlink() or not state_root.is_dir():
|
||||
raise SystemExit(50)
|
||||
temporary_state = state_path.with_name(f".{state_path.name}.{os.getpid()}.tmp")
|
||||
payload = {"repository": repository, "revision": legacy_revision, "status": "success"}
|
||||
try:
|
||||
with temporary_state.open("x", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(payload, indent=2, sort_keys=True) + "\n")
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.chmod(temporary_state, 0o600)
|
||||
os.replace(temporary_state, state_path)
|
||||
directory_fd = os.open(str(state_root), os.O_RDONLY)
|
||||
try:
|
||||
os.fsync(directory_fd)
|
||||
finally:
|
||||
os.close(directory_fd)
|
||||
except Exception:
|
||||
try:
|
||||
temporary_state.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
raise SystemExit(51)
|
||||
|
||||
try:
|
||||
controller_path = Path("/opt/gitea-deploy/deploy.py")
|
||||
if controller_path.is_symlink() or not controller_path.is_file():
|
||||
raise RuntimeError("controller module is unavailable")
|
||||
controller_spec = importlib.util.spec_from_file_location(
|
||||
"appops_locked_portfolio_bootstrap", controller_path
|
||||
)
|
||||
if controller_spec is None or controller_spec.loader is None:
|
||||
raise RuntimeError("controller module cannot be loaded")
|
||||
controller = importlib.util.module_from_spec(controller_spec)
|
||||
controller_spec.loader.exec_module(controller)
|
||||
controller.bootstrap_legacy_state(repository, project, legacy_revision)
|
||||
except Exception:
|
||||
raise SystemExit(52)
|
||||
|
||||
try:
|
||||
final_state = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
raise SystemExit(53)
|
||||
if not (
|
||||
isinstance(final_state, dict)
|
||||
and final_state.get("schema_version") == 3
|
||||
and final_state.get("repository") == repository
|
||||
and final_state.get("live_revision") == legacy_revision
|
||||
and final_state.get("live_status") == "healthy"
|
||||
and final_state.get("live_legacy_provenance") is True
|
||||
):
|
||||
raise SystemExit(53)
|
||||
|
||||
print("enrolled", flush=True)
|
||||
'''
|
||||
|
||||
|
||||
class PortfolioDeploymentEnrollment:
|
||||
"""Enroll or reconcile the fixed paired Portfolio exact-source controller target."""
|
||||
|
||||
def __init__(self, portainer: PortainerConnector | None = None):
|
||||
self.portainer = portainer or PortainerConnector()
|
||||
|
||||
def _inspect_current_target(self, *, endpoint: str, container_id: str) -> dict[str, Any] | None:
|
||||
safe_container = quote(container_id, safe="")
|
||||
try:
|
||||
created = self.portainer._request(
|
||||
"POST",
|
||||
f"/api/endpoints/{endpoint}/docker/containers/{safe_container}/exec",
|
||||
json={
|
||||
"AttachStdout": True,
|
||||
"AttachStderr": False,
|
||||
"Tty": True,
|
||||
"Cmd": ["python3", "-c", _INSPECT_SCRIPT, _REPOSITORY],
|
||||
},
|
||||
).json()
|
||||
exec_id = str(created.get("Id") or "")
|
||||
if not exec_id:
|
||||
return None
|
||||
safe_exec = quote(exec_id, safe="")
|
||||
response = self.portainer._request(
|
||||
"POST",
|
||||
f"/api/endpoints/{endpoint}/docker/exec/{safe_exec}/start",
|
||||
json={"Detach": False, "Tty": True},
|
||||
)
|
||||
raw = (response.text or "").strip()
|
||||
if not raw or len(raw) > 4096:
|
||||
return None
|
||||
decoded = json.loads(raw)
|
||||
return decoded if isinstance(decoded, dict) else None
|
||||
except (RuntimeError, ValueError, TypeError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
def run(self) -> dict[str, Any]:
|
||||
status = self.portainer.get_container_status(_CONTROLLER)
|
||||
if status.get("status") != "ok":
|
||||
return {
|
||||
"status": "controller_unavailable",
|
||||
"reason": redact_message(
|
||||
str(status.get("reason") or status.get("status") or "unavailable")
|
||||
),
|
||||
}
|
||||
controller = dict(status.get("container") or {})
|
||||
if controller.get("name") != _CONTROLLER or controller.get("state") != "running":
|
||||
return {
|
||||
"status": "controller_unavailable",
|
||||
"reason": "deployment controller is not running",
|
||||
}
|
||||
container_id = str(controller.get("id") or "")
|
||||
config = self.portainer._config()
|
||||
if not config or not container_id:
|
||||
return {
|
||||
"status": "connector_unavailable",
|
||||
"reason": "Portainer deployment control is unavailable",
|
||||
}
|
||||
|
||||
endpoint = quote(str(config.endpoint_id), safe="")
|
||||
current_target = self._inspect_current_target(endpoint=endpoint, container_id=container_id)
|
||||
if current_target is not None:
|
||||
container_revisions: dict[str, str | None] = {}
|
||||
for required in ("Portfolio", "Portfolio-api"):
|
||||
try:
|
||||
inspected_container = self.portainer._request(
|
||||
"GET",
|
||||
f"/api/endpoints/{endpoint}/docker/containers/{quote(required, safe='')}/json",
|
||||
).json()
|
||||
config_payload = inspected_container.get("Config") if isinstance(inspected_container, dict) else None
|
||||
labels = config_payload.get("Labels") if isinstance(config_payload, dict) else None
|
||||
revision = labels.get("tech.itworx.commit") if isinstance(labels, dict) else None
|
||||
container_revisions[required] = str(revision) if revision else None
|
||||
except (RuntimeError, ValueError, TypeError):
|
||||
container_revisions[required] = None
|
||||
current_target["container_revisions"] = container_revisions
|
||||
command = [
|
||||
"python3",
|
||||
"-c",
|
||||
_ENROLL_SCRIPT,
|
||||
_REPOSITORY,
|
||||
_LEGACY_REVISION,
|
||||
_RECOVERY_LIVE_REVISION,
|
||||
_RECOVERY_FAILED_REVISION,
|
||||
_RECOVERY_FAILURE_MESSAGE,
|
||||
json.dumps(_PROJECT, separators=(",", ":"), sort_keys=True),
|
||||
json.dumps(_LEGACY_SINGLE_PROJECT, separators=(",", ":"), sort_keys=True),
|
||||
]
|
||||
safe_container = quote(container_id, safe="")
|
||||
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": "Portfolio enrollment 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() + 120
|
||||
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": "error", "reason": "Portfolio enrollment timed out"}
|
||||
reasons = {
|
||||
20: "deployment projects registry is unavailable or unsafe",
|
||||
21: "deployment projects registry is not a regular file",
|
||||
22: "deployment projects registry does not contain an object",
|
||||
23: "Portfolio already has an unexpected deployment target",
|
||||
24: "mounted Portfolio bare repository is unavailable",
|
||||
25: "known Portfolio deployment revisions cannot be resolved",
|
||||
26: "Portfolio main cannot be resolved",
|
||||
27: "known Portfolio live revision is not an ancestor of main",
|
||||
28: "required Portfolio docker-compose.yml is absent from main",
|
||||
29: "deployment registry lock file cannot be opened",
|
||||
30: "deployment registry lock cannot be acquired",
|
||||
31: "deployment projects registry cannot be read as JSON",
|
||||
32: "deployment projects registry backup cannot be created",
|
||||
34: "deployment projects registry replacement failed",
|
||||
36: "deployment projects registry host alias is unavailable or mismatched",
|
||||
40: "Portfolio fixed enrollment contract was altered",
|
||||
41: "Portfolio production environment file is unavailable or unsafe",
|
||||
42: "Portfolio deployment state file is unavailable or invalid",
|
||||
43: "Portfolio deployment state conflicts with the verified live baseline",
|
||||
44: "Portfolio Compose project inventory could not be read",
|
||||
45: "Portfolio Compose project does not contain exactly the expected web/API containers",
|
||||
46: "required Portfolio production container is missing or unhealthy",
|
||||
47: "live Portfolio container revision does not match the verified legacy baseline",
|
||||
50: "deployment state directory is unavailable or unsafe",
|
||||
51: "verified Portfolio legacy state could not be written durably",
|
||||
52: "deployment controller rejected Portfolio legacy-state bootstrap",
|
||||
53: "Portfolio legacy-state bootstrap did not produce the expected managed state",
|
||||
54: "verified stale Portfolio controller state could not be reconciled safely",
|
||||
55: "live Portfolio runtime no longer matches the controller-recorded modern artifacts",
|
||||
56: "verified Portfolio runtime state could not be reconciled durably",
|
||||
57: "reconciled Portfolio runtime state did not verify after write",
|
||||
58: "Portfolio main changed after the bounded recovery contract was recorded",
|
||||
}
|
||||
if exit_code != 0:
|
||||
reason = reasons.get(
|
||||
exit_code,
|
||||
"Portfolio enrollment failed closed before any application deployment",
|
||||
)
|
||||
if exit_code in {23, 43, 47, 55, 58} and current_target is not None:
|
||||
reason = redact_message(
|
||||
f"{reason}: {json.dumps(current_target, sort_keys=True, separators=(',', ':'))}"
|
||||
)
|
||||
return {"status": "validation_failed", "reason": reason}
|
||||
return {
|
||||
"status": "enrolled",
|
||||
"repository": _REPOSITORY,
|
||||
"legacy_revision": _LEGACY_REVISION,
|
||||
"compose_project": _PROJECT["compose_project"],
|
||||
"containers": list(_PROJECT["containers"]),
|
||||
}
|
||||
Reference in new issue
Block a user