Publish curated Unraid-AppOps-Gateway source from 4f159a7e60d5becc5218ef4c541e2005d733a537
This commit is contained in:
1 parent
ec6f5b559f
commit
235d8fd493
7 files changed
+783
-50
No files matched your search
@@ -21,12 +21,11 @@ _MAX_CONTAINERS = 32
|
||||
# Fixed application-owned mutation. Callers never provide a command or host
|
||||
# path. Repository-derived topology is validated before it reaches this script,
|
||||
# then re-bound to the exact immutable Git revision inside the controller.
|
||||
_REGISTER_SCRIPT = r'''
|
||||
_REGISTER_SCRIPT = r"""
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -38,11 +37,35 @@ lock_path = Path("/mnt/cache/appdata/gitea-deploy-runners/deploy.lock")
|
||||
repository, revision, branch, raw_project = sys.argv[1:5]
|
||||
project = json.loads(raw_project)
|
||||
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise SystemExit(20)
|
||||
info = path.stat()
|
||||
if not stat.S_ISREG(info.st_mode):
|
||||
raise SystemExit(21)
|
||||
def _validation_compatible(repository, validation):
|
||||
expected_validation = {
|
||||
"validation_gate_version": 1,
|
||||
"required": True,
|
||||
"contexts": ["Managed validation / full (push)"],
|
||||
"timeout_seconds": 3600 if repository == "jens/mediarr" else 1800,
|
||||
"poll_interval_seconds": 5,
|
||||
"max_poll_interval_seconds": 30,
|
||||
}
|
||||
if not isinstance(validation, dict) or validation.get("required") is not True or any(
|
||||
type(validation.get(key)) is not int
|
||||
for key in ("validation_gate_version", "timeout_seconds", "poll_interval_seconds", "max_poll_interval_seconds")
|
||||
):
|
||||
return False
|
||||
if validation == expected_validation:
|
||||
return True
|
||||
return repository == "jens/mediarr" and validation == {
|
||||
**expected_validation,
|
||||
"contexts": ["CI / gates (push)", "Managed validation / full (push)"],
|
||||
}
|
||||
|
||||
for directory in (path.parent, host_path.parent, host_path.parent.parent):
|
||||
info = directory.lstat()
|
||||
if not stat.S_ISDIR(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o022:
|
||||
raise SystemExit(21)
|
||||
for registry_path in (path, host_path):
|
||||
info = registry_path.lstat()
|
||||
if not stat.S_ISREG(info.st_mode) or info.st_uid != 0 or stat.S_IMODE(info.st_mode) != 0o600:
|
||||
raise SystemExit(21)
|
||||
|
||||
try:
|
||||
if host_path.is_symlink() or not host_path.is_file() or not os.path.samefile(path, host_path):
|
||||
@@ -117,7 +140,18 @@ with lock_handle as lock:
|
||||
raise SystemExit(30)
|
||||
|
||||
try:
|
||||
projects = json.loads(mutation_path.read_text(encoding="utf-8"))
|
||||
registry_info = mutation_path.lstat()
|
||||
registry_identity = (registry_info.st_dev, registry_info.st_ino, registry_info.st_mode,
|
||||
registry_info.st_uid, registry_info.st_gid, registry_info.st_mtime_ns,
|
||||
registry_info.st_ctime_ns, registry_info.st_size)
|
||||
if not stat.S_ISREG(registry_info.st_mode) or registry_info.st_uid != 0 or stat.S_IMODE(registry_info.st_mode) != 0o600:
|
||||
raise SystemExit(31)
|
||||
descriptor = os.open(mutation_path, os.O_RDONLY | os.O_NOFOLLOW)
|
||||
with os.fdopen(descriptor, "rb") as stream:
|
||||
original = stream.read(4 * 1024 * 1024 + 1)
|
||||
if len(original) > 4 * 1024 * 1024:
|
||||
raise SystemExit(31)
|
||||
projects = json.loads(original)
|
||||
except Exception:
|
||||
raise SystemExit(31)
|
||||
if not isinstance(projects, dict):
|
||||
@@ -185,23 +219,32 @@ with lock_handle as lock:
|
||||
raise SystemExit(23)
|
||||
validation = current.get("validation")
|
||||
if validation is not None:
|
||||
expected_validation = {
|
||||
"validation_gate_version": 1,
|
||||
"required": True,
|
||||
"contexts": ["Managed validation / full (push)"],
|
||||
"timeout_seconds": 3600 if repository == "jens/mediarr" else 1800,
|
||||
"poll_interval_seconds": 5,
|
||||
"max_poll_interval_seconds": 30,
|
||||
}
|
||||
if validation != expected_validation:
|
||||
if not _validation_compatible(repository, validation):
|
||||
raise SystemExit(23)
|
||||
|
||||
if changed:
|
||||
backup = mutation_path.with_name(
|
||||
f"projects.json.appops-backup-{int(time.time())}-{os.getpid()}"
|
||||
)
|
||||
try:
|
||||
shutil.copy2(mutation_path, backup)
|
||||
backup_root = Path("/mnt/cache/appdata/gitea-deploy-runners/state/registry-backups")
|
||||
for directory in (backup_root.parent, backup_root.parent.parent):
|
||||
directory_info = directory.lstat()
|
||||
if not stat.S_ISDIR(directory_info.st_mode) or directory_info.st_uid != 0 or directory_info.st_mode & 0o022:
|
||||
raise SystemExit(32)
|
||||
backup_root.mkdir(mode=0o700, exist_ok=True)
|
||||
directory_info = backup_root.lstat()
|
||||
if not stat.S_ISDIR(directory_info.st_mode) or directory_info.st_uid != 0 or stat.S_IMODE(directory_info.st_mode) != 0o700:
|
||||
raise SystemExit(32)
|
||||
backup = backup_root / f"projects.json.appops-backup-{time.time_ns()}-{os.getpid()}"
|
||||
fd = os.open(backup, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
||||
with os.fdopen(fd, "wb") as handle:
|
||||
handle.write(original)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
for directory in (backup_root.parent, backup_root):
|
||||
fd = os.open(directory, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
||||
try:
|
||||
os.fsync(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
except Exception:
|
||||
raise SystemExit(32)
|
||||
|
||||
@@ -210,11 +253,11 @@ with lock_handle as lock:
|
||||
temporary = mutation_path.with_name(f".{mutation_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:
|
||||
descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
||||
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
||||
handle.write(payload)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.chmod(temporary, stat.S_IMODE(info.st_mode))
|
||||
except Exception:
|
||||
try:
|
||||
temporary.unlink(missing_ok=True)
|
||||
@@ -223,6 +266,11 @@ with lock_handle as lock:
|
||||
raise SystemExit(33)
|
||||
|
||||
try:
|
||||
latest = mutation_path.lstat()
|
||||
latest_identity = (latest.st_dev, latest.st_ino, latest.st_mode, latest.st_uid,
|
||||
latest.st_gid, latest.st_mtime_ns, latest.st_ctime_ns, latest.st_size)
|
||||
if latest_identity != registry_identity or mutation_path.read_bytes() != original or not os.path.samefile(path, mutation_path):
|
||||
raise SystemExit(34)
|
||||
os.replace(temporary, mutation_path)
|
||||
except Exception:
|
||||
try:
|
||||
@@ -242,7 +290,7 @@ with lock_handle as lock:
|
||||
os.close(directory_fd)
|
||||
|
||||
print("registered" if changed else "already-registered", flush=True)
|
||||
'''
|
||||
"""
|
||||
|
||||
|
||||
def _safe_compose_file(value: str) -> str:
|
||||
@@ -314,10 +362,14 @@ class DeploymentTargetRegistry:
|
||||
normalized_revision = str(revision or "").strip().lower()
|
||||
normalized_branch = str(branch or "").strip()
|
||||
if not _REPOSITORY.fullmatch(normalized_repository):
|
||||
raise ValueError("Repository must be a canonical Jens owner/name identifier")
|
||||
raise ValueError(
|
||||
"Repository must be a canonical Jens owner/name identifier"
|
||||
)
|
||||
slug = DeploymentTargetRegistry._slug(normalized_repository)
|
||||
if not _SLUG.fullmatch(slug):
|
||||
raise ValueError("Repository name cannot be used as a bounded deployment slug")
|
||||
raise ValueError(
|
||||
"Repository name cannot be used as a bounded deployment slug"
|
||||
)
|
||||
if not _REVISION.fullmatch(normalized_revision):
|
||||
raise ValueError("Revision must be a full lowercase 40-character Git SHA")
|
||||
if (
|
||||
@@ -328,15 +380,25 @@ class DeploymentTargetRegistry:
|
||||
):
|
||||
raise ValueError("Default branch is outside the bounded Git ref contract")
|
||||
normalized_compose_file = _safe_compose_file(compose_file)
|
||||
normalized_project = str(
|
||||
compose_project
|
||||
or DeploymentTargetRegistry.default_compose_project(normalized_repository)
|
||||
).strip().lower()
|
||||
normalized_project = (
|
||||
str(
|
||||
compose_project
|
||||
or DeploymentTargetRegistry.default_compose_project(
|
||||
normalized_repository
|
||||
)
|
||||
)
|
||||
.strip()
|
||||
.lower()
|
||||
)
|
||||
if not _COMPOSE_PROJECT.fullmatch(normalized_project):
|
||||
raise ValueError("Compose project is outside the bounded naming contract")
|
||||
normalized_containers = [str(item or "").strip() for item in (containers or [slug])]
|
||||
normalized_containers = [
|
||||
str(item or "").strip() for item in (containers or [slug])
|
||||
]
|
||||
if not normalized_containers or len(normalized_containers) > _MAX_CONTAINERS:
|
||||
raise ValueError("Deployment target must contain between 1 and 32 containers")
|
||||
raise ValueError(
|
||||
"Deployment target must contain between 1 and 32 containers"
|
||||
)
|
||||
if any(not _CONTAINER.fullmatch(item) for item in normalized_containers):
|
||||
raise ValueError("Container name is outside the bounded naming contract")
|
||||
if len(set(normalized_containers)) != len(normalized_containers):
|
||||
@@ -396,7 +458,10 @@ class DeploymentTargetRegistry:
|
||||
),
|
||||
}
|
||||
controller = dict(status.get("container") or {})
|
||||
if controller.get("name") != _CONTROLLER or controller.get("state") != "running":
|
||||
if (
|
||||
controller.get("name") != _CONTROLLER
|
||||
or controller.get("state") != "running"
|
||||
):
|
||||
return {
|
||||
"status": "controller_unavailable",
|
||||
"reason": "deployment controller is not running",
|
||||
|
||||
Reference in new issue
Block a user