#!/usr/bin/env python3 import argparse import json import os import re 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") 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"} 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"), ), "compose_no_deps": True, "services": ("notification-postgres", "notification-core", "ai-workspace-hub", "launcher", "reverse-proxy"), "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", ), }, "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"}, }, ), }, } 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 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 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 == "docker-compose.yml": 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", ): 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 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" if 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 == "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" 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/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", ): 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 == "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 == "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 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 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) 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_services(component, entries=None): 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_authentik = any(rel == "authentik/custom-templates" or rel.startswith("authentik/custom-templates/") for rel in entries) if touches_notification or touches_compose: add("notification-postgres", "notification-core", "launcher", "reverse-proxy") if touches_ai_workspace or touches_compose: add("ai-workspace-hub", "reverse-proxy") if touches_ai_workspace_assistant or touches_compose: add("ai-workspace-postgres", "ai-workspace-assistant") 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_files(component): 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) builds = [] if touches_notification or touches_compose: builds.append(( Path("/volume1/docker/nodedc-platform/platform/notification-core"), ("build", "--no-cache", "-t", "nodedc/notification-core:local", "."), )) if touches_ai_workspace or touches_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 touches_compose: builds.append(( Path("/volume1/docker/nodedc-platform/platform/ai-workspace-assistant"), ("build", "--no-cache", "-t", "nodedc/ai-workspace-assistant: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 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)) 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 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 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 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 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): compose_root = component_compose_root(component) cmd = [*compose_base_cmd(component), "up", "-d", "--force-recreate"] if COMPONENTS[component].get("compose_build"): cmd.append("--build") if COMPONENTS[component].get("compose_no_deps"): 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): if component == "dc-cms": (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): run_build(component, entries) prepare_component_runtime(component) run_compose(component, services) 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): 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) if touches_compose or touches_ai_workspace_assistant: checks.append("http://127.0.0.1:18082/healthz") return tuple(checks) def run_healthchecks(component, entries=None): for url in component_healthchecks(component, entries): healthcheck_url(url) 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 manifest = 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) 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 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 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 not bootstrap_root or compose_entry not in entries: die(f"compose file not found: {compose_file}") elif not (compose_root / "docker-compose.yml").is_file(): die(f"docker-compose.yml not found in component compose root: {compose_root}") compose_env_file = component_compose_env_file(component) if compose_env_file and not compose_env_file.is_file(): die(f"compose env file not found: {compose_env_file}") if not DOCKER.is_file(): die(f"docker not found: {DOCKER}") 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 any( rel == "nodedc-source/dist" or rel.startswith("nodedc-source/dist/") for rel in 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 COMPONENTS[component].get("publish_dist"): 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: 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), "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)