NODEDC_PLATFORM/infra/deploy-runner/nodedc-deploy

3356 lines
141 KiB
Python
Executable File

#!/usr/bin/env python3
import argparse
import json
import os
import re
import secrets
import shutil
import stat
import subprocess
import sys
import tarfile
import tempfile
import time
import urllib.error
import urllib.request
from datetime import datetime, timezone
from pathlib import Path, PurePosixPath
EXPECTED_SELF = Path("/usr/local/sbin/nodedc-deploy")
DEPLOY_ROOT = Path("/volume1/docker/nodedc-deploy")
INBOX = DEPLOY_ROOT / "inbox"
APPLIED_DIR = DEPLOY_ROOT / "applied"
FAILED_DIR = DEPLOY_ROOT / "failed"
BACKUPS_DIR = DEPLOY_ROOT / "backups"
STATE_DIR = DEPLOY_ROOT / "state"
TMP_DIR = DEPLOY_ROOT / ".tmp"
STATE_FILE = STATE_DIR / "applied.jsonl"
FAILED_STATE_FILE = STATE_DIR / "failed.jsonl"
LOCK_DIR = STATE_DIR / "deploy.lock"
DOCKER = Path("/usr/local/bin/docker")
MAP_GATEWAY_SECRET_DIR = Path("/volume1/docker/nodedc-platform/secrets")
MAP_GATEWAY_SECRET_FILE = MAP_GATEWAY_SECRET_DIR / "map-gateway-admin-secret"
MAP_EGRESS_PROXY_SECRET_FILE = MAP_GATEWAY_SECRET_DIR / "map-egress-proxy-token"
PROXY_CONTUR_ENV_FILE = Path("/volume1/docker/proxy-contur/.env")
DC_AMD_PROXY_RUNTIME_DIR = Path("/volume1/docker/dc-amd-proxy/runtime")
EXTERNAL_DATA_PLANE_PROVISIONER_SECRET_DIR = MAP_GATEWAY_SECRET_DIR / "external-data-plane-provisioner"
EXTERNAL_DATA_PLANE_PROVISIONER_SECRET_FILE = EXTERNAL_DATA_PLANE_PROVISIONER_SECRET_DIR / "token"
EXTERNAL_DATA_PLANE_READER_GRANTS_DIR = MAP_GATEWAY_SECRET_DIR / "external-data-plane-reader-grants"
FOUNDRY_BINDING_GRANTS_DIR = MAP_GATEWAY_SECRET_DIR / "foundry-binding-grants"
N8N_PRIVATE_EXTENSION_RELEASES_ROOT = Path("/volume1/docker/nodedc-platform/n8n-private-extensions")
ENGINE_N8N_SEALED_RELEASES_ROOT = Path("/volume2/nodedc-demo/n8n-private-extensions")
ENGINE_N8N_TRANSITION_DESCRIPTOR_REL = "nodedc-source/services/n8n/private-extensions/ndc-activation.json"
ENGINE_N8N_COMPOSE_OVERRIDE_REL = "nodedc-source/services/n8n/private-extensions/docker-compose.ndc-private-extension.yml"
ENGINE_N8N_SCHEMA_ROOT_REL = "nodedc-source/server/assets/n8n/schema/v2.3.2"
ENGINE_N8N_NODES_CATALOG_REL = f"{ENGINE_N8N_SCHEMA_ROOT_REL}/nodes.catalog.json"
ENGINE_N8N_CREDENTIALS_CATALOG_REL = f"{ENGINE_N8N_SCHEMA_ROOT_REL}/credentials.catalog.json"
ENGINE_N8N_SCHEMA_META_REL = f"{ENGINE_N8N_SCHEMA_ROOT_REL}/meta.json"
ENGINE_N8N_ICON_REL = "nodedc-source/server/assets/n8n/icons/ndc.svg"
ENGINE_N8N_DARK_ICON_REL = "nodedc-source/server/assets/n8n/icons/ndc.dark.svg"
ENGINE_N8N_VERSION = "2.3.2"
ENGINE_N8N_BASE_IMAGE = "docker.n8n.io/n8nio/n8n:2.3.2"
ENGINE_N8N_BASE_ARCHITECTURE = "amd64"
ENGINE_N8N_RUNTIME_PACKAGE_PATH = "/home/node/.n8n/nodes/node_modules/n8n-nodes-ndc"
ENGINE_N8N_ICON_SHA256 = "1c928fc996d8b82121e8f8e327d74b1dd21556c106de99c5e8d317d926c0ba17"
ENGINE_N8N_DARK_ICON_SHA256 = "ef3b8551da4ce04527736405afa9a220a2c76d047bd18f6f7124b9adb4216b0c"
ENGINE_N8N_ACTIVATION_NODES_CATALOG_JSON_SHA256 = "230f712230f7ee8debaa4b44a4358cc19c205bc43305d8ed89d5e3daac466506"
ENGINE_N8N_ACTIVATION_CREDENTIALS_CATALOG_JSON_SHA256 = "a26824ffc0e15db857c792153de3417febc0dbba4880380d6c8cd544b3c80f4d"
ENGINE_N8N_ACTIVATION_META_JSON_SHA256 = "caf7635420c61333e65f68350f5567217aed96fb51354b38764bcac2c24e7966"
ENGINE_N8N_INACTIVE_NODES_CATALOG_JSON_SHA256 = "b70d9d8130d498c55de46a5d0758c844f1242b70803457b2291ab3a9de8056f2"
ENGINE_N8N_INACTIVE_CREDENTIALS_CATALOG_JSON_SHA256 = "680e9f52aac791efbd38e3bd99bd51ef5cded9756d867897c6c2755850e87b50"
ENGINE_N8N_INACTIVE_META_JSON_SHA256 = "0ac555d7ab31cb0ca042db4f474e83cfefbf20b5bbb2e1509fead27ed21e8acb"
MAP_GATEWAY_RUNTIME_GID = 1000
EXTERNAL_DATA_PLANE_RUNTIME_UID = 11006
EXTERNAL_DATA_PLANE_RUNTIME_GID = 11006
MAX_ARTIFACT_BYTES = 512 * 1024 * 1024
MAX_MEMBER_COUNT = 20000
MAX_PAYLOAD_BYTES = 1024 * 1024 * 1024
MAX_FILE_BYTES = 256 * 1024 * 1024
PATCH_ID_RE = re.compile(r"^[A-Za-z0-9._-]{1,96}$")
MANIFEST_KEYS = {"id", "component", "type"}
MAP_GATEWAY_SECRET_RE = re.compile(r"^[A-Za-z0-9_-]{48,256}$")
EXTERNAL_DATA_PLANE_PROVISIONER_SECRET_RE = re.compile(r"^[A-Za-z0-9_-]{48,256}$")
N8N_PRIVATE_EXTENSION_RELEASE_RE = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+-[a-f0-9]{16}$")
N8N_PRIVATE_EXTENSION_NODES = (
"dist/nodes/NdcDataProductPublish/NdcDataProductPublish.node.js",
"dist/nodes/NdcDataProductRead/NdcDataProductRead.node.js",
"dist/nodes/NdcFoundryBinding/NdcFoundryBinding.node.js",
)
N8N_PRIVATE_EXTENSION_CREDENTIALS = (
"dist/credentials/NdcDataProductWriterApi.credentials.js",
"dist/credentials/NdcDataProductReaderApi.credentials.js",
"dist/credentials/NdcFoundryBindingApi.credentials.js",
)
ENGINE_N8N_NODE_TYPES = (
"n8n-nodes-ndc.ndcDataProductPublish",
"n8n-nodes-ndc.ndcDataProductRead",
"n8n-nodes-ndc.ndcFoundryBinding",
)
ENGINE_N8N_CREDENTIAL_TYPES = (
"ndcDataProductWriterApi",
"ndcDataProductReaderApi",
"ndcFoundryBindingApi",
)
ENGINE_BASE_COMPOSE_SERVICES = (
"postgresql",
"authentik-server",
"authentik-worker",
"n8n-postgres",
"ops-postgres",
"n8n",
"nodedc-backend",
"nodedc-frontend-build",
"app",
)
COMPONENTS = {
"engine": {
"payload_root": Path("/volume2/nodedc-demo"),
"compose_root": Path("/volume2/nodedc-demo"),
"services": ("nodedc-backend", "app"),
"compose_no_deps": True,
"publish_dist": True,
"healthchecks": (
"http://127.0.0.1:8080/",
"http://127.0.0.1:3001/health",
),
},
"launcher": {
"payload_root": Path("/volume1/docker/nodedc-platform/launcher/source"),
"build_root": Path("/volume1/docker/nodedc-platform/launcher/source"),
"build": ("build", "--no-cache", "-t", "nodedc/launcher:local", "."),
"compose_root": Path("/volume1/docker/nodedc-platform/platform"),
"compose_env_file": Path("/volume1/docker/nodedc-platform/platform/.env.synology"),
"compose_files": (
Path("/volume1/docker/nodedc-platform/platform/docker-compose.platform-http.yml"),
),
"compose_no_deps": True,
"services": ("launcher",),
"healthchecks": (
{
"url": "http://127.0.0.1:18080/healthz",
"headers": {"Host": "hub.nodedc.ru"},
},
),
},
"platform": {
"payload_root": Path("/volume1/docker/nodedc-platform"),
"compose_root": Path("/volume1/docker/nodedc-platform/platform"),
"compose_env_file": Path("/volume1/docker/nodedc-platform/platform/.env.synology"),
"compose_files": (
Path("/volume1/docker/nodedc-platform/platform/docker-compose.platform-http.yml"),
Path("/volume1/docker/nodedc-platform/platform/docker-compose.external-data-plane.yml"),
),
"compose_no_deps": True,
"services": ("notification-postgres", "notification-core", "ai-workspace-hub", "launcher", "reverse-proxy", "external-data-plane-postgres", "external-data-plane", "map-gateway"),
"healthchecks": (
"http://127.0.0.1:5185/healthz",
"http://127.0.0.1:18081/healthz",
{
"url": "http://127.0.0.1:18080/healthz",
"headers": {"Host": "hub.nodedc.ru"},
},
{
"url": "http://127.0.0.1:18080/",
"headers": {"Host": "id.nodedc.ru"},
},
),
},
"tasker": {
"payload_root": Path("/volume1/docker/nodedc-platform/tasker"),
"compose_root": Path("/volume1/docker/nodedc-platform/tasker/plane-app"),
"compose_project": "nodedc-tasker",
"compose_env_file": Path("/volume1/docker/nodedc-platform/tasker/plane-app/.env.synology"),
"compose_files": (
Path("/volume1/docker/nodedc-platform/tasker/plane-app/docker-compose.yaml"),
Path("/volume1/docker/nodedc-platform/tasker/plane-app/docker-compose.synology.override.yml"),
),
"services": ("api", "worker", "beat-worker", "web"),
"healthchecks": (
{
"url": "http://127.0.0.1:18090/",
"headers": {"Host": "ops.nodedc.ru"},
},
),
},
"ops-agents": {
"payload_root": Path("/volume1/docker/nodedc-platform/ops-agents"),
"compose_root": Path("/volume1/docker/nodedc-platform/ops-agents"),
"compose_env_file": Path("/volume1/docker/nodedc-platform/ops-agents/.env"),
"compose_files": (
Path("/volume1/docker/nodedc-platform/ops-agents/docker-compose.synology.yml"),
),
"compose_build": True,
"compose_no_deps": True,
"services": ("agent-gateway",),
"healthchecks": (
"http://172.22.0.222:18190/readyz",
),
},
"bim-viewer": {
"payload_root": Path("/volume1/docker/nodedc-platform/bim-viewer/source"),
"build_root": Path("/volume1/docker/nodedc-platform/bim-viewer/source/converter"),
"build": ("build", "--no-cache", "-t", "nodedc/bim-converter:local", "."),
"compose_root": Path("/volume1/docker/nodedc-platform/bim-viewer/source"),
"compose_env_file": Path("/volume1/docker/nodedc-platform/bim-viewer/source/.env"),
"compose_files": (
Path("/volume1/docker/nodedc-platform/bim-viewer/source/docker-compose.beam.yml"),
),
"compose_no_deps": True,
"services": ("ndc-beam-viewer", "nodedc-bim-converter"),
"healthchecks": (
"http://127.0.0.1:18100/api/auth/session",
),
},
"n8n-private-extension": {
# This component only stages a verified, immutable offline release in
# Platform-owned storage. It deliberately has no Compose file, service,
# container mutation or Engine activation side effect.
"payload_root": N8N_PRIVATE_EXTENSION_RELEASES_ROOT,
"bootstrap_root": True,
"artifact_only": True,
"immutable_payload": True,
"services": (),
},
"module-foundry": {
"payload_root": Path("/volume1/docker/nodedc-platform/module-foundry/source"),
"compose_root": Path("/volume1/docker/nodedc-platform/module-foundry/source/infra"),
"compose_project": "nodedc-module-foundry",
"compose_env_file": Path("/volume1/docker/nodedc-platform/module-foundry/source/.env"),
"compose_files": (
Path("/volume1/docker/nodedc-platform/module-foundry/source/infra/docker-compose.module-foundry.yml"),
),
"bootstrap_root": True,
"compose_build": True,
"compose_no_deps": True,
"services": ("nodedc-module-foundry",),
"healthchecks": (
"http://172.22.0.222:9920/healthz",
),
},
"proxy-contur": {
"payload_root": Path("/volume1/docker/proxy-contur"),
"compose_root": Path("/volume1/docker/proxy-contur"),
"compose_project": "proxy-contur",
"compose_env_file": Path("/volume1/docker/proxy-contur/.env"),
"compose_files": (
Path("/volume1/docker/proxy-contur/docker-compose.yml"),
),
"compose_build": True,
"compose_no_deps": True,
"services": ("proxy-contur",),
"health_container": "proxy-contur",
},
"dc-amd-proxy": {
"payload_root": Path("/volume1/docker/dc-amd-proxy"),
"compose_root": Path("/volume1/docker/dc-amd-proxy"),
"compose_project": "dc-amd-proxy",
"bootstrap_root": True,
"compose_build": True,
"compose_no_deps": True,
"services": ("dc-amd-proxy",),
"health_container": "dc-amd-proxy",
},
"dc-cms": {
"payload_root": Path("/volume1/docker/dc-cms/source"),
"compose_root": Path("/volume1/docker/dc-cms/source/infra"),
"compose_env_file": Path("/volume1/docker/dc-cms/source/infra/.env.synology"),
"compose_files": (
Path("/volume1/docker/dc-cms/source/infra/docker-compose.yml"),
),
"bootstrap_root": True,
"compose_build": True,
"services": ("postgresql-authentik", "authentik-server", "authentik-worker", "authentik-bootstrap", "cms-app", "reverse-proxy"),
"healthchecks": (
{
"url": "http://172.22.0.222:9918/auth/login?returnTo=%2F",
"headers": {"Host": "cms.dcserve.ru"},
},
{
"url": "http://172.22.0.222:9919/",
"headers": {"Host": "auth.dcserve.ru"},
},
),
},
"dc-cms-site-nodedc": {
"payload_root": Path("/volume1/docker/dc-cms/sites/nodedc"),
"compose_root": Path("/volume1/docker/dc-cms/source/infra"),
"compose_env_file": Path("/volume1/docker/dc-cms/source/infra/.env.synology"),
"compose_files": (
Path("/volume1/docker/dc-cms/source/infra/docker-compose.yml"),
),
"compose_no_deps": True,
"services": ("cms-app", "reverse-proxy"),
"healthchecks": (
{
"url": "http://172.22.0.222:9918/auth/login?returnTo=%2F",
"headers": {"Host": "cms.dcserve.ru"},
},
),
},
}
class NoRedirectHandler(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
NO_REDIRECT_OPENER = urllib.request.build_opener(NoRedirectHandler)
class DeployError(Exception):
pass
def die(message):
raise DeployError(message)
def utc_now():
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
def stamp():
return datetime.now().strftime("%Y%m%d-%H%M%S")
def sha256_file(path):
import hashlib
h = hashlib.sha256()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
h.update(chunk)
return h.hexdigest()
def sha256_json_value(value):
import hashlib
canonical = json.dumps(
value,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
return hashlib.sha256(canonical).hexdigest()
def safe_name(value):
return re.sub(r"[^A-Za-z0-9._-]", "_", value)
def is_relative_to(child, parent):
try:
child.relative_to(parent)
return True
except ValueError:
return False
def ensure_layout():
for path in (INBOX, APPLIED_DIR, FAILED_DIR, BACKUPS_DIR, STATE_DIR, TMP_DIR):
path.mkdir(parents=True, exist_ok=True)
for path in (STATE_DIR, BACKUPS_DIR, TMP_DIR):
os.chown(path, 0, 0)
path.chmod(0o700)
for path in (APPLIED_DIR, FAILED_DIR):
os.chown(path, 0, 0)
path.chmod(0o755)
def ensure_platform_runtime_secret(secret_file, secret_re, label):
# This is Platform infrastructure state, not a product setting. The
# root-owned runner creates it once and only explicitly bound service mounts
# can read it. It must never be copied into an artifact or a shared .env.
secret_dir = secret_file.parent
try:
directory_stat = secret_dir.lstat()
except FileNotFoundError:
secret_dir.mkdir(parents=True, exist_ok=False)
directory_stat = secret_dir.lstat()
if stat.S_ISLNK(directory_stat.st_mode) or not stat.S_ISDIR(directory_stat.st_mode):
die(f"{label} secret directory is unsafe: {secret_dir}")
os.chown(secret_dir, 0, MAP_GATEWAY_RUNTIME_GID)
secret_dir.chmod(0o710)
try:
secret_stat = secret_file.lstat()
except FileNotFoundError:
secret_value = secrets.token_urlsafe(48)
temporary = secret_dir / f".{secret_file.name}.{os.getpid()}.{time.time_ns()}.tmp"
descriptor = None
try:
descriptor = os.open(str(temporary), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o640)
os.write(descriptor, f"{secret_value}\n".encode("ascii"))
os.fsync(descriptor)
os.fchown(descriptor, 0, MAP_GATEWAY_RUNTIME_GID)
os.fchmod(descriptor, 0o640)
os.close(descriptor)
descriptor = None
os.replace(temporary, secret_file)
fsync_directory(secret_dir)
finally:
if descriptor is not None:
os.close(descriptor)
if temporary.exists():
temporary.unlink()
return "created"
if stat.S_ISLNK(secret_stat.st_mode) or not stat.S_ISREG(secret_stat.st_mode):
die(f"{label} secret file is unsafe: {secret_file}")
if secret_stat.st_uid != 0 or secret_stat.st_mode & (stat.S_IWGRP | stat.S_IWOTH):
die(f"{label} secret file has unsafe ownership or permissions: {secret_file}")
if secret_stat.st_size > 512:
die(f"{label} secret file is too large: {secret_file}")
try:
secret_value = secret_file.read_text(encoding="ascii").strip()
except UnicodeDecodeError:
die(f"{label} secret file is not ascii: {secret_file}")
if not secret_re.fullmatch(secret_value):
die(f"{label} secret file has invalid format: {secret_file}")
os.chown(secret_file, 0, MAP_GATEWAY_RUNTIME_GID)
secret_file.chmod(0o640)
return "reused"
def ensure_map_gateway_admin_secret():
return ensure_platform_runtime_secret(
MAP_GATEWAY_SECRET_FILE,
MAP_GATEWAY_SECRET_RE,
"map gateway",
)
def read_proxy_contur_token():
try:
source_stat = PROXY_CONTUR_ENV_FILE.lstat()
except FileNotFoundError:
die("proxy-contur env file is required for Map egress")
if stat.S_ISLNK(source_stat.st_mode) or not stat.S_ISREG(source_stat.st_mode):
die("proxy-contur env file is unsafe")
if source_stat.st_size > 64 * 1024 or source_stat.st_mode & (stat.S_IWGRP | stat.S_IWOTH):
die("proxy-contur env file has unsafe permissions")
try:
lines = PROXY_CONTUR_ENV_FILE.read_text(encoding="utf-8").splitlines()
except UnicodeDecodeError:
die("proxy-contur env file is not utf-8")
value = None
for raw in lines:
line = raw.strip()
if not line or line.startswith("#"):
continue
if line.startswith("export "):
line = line[7:].lstrip()
if not line.startswith("PROXY_TOKEN="):
continue
value = line.partition("=")[2].strip()
if len(value) >= 2 and value[0] in ("'", '"') and value[-1] == value[0]:
value = value[1:-1]
break
if not value or len(value) > 4096 or any(ord(char) < 33 or ord(char) == 127 for char in value):
die("proxy-contur PROXY_TOKEN is missing or invalid")
return value
def sync_map_egress_proxy_secret():
# The Map Gateway gets a read-only file copy of the existing proxy token.
# The value is never printed, placed in an artifact, or exposed to Foundry.
token = read_proxy_contur_token()
try:
parent_stat = MAP_GATEWAY_SECRET_DIR.lstat()
except FileNotFoundError:
MAP_GATEWAY_SECRET_DIR.mkdir(parents=True, exist_ok=False)
parent_stat = MAP_GATEWAY_SECRET_DIR.lstat()
if stat.S_ISLNK(parent_stat.st_mode) or not stat.S_ISDIR(parent_stat.st_mode):
die(f"map egress parent secret directory is unsafe: {MAP_GATEWAY_SECRET_DIR}")
os.chown(MAP_GATEWAY_SECRET_DIR, 0, MAP_GATEWAY_RUNTIME_GID)
MAP_GATEWAY_SECRET_DIR.chmod(0o710)
if MAP_EGRESS_PROXY_SECRET_FILE.exists() or MAP_EGRESS_PROXY_SECRET_FILE.is_symlink():
target_stat = MAP_EGRESS_PROXY_SECRET_FILE.lstat()
if stat.S_ISLNK(target_stat.st_mode) or not stat.S_ISREG(target_stat.st_mode):
die("map egress proxy secret file is unsafe")
temporary = MAP_GATEWAY_SECRET_DIR / f".{MAP_EGRESS_PROXY_SECRET_FILE.name}.{os.getpid()}.{time.time_ns()}.tmp"
descriptor = None
try:
descriptor = os.open(str(temporary), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o640)
os.write(descriptor, f"{token}\n".encode("utf-8"))
os.fsync(descriptor)
os.fchown(descriptor, 0, MAP_GATEWAY_RUNTIME_GID)
os.fchmod(descriptor, 0o640)
os.close(descriptor)
descriptor = None
os.replace(temporary, MAP_EGRESS_PROXY_SECRET_FILE)
fsync_directory(MAP_GATEWAY_SECRET_DIR)
finally:
if descriptor is not None:
os.close(descriptor)
if temporary.exists():
temporary.unlink()
return "synced"
def ensure_root_owned_grant_directory(path, label):
try:
parent_stat = MAP_GATEWAY_SECRET_DIR.lstat()
except FileNotFoundError:
MAP_GATEWAY_SECRET_DIR.mkdir(parents=True, exist_ok=False)
parent_stat = MAP_GATEWAY_SECRET_DIR.lstat()
if stat.S_ISLNK(parent_stat.st_mode) or not stat.S_ISDIR(parent_stat.st_mode):
die(f"{label} parent directory is unsafe: {MAP_GATEWAY_SECRET_DIR}")
os.chown(MAP_GATEWAY_SECRET_DIR, 0, MAP_GATEWAY_RUNTIME_GID)
MAP_GATEWAY_SECRET_DIR.chmod(0o710)
try:
directory_stat = path.lstat()
except FileNotFoundError:
path.mkdir(parents=False, exist_ok=False)
directory_stat = path.lstat()
if stat.S_ISLNK(directory_stat.st_mode) or not stat.S_ISDIR(directory_stat.st_mode):
die(f"{label} directory is unsafe: {path}")
os.chown(path, 0, 0)
path.chmod(0o500)
for entry in path.iterdir():
entry_stat = entry.lstat()
if (not re.fullmatch(r"[a-f0-9]{64}", entry.name)
or stat.S_ISLNK(entry_stat.st_mode)
or not stat.S_ISREG(entry_stat.st_mode)
or entry_stat.st_uid != 0
or entry_stat.st_gid != 0
or stat.S_IMODE(entry_stat.st_mode) != 0o400
or entry_stat.st_size < 2
or entry_stat.st_size > 128 * 1024):
die(f"{label} record is unsafe: {entry}")
def ensure_external_data_plane_provisioner_secret():
# Unlike the Map Gateway key, this capability can mint scoped writers.
# Keep it in an isolated child directory readable only by the dedicated
# EDP/provisioner uid, never by the shared gid 1000.
try:
parent_stat = MAP_GATEWAY_SECRET_DIR.lstat()
except FileNotFoundError:
MAP_GATEWAY_SECRET_DIR.mkdir(parents=True, exist_ok=False)
parent_stat = MAP_GATEWAY_SECRET_DIR.lstat()
if stat.S_ISLNK(parent_stat.st_mode) or not stat.S_ISDIR(parent_stat.st_mode):
die(f"external data plane parent secret directory is unsafe: {MAP_GATEWAY_SECRET_DIR}")
os.chown(MAP_GATEWAY_SECRET_DIR, 0, MAP_GATEWAY_RUNTIME_GID)
MAP_GATEWAY_SECRET_DIR.chmod(0o710)
try:
directory_stat = EXTERNAL_DATA_PLANE_PROVISIONER_SECRET_DIR.lstat()
except FileNotFoundError:
EXTERNAL_DATA_PLANE_PROVISIONER_SECRET_DIR.mkdir(parents=False, exist_ok=False)
directory_stat = EXTERNAL_DATA_PLANE_PROVISIONER_SECRET_DIR.lstat()
if stat.S_ISLNK(directory_stat.st_mode) or not stat.S_ISDIR(directory_stat.st_mode):
die(f"external data plane provisioner secret directory is unsafe: {EXTERNAL_DATA_PLANE_PROVISIONER_SECRET_DIR}")
os.chown(EXTERNAL_DATA_PLANE_PROVISIONER_SECRET_DIR, EXTERNAL_DATA_PLANE_RUNTIME_UID, EXTERNAL_DATA_PLANE_RUNTIME_GID)
EXTERNAL_DATA_PLANE_PROVISIONER_SECRET_DIR.chmod(0o500)
try:
secret_stat = EXTERNAL_DATA_PLANE_PROVISIONER_SECRET_FILE.lstat()
except FileNotFoundError:
secret_value = secrets.token_urlsafe(48)
temporary = EXTERNAL_DATA_PLANE_PROVISIONER_SECRET_DIR / f".{EXTERNAL_DATA_PLANE_PROVISIONER_SECRET_FILE.name}.{os.getpid()}.{time.time_ns()}.tmp"
descriptor = None
try:
descriptor = os.open(str(temporary), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o400)
os.write(descriptor, f"{secret_value}\n".encode("ascii"))
os.fsync(descriptor)
os.fchown(descriptor, EXTERNAL_DATA_PLANE_RUNTIME_UID, EXTERNAL_DATA_PLANE_RUNTIME_GID)
os.fchmod(descriptor, 0o400)
os.close(descriptor)
descriptor = None
os.replace(temporary, EXTERNAL_DATA_PLANE_PROVISIONER_SECRET_FILE)
fsync_directory(EXTERNAL_DATA_PLANE_PROVISIONER_SECRET_DIR)
finally:
if descriptor is not None:
os.close(descriptor)
if temporary.exists():
temporary.unlink()
return "created"
if stat.S_ISLNK(secret_stat.st_mode) or not stat.S_ISREG(secret_stat.st_mode):
die(f"external data plane provisioner secret file is unsafe: {EXTERNAL_DATA_PLANE_PROVISIONER_SECRET_FILE}")
if (secret_stat.st_uid != EXTERNAL_DATA_PLANE_RUNTIME_UID or
secret_stat.st_gid != EXTERNAL_DATA_PLANE_RUNTIME_GID or
stat.S_IMODE(secret_stat.st_mode) != 0o400):
die(f"external data plane provisioner secret file has unsafe ownership or permissions: {EXTERNAL_DATA_PLANE_PROVISIONER_SECRET_FILE}")
if secret_stat.st_size > 512:
die(f"external data plane provisioner secret file is too large: {EXTERNAL_DATA_PLANE_PROVISIONER_SECRET_FILE}")
try:
secret_value = EXTERNAL_DATA_PLANE_PROVISIONER_SECRET_FILE.read_text(encoding="ascii").strip()
except UnicodeDecodeError:
die(f"external data plane provisioner secret file is not ascii: {EXTERNAL_DATA_PLANE_PROVISIONER_SECRET_FILE}")
if not EXTERNAL_DATA_PLANE_PROVISIONER_SECRET_RE.fullmatch(secret_value):
die(f"external data plane provisioner secret file has invalid format: {EXTERNAL_DATA_PLANE_PROVISIONER_SECRET_FILE}")
return "reused"
def fsync_directory(path):
descriptor = os.open(str(path), os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
def require_root():
if os.geteuid() != 0:
die("run with sudo")
def verify_file_mode(path, require_root_owner):
try:
st = path.lstat()
except FileNotFoundError:
die(f"missing path: {path}")
if stat.S_ISLNK(st.st_mode):
die(f"path must not be a symlink: {path}")
if require_root_owner and (st.st_uid != 0 or st.st_gid != 0):
die(f"path must be root:root: {path}")
if st.st_mode & stat.S_IWGRP:
die(f"path must not be group-writable: {path}")
if st.st_mode & stat.S_IWOTH:
die(f"path must not be world-writable: {path}")
def verify_install():
invoked = Path(sys.argv[0]).absolute()
if invoked != EXPECTED_SELF:
die(f"runner must be invoked as {EXPECTED_SELF}, current: {invoked}")
verify_file_mode(EXPECTED_SELF, require_root_owner=True)
verify_file_mode(Path("/usr/local"), require_root_owner=True)
verify_file_mode(Path("/usr/local/sbin"), require_root_owner=True)
print(f"path={EXPECTED_SELF}")
print(f"sha256={sha256_file(EXPECTED_SELF)}")
print("verify-install-ok")
def validate_artifact_location(path):
if path.suffix != ".tgz":
die("artifact must have .tgz extension")
try:
st = path.lstat()
except FileNotFoundError:
die(f"artifact not found: {path}")
if stat.S_ISLNK(st.st_mode):
die(f"artifact must not be a symlink: {path}")
if not stat.S_ISREG(st.st_mode):
die(f"artifact must be a regular file: {path}")
if st.st_size > MAX_ARTIFACT_BYTES:
die(f"artifact too large: {st.st_size} bytes")
inbox_real = INBOX.resolve()
parent_real = path.parent.resolve()
if parent_real != inbox_real:
die(f"artifact must be inside {INBOX}")
name = path.name
if "/" in name or ".." in name or name.startswith("."):
die(f"unsafe artifact filename: {name}")
def validate_posix_path(value):
if not value:
die("empty path")
if "\\" in value:
die(f"backslash is not allowed in path: {value}")
pure = PurePosixPath(value)
if pure.is_absolute():
die(f"absolute path rejected: {value}")
if any(part in ("", ".", "..") for part in pure.parts):
die(f"path escape rejected: {value}")
if any(ord(ch) < 32 for ch in value):
die(f"control character rejected in path: {value!r}")
return pure
def tar_name_to_path(work_dir, name):
pure = validate_posix_path(name)
target = work_dir.joinpath(*pure.parts)
resolved = target.resolve(strict=False)
if not is_relative_to(resolved, work_dir.resolve()):
die(f"tar path escaped work dir: {name}")
return target
def validate_tar_member(member):
name = member.name
validate_posix_path(name)
if name not in ("manifest.env", "files.txt", "payload") and not name.startswith("payload/"):
die(f"unexpected tar member: {name}")
if not (member.isfile() or member.isdir()):
die(f"unsupported tar member type: {name}")
if member.mode & stat.S_ISUID or member.mode & stat.S_ISGID:
die(f"setuid/setgid tar member rejected: {name}")
if member.isfile() and member.size > MAX_FILE_BYTES:
die(f"file too large in artifact: {name}")
def scan_tar(artifact):
member_count = 0
payload_bytes = 0
names = set()
with tarfile.open(artifact, "r:gz") as tar:
for member in tar:
member_count += 1
if member_count > MAX_MEMBER_COUNT:
die("too many files in artifact")
validate_tar_member(member)
if member.name in names:
die(f"duplicate tar member rejected: {member.name}")
names.add(member.name)
if member.isfile() and member.name.startswith("payload/"):
payload_bytes += member.size
if payload_bytes > MAX_PAYLOAD_BYTES:
die("payload is too large")
if "manifest.env" not in names:
die("manifest.env missing")
if "files.txt" not in names:
die("files.txt missing")
if not any(name == "payload" or name.startswith("payload/") for name in names):
die("payload missing")
def safe_extract(artifact, work_dir):
scan_tar(artifact)
with tarfile.open(artifact, "r:gz") as tar:
for member in tar:
validate_tar_member(member)
target = tar_name_to_path(work_dir, member.name)
if member.isdir():
target.mkdir(parents=True, exist_ok=True)
target.chmod(0o755)
continue
if target.exists() and target.is_dir():
die(f"file target already exists as directory: {member.name}")
target.parent.mkdir(parents=True, exist_ok=True)
source = tar.extractfile(member)
if source is None:
die(f"cannot read tar member: {member.name}")
tmp = target.with_name(f"{target.name}.extracting")
with source, tmp.open("wb") as out:
shutil.copyfileobj(source, out, 1024 * 1024)
tmp.chmod(0o644)
os.replace(tmp, target)
def parse_manifest(path):
data = {}
with path.open("r", encoding="utf-8") as f:
for lineno, line in enumerate(f, 1):
line = line.rstrip("\n")
if not line:
continue
if "=" not in line:
die(f"manifest line {lineno} must be key=value")
key, value = line.split("=", 1)
if key not in MANIFEST_KEYS:
die(f"unsupported manifest key: {key}")
if key in data:
die(f"duplicate manifest key: {key}")
if any(ord(ch) < 32 for ch in value):
die(f"control character in manifest value: {key}")
data[key] = value
missing = sorted(MANIFEST_KEYS - set(data))
if missing:
die(f"manifest keys missing: {', '.join(missing)}")
if not PATCH_ID_RE.match(data["id"]):
die("manifest id contains unsafe characters")
if data["type"] != "app-overlay":
die(f"unsupported artifact type: {data['type']}")
if data["component"] not in COMPONENTS:
die(f"unsupported component: {data['component']}")
return data
def parse_files_list(path):
entries = []
seen = set()
with path.open("r", encoding="utf-8") as f:
for lineno, raw in enumerate(f, 1):
rel = raw.rstrip("\n")
if not rel:
continue
if rel.strip() != rel:
die(f"files.txt line {lineno} has leading/trailing whitespace")
validate_posix_path(rel)
if rel in seen:
die(f"duplicate files.txt entry: {rel}")
seen.add(rel)
entries.append(rel)
if not entries:
die("files.txt is empty")
return entries
def denied_payload_path(component, rel):
lower = rel.lower()
parts = lower.split("/")
base = parts[-1]
if component == "engine" and rel in (
"docker-compose.yml",
"nodedc-source/services/n8n/private-extensions/n8n-nodes-ndc/Dockerfile",
):
pass
elif component == "platform" and rel in (
"platform/docker-compose.platform-http.yml",
"platform/notification-core/Dockerfile",
"platform/ai-workspace-hub/Dockerfile",
"platform/ai-workspace-assistant/Dockerfile",
"platform/ontology-core/Dockerfile",
"platform/gelios-gateway/Dockerfile",
"platform/services/map-gateway/Dockerfile",
"platform/services/external-data-plane/Dockerfile",
):
pass
elif component == "bim-viewer" and rel in (
"converter/Dockerfile",
):
pass
elif component == "dc-cms" and rel in (
"Dockerfile",
"infra/docker-compose.yml",
):
pass
elif component == "module-foundry" and rel in (
"Dockerfile",
"infra/docker-compose.module-foundry.yml",
):
pass
elif component == "proxy-contur" and rel in (
"Dockerfile",
"docker-compose.yml",
):
pass
elif component == "dc-amd-proxy" and rel in (
"Dockerfile",
"docker-compose.yml",
):
pass
elif base in (".env", "dockerfile", "docker-compose.yml", "compose.yml"):
return "sensitive or infrastructure filename"
if base.endswith(".env") or base.endswith((".pem", ".crt", ".key", ".p12", ".pfx")):
return "secret-like file extension"
if base.endswith((".sh", ".bash", ".zsh")):
return "shell files are not allowed in app-overlay artifacts"
if base.endswith(".zip"):
return "zip backup files are not deployable artifacts"
if any(part in (".git", "node_modules") for part in parts):
return "repository/build dependency directory"
# `packages/tokens` is a versioned UI design-token package, not a secret
# store. Keep this narrowly scoped exception so the general filename guard
# remains in force for every other Module Foundry payload path.
if not (component == "module-foundry" and lower.startswith("packages/tokens/")) and any(
token in lower for token in ("secret", "token", "password")
):
return "secret-like path"
runtime_prefixes = ()
if component == "engine":
runtime_prefixes = (
"nodedc-source/server/data",
"nodedc-source/server/storage",
"nodedc-source/server/logs",
"nodedc-source/public/storage",
"nodedc-source/dist/storage",
)
if rel == "nodedc-source/server/.env":
return "server env file"
elif component == "launcher":
runtime_prefixes = (
"server/data",
"server/storage",
"server/logs",
"public/storage",
"dist/storage",
)
if rel == "server/.env":
return "server env file"
elif component == "platform":
runtime_prefixes = (
"backups",
"launcher",
"ops-agents",
"tasker",
"platform/.env.synology",
"platform/authentik",
"platform/docs",
"authentik/data",
"authentik/certs",
"authentik/media",
"authentik/postgresql",
)
if rel.startswith("platform/.env"):
return "platform env file"
if rel.startswith(("platform/Caddyfile.http.bak", "platform/docker-compose.platform-http.yml.bak")):
return "platform backup file"
elif component == "tasker":
runtime_prefixes = (
"plane-app/.local-web-root",
"plane-app/archive",
"plane-app/backup",
"plane-app/plane.env",
"plane-app/plane.env.example",
"plane-app/plane.env.staging.example",
"plane-app/plane.env.synology",
"plane-app/plane.env.synology.base",
"plane-src/.cache",
"plane-src/.next",
"plane-src/.pnpm-store",
"plane-src/.react-router",
"plane-src/.turbo",
"plane-src/build",
"plane-src/coverage",
"plane-src/dist",
"plane-src/playwright-report",
"plane-src/test-results",
)
if rel.startswith(("plane-app/docker-compose.yaml.bak", "plane-app/docker-compose.synology.override.yml.bak")):
return "tasker backup file"
elif component == "ops-agents":
runtime_prefixes = (
"dist",
)
if rel.startswith(".env"):
return "ops-agents env file"
elif component == "bim-viewer":
runtime_prefixes = (
"dist",
"frontend/dist",
"server/data",
"server/logs",
"server/storage",
"server/tmp",
)
if rel == ".env" or rel.startswith(".env."):
if rel != ".env.synology.example":
return "bim-viewer env file"
if rel.startswith(("docker-compose.beam.yml.bak",)):
return "bim-viewer backup file"
elif component == "module-foundry":
runtime_prefixes = (
"runtime-data",
"node_modules",
"apps/catalog/dist",
"server/data",
"server/logs",
"server/storage",
)
if rel == ".env" or rel.startswith(".env."):
if rel != ".env.example":
return "module-foundry env file"
if rel.startswith(("Dockerfile.bak", "infra/docker-compose.module-foundry.yml.bak")):
return "module-foundry backup file"
elif component == "n8n-private-extension":
# An extension release is inert data at this boundary. Activation is
# Engine-owned and must never be smuggled into the artifact as a script,
# Compose file, environment file, pointer or mount configuration.
extension_parts = PurePosixPath(rel).parts
is_release_directory = (
len(extension_parts) == 3
and extension_parts[0] == "releases"
and extension_parts[1] == "n8n-nodes-ndc"
and N8N_PRIVATE_EXTENSION_RELEASE_RE.fullmatch(extension_parts[2])
)
if not is_release_directory and base not in ("package.tgz", "release.json", "rollback.json"):
return "unexpected private extension release member"
elif component == "proxy-contur":
runtime_prefixes = (
"node_modules",
)
if rel == ".env" or rel.startswith(".env."):
return "proxy-contur env file"
if rel.startswith(("Dockerfile.bak", "docker-compose.yml.bak")):
return "proxy-contur backup file"
elif component == "dc-amd-proxy":
runtime_prefixes = (
"node_modules",
"runtime",
)
if rel == ".env" or rel.startswith(".env."):
return "dc-amd-proxy env file"
if rel.startswith(("Dockerfile.bak", "docker-compose.yml.bak")):
return "dc-amd-proxy backup file"
elif component == "dc-cms":
runtime_prefixes = (
"admin/uploads",
"infra/authentik/certs",
"infra/authentik/data",
"infra/authentik/media",
"infra/authentik/postgresql",
"server/data",
"server/logs",
"server/storage",
"sites",
)
if rel == ".env" or rel.startswith(".env."):
return "dc-cms root env file"
if rel in ("infra/.env", "infra/.env.synology"):
return "dc-cms env file"
if rel.startswith("infra/.env.") and rel not in (
"infra/.env.example",
"infra/.env.synology.example",
):
return "dc-cms env file"
if rel.startswith(("Dockerfile.bak", "infra/docker-compose.yml.bak")):
return "dc-cms backup file"
elif component == "dc-cms-site-nodedc":
runtime_prefixes = (
"assets/.trash",
"assets/uploads/.trash",
"node_modules",
)
if any(rel == prefix or rel.startswith(prefix + "/") for prefix in runtime_prefixes):
return "runtime data path"
return None
def allowed_payload_path(component, rel):
validate_posix_path(rel)
reason = denied_payload_path(component, rel)
if reason:
die(f"path rejected: {rel}: {reason}")
if component == "engine":
if rel in ("nodedc-source/package.json", "nodedc-source/package-lock.json"):
return True
if rel == "docker-compose.yml":
return True
if rel.startswith((
"nodedc-source/server/",
"nodedc-source/src/",
"nodedc-source/dist/",
"nodedc-source/public/",
"nodedc-source/workers/ai-workspace-bridge/",
)):
return True
if rel == "nodedc-source/services/n8n/private-extensions" or rel.startswith(
"nodedc-source/services/n8n/private-extensions/"
):
return True
if rel == "nodedc-source/dist":
return True
if component == "launcher":
if rel in (
"index.html",
"package.json",
"package-lock.json",
"tsconfig.json",
"tsconfig.app.json",
"tsconfig.node.json",
"vite.config.ts",
):
return True
if rel.startswith(("server/", "src/", "public/", "scripts/", "dc-ui-guideline/")):
return True
if component == "platform":
if rel in (
"platform/Caddyfile.http",
"platform/docker-compose.platform-http.yml",
"platform/docker-compose.external-data-plane.yml",
):
return True
if rel == "platform/notification-core" or rel.startswith("platform/notification-core/"):
return True
if rel == "platform/ai-workspace-hub" or rel.startswith("platform/ai-workspace-hub/"):
return True
if rel == "platform/ai-workspace-assistant" or rel.startswith("platform/ai-workspace-assistant/"):
return True
if rel == "platform/ontology-core" or rel.startswith("platform/ontology-core/"):
return True
if rel == "platform/gelios-gateway" or rel.startswith("platform/gelios-gateway/"):
return True
if rel == "platform/services/map-gateway" or rel.startswith("platform/services/map-gateway/"):
return True
if rel == "platform/services/external-data-plane" or rel.startswith("platform/services/external-data-plane/"):
return True
if rel == "platform/packages/external-provider-contract" or rel.startswith("platform/packages/external-provider-contract/"):
return True
if rel == "authentik/custom-templates" or rel.startswith("authentik/custom-templates/"):
return True
if component == "tasker":
if rel in (
"plane-app/docker-compose.yaml",
"plane-app/docker-compose.synology.override.yml",
):
return True
if rel.startswith((
"plane-src/apps/api/",
"plane-src/apps/web/",
"plane-src/packages/",
)):
return True
if component == "ops-agents":
if rel in (
".dockerignore",
".env.synology.example",
".gitignore",
"Dockerfile",
"README.md",
"docker-compose.local.yml",
"docker-compose.synology.yml",
"package.json",
"package-lock.json",
"tsconfig.json",
):
return True
if rel.startswith((
"docs/",
"migrations/",
"src/",
)):
return True
if component == "bim-viewer":
if rel in (
"converter",
"embeddedDemos",
"frontend",
"images",
"locales",
"processor",
"report",
"src",
):
return True
if rel in (
".env.synology.example",
".esdoc.json",
".gitattributes",
".gitignore",
".release-please-manifest.json",
"CHANGELOG.md",
"CODE_OF_CONDUCT.md",
"LICENSE",
"README.md",
"SECURITY.md",
"_config.yml",
"changelog-template.hbs",
"docker-compose.beam.yml",
"embeddedViewer.html",
"index.js",
"package.json",
"package-lock.json",
"release-please-config.json",
"rollup.config.js",
"rollup.dev.config.js",
"webComponentExample.html",
"xeokit-bim-viewer.css",
):
return True
if rel.startswith((
"converter/",
"embeddedDemos/",
"frontend/",
"images/",
"locales/",
"processor/",
"report/",
"server/",
"src/",
)):
return True
if component == "module-foundry":
if rel in (
".dockerignore",
".env.example",
".gitignore",
"Dockerfile",
"package.json",
"package-lock.json",
"tsconfig.base.json",
"infra/docker-compose.module-foundry.yml",
):
return True
if rel in ("apps", "packages", "registry", "runtime-seed", "scripts", "server"):
return True
if rel.startswith((
"apps/",
"packages/",
"registry/",
"runtime-seed/",
"scripts/",
"server/",
)):
return True
if component == "n8n-private-extension":
parts = PurePosixPath(rel).parts
if (
len(parts) == 3
and parts[0] == "releases"
and parts[1] == "n8n-nodes-ndc"
and N8N_PRIVATE_EXTENSION_RELEASE_RE.fullmatch(parts[2])
):
return True
if (
len(parts) == 4
and parts[0] == "releases"
and parts[1] == "n8n-nodes-ndc"
and N8N_PRIVATE_EXTENSION_RELEASE_RE.fullmatch(parts[2])
and parts[3] in ("package.tgz", "release.json", "rollback.json")
):
return True
if component == "proxy-contur":
if rel in (
"Dockerfile",
"README.md",
"docker-compose.yml",
"package.json",
"package-lock.json",
"server.js",
):
return True
if component == "dc-amd-proxy":
if rel in (
"Dockerfile",
"README.md",
"docker-compose.yml",
"package.json",
"server.mjs",
):
return True
if component == "dc-cms":
if rel in (
".dockerignore",
".gitignore",
"Dockerfile",
"README.md",
"package.json",
"package-lock.json",
"infra/.env.example",
"infra/.env.synology.example",
"infra/docker-compose.yml",
"infra/authentik/bootstrap-cms.py",
):
return True
if rel.startswith((
"admin/",
"infra/authentik/custom-templates/",
"infra/reverse-proxy/",
"projects/",
"server/",
)):
return True
if component == "dc-cms-site-nodedc":
if rel in (
".gitignore",
"README.md",
"asset-manifest.json",
"index.html",
"package.json",
"package-lock.json",
"robots.txt",
"sitemap.xml",
):
return True
if rel.startswith((
"assets/",
"content/",
"knowledge/",
"templates/",
"tools/",
)):
return True
die(f"path allowlist rejected: {rel}")
def path_is_covered_by_files_list(rel, entries):
return any(rel == entry or rel.startswith(entry.rstrip("/") + "/") for entry in entries)
def validate_payload_tree(component, payload_dir, entries):
root = payload_dir.resolve()
for path in payload_dir.rglob("*"):
resolved = path.resolve(strict=False)
if not is_relative_to(resolved, root):
die(f"payload path escaped payload dir: {path}")
rel = path.relative_to(payload_dir).as_posix()
if path.is_symlink():
die(f"payload symlink rejected: {rel}")
if not path.is_file() and not path.is_dir():
die(f"payload special file rejected: {rel}")
if path.is_dir():
continue
allowed_payload_path(component, rel)
if not path_is_covered_by_files_list(rel, entries):
die(f"payload file is not covered by files.txt: {rel}")
def read_strict_json(path, label, max_bytes=64 * 1024):
try:
file_stat = path.lstat()
except FileNotFoundError:
die(f"{label} missing: {path}")
if stat.S_ISLNK(file_stat.st_mode) or not stat.S_ISREG(file_stat.st_mode):
die(f"{label} must be a regular file")
if file_stat.st_size < 2 or file_stat.st_size > max_bytes:
die(f"{label} has invalid size")
def reject_duplicate_keys(pairs):
result = {}
for key, value in pairs:
if key in result:
die(f"{label} has duplicate key: {key}")
result[key] = value
return result
try:
return json.loads(
path.read_text(encoding="utf-8"),
object_pairs_hook=reject_duplicate_keys,
)
except UnicodeDecodeError:
die(f"{label} is not utf-8")
except json.JSONDecodeError as exc:
die(f"{label} is invalid json: {exc.msg}")
def require_exact_json_keys(value, expected, label):
if not isinstance(value, dict):
die(f"{label} must be an object")
actual = set(value)
if actual != set(expected):
die(f"{label} keys mismatch: expected={sorted(expected)} actual={sorted(actual)}")
def validate_n8n_package_tarball(package_path, release):
package_stat = package_path.lstat()
if stat.S_ISLNK(package_stat.st_mode) or not stat.S_ISREG(package_stat.st_mode):
die("private extension package must be a regular file")
if package_stat.st_size < 1024 or package_stat.st_size > 32 * 1024 * 1024:
die("private extension package has invalid size")
if sha256_file(package_path) != release["package"]["sha256"]:
die("private extension package sha256 mismatch")
if package_stat.st_size != release["package"]["bytes"]:
die("private extension package byte count mismatch")
names = set()
package_bytes = 0
package_json_bytes = None
packed_node_sources = {}
with tarfile.open(package_path, "r:gz") as archive:
for member_count, member in enumerate(archive, 1):
if member_count > 512:
die("private extension package has too many members")
validate_posix_path(member.name)
if member.name in names:
die(f"duplicate private extension package member: {member.name}")
names.add(member.name)
if member.name != "package" and not member.name.startswith("package/"):
die(f"private extension package member escaped package root: {member.name}")
if not (member.isfile() or member.isdir()):
die(f"unsupported private extension package member: {member.name}")
if member.name == "package":
if not member.isdir():
die("private extension package root must be a directory")
else:
package_rel = PurePosixPath(member.name).relative_to("package")
if any(part.startswith(".") or part.startswith("._") for part in package_rel.parts):
die(f"private extension package hidden member rejected: {member.name}")
if not (
package_rel.as_posix() in ("README.md", "package.json")
or package_rel.as_posix().startswith("dist/")
):
die(f"private extension package member is not allowlisted: {member.name}")
expected_mode = 0o644 if member.isfile() else 0o755
if stat.S_IMODE(member.mode) != expected_mode:
die(f"unsafe private extension package mode: {member.name}")
if member.isfile():
package_bytes += member.size
if member.size > 4 * 1024 * 1024 or package_bytes > 64 * 1024 * 1024:
die("private extension package payload is too large")
if member.name == "package/package.json":
source = archive.extractfile(member)
if source is None:
die("private extension package.json cannot be read")
package_json_bytes = source.read(64 * 1024 + 1)
if member.name.startswith("package/dist/nodes/") and member.name.endswith(".node.js"):
source = archive.extractfile(member)
if source is None:
die(f"private extension packed node cannot be read: {member.name}")
packed_node_sources[member.name[len("package/"):]] = source.read(4 * 1024 * 1024 + 1)
if package_json_bytes is None or len(package_json_bytes) > 64 * 1024:
die("private extension package.json missing or too large")
try:
package_json = json.loads(package_json_bytes.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
die("private extension package.json is invalid")
if package_json.get("name") != "n8n-nodes-ndc":
die("private extension package name mismatch")
if package_json.get("version") != release["package"]["version"]:
die("private extension package version mismatch")
if package_json.get("private") is not True:
die("private extension package must remain private")
if package_json.get("dependencies") not in (None, {}):
die("private extension runtime dependencies are forbidden")
scripts = package_json.get("scripts") or {}
if not isinstance(scripts, dict):
die("private extension package scripts must be an object")
for lifecycle in ("preinstall", "install", "postinstall", "prepack", "prepare", "postpack"):
if lifecycle in scripts:
die(f"private extension lifecycle script is forbidden: {lifecycle}")
n8n = package_json.get("n8n")
if not isinstance(n8n, dict):
die("private extension n8n registration missing")
registered = []
expected_registrations = {
"nodes": N8N_PRIVATE_EXTENSION_NODES,
"credentials": N8N_PRIVATE_EXTENSION_CREDENTIALS,
}
for field, expected in expected_registrations.items():
values = n8n.get(field)
if not isinstance(values, list) or not values:
die(f"private extension n8n.{field} registration missing")
if values != list(expected):
die(f"private extension n8n.{field} registration mismatch")
for value in values:
if not isinstance(value, str) or not value.startswith("dist/") or value not in names and f"package/{value}" not in names:
die(f"private extension n8n.{field} member missing: {value}")
registered.append(value)
if len(registered) != len(set(registered)):
die("private extension n8n registration contains duplicates")
packed_nodes = sorted(
name[len("package/"):]
for name in names
if name.startswith("package/dist/nodes/") and name.endswith(".node.js")
)
packed_credentials = sorted(
name[len("package/"):]
for name in names
if name.startswith("package/dist/credentials/") and name.endswith(".credentials.js")
)
if packed_nodes != sorted(N8N_PRIVATE_EXTENSION_NODES):
die("private extension packed node set mismatch")
if packed_credentials != sorted(N8N_PRIVATE_EXTENSION_CREDENTIALS):
die("private extension packed credential set mismatch")
for node_path in N8N_PRIVATE_EXTENSION_NODES:
source = packed_node_sources.get(node_path)
if source is None or len(source) > 4 * 1024 * 1024:
die(f"private extension packed node source missing or too large: {node_path}")
if b"usableAsTool" in source:
die(f"private extension packed node would generate a tool variant: {node_path}")
def validate_n8n_private_extension_release(payload_dir, entries):
if len(entries) != 1:
die("private extension artifact must contain exactly one immutable release entry")
release_rel = entries[0]
parts = PurePosixPath(release_rel).parts
if (
len(parts) != 3
or parts[0] != "releases"
or parts[1] != "n8n-nodes-ndc"
or not N8N_PRIVATE_EXTENSION_RELEASE_RE.fullmatch(parts[2])
):
die("private extension files.txt entry must be a digest-bound release directory")
release_dir = payload_dir / release_rel
expected_files = {"package.tgz", "release.json", "rollback.json"}
actual_files = {path.name for path in release_dir.iterdir() if path.is_file()}
actual_dirs = [path.name for path in release_dir.iterdir() if path.is_dir()]
if actual_dirs or actual_files != expected_files:
die("private extension release must contain only package.tgz, release.json and rollback.json")
release = read_strict_json(release_dir / "release.json", "private extension release manifest")
require_exact_json_keys(release, ("schemaVersion", "releaseId", "package", "storage", "activation"), "private extension release manifest")
require_exact_json_keys(release["package"], ("name", "version", "sha256", "bytes", "runtimeTypePrefix"), "private extension package manifest")
require_exact_json_keys(release["storage"], ("relativePath", "immutable"), "private extension storage manifest")
require_exact_json_keys(
release["activation"],
(
"owner",
"status",
"requiredCommunityPackagePath",
"requiresAtomicReleaseSwitch",
"requiresAllN8nProcessesRestart",
"requiresMcpSchemaAcceptance",
"rollbackBaselinePolicy",
),
"private extension activation manifest",
)
require_exact_json_keys(
release["activation"]["rollbackBaselinePolicy"],
("allowed", "firstActivation", "requiresPreActivationVerification"),
"private extension rollback baseline policy",
)
release_id = parts[2]
package = release["package"]
activation = release["activation"]
if release["schemaVersion"] != "nodedc.n8n-private-extension-release/v2":
die("private extension release schema mismatch")
if release["releaseId"] != release_id:
die("private extension release id mismatch")
if package["name"] != "n8n-nodes-ndc" or package["runtimeTypePrefix"] != "n8n-nodes-ndc.":
die("private extension package identity mismatch")
if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", str(package["version"])):
die("private extension package version is invalid")
if not re.fullmatch(r"[a-f0-9]{64}", str(package["sha256"])):
die("private extension package sha256 is invalid")
if not isinstance(package["bytes"], int) or isinstance(package["bytes"], bool):
die("private extension package byte count is invalid")
if release_id != f"{package['version']}-{package['sha256'][:16]}":
die("private extension release id is not digest-bound")
if release["storage"] != {"relativePath": release_rel, "immutable": True}:
die("private extension storage manifest mismatch")
rollback_baseline_policy = {
"allowed": [
"previous_verified_immutable_release",
"verified_inactive",
],
"firstActivation": "verified_inactive",
"requiresPreActivationVerification": True,
}
if activation != {
"owner": "engine",
"status": "blocked_pending_engine_owned_mount",
"requiredCommunityPackagePath": "/home/node/.n8n/nodes/node_modules/n8n-nodes-ndc",
"requiresAtomicReleaseSwitch": True,
"requiresAllN8nProcessesRestart": True,
"requiresMcpSchemaAcceptance": True,
"rollbackBaselinePolicy": rollback_baseline_policy,
}:
die("private extension activation boundary mismatch")
rollback = read_strict_json(release_dir / "rollback.json", "private extension rollback manifest")
require_exact_json_keys(
rollback,
("schemaVersion", "releaseId", "packageSha256", "mode", "baselinePolicy", "steps", "forbidden"),
"private extension rollback manifest",
)
require_exact_json_keys(
rollback["baselinePolicy"],
("allowed", "firstActivation", "requiresPreActivationVerification"),
"private extension rollback manifest baseline policy",
)
if rollback != {
"schemaVersion": "nodedc.n8n-private-extension-rollback/v2",
"releaseId": release_id,
"packageSha256": package["sha256"],
"mode": "engine-owned-atomic-release-switch",
"baselinePolicy": rollback_baseline_policy,
"steps": [
"select_verified_previous_release_or_preverified_inactive_baseline",
"switch_engine_owned_mount_atomically",
"restart_all_n8n_processes",
"verify_mcp_schema_state_matches_selected_baseline",
],
"forbidden": [
"delete_active_release",
"mutate_engine_core",
"live_npm_install",
],
}:
die("private extension rollback manifest mismatch")
validate_n8n_package_tarball(release_dir / "package.tgz", release)
def is_engine_n8n_transition(component, entries):
return component == "engine" and entries is not None and ENGINE_N8N_TRANSITION_DESCRIPTOR_REL in entries
def read_engine_n8n_transition_descriptor(path, label="Engine n8n transition descriptor"):
descriptor = read_strict_json(path, label)
require_exact_json_keys(
descriptor,
(
"schemaVersion",
"action",
"releaseId",
"packageVersion",
"packageSha256",
"n8nVersion",
"baseImage",
"baseImageArchitecture",
"baseImageIdentityPolicy",
"sealedReleaseRelativePath",
"composeOverride",
"runtimePackagePath",
"topologyServices",
"expectedCurrent",
"expectedNodeTypes",
"expectedCredentialTypes",
"rollbackBaseline",
),
label,
)
action = descriptor.get("action")
if descriptor.get("schemaVersion") != "nodedc.engine-n8n-private-extension-transition/v1":
die("Engine n8n transition schema mismatch")
if action not in ("activate", "rollback-inactive"):
die("Engine n8n transition action mismatch")
release_id = descriptor.get("releaseId")
package_version = descriptor.get("packageVersion")
package_sha256 = descriptor.get("packageSha256")
if not isinstance(release_id, str) or not N8N_PRIVATE_EXTENSION_RELEASE_RE.fullmatch(release_id):
die("Engine n8n transition release id is invalid")
if not isinstance(package_version, str) or not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", package_version):
die("Engine n8n transition package version is invalid")
if not isinstance(package_sha256, str) or not re.fullmatch(r"[a-f0-9]{64}", package_sha256):
die("Engine n8n transition package sha256 is invalid")
if release_id != f"{package_version}-{package_sha256[:16]}":
die("Engine n8n transition release identity mismatch")
expected_sealed_path = f"n8n-private-extensions/releases/n8n-nodes-ndc/{release_id}/package"
exact_common = {
"n8nVersion": ENGINE_N8N_VERSION,
"baseImage": ENGINE_N8N_BASE_IMAGE,
"baseImageArchitecture": ENGINE_N8N_BASE_ARCHITECTURE,
"baseImageIdentityPolicy": "running-container-and-local-tag-must-match",
"sealedReleaseRelativePath": expected_sealed_path,
"composeOverride": ENGINE_N8N_COMPOSE_OVERRIDE_REL,
"runtimePackagePath": ENGINE_N8N_RUNTIME_PACKAGE_PATH,
"topologyServices": ["n8n"],
"rollbackBaseline": "verified_inactive",
}
for key, expected in exact_common.items():
if descriptor.get(key) != expected:
die(f"Engine n8n transition {key} mismatch")
if action == "activate":
if descriptor.get("expectedCurrent") != "verified_inactive":
die("Engine n8n activation must start from the verified inactive baseline")
if descriptor.get("expectedNodeTypes") != list(ENGINE_N8N_NODE_TYPES):
die("Engine n8n activation node type set mismatch")
if descriptor.get("expectedCredentialTypes") != list(ENGINE_N8N_CREDENTIAL_TYPES):
die("Engine n8n activation credential type set mismatch")
else:
if descriptor.get("expectedCurrent") != release_id:
die("Engine n8n rollback expected-current release mismatch")
if descriptor.get("expectedNodeTypes") != [] or descriptor.get("expectedCredentialTypes") != []:
die("Engine n8n rollback must select the inactive catalog")
return descriptor
def expected_engine_n8n_compose_override(descriptor):
health_script = (
"const http=require('http');const req=http.get('http://127.0.0.1:5678/healthz/readiness',"
"r=>{r.resume();process.exit(r.statusCode===200?0:1)});req.on('error',()=>process.exit(1));"
"req.setTimeout(4000,()=>{req.destroy();process.exit(1)});"
)
health_test = json.dumps(["CMD", "node", "-e", health_script], separators=(",", ":"))
mount_source = f"/volume2/nodedc-demo/{descriptor['sealedReleaseRelativePath']}"
return "\n".join((
"services:",
" n8n:",
f" image: {ENGINE_N8N_BASE_IMAGE}",
" platform: linux/amd64",
" pull_policy: never",
" environment:",
" N8N_USER_FOLDER: /home/node",
' N8N_COMMUNITY_PACKAGES_ENABLED: "true"',
' N8N_COMMUNITY_PACKAGES_PREVENT_LOADING: "false"',
' N8N_REINSTALL_MISSING_PACKAGES: "false"',
" volumes:",
f" - {mount_source}:{ENGINE_N8N_RUNTIME_PACKAGE_PATH}:ro",
" healthcheck:",
f" test: {health_test}",
" interval: 10s",
" timeout: 5s",
" retries: 30",
" start_period: 30s",
" labels:",
f" nodedc.n8n-private-extension.release: {descriptor['releaseId']}",
f" nodedc.n8n-private-extension.package-sha256: {descriptor['packageSha256']}",
"",
))
def validate_engine_n8n_catalog_payload(payload_dir, descriptor):
action = descriptor["action"]
nodes_path = payload_dir / ENGINE_N8N_NODES_CATALOG_REL
credentials_path = payload_dir / ENGINE_N8N_CREDENTIALS_CATALOG_REL
meta_path = payload_dir / ENGINE_N8N_SCHEMA_META_REL
nodes = read_strict_json(
nodes_path,
"Engine n8n node catalog",
max_bytes=32 * 1024 * 1024,
)
credentials = read_strict_json(
credentials_path,
"Engine n8n credential catalog",
max_bytes=4 * 1024 * 1024,
)
meta = read_strict_json(meta_path, "Engine n8n schema metadata")
if not isinstance(nodes, list) or not isinstance(credentials, list):
die("Engine n8n schema catalogs must be arrays")
private_nodes = [
item for item in nodes
if isinstance(item, dict) and str(item.get("name") or "").startswith("n8n-nodes-ndc.")
]
private_credentials = [
item for item in credentials
if isinstance(item, dict) and str(item.get("name") or "") in ENGINE_N8N_CREDENTIAL_TYPES
]
expected_node_types = list(ENGINE_N8N_NODE_TYPES) if action == "activate" else []
expected_credential_types = list(ENGINE_N8N_CREDENTIAL_TYPES) if action == "activate" else []
if [item.get("name") for item in private_nodes] != expected_node_types:
die("Engine n8n pinned node catalog exact set mismatch")
if [item.get("name") for item in private_credentials] != expected_credential_types:
die("Engine n8n pinned credential catalog exact set mismatch")
if any("usableAsTool" in item for item in private_nodes):
die("Engine n8n pinned node catalog would generate tool variants")
if action == "activate":
if len(nodes) != 437 or len(credentials) != 388:
die("Engine n8n activation catalog count mismatch")
if any(not str(item.get("displayName") or "").startswith("NDC ") for item in private_nodes):
die("Engine n8n private node visible name mismatch")
if any(not str(item.get("displayName") or "").startswith("NDC ") for item in private_credentials):
die("Engine n8n private credential visible name mismatch")
private_catalog_text = json.dumps(
{"nodes": private_nodes, "credentials": private_credentials},
ensure_ascii=False,
).lower()
if "gelios" in private_catalog_text or "robot2b" in private_catalog_text:
die("provider business identity is forbidden in the Engine n8n extension catalog")
else:
if len(nodes) != 434 or len(credentials) != 385:
die("Engine n8n inactive baseline catalog count mismatch")
if not isinstance(meta, dict):
die("Engine n8n schema metadata must be an object")
if meta.get("n8nVersion") != ENGINE_N8N_VERSION:
die("Engine n8n schema metadata version mismatch")
if meta.get("nodeCount") != len(nodes) or meta.get("credentialCount") != len(credentials):
die("Engine n8n schema metadata count mismatch")
if action == "activate":
if meta.get("source") != f"n8n-core+n8n-nodes-ndc@{descriptor['packageVersion']}":
die("Engine n8n schema metadata source mismatch")
if sha256_file(payload_dir / ENGINE_N8N_ICON_REL) != ENGINE_N8N_ICON_SHA256:
die("Engine n8n light icon sha256 mismatch")
if sha256_file(payload_dir / ENGINE_N8N_DARK_ICON_REL) != ENGINE_N8N_DARK_ICON_SHA256:
die("Engine n8n dark icon sha256 mismatch")
expected_catalog_hashes = (
(nodes, ENGINE_N8N_ACTIVATION_NODES_CATALOG_JSON_SHA256, "node"),
(credentials, ENGINE_N8N_ACTIVATION_CREDENTIALS_CATALOG_JSON_SHA256, "credential"),
(meta, ENGINE_N8N_ACTIVATION_META_JSON_SHA256, "metadata"),
)
else:
expected_catalog_hashes = (
(nodes, ENGINE_N8N_INACTIVE_NODES_CATALOG_JSON_SHA256, "node"),
(credentials, ENGINE_N8N_INACTIVE_CREDENTIALS_CATALOG_JSON_SHA256, "credential"),
(meta, ENGINE_N8N_INACTIVE_META_JSON_SHA256, "metadata"),
)
for catalog, expected_sha256, label in expected_catalog_hashes:
if sha256_json_value(catalog) != expected_sha256:
die(f"Engine n8n {action} {label} catalog sha256 mismatch")
def validate_engine_n8n_transition(payload_dir, entries):
descriptor = read_engine_n8n_transition_descriptor(payload_dir / ENGINE_N8N_TRANSITION_DESCRIPTOR_REL)
activation_entries = [
ENGINE_N8N_TRANSITION_DESCRIPTOR_REL,
ENGINE_N8N_COMPOSE_OVERRIDE_REL,
ENGINE_N8N_NODES_CATALOG_REL,
ENGINE_N8N_CREDENTIALS_CATALOG_REL,
ENGINE_N8N_SCHEMA_META_REL,
ENGINE_N8N_ICON_REL,
ENGINE_N8N_DARK_ICON_REL,
]
rollback_entries = [
ENGINE_N8N_TRANSITION_DESCRIPTOR_REL,
ENGINE_N8N_NODES_CATALOG_REL,
ENGINE_N8N_CREDENTIALS_CATALOG_REL,
ENGINE_N8N_SCHEMA_META_REL,
]
expected_entries = activation_entries if descriptor["action"] == "activate" else rollback_entries
if entries != expected_entries:
die("Engine n8n transition files.txt exact set/order mismatch")
if descriptor["action"] == "activate":
override = payload_dir / ENGINE_N8N_COMPOSE_OVERRIDE_REL
try:
override_text = override.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
die("Engine n8n Compose override cannot be read")
if override_text != expected_engine_n8n_compose_override(descriptor):
die("Engine n8n Compose override mismatch")
if "N8N_CUSTOM_EXTENSIONS" in override_text or "CUSTOM." in override_text:
die("Engine n8n custom-extension loader is forbidden")
validate_engine_n8n_catalog_payload(payload_dir, descriptor)
return descriptor
def current_engine_n8n_transition_descriptor():
path = component_root("engine") / ENGINE_N8N_TRANSITION_DESCRIPTOR_REL
if not path.exists():
return None
if path.is_symlink() or not path.is_file():
die("installed Engine n8n transition descriptor is unsafe")
return read_engine_n8n_transition_descriptor(path, "installed Engine n8n transition descriptor")
def validate_engine_n8n_staged_release(descriptor):
release_rel = f"releases/n8n-nodes-ndc/{descriptor['releaseId']}"
release_dir = N8N_PRIVATE_EXTENSION_RELEASES_ROOT / release_rel
current = N8N_PRIVATE_EXTENSION_RELEASES_ROOT
for part in PurePosixPath(release_rel).parts:
current = current / part
try:
current_stat = current.lstat()
except FileNotFoundError:
die(f"staged private extension release is missing: {release_rel}")
if stat.S_ISLNK(current_stat.st_mode):
die(f"staged private extension release symlink rejected: {current}")
validate_n8n_private_extension_release(N8N_PRIVATE_EXTENSION_RELEASES_ROOT, [release_rel])
release = read_strict_json(release_dir / "release.json", "staged private extension release manifest")
if release.get("releaseId") != descriptor["releaseId"]:
die("staged private extension release id mismatch")
if release.get("package", {}).get("version") != descriptor["packageVersion"]:
die("staged private extension package version mismatch")
if release.get("package", {}).get("sha256") != descriptor["packageSha256"]:
die("staged private extension package sha256 mismatch")
for path in [release_dir, *release_dir.rglob("*")]:
path_stat = path.lstat()
if path_stat.st_uid != 0 or path_stat.st_gid != 0:
die(f"staged private extension release ownership mismatch: {path}")
expected_mode = 0o555 if stat.S_ISDIR(path_stat.st_mode) else 0o444
if stat.S_IMODE(path_stat.st_mode) != expected_mode:
die(f"staged private extension release mode mismatch: {path}")
return release_dir
def load_artifact(artifact, work_dir):
safe_extract(artifact, work_dir)
manifest_path = work_dir / "manifest.env"
files_path = work_dir / "files.txt"
payload_dir = work_dir / "payload"
if not manifest_path.is_file():
die("manifest.env missing after extract")
if not files_path.is_file():
die("files.txt missing after extract")
if not payload_dir.is_dir():
die("payload directory missing after extract")
manifest = parse_manifest(manifest_path)
entries = parse_files_list(files_path)
for rel in entries:
allowed_payload_path(manifest["component"], rel)
if not (payload_dir / rel).exists():
die(f"files.txt entry missing in payload: {rel}")
validate_payload_tree(manifest["component"], payload_dir, entries)
if manifest["component"] == "n8n-private-extension":
validate_n8n_private_extension_release(payload_dir, entries)
if manifest["component"] == "engine":
touches_private_extension = any(
rel == "nodedc-source/services/n8n/private-extensions"
or rel.startswith("nodedc-source/services/n8n/private-extensions/")
for rel in entries
)
if touches_private_extension and not is_engine_n8n_transition(manifest["component"], entries):
die("Engine private-extension payload requires the canonical transition descriptor")
if is_engine_n8n_transition(manifest["component"], entries):
validate_engine_n8n_transition(payload_dir, entries)
return manifest, entries, payload_dir
def load_state(path):
if not path.exists():
return []
rows = []
with path.open("r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
rows.append(json.loads(line))
return rows
def append_jsonl(path, row):
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("a", encoding="utf-8") as f:
f.write(json.dumps(row, ensure_ascii=False, sort_keys=True) + "\n")
def state_has_sha(sha):
return any(row.get("sha256") == sha for row in load_state(STATE_FILE))
def state_has_patch_id(patch_id):
return any(row.get("id") == patch_id for row in load_state(STATE_FILE))
class DeployLock:
def __enter__(self):
try:
LOCK_DIR.mkdir()
except FileExistsError:
die(f"deploy lock is busy: {LOCK_DIR}")
(LOCK_DIR / "pid").write_text(str(os.getpid()), encoding="utf-8")
return self
def __exit__(self, exc_type, exc, tb):
shutil.rmtree(LOCK_DIR, ignore_errors=True)
def component_root(component):
return COMPONENTS[component]["payload_root"]
def component_compose_root(component):
return COMPONENTS[component].get("compose_root", component_root(component))
def component_compose_project(component):
return COMPONENTS[component].get("compose_project")
def component_artifact_only(component):
return bool(COMPONENTS[component].get("artifact_only"))
def component_services(component, entries=None):
if is_engine_n8n_transition(component, entries):
# The actual Engine topology has one n8n process and no separately
# deployed worker/webhook services. The release barrier therefore
# force-recreates exactly this service and never touches its database.
return ("n8n",)
if component == "dc-cms" and entries is not None:
selected = []
def add(*services):
for service in services:
if service not in selected:
selected.append(service)
touches_compose = any(rel == "infra/docker-compose.yml" for rel in entries)
touches_proxy = any(rel == "infra/reverse-proxy" or rel.startswith("infra/reverse-proxy/") for rel in entries)
touches_authentik = any(rel == "infra/authentik" or rel.startswith("infra/authentik/") for rel in entries)
touches_app = any(
rel in ("Dockerfile", "package.json", "package-lock.json", "README.md")
or rel.startswith(("admin/", "projects/", "server/"))
for rel in entries
)
if touches_compose:
return COMPONENTS[component]["services"]
if touches_authentik:
add("postgresql-authentik", "authentik-server", "authentik-worker", "authentik-bootstrap", "cms-app", "reverse-proxy")
if touches_app:
add("cms-app")
if touches_proxy:
add("reverse-proxy")
if selected:
return tuple(selected)
if component == "tasker" and entries is not None:
selected = ["api", "worker", "beat-worker", "web"]
touches_compose = any(
rel in ("plane-app/docker-compose.yaml", "plane-app/docker-compose.synology.override.yml")
for rel in entries
)
touches_proxy = any(rel == "plane-src/apps/proxy/Caddyfile.ce" for rel in entries)
if touches_compose or touches_proxy:
selected.append("proxy")
return tuple(selected)
if component == "platform" and entries is not None:
selected = []
def add(*services):
for service in services:
if service not in selected:
selected.append(service)
touches_compose = any(rel == "platform/docker-compose.platform-http.yml" for rel in entries)
touches_caddy = any(rel == "platform/Caddyfile.http" for rel in entries)
touches_notification = any(rel == "platform/notification-core" or rel.startswith("platform/notification-core/") for rel in entries)
touches_ai_workspace = any(rel == "platform/ai-workspace-hub" or rel.startswith("platform/ai-workspace-hub/") for rel in entries)
touches_ai_workspace_assistant = any(rel == "platform/ai-workspace-assistant" or rel.startswith("platform/ai-workspace-assistant/") for rel in entries)
touches_ontology = any(rel == "platform/ontology-core" or rel.startswith("platform/ontology-core/") for rel in entries)
touches_gelios = any(rel == "platform/gelios-gateway" or rel.startswith("platform/gelios-gateway/") for rel in entries)
touches_map_gateway = any(rel == "platform/services/map-gateway" or rel.startswith("platform/services/map-gateway/") for rel in entries)
touches_external_data_plane = any(rel == "platform/services/external-data-plane" or rel.startswith("platform/services/external-data-plane/") or rel == "platform/packages/external-provider-contract" or rel.startswith("platform/packages/external-provider-contract/") or rel == "platform/docker-compose.external-data-plane.yml" for rel in entries)
touches_authentik = any(rel == "authentik/custom-templates" or rel.startswith("authentik/custom-templates/") for rel in entries)
map_gateway_only_compose = touches_compose and touches_map_gateway and not any((touches_notification, touches_ai_workspace, touches_ai_workspace_assistant, touches_ontology, touches_gelios, touches_external_data_plane, touches_authentik))
restart_all_for_compose = touches_compose and not map_gateway_only_compose
if touches_notification or restart_all_for_compose:
add("notification-postgres", "notification-core", "launcher", "reverse-proxy")
if touches_ai_workspace or restart_all_for_compose:
add("ai-workspace-hub", "reverse-proxy")
if touches_ai_workspace_assistant or restart_all_for_compose:
add("ai-workspace-postgres", "ai-workspace-assistant")
if touches_ontology or restart_all_for_compose:
add("ontology-core", "ai-workspace-hub")
if touches_gelios or restart_all_for_compose:
add("gelios-postgres", "gelios-gateway")
if touches_map_gateway:
add("map-gateway")
if touches_external_data_plane:
add("external-data-plane-postgres", "external-data-plane")
if touches_authentik:
add("authentik-server", "authentik-worker", "reverse-proxy")
if touches_caddy:
add("reverse-proxy")
if selected:
return tuple(selected)
return COMPONENTS[component]["services"]
def component_compose_no_deps(component, entries=None):
if component == "dc-cms" and entries is not None:
touches_compose = any(rel == "infra/docker-compose.yml" for rel in entries)
touches_proxy = any(rel == "infra/reverse-proxy" or rel.startswith("infra/reverse-proxy/") for rel in entries)
touches_authentik = any(rel == "infra/authentik" or rel.startswith("infra/authentik/") for rel in entries)
return not (touches_compose or touches_proxy or touches_authentik)
return bool(COMPONENTS[component].get("compose_no_deps"))
def component_compose_files(component):
if component == "engine":
root = component_root(component)
files = [root / "docker-compose.yml"]
descriptor = current_engine_n8n_transition_descriptor()
if descriptor and descriptor["action"] == "activate":
override = root / ENGINE_N8N_COMPOSE_OVERRIDE_REL
if override.is_symlink() or not override.is_file():
die("active Engine n8n Compose override is missing or unsafe")
try:
override_text = override.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
die("active Engine n8n Compose override cannot be read")
if override_text != expected_engine_n8n_compose_override(descriptor):
die("installed Engine n8n Compose override drift detected")
files.append(override)
return tuple(files)
return COMPONENTS[component].get("compose_files", ())
def component_compose_env_file(component):
return COMPONENTS[component].get("compose_env_file")
def component_build_root(component):
return COMPONENTS[component].get("build_root")
def component_build_args(component, entries=None):
if component == "platform" and entries is not None:
touches_notification = any(rel == "platform/notification-core" or rel.startswith("platform/notification-core/") for rel in entries)
return COMPONENTS[component].get("build") if touches_notification else None
if component == "bim-viewer" and entries is not None:
touches_converter = any(rel == "converter" or rel.startswith("converter/") for rel in entries)
return COMPONENTS[component].get("build") if touches_converter else None
return COMPONENTS[component].get("build")
def component_builds(component, entries=None):
if component == "platform" and entries is not None:
touches_compose = any(rel == "platform/docker-compose.platform-http.yml" for rel in entries)
touches_notification = any(rel == "platform/notification-core" or rel.startswith("platform/notification-core/") for rel in entries)
touches_ai_workspace = any(rel == "platform/ai-workspace-hub" or rel.startswith("platform/ai-workspace-hub/") for rel in entries)
touches_ai_workspace_assistant = any(rel == "platform/ai-workspace-assistant" or rel.startswith("platform/ai-workspace-assistant/") for rel in entries)
touches_ontology = any(rel == "platform/ontology-core" or rel.startswith("platform/ontology-core/") for rel in entries)
touches_gelios = any(rel == "platform/gelios-gateway" or rel.startswith("platform/gelios-gateway/") for rel in entries)
touches_map_gateway = any(rel == "platform/services/map-gateway" or rel.startswith("platform/services/map-gateway/") for rel in entries)
touches_external_data_plane = any(rel == "platform/services/external-data-plane" or rel.startswith("platform/services/external-data-plane/") or rel == "platform/packages/external-provider-contract" or rel.startswith("platform/packages/external-provider-contract/") or rel == "platform/docker-compose.external-data-plane.yml" for rel in entries)
map_gateway_only_compose = touches_compose and touches_map_gateway and not any((touches_notification, touches_ai_workspace, touches_ai_workspace_assistant, touches_ontology, touches_gelios, touches_external_data_plane))
rebuild_all_for_compose = touches_compose and not map_gateway_only_compose
builds = []
if touches_notification or rebuild_all_for_compose:
builds.append((
Path("/volume1/docker/nodedc-platform/platform/notification-core"),
("build", "--no-cache", "-t", "nodedc/notification-core:local", "."),
))
if touches_ai_workspace or rebuild_all_for_compose:
builds.append((
Path("/volume1/docker/nodedc-platform/platform/ai-workspace-hub"),
("build", "--no-cache", "-t", "nodedc/ai-workspace-hub:local", "."),
))
if touches_ai_workspace_assistant or rebuild_all_for_compose:
builds.append((
Path("/volume1/docker/nodedc-platform/platform/ai-workspace-assistant"),
("build", "--no-cache", "-t", "nodedc/ai-workspace-assistant:local", "."),
))
if touches_ontology or rebuild_all_for_compose:
builds.append((
Path("/volume1/docker/nodedc-platform/platform/ontology-core"),
("build", "--no-cache", "-t", "nodedc/ontology-core:local", "."),
))
if touches_gelios or rebuild_all_for_compose:
builds.append((
Path("/volume1/docker/nodedc-platform/platform/gelios-gateway"),
("build", "--no-cache", "-t", "nodedc/gelios-gateway:local", "."),
))
if touches_map_gateway:
builds.append((
Path("/volume1/docker/nodedc-platform/platform/services/map-gateway"),
("build", "--no-cache", "-t", "nodedc/map-gateway:local", "."),
))
if touches_external_data_plane:
builds.append((
Path("/volume1/docker/nodedc-platform/platform"),
("build", "--no-cache", "-f", "services/external-data-plane/Dockerfile", "-t", "nodedc/external-data-plane:local", "."),
))
return tuple(builds)
if component == "tasker":
plane_src = Path("/volume1/docker/nodedc-platform/tasker/plane-src")
return (
(
plane_src / "apps/api",
(
"build",
"--network=host",
"-t",
"nodedc/plane-backend:local",
"-f",
"Dockerfile.api",
".",
),
),
(
plane_src,
(
"build",
"--network=host",
"--build-arg",
"VITE_NODEDC_LAUNCHER_URL=https://hub.nodedc.ru",
"--build-arg",
"VITE_NODEDC_OIDC_LOGIN_ENABLED=1",
"-t",
"nodedc/plane-frontend:ru",
"-f",
"apps/web/Dockerfile.web.nas-legacy",
".",
),
),
)
build_root = component_build_root(component)
build_args = component_build_args(component, entries)
if build_root and build_args:
return ((build_root, build_args),)
return ()
def docker_json(args, label):
result = subprocess.run(
[str(DOCKER), *args],
check=False,
capture_output=True,
text=True,
)
if result.returncode != 0:
die(f"{label} failed: exit={result.returncode}")
try:
return json.loads(result.stdout)
except json.JSONDecodeError:
die(f"{label} returned invalid JSON")
def engine_n8n_container_ids():
result = subprocess.run(
[*compose_base_cmd("engine"), "ps", "-q", "n8n"],
cwd=str(component_compose_root("engine")),
check=False,
capture_output=True,
text=True,
)
if result.returncode != 0:
die("Engine n8n container lookup failed")
container_ids = [line.strip() for line in result.stdout.splitlines() if line.strip()]
if len(container_ids) != 1:
die(f"Engine n8n topology mismatch: expected=1 actual={len(container_ids)}")
return container_ids
def inspect_engine_n8n_base_image():
images = docker_json(["image", "inspect", ENGINE_N8N_BASE_IMAGE], "Engine n8n base image inspect")
if not isinstance(images, list) or len(images) != 1 or not isinstance(images[0], dict):
die("Engine n8n base image inspect shape mismatch")
image = images[0]
image_id = image.get("Id")
if not isinstance(image_id, str) or not re.fullmatch(r"sha256:[a-f0-9]{64}", image_id):
die("Engine n8n base image id is invalid")
if image.get("Architecture") != ENGINE_N8N_BASE_ARCHITECTURE or image.get("Os") != "linux":
die("Engine n8n base image platform mismatch")
return {
"id": image_id,
"architecture": image.get("Architecture"),
"repo_digests": tuple(image.get("RepoDigests") or ()),
}
def inspect_container(container_id):
containers = docker_json(["inspect", container_id], "Engine n8n container inspect")
if not isinstance(containers, list) or len(containers) != 1 or not isinstance(containers[0], dict):
die("Engine n8n container inspect shape mismatch")
return containers[0]
def engine_n8n_version(container_id):
result = subprocess.run(
[str(DOCKER), "exec", container_id, "n8n", "--version"],
check=False,
capture_output=True,
text=True,
timeout=60,
)
version = result.stdout.strip()
if result.returncode != 0 or version != ENGINE_N8N_VERSION:
die("Engine n8n runtime version mismatch")
return version
def engine_n8n_private_loader_catalog(container_id):
container = inspect_container(container_id)
package_mounts = [
mount for mount in (container.get("Mounts") or ())
if mount.get("Destination") == ENGINE_N8N_RUNTIME_PACKAGE_PATH
]
if not package_mounts:
return {"node_types": [], "credential_types": []}
if len(package_mounts) != 1 or package_mounts[0].get("RW") is not False:
die("Engine n8n package-loader probe found an unsafe package mount")
script = (
"const {PackageDirectoryLoader}=require('/usr/local/lib/node_modules/n8n/node_modules/n8n-core');"
"(async()=>{const loader=new PackageDirectoryLoader('"
+ ENGINE_N8N_RUNTIME_PACKAGE_PATH
+ "');await loader.loadAll();process.stdout.write(JSON.stringify({packageName:loader.packageName,"
"nodes:loader.types.nodes.map(x=>x.name),credentials:loader.types.credentials.map(x=>x.name)}))})()"
".catch(()=>process.exit(1));"
)
result = subprocess.run(
[str(DOCKER), "exec", container_id, "node", "-e", script],
check=False,
capture_output=True,
text=True,
timeout=120,
)
if result.returncode != 0:
die("Engine n8n package-loader probe failed")
try:
catalog = json.loads(result.stdout)
except json.JSONDecodeError:
die("Engine n8n package-loader probe returned invalid JSON")
require_exact_json_keys(catalog, ("packageName", "nodes", "credentials"), "Engine n8n package-loader probe")
if catalog.get("packageName") != "n8n-nodes-ndc":
die("Engine n8n package-loader probe package identity mismatch")
nodes = catalog.get("nodes")
credentials = catalog.get("credentials")
if not isinstance(nodes, list) or not all(isinstance(value, str) for value in nodes):
die("Engine n8n package-loader probe node set is invalid")
if not isinstance(credentials, list) or not all(isinstance(value, str) for value in credentials):
die("Engine n8n package-loader probe credential set is invalid")
return {
"node_types": [f"n8n-nodes-ndc.{value}" for value in nodes],
"credential_types": credentials,
}
def engine_n8n_current_state():
descriptor = current_engine_n8n_transition_descriptor()
if descriptor and descriptor["action"] == "activate":
return descriptor["releaseId"], descriptor
return "verified_inactive", descriptor
def validate_engine_n8n_base_compose_source():
compose_path = component_root("engine") / "docker-compose.yml"
try:
compose = compose_path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
die("Engine base Compose cannot be read")
services = re.findall(r"(?m)^ ([A-Za-z0-9_-]+):\s*$", compose)
if tuple(services) != ENGINE_BASE_COMPOSE_SERVICES:
die("Engine base Compose exact service topology mismatch")
match = re.search(r"(?ms)^ n8n:\s*\n(?P<body>.*?)(?=^ [A-Za-z0-9_-]+:\s*$)", compose)
if not match:
die("Engine base Compose n8n service cannot be isolated")
n8n_service = match.group("body")
expected_image = "image: docker.n8n.io/n8nio/n8n:${N8N_IMAGE_TAG:-2.3.2}"
if expected_image not in n8n_service:
die("Engine base Compose n8n version mismatch")
required_fragments = (
"./n8n-data:/home/node/.n8n",
"./nodedc-source/services/n8n/runtime-plugin:/opt/nodedc/runtime-plugin:ro",
"n8n-postgres:",
)
if any(fragment not in n8n_service for fragment in required_fragments):
die("Engine base Compose n8n persistence/runtime dependency mismatch")
forbidden_fragments = (
"build:",
"N8N_CUSTOM_EXTENSIONS",
"CUSTOM.",
ENGINE_N8N_RUNTIME_PACKAGE_PATH,
"N8N_USER_FOLDER",
"N8N_COMMUNITY_PACKAGES_ENABLED",
"N8N_COMMUNITY_PACKAGES_PREVENT_LOADING",
"N8N_REINSTALL_MISSING_PACKAGES",
)
if any(fragment in n8n_service for fragment in forbidden_fragments):
die("Engine base Compose contains a non-baseline n8n activation setting")
def preflight_engine_n8n_transition(descriptor, enforce_expected_current):
validate_engine_n8n_base_compose_source()
if descriptor["action"] == "activate":
validate_engine_n8n_staged_release(descriptor)
base_image = inspect_engine_n8n_base_image()
container_id = engine_n8n_container_ids()[0]
container = inspect_container(container_id)
state = container.get("State") or {}
if state.get("Status") != "running":
die("Engine n8n baseline container is not running")
if container.get("Image") != base_image["id"]:
die("Engine n8n running image does not match the local 2.3.2 base image")
version = engine_n8n_version(container_id)
current_state, current_descriptor = engine_n8n_current_state()
state_matches = current_state == descriptor["expectedCurrent"]
current_catalog = engine_n8n_private_loader_catalog(container_id)
current_types = current_catalog["node_types"]
current_credentials = current_catalog["credential_types"]
expected_current_types = list(ENGINE_N8N_NODE_TYPES) if current_state != "verified_inactive" else []
expected_current_credentials = list(ENGINE_N8N_CREDENTIAL_TYPES) if current_state != "verified_inactive" else []
if current_types != expected_current_types:
die("Engine n8n current live private-node set does not match its transition state")
if current_credentials != expected_current_credentials:
die("Engine n8n current live private-credential set does not match its transition state")
catalog_descriptor = (
current_descriptor
if current_state != "verified_inactive" and current_descriptor is not None
else inactive_engine_n8n_descriptor(descriptor)
)
validate_engine_n8n_catalog_payload(component_root("engine"), catalog_descriptor)
if enforce_expected_current and not state_matches:
die(
"Engine n8n transition stale current state: "
f"expected={descriptor['expectedCurrent']} actual={current_state}"
)
if current_descriptor and current_state != "verified_inactive":
if current_descriptor.get("packageSha256") != descriptor["packageSha256"]:
if enforce_expected_current:
die("Engine n8n current release package sha256 mismatch")
return {
"base_image_id": base_image["id"],
"base_image_architecture": base_image["architecture"],
"base_image_repo_digests": base_image["repo_digests"],
"container_id": container_id,
"current_state": current_state,
"current_types": current_types,
"current_credentials": current_credentials,
"n8n_version": version,
"state_matches": state_matches,
}
def add_engine_n8n_sealed_member_paths(expected_paths, member_name):
parts = PurePosixPath(member_name).parts
if not parts or parts[0] != "package":
die("Engine sealed n8n package member root mismatch")
for depth in range(1, len(parts) + 1):
expected_paths.add(PurePosixPath(*parts[:depth]).as_posix())
def validate_engine_n8n_sealed_release(release_dir, staged_release_dir, descriptor):
if release_dir.is_symlink() or not release_dir.is_dir():
die("Engine sealed n8n release is missing or unsafe")
for filename in ("package.tgz", "release.json", "rollback.json"):
installed = release_dir / filename
staged = staged_release_dir / filename
if installed.is_symlink() or not installed.is_file():
die(f"Engine sealed n8n release member is unsafe: {filename}")
if sha256_file(installed) != sha256_file(staged):
die(f"Engine sealed n8n release member mismatch: {filename}")
package_root = release_dir / "package"
if package_root.is_symlink() or not package_root.is_dir():
die("Engine sealed n8n package tree is missing or unsafe")
expected_paths = {"package", "package.tgz", "release.json", "rollback.json"}
with tarfile.open(staged_release_dir / "package.tgz", "r:gz") as archive:
for member in archive:
# npm package tarballs do not have to carry explicit directory
# members. Extraction still creates those parent directories, so
# they are part of the exact sealed tree rather than unexpected
# content.
add_engine_n8n_sealed_member_paths(expected_paths, member.name)
target = release_dir.joinpath(*PurePosixPath(member.name).parts)
target_stat = target.lstat()
if stat.S_ISLNK(target_stat.st_mode):
die(f"Engine sealed n8n package symlink rejected: {member.name}")
if member.isdir():
if not stat.S_ISDIR(target_stat.st_mode):
die(f"Engine sealed n8n package directory mismatch: {member.name}")
else:
if not stat.S_ISREG(target_stat.st_mode) or target_stat.st_size != member.size:
die(f"Engine sealed n8n package file mismatch: {member.name}")
source = archive.extractfile(member)
if source is None:
die(f"Engine sealed n8n package file unreadable: {member.name}")
import hashlib
expected_sha = hashlib.sha256(source.read()).hexdigest()
if sha256_file(target) != expected_sha:
die(f"Engine sealed n8n package content mismatch: {member.name}")
actual_paths = {
path.relative_to(release_dir).as_posix()
for path in release_dir.rglob("*")
}
if actual_paths != expected_paths:
die("Engine sealed n8n release tree exact member set mismatch")
for path in [release_dir, *release_dir.rglob("*")]:
path_stat = path.lstat()
if path_stat.st_uid != 0 or path_stat.st_gid != 0:
die(f"Engine sealed n8n release ownership mismatch: {path}")
expected_mode = 0o555 if stat.S_ISDIR(path_stat.st_mode) else 0o444
if stat.S_IMODE(path_stat.st_mode) != expected_mode:
die(f"Engine sealed n8n release mode mismatch: {path}")
package_json = read_strict_json(package_root / "package.json", "Engine sealed n8n package.json")
if package_json.get("name") != "n8n-nodes-ndc" or package_json.get("version") != descriptor["packageVersion"]:
die("Engine sealed n8n package identity mismatch")
def ensure_engine_n8n_sealed_release(descriptor, current_stamp):
staged_release_dir = validate_engine_n8n_staged_release(descriptor)
release_parent = ENGINE_N8N_SEALED_RELEASES_ROOT / "releases" / "n8n-nodes-ndc"
release_parent.mkdir(parents=True, exist_ok=True)
for path in (
ENGINE_N8N_SEALED_RELEASES_ROOT,
ENGINE_N8N_SEALED_RELEASES_ROOT / "releases",
release_parent,
):
path_stat = path.lstat()
if stat.S_ISLNK(path_stat.st_mode) or not stat.S_ISDIR(path_stat.st_mode):
die(f"Engine sealed n8n release parent is unsafe: {path}")
os.chown(path, 0, 0)
path.chmod(0o755)
release_dir = release_parent / descriptor["releaseId"]
if release_dir.exists() or release_dir.is_symlink():
validate_engine_n8n_sealed_release(release_dir, staged_release_dir, descriptor)
return release_dir
next_dir = release_parent / f".{descriptor['releaseId']}.next-{current_stamp}"
if next_dir.exists() or next_dir.is_symlink():
die(f"Engine sealed n8n release staging path already exists: {next_dir}")
next_dir.mkdir(mode=0o700)
for filename in ("package.tgz", "release.json", "rollback.json"):
shutil.copy2(staged_release_dir / filename, next_dir / filename)
with tarfile.open(staged_release_dir / "package.tgz", "r:gz") as archive:
for member in archive:
relative = PurePosixPath(member.name)
target = next_dir.joinpath(*relative.parts)
if member.isdir():
target.mkdir(parents=True, exist_ok=True)
continue
target.parent.mkdir(parents=True, exist_ok=True)
source = archive.extractfile(member)
if source is None:
die(f"Engine sealed n8n package member unreadable: {member.name}")
with target.open("xb") as output:
shutil.copyfileobj(source, output)
for path in [next_dir, *next_dir.rglob("*")]:
path_stat = path.lstat()
if stat.S_ISLNK(path_stat.st_mode):
die(f"Engine sealed n8n release symlink rejected: {path}")
os.chown(path, 0, 0)
if stat.S_ISDIR(path_stat.st_mode):
path.chmod(0o555)
elif stat.S_ISREG(path_stat.st_mode):
path.chmod(0o444)
else:
die(f"Engine sealed n8n release special member rejected: {path}")
if release_dir.exists() or release_dir.is_symlink():
die("Engine sealed n8n release appeared concurrently")
next_dir.rename(release_dir)
validate_engine_n8n_sealed_release(release_dir, staged_release_dir, descriptor)
return release_dir
def wait_engine_n8n_readiness(container_id):
script = (
"const http=require('http');const req=http.get('http://127.0.0.1:5678/healthz/readiness',"
"r=>{r.resume();process.exit(r.statusCode===200?0:1)});req.on('error',()=>process.exit(1));"
"req.setTimeout(4000,()=>{req.destroy();process.exit(1)});"
)
for _attempt in range(1, 61):
result = subprocess.run(
[str(DOCKER), "exec", container_id, "node", "-e", script],
check=False,
capture_output=True,
text=True,
timeout=10,
)
if result.returncode == 0:
return
time.sleep(5)
die("Engine n8n readiness check failed")
def accept_engine_n8n_runtime(descriptor):
base_image = inspect_engine_n8n_base_image()
container_id = engine_n8n_container_ids()[0]
container = inspect_container(container_id)
state = container.get("State") or {}
if state.get("Status") != "running" or container.get("Image") != base_image["id"]:
die("Engine n8n runtime image/state acceptance failed")
wait_engine_n8n_readiness(container_id)
engine_n8n_version(container_id)
config = container.get("Config") or {}
env = set(config.get("Env") or ())
mounts = container.get("Mounts") or ()
package_mounts = [mount for mount in mounts if mount.get("Destination") == ENGINE_N8N_RUNTIME_PACKAGE_PATH]
if descriptor["action"] == "activate":
expected_source = f"/volume2/nodedc-demo/{descriptor['sealedReleaseRelativePath']}"
if len(package_mounts) != 1:
die("Engine n8n package mount count mismatch")
package_mount = package_mounts[0]
if package_mount.get("Source") != expected_source or package_mount.get("RW") is not False:
die("Engine n8n sealed package mount mismatch")
required_env = {
"N8N_USER_FOLDER=/home/node",
"N8N_COMMUNITY_PACKAGES_ENABLED=true",
"N8N_COMMUNITY_PACKAGES_PREVENT_LOADING=false",
"N8N_REINSTALL_MISSING_PACKAGES=false",
}
if not required_env.issubset(env):
die("Engine n8n package loader environment mismatch")
labels = config.get("Labels") or {}
if labels.get("nodedc.n8n-private-extension.release") != descriptor["releaseId"]:
die("Engine n8n release label mismatch")
if labels.get("nodedc.n8n-private-extension.package-sha256") != descriptor["packageSha256"]:
die("Engine n8n package label mismatch")
else:
if package_mounts:
die("Engine n8n inactive baseline still has the private package mount")
inactive_forbidden = (
"N8N_USER_FOLDER=",
"N8N_COMMUNITY_PACKAGES_ENABLED=",
"N8N_COMMUNITY_PACKAGES_PREVENT_LOADING=",
"N8N_REINSTALL_MISSING_PACKAGES=",
)
if any(item.startswith(inactive_forbidden) for item in env):
die("Engine n8n inactive baseline still has private package loader flags")
if any(item.startswith("N8N_CUSTOM_EXTENSIONS=") or item.startswith("CUSTOM.") for item in env):
die("Engine n8n custom extension environment is forbidden")
started_at = str(state.get("StartedAt") or "")
logs = subprocess.run(
[str(DOCKER), "logs", "--since", started_at, container_id],
check=False,
capture_output=True,
text=True,
timeout=60,
)
if logs.returncode != 0:
die("Engine n8n loader log inspection failed")
scoped_logs = f"{logs.stdout}\n{logs.stderr}".lower()
error_patterns = (
r"n8n-nodes-ndc.{0,400}(?:cannot find|error|failed|duplicate|eacces|syntaxerror)",
r"(?:cannot find|error loading|failed to load|duplicate|eacces|syntaxerror).{0,400}n8n-nodes-ndc",
)
if any(re.search(pattern, scoped_logs, re.DOTALL) for pattern in error_patterns):
die("Engine n8n private extension loader log acceptance failed")
expected_types = list(ENGINE_N8N_NODE_TYPES) if descriptor["action"] == "activate" else []
expected_credentials = list(ENGINE_N8N_CREDENTIAL_TYPES) if descriptor["action"] == "activate" else []
loader_catalog = engine_n8n_private_loader_catalog(container_id)
if loader_catalog["node_types"] != expected_types:
die("Engine n8n live package-loader node catalog acceptance failed")
if loader_catalog["credential_types"] != expected_credentials:
die("Engine n8n live package-loader credential catalog acceptance failed")
validate_engine_n8n_catalog_payload(component_root("engine"), descriptor)
first_restart_count = int(container.get("RestartCount") or 0)
time.sleep(2)
stable_container = inspect_container(container_id)
if stable_container.get("State", {}).get("Status") != "running":
die("Engine n8n container did not remain running")
if int(stable_container.get("RestartCount") or 0) != first_restart_count:
die("Engine n8n container restarted during acceptance")
return {
"container_id": container_id,
"image_id": base_image["id"],
"node_types": expected_types,
"credential_types": list(descriptor["expectedCredentialTypes"]),
}
def plan_artifact(artifact):
validate_artifact_location(artifact)
ensure_layout()
sha = sha256_file(artifact)
with tempfile.TemporaryDirectory(prefix="plan-", dir=TMP_DIR) as tmp:
manifest, entries, payload_dir = load_artifact(artifact, Path(tmp))
transition_descriptor = None
transition_preflight = None
if is_engine_n8n_transition(manifest["component"], entries):
transition_descriptor = read_engine_n8n_transition_descriptor(
payload_dir / ENGINE_N8N_TRANSITION_DESCRIPTOR_REL
)
transition_preflight = preflight_engine_n8n_transition(
transition_descriptor,
enforce_expected_current=False,
)
component = manifest["component"]
root = component_root(component)
compose_root = component_compose_root(component)
services = component_services(component, entries)
builds = component_builds(component, entries)
print("== plan ==")
print(f"artifact={artifact.name}")
print(f"sha256={sha}")
print(f"id={manifest['id']}")
print(f"component={component}")
print(f"type={manifest['type']}")
print(f"payload_root={root}")
print(f"compose_root={compose_root}")
compose_project = component_compose_project(component)
if compose_project:
print(f"compose_project={compose_project}")
for build_root, build_args in builds:
print(f"build_root={build_root}")
print(f"build={' '.join((str(DOCKER),) + tuple(build_args))}")
print(f"services={' '.join(services)}")
if transition_descriptor:
print(f"n8n_transition={transition_descriptor['action']}")
print(f"n8n_version={transition_descriptor['n8nVersion']}")
print(f"n8n_release={transition_descriptor['releaseId']}")
print(f"n8n_package_sha256={transition_descriptor['packageSha256']}")
print(f"n8n_base_image={transition_descriptor['baseImage']}")
print(f"n8n_base_image_id={transition_preflight['base_image_id']}")
print(f"n8n_base_image_architecture={transition_preflight['base_image_architecture']}")
for repo_digest in transition_preflight["base_image_repo_digests"]:
print(f"n8n_base_image_repo_digest={repo_digest}")
print(f"n8n_current_state={transition_preflight['current_state']}")
print(f"n8n_expected_current={transition_descriptor['expectedCurrent']}")
print(f"n8n_state_matches={'yes' if transition_preflight['state_matches'] else 'not-yet'}")
print(f"n8n_sealed_package=/volume2/nodedc-demo/{transition_descriptor['sealedReleaseRelativePath']}")
print("n8n_build=none")
print("n8n_pull=never")
print("n8n_force_recreate=yes")
print("n8n_persistent_data=preserved:/volume2/nodedc-demo/n8n-data")
print("n8n_database=preserved:n8n-postgres")
print(f"n8n_expected_node_types={','.join(transition_descriptor['expectedNodeTypes'])}")
print(f"n8n_expected_credential_types={','.join(transition_descriptor['expectedCredentialTypes'])}")
print("n8n_rollback_baseline=verified_inactive")
touches_map_gateway = component == "platform" and any(rel == "platform/services/map-gateway" or rel.startswith("platform/services/map-gateway/") for rel in entries)
touches_external_data_plane = component == "platform" and any(
rel == "platform/services/external-data-plane"
or rel.startswith("platform/services/external-data-plane/")
or rel == "platform/packages/external-provider-contract"
or rel.startswith("platform/packages/external-provider-contract/")
or rel == "platform/docker-compose.external-data-plane.yml"
for rel in entries
)
if component == "module-foundry" or touches_map_gateway:
print(f"runtime_secret=runner-managed:{MAP_GATEWAY_SECRET_FILE}")
if component == "module-foundry":
print(f"runtime_grants=runner-managed:{EXTERNAL_DATA_PLANE_READER_GRANTS_DIR}")
print(f"runtime_grants=runner-managed:{FOUNDRY_BINDING_GRANTS_DIR}")
if touches_external_data_plane:
print(f"runtime_secret=runner-managed:{EXTERNAL_DATA_PLANE_PROVISIONER_SECRET_FILE}")
print(f"runtime_grants=runner-managed:{EXTERNAL_DATA_PLANE_READER_GRANTS_DIR}")
if component in ("proxy-contur", "dc-amd-proxy") or touches_map_gateway:
print(f"runtime_secret=runner-synced:{MAP_EGRESS_PROXY_SECRET_FILE}")
if component == "dc-amd-proxy":
print(f"runtime_state=runner-prepared:{DC_AMD_PROXY_RUNTIME_DIR}")
if state_has_sha(sha):
print("state=sha-already-applied")
elif state_has_patch_id(manifest["id"]):
print("state=patch-id-already-applied")
else:
print("state=new")
print("== files ==")
for rel in entries:
print(f" {rel}")
def add_backup_path(tar, root, rel):
src = root / rel
if src.exists():
tar.add(src, arcname=rel, recursive=True)
return True
return False
def create_backup(root, backup_dir, entries, include_nginx_html):
existing = []
missing = []
with tarfile.open(backup_dir / "source-before.tgz", "w:gz") as tar:
for rel in entries:
if add_backup_path(tar, root, rel):
existing.append(rel)
else:
missing.append(rel)
if include_nginx_html and (root / "nginx-html").exists():
tar.add(root / "nginx-html", arcname="nginx-html", recursive=True)
existing.append("nginx-html")
(backup_dir / "existing-files.txt").write_text("\n".join(existing) + ("\n" if existing else ""), encoding="utf-8")
(backup_dir / "missing-files.txt").write_text("\n".join(missing) + ("\n" if missing else ""), encoding="utf-8")
def read_backup_path_list(path):
if not path.is_file():
die(f"deploy backup path list missing: {path}")
return [line.strip() for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]
def inactive_engine_n8n_descriptor(descriptor):
inactive = dict(descriptor)
inactive["action"] = "rollback-inactive"
inactive["expectedCurrent"] = descriptor["releaseId"]
inactive["expectedNodeTypes"] = []
inactive["expectedCredentialTypes"] = []
return inactive
def restore_engine_n8n_transition(root, backup_dir, entries, descriptor, current_stamp):
existing = set(read_backup_path_list(backup_dir / "existing-files.txt"))
missing = set(read_backup_path_list(backup_dir / "missing-files.txt"))
if existing | missing != set(entries):
die("Engine n8n rollback backup entry set mismatch")
required_baseline = {
ENGINE_N8N_NODES_CATALOG_REL,
ENGINE_N8N_CREDENTIALS_CATALOG_REL,
ENGINE_N8N_SCHEMA_META_REL,
}
if not required_baseline.issubset(existing):
die("Engine n8n rollback baseline catalogs were not present before apply")
with tempfile.TemporaryDirectory(prefix="engine-n8n-rollback-", dir=TMP_DIR) as tmp:
restore_root = Path(tmp)
with tarfile.open(backup_dir / "source-before.tgz", "r:gz") as archive:
for rel in entries:
if rel not in existing:
continue
try:
member = archive.getmember(rel)
except KeyError:
die(f"Engine n8n rollback backup member missing: {rel}")
if not member.isfile():
die(f"Engine n8n rollback backup member is not a file: {rel}")
source = archive.extractfile(member)
if source is None:
die(f"Engine n8n rollback backup member unreadable: {rel}")
staged = restore_root / rel
staged.parent.mkdir(parents=True, exist_ok=True)
with staged.open("xb") as output:
shutil.copyfileobj(source, output)
replace_file(staged, root / rel, f"{current_stamp}-rollback")
if ENGINE_N8N_TRANSITION_DESCRIPTOR_REL not in existing:
inactive = inactive_engine_n8n_descriptor(descriptor)
descriptor_path = root / ENGINE_N8N_TRANSITION_DESCRIPTOR_REL
descriptor_path.parent.mkdir(parents=True, exist_ok=True)
next_path = descriptor_path.with_name(f"{descriptor_path.name}.next-{current_stamp}-rollback")
if next_path.exists() or next_path.is_symlink():
die(f"Engine n8n rollback descriptor staging path exists: {next_path}")
next_path.write_text(json.dumps(inactive, indent=2) + "\n", encoding="utf-8")
os.replace(next_path, descriptor_path)
restored_descriptor = current_engine_n8n_transition_descriptor()
if restored_descriptor is None:
die("Engine n8n rollback did not restore an explicit baseline descriptor")
run_compose("engine", ("n8n",), entries)
accept_engine_n8n_runtime(restored_descriptor)
return restored_descriptor["action"]
def replace_file(src, dst, current_stamp):
dst.parent.mkdir(parents=True, exist_ok=True)
tmp = dst.with_name(f"{dst.name}.next-{current_stamp}")
if tmp.exists():
die(f"staging path already exists: {tmp}")
shutil.copy2(src, tmp)
os.replace(tmp, dst)
def replace_directory(src, dst, current_stamp):
dst.parent.mkdir(parents=True, exist_ok=True)
next_path = dst.with_name(f"{dst.name}.next-{current_stamp}")
prev_path = dst.with_name(f"{dst.name}.prev-{current_stamp}")
if next_path.exists() or prev_path.exists():
die(f"staging path already exists near: {dst}")
shutil.copytree(src, next_path)
if dst.exists():
dst.rename(prev_path)
next_path.rename(dst)
def copy_payload_path(payload_dir, root, rel, current_stamp):
src = payload_dir / rel
dst = root / rel
root_real = root.resolve()
dst_real = dst.resolve(strict=False)
if not is_relative_to(dst_real, root_real):
die(f"target escaped component root: {rel}")
if src.is_dir():
replace_directory(src, dst, current_stamp)
elif src.is_file():
replace_file(src, dst, current_stamp)
else:
die(f"payload path is neither file nor directory: {rel}")
def seal_n8n_private_extension_release(root, rel):
release_dir = root / rel
if not release_dir.is_dir() or release_dir.is_symlink():
die("private extension release was not installed as a directory")
for path in [release_dir, *release_dir.rglob("*")]:
path_stat = path.lstat()
if stat.S_ISLNK(path_stat.st_mode):
die(f"private extension installed symlink rejected: {path}")
os.chown(path, 0, 0)
if stat.S_ISDIR(path_stat.st_mode):
path.chmod(0o555)
elif stat.S_ISREG(path_stat.st_mode):
path.chmod(0o444)
else:
die(f"private extension installed special file rejected: {path}")
def publish_engine_dist(root, current_stamp):
dist = root / "nodedc-source" / "dist"
if not dist.is_dir():
return
dst = root / "nginx-html"
next_path = root / f"nginx-html.next-{current_stamp}"
prev_path = root / f"nginx-html.prev-{current_stamp}"
if next_path.exists() or prev_path.exists():
die("nginx-html staging path already exists")
shutil.copytree(dist, next_path)
if dst.exists():
dst.rename(prev_path)
next_path.rename(dst)
def component_publish_dist(component, entries=None):
if not COMPONENTS[component].get("publish_dist"):
return False
if component == "engine":
return entries is not None and any(
rel == "nodedc-source/dist" or rel.startswith("nodedc-source/dist/")
for rel in entries
)
return True
def run_build(component, entries=None):
for build_root, build_args in component_builds(component, entries):
dockerfile = "Dockerfile"
for idx, arg in enumerate(build_args):
if arg == "-f" and idx + 1 < len(build_args):
dockerfile = build_args[idx + 1]
break
if not (build_root / dockerfile).is_file():
die(f"Dockerfile not found in build root: {build_root / dockerfile}")
cmd = [str(DOCKER), *build_args]
env = os.environ.copy()
if component == "tasker":
env["DOCKER_BUILDKIT"] = "0"
subprocess.run(cmd, cwd=str(build_root), env=env, check=True)
def compose_base_cmd(component):
cmd = [str(DOCKER), "compose"]
compose_project = component_compose_project(component)
if compose_project:
cmd.extend(["-p", compose_project])
env_file = component_compose_env_file(component)
if env_file:
cmd.extend(["--env-file", str(env_file)])
for compose_file in component_compose_files(component):
cmd.extend(["-f", str(compose_file)])
return cmd
def run_compose(component, services, entries=None):
compose_root = component_compose_root(component)
cmd = [*compose_base_cmd(component), "up", "-d", "--force-recreate"]
if is_engine_n8n_transition(component, entries):
cmd.extend(["--pull", "never"])
if COMPONENTS[component].get("compose_build"):
cmd.append("--build")
if component_compose_no_deps(component, entries):
cmd.append("--no-deps")
cmd.extend(services)
try:
subprocess.run(cmd, cwd=str(compose_root), check=True)
except subprocess.CalledProcessError:
subprocess.run(
[*compose_base_cmd(component), "logs", "--no-color", "--tail=180", *services],
cwd=str(compose_root),
check=False,
)
raise
subprocess.run([*compose_base_cmd(component), "ps"], cwd=str(compose_root), check=True)
def prepare_component_runtime(component, entries=None):
if is_engine_n8n_transition(component, entries):
descriptor = current_engine_n8n_transition_descriptor()
if descriptor is None:
die("installed Engine n8n transition descriptor is missing")
if descriptor["action"] == "activate":
ensure_engine_n8n_sealed_release(descriptor, stamp())
return
if component == "module-foundry":
ensure_map_gateway_admin_secret()
ensure_root_owned_grant_directory(
EXTERNAL_DATA_PLANE_READER_GRANTS_DIR,
"external data plane reader grants",
)
ensure_root_owned_grant_directory(
FOUNDRY_BINDING_GRANTS_DIR,
"foundry binding grants",
)
return
if component == "proxy-contur":
sync_map_egress_proxy_secret()
return
if component == "dc-amd-proxy":
# The connector access value arrives only through the one-time pairing
# endpoint. The artifact must never carry it, and the service user is
# the only non-root identity that can write this directory.
sync_map_egress_proxy_secret()
try:
runtime_stat = DC_AMD_PROXY_RUNTIME_DIR.lstat()
except FileNotFoundError:
DC_AMD_PROXY_RUNTIME_DIR.mkdir(parents=True, exist_ok=False)
runtime_stat = DC_AMD_PROXY_RUNTIME_DIR.lstat()
if stat.S_ISLNK(runtime_stat.st_mode) or not stat.S_ISDIR(runtime_stat.st_mode):
die(f"dc-amd-proxy runtime directory is unsafe: {DC_AMD_PROXY_RUNTIME_DIR}")
os.chown(DC_AMD_PROXY_RUNTIME_DIR, MAP_GATEWAY_RUNTIME_GID, MAP_GATEWAY_RUNTIME_GID)
DC_AMD_PROXY_RUNTIME_DIR.chmod(0o700)
connector_state = DC_AMD_PROXY_RUNTIME_DIR / "connector-access"
if connector_state.exists() or connector_state.is_symlink():
connector_stat = connector_state.lstat()
if (
stat.S_ISLNK(connector_stat.st_mode)
or not stat.S_ISREG(connector_stat.st_mode)
or connector_stat.st_uid != MAP_GATEWAY_RUNTIME_GID
or connector_stat.st_mode & (stat.S_IRWXG | stat.S_IRWXO)
or connector_stat.st_size > 512
):
die("dc-amd-proxy connector pair state is unsafe")
return
if component == "platform" and entries is not None:
touches_map_gateway = any(rel == "platform/services/map-gateway" or rel.startswith("platform/services/map-gateway/") for rel in entries)
touches_external_data_plane = any(rel == "platform/services/external-data-plane" or rel.startswith("platform/services/external-data-plane/") or rel == "platform/packages/external-provider-contract" or rel.startswith("platform/packages/external-provider-contract/") or rel == "platform/docker-compose.external-data-plane.yml" for rel in entries)
if touches_map_gateway:
# These NAS directories are the only canonical persistent stores
# for all Map Page instances. Creation lives in the root-owned
# runner, never in a user-supplied artifact or a Compose side effect.
cache_root = Path("/volume1/docker/nodedc-platform/map-gateway")
for cache_dir in (cache_root / "live-tile-cache", cache_root / "offline-snapshot"):
cache_dir.mkdir(parents=True, exist_ok=True)
os.chown(cache_dir, 1000, 1000)
os.chmod(cache_dir, 0o750)
ensure_map_gateway_admin_secret()
sync_map_egress_proxy_secret()
if touches_external_data_plane:
ensure_external_data_plane_provisioner_secret()
ensure_root_owned_grant_directory(
EXTERNAL_DATA_PLANE_READER_GRANTS_DIR,
"external data plane reader grants",
)
return
if component in ("dc-cms", "dc-cms-site-nodedc"):
(Path("/volume1/docker/dc-cms/sites") / "nodedc").mkdir(parents=True, exist_ok=True)
return
if component != "bim-viewer":
return
data_root = component_root(component) / "server" / "data"
for rel in ("uploads", "projects", "shares", "models", "comments"):
(data_root / rel).mkdir(parents=True, exist_ok=True)
def run_component_runtime(component, entries, services):
if component_artifact_only(component):
return
run_build(component, entries)
prepare_component_runtime(component, entries)
run_compose(component, services, entries)
def healthcheck_url(check):
headers = {}
if isinstance(check, dict):
url = check.get("url")
headers = check.get("headers") or {}
else:
url = check
last_error = None
for _attempt in range(1, 61):
try:
request = urllib.request.Request(url, headers=headers)
with NO_REDIRECT_OPENER.open(request, timeout=10) as response:
if 200 <= response.status < 400:
return
last_error = f"HTTP {response.status}"
except urllib.error.HTTPError as exc:
if 200 <= exc.code < 400:
return
last_error = f"HTTP {exc.code}"
except Exception as exc:
last_error = str(exc)
time.sleep(5)
die(f"healthcheck failed for {url}: {last_error}")
def component_healthchecks(component, entries=None):
if is_engine_n8n_transition(component, entries):
# The transition does not restart the Engine UI/backend generation.
# Its own acceptance below verifies n8n readiness, image, mount, logs,
# live node export and the pinned Engine MCP catalogs.
return ()
checks = list(COMPONENTS[component].get("healthchecks", ()))
if component == "platform" and entries is not None:
touches_compose = any(rel == "platform/docker-compose.platform-http.yml" for rel in entries)
touches_ai_workspace_assistant = any(rel == "platform/ai-workspace-assistant" or rel.startswith("platform/ai-workspace-assistant/") for rel in entries)
touches_ontology = any(rel == "platform/ontology-core" or rel.startswith("platform/ontology-core/") for rel in entries)
touches_gelios = any(rel == "platform/gelios-gateway" or rel.startswith("platform/gelios-gateway/") for rel in entries)
touches_map_gateway = any(rel == "platform/services/map-gateway" or rel.startswith("platform/services/map-gateway/") for rel in entries)
touches_external_data_plane = any(rel == "platform/services/external-data-plane" or rel.startswith("platform/services/external-data-plane/") or rel == "platform/packages/external-provider-contract" or rel.startswith("platform/packages/external-provider-contract/") or rel == "platform/docker-compose.external-data-plane.yml" for rel in entries)
map_gateway_only_compose = touches_compose and touches_map_gateway and not any((touches_ai_workspace_assistant, touches_ontology, touches_gelios, touches_external_data_plane))
healthcheck_all_for_compose = touches_compose and not map_gateway_only_compose
if healthcheck_all_for_compose or touches_ai_workspace_assistant:
checks.append("http://127.0.0.1:18082/healthz")
if healthcheck_all_for_compose or touches_ontology:
checks.append("http://127.0.0.1:18104/healthz")
if healthcheck_all_for_compose or touches_gelios:
checks.append("http://127.0.0.1:18105/healthz")
if touches_map_gateway:
checks.append({"url": "http://127.0.0.1:18103/healthz", "headers": {"x-nodedc-user-id": "deploy-healthcheck"}})
if touches_external_data_plane:
checks.append("http://127.0.0.1:18106/healthz")
return tuple(checks)
def healthcheck_container(container_name):
last_status = "unknown"
for _attempt in range(1, 61):
result = subprocess.run(
[str(DOCKER), "inspect", "--format", "{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}", container_name],
check=False,
capture_output=True,
text=True,
)
status_text = result.stdout.strip().lower()
last_status = status_text or result.stderr.strip() or f"inspect-exit-{result.returncode}"
if status_text in ("healthy", "running"):
return
if status_text in ("unhealthy", "exited", "dead"):
break
time.sleep(5)
die(f"container healthcheck failed for {container_name}: {last_status}")
def run_healthchecks(component, entries=None):
if is_engine_n8n_transition(component, entries):
descriptor = current_engine_n8n_transition_descriptor()
if descriptor is None:
die("installed Engine n8n transition descriptor is missing during acceptance")
accept_engine_n8n_runtime(descriptor)
return
for url in component_healthchecks(component, entries):
healthcheck_url(url)
container_name = COMPONENTS[component].get("health_container")
if container_name:
healthcheck_container(container_name)
def move_artifact(src, target_dir, suffix=""):
target_dir.mkdir(parents=True, exist_ok=True)
name = src.name + suffix
dst = target_dir / name
if dst.exists():
dst = target_dir / f"{src.name}.{stamp()}"
src.rename(dst)
return dst
def apply_artifact(artifact):
require_root()
validate_artifact_location(artifact)
ensure_layout()
sha = sha256_file(artifact)
backup_id = None
backup_dir = None
manifest = None
entries = None
component = None
root = None
transition_descriptor = None
apply_started = False
current_stamp = stamp()
with DeployLock():
try:
with tempfile.TemporaryDirectory(prefix=f"apply-{current_stamp}-", dir=TMP_DIR) as tmp:
work = Path(tmp)
manifest, entries, payload_dir = load_artifact(artifact, work)
patch_id = manifest["id"]
component = manifest["component"]
root = component_root(component)
compose_root = component_compose_root(component)
bootstrap_root = bool(COMPONENTS[component].get("bootstrap_root"))
services = component_services(component, entries)
artifact_only = component_artifact_only(component)
if state_has_sha(sha):
die(f"artifact sha already applied: {sha}")
if state_has_patch_id(patch_id):
die(f"patch id already applied: {patch_id}")
if not root.is_dir():
if bootstrap_root:
root.mkdir(parents=True, exist_ok=True)
else:
die(f"component payload root not found: {root}")
if not artifact_only and not compose_root.is_dir():
if bootstrap_root and is_relative_to(compose_root.resolve(strict=False), root.resolve(strict=False)):
compose_root.mkdir(parents=True, exist_ok=True)
else:
die(f"component compose root not found: {compose_root}")
compose_files = component_compose_files(component)
if not artifact_only and compose_files:
for compose_file in compose_files:
if not compose_file.is_file():
compose_entry = None
if is_relative_to(compose_file.resolve(strict=False), root.resolve(strict=False)):
compose_entry = compose_file.resolve(strict=False).relative_to(root.resolve(strict=False)).as_posix()
if compose_entry not in entries:
die(f"compose file not found: {compose_file}")
elif not artifact_only and not (compose_root / "docker-compose.yml").is_file():
# A bootstrap component begins with no payload root. Its
# compose file may therefore be supplied by the same
# reviewed overlay that is about to create that root.
compose_entry = None
if is_relative_to(compose_root.resolve(strict=False), root.resolve(strict=False)):
compose_entry = compose_root.resolve(strict=False).relative_to(root.resolve(strict=False)).joinpath("docker-compose.yml").as_posix()
if not (bootstrap_root and compose_entry in entries):
die(f"docker-compose.yml not found in component compose root: {compose_root}")
compose_env_file = component_compose_env_file(component)
if not artifact_only and compose_env_file and not compose_env_file.is_file():
die(f"compose env file not found: {compose_env_file}")
if not artifact_only and not DOCKER.is_file():
die(f"docker not found: {DOCKER}")
if is_engine_n8n_transition(component, entries):
transition_descriptor = read_engine_n8n_transition_descriptor(
payload_dir / ENGINE_N8N_TRANSITION_DESCRIPTOR_REL
)
preflight_engine_n8n_transition(
transition_descriptor,
enforce_expected_current=True,
)
if COMPONENTS[component].get("immutable_payload"):
for rel in entries:
destination = root / rel
if destination.exists() or destination.is_symlink():
die(f"immutable release already exists: {rel}")
backup_id = safe_name(f"{component}-{patch_id}-{current_stamp}")
backup_dir = BACKUPS_DIR / backup_id
backup_dir.mkdir(parents=True, exist_ok=False)
(backup_dir / "manifest.env").write_text((work / "manifest.env").read_text(encoding="utf-8"), encoding="utf-8")
(backup_dir / "files.txt").write_text((work / "files.txt").read_text(encoding="utf-8"), encoding="utf-8")
include_nginx_html = component == "engine" and component_publish_dist(component, entries)
create_backup(root, backup_dir, entries, include_nginx_html)
apply_started = True
for rel in entries:
copy_payload_path(payload_dir, root, rel, current_stamp)
if component == "n8n-private-extension":
for rel in entries:
seal_n8n_private_extension_release(root, rel)
if component_publish_dist(component, entries):
publish_engine_dist(root, current_stamp)
run_component_runtime(component, entries, services)
run_healthchecks(component, entries)
applied_path = move_artifact(artifact, APPLIED_DIR)
append_jsonl(STATE_FILE, {
"applied_at": utc_now(),
"artifact": applied_path.name,
"backup_id": backup_id,
"component": component,
"id": patch_id,
"sha256": sha,
"status": "ok",
})
print(f"deploy-ok patch={patch_id} component={component} backup={backup_id}")
except Exception as exc:
rollback_status = "not-required"
if (
apply_started
and backup_dir is not None
and root is not None
and transition_descriptor is not None
and is_engine_n8n_transition(component, entries)
):
try:
restored_action = restore_engine_n8n_transition(
root,
backup_dir,
entries,
transition_descriptor,
current_stamp,
)
rollback_status = f"ok:{restored_action}"
print(f"engine-n8n-automatic-rollback={rollback_status}", file=sys.stderr)
except Exception as rollback_exc:
rollback_status = f"failed:{type(rollback_exc).__name__}"
print("engine-n8n-automatic-rollback=failed", file=sys.stderr)
failed_path = None
if artifact.exists():
try:
failed_path = move_artifact(artifact, FAILED_DIR, suffix=f".{current_stamp}")
except Exception:
failed_path = None
append_jsonl(FAILED_STATE_FILE, {
"artifact": str(failed_path.name if failed_path else artifact.name),
"backup_id": backup_id,
"component": manifest.get("component") if manifest else None,
"failed_at": utc_now(),
"id": manifest.get("id") if manifest else None,
"message": str(exc),
"rollback_status": rollback_status,
"sha256": sha,
"started_apply": apply_started,
"status": "failed",
})
if backup_id:
print(f"backup={backup_id}", file=sys.stderr)
raise
def main(argv):
parser = argparse.ArgumentParser(prog="nodedc-deploy")
sub = parser.add_subparsers(dest="cmd", required=True)
plan = sub.add_parser("plan")
plan.add_argument("artifact")
apply = sub.add_parser("apply")
apply.add_argument("artifact")
sub.add_parser("verify-install")
args = parser.parse_args(argv)
if args.cmd == "verify-install":
verify_install()
return 0
artifact = Path(args.artifact).absolute()
if args.cmd == "plan":
plan_artifact(artifact)
return 0
if args.cmd == "apply":
apply_artifact(artifact)
return 0
parser.print_help()
return 2
if __name__ == "__main__":
try:
raise SystemExit(main(sys.argv[1:]))
except DeployError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
raise SystemExit(1)