9948 lines
454 KiB
Python
Executable File
9948 lines
454 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
import argparse
|
|
import base64
|
|
import hashlib
|
|
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"
|
|
RUNTIME_DIR = DEPLOY_ROOT / "runtime"
|
|
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"
|
|
ENGINE_CREDENTIAL_PROVISIONER_PRIVATE_KEY_FILE = EXTERNAL_DATA_PLANE_PROVISIONER_SECRET_DIR / "engine-credential-provisioner-ed25519.pem"
|
|
ENGINE_CREDENTIAL_SINK_STATE_DIR = Path("/volume2/nodedc-demo/nodedc-data/engine-credential-sink")
|
|
ENGINE_CREDENTIAL_SINK_PUBLIC_KEY_FILE = ENGINE_CREDENTIAL_SINK_STATE_DIR / "issuer-public-key.pem"
|
|
ENGINE_CREDENTIAL_PROVISIONER_KEY_ID = "engine-credential-provisioner-20260716-001"
|
|
ENGINE_CREDENTIAL_BACKEND_BASE_IMAGE = "node:20-alpine"
|
|
ENGINE_CREDENTIAL_BACKEND_IMAGE = "nodedc/engine-backend:credential-sink-20260716-001"
|
|
ENGINE_CREDENTIAL_BACKEND_PACKAGE_LOCK_SHA256 = "254f9086142eb469a0bcfcb393330a126e4d128a0df7455f0ac71d52b95e770b"
|
|
ENGINE_CREDENTIAL_BACKEND_NODE_MODULES_DIR = Path("/volume2/nodedc-demo/nodedc-backend-node_modules")
|
|
ENGINE_CREDENTIAL_BACKEND_OVERRIDE_TEMPLATE_REL = "nodedc-source/services/backend/credential-sink/docker-compose.immutable-runtime.yml"
|
|
ENGINE_CREDENTIAL_BACKEND_RUNTIME_DIR = RUNTIME_DIR / "engine-credential-sink"
|
|
ENGINE_CREDENTIAL_BACKEND_OVERRIDE_FILE = ENGINE_CREDENTIAL_BACKEND_RUNTIME_DIR / "docker-compose.immutable-runtime.yml"
|
|
ENGINE_CREDENTIAL_BACKEND_ROOTFS_FILE = ENGINE_CREDENTIAL_BACKEND_RUNTIME_DIR / "canonical-rootfs.tar"
|
|
ENGINE_CREDENTIAL_BACKEND_METADATA_FILE = ENGINE_CREDENTIAL_BACKEND_RUNTIME_DIR / "runtime-metadata.json"
|
|
ENGINE_CREDENTIAL_BACKEND_ACTIVATION_FILE = ENGINE_CREDENTIAL_BACKEND_RUNTIME_DIR / "activation.ready"
|
|
ENGINE_DATA_PRODUCT_PUBLISH_GRANT_OVERRIDE_REL = "nodedc-source/services/backend/data-product-publish-grant/docker-compose.immutable-runtime.yml"
|
|
ENGINE_DATA_PRODUCT_READ_GRANT_OVERRIDE_REL = "nodedc-source/services/backend/data-product-read-grant/docker-compose.immutable-runtime.yml"
|
|
ENGINE_AGENT_FULL_GRANT_MIGRATION_STORE_REL = "nodedc-source/server/engineAgents/store.js"
|
|
ENGINE_AGENT_FULL_GRANT_MIGRATION_PREDECESSOR_SHA256 = "52daa43499d6d9a97fe7ffa891edb9212b7791e733f91dd3ca686d42739b7e9a"
|
|
ENGINE_AGENT_FULL_GRANT_MIGRATION_TARGET_SHA256 = "2e62654c2dc12905efcc83a9dff45a818dd7b47924a10600160835c4416540e9"
|
|
ENGINE_DATA_PRODUCT_PUBLISH_GRANT_PREDECESSOR_COMPOSE_SHA256 = "258cebb64ff1943c939655cc55bdce00fc5c4dced67ec291d84d6df066ace50e"
|
|
ENGINE_EDP_MANAGED_PROVISIONER_SECRET_DIR = MAP_GATEWAY_SECRET_DIR / "engine-edp-managed-provisioner"
|
|
ENGINE_EDP_MANAGED_PROVISIONER_PRIVATE_KEY_FILE = ENGINE_EDP_MANAGED_PROVISIONER_SECRET_DIR / "private-key.pem"
|
|
ENGINE_EDP_MANAGED_PROVISIONER_TRUST_DIR = Path("/volume1/docker/nodedc-platform/trust/engine-managed-provisioner")
|
|
ENGINE_EDP_MANAGED_PROVISIONER_PUBLIC_KEY_FILE = ENGINE_EDP_MANAGED_PROVISIONER_TRUST_DIR / "public-key.pem"
|
|
FOUNDRY_EDP_MANAGED_PROVISIONER_SECRET_DIR = MAP_GATEWAY_SECRET_DIR / "foundry-edp-managed-provisioner"
|
|
FOUNDRY_EDP_MANAGED_PROVISIONER_PRIVATE_KEY_FILE = FOUNDRY_EDP_MANAGED_PROVISIONER_SECRET_DIR / "private-key.pem"
|
|
FOUNDRY_EDP_MANAGED_PROVISIONER_TRUST_DIR = Path("/volume1/docker/nodedc-platform/trust/foundry-managed-provisioner")
|
|
FOUNDRY_EDP_MANAGED_PROVISIONER_PUBLIC_KEY_FILE = FOUNDRY_EDP_MANAGED_PROVISIONER_TRUST_DIR / "public-key.pem"
|
|
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_NODE_INTELLIGENCE_RELEASE_ID = "2.33.2-974a9fb3492f"
|
|
ENGINE_NODE_INTELLIGENCE_UPSTREAM_COMMIT = "974a9fb3492fe2c4984ee0549085d531cdc6242a"
|
|
ENGINE_NODE_INTELLIGENCE_IMAGE = (
|
|
f"nodedc/engine-node-intelligence:{ENGINE_NODE_INTELLIGENCE_RELEASE_ID}"
|
|
)
|
|
ENGINE_NODE_INTELLIGENCE_SERVICE = "nodedc-node-intelligence"
|
|
ENGINE_NODE_INTELLIGENCE_RUNTIME_UID = 11007
|
|
ENGINE_NODE_INTELLIGENCE_RUNTIME_GID = 11007
|
|
ENGINE_NODE_INTELLIGENCE_RUNTIME_DIR = RUNTIME_DIR / "engine-node-intelligence"
|
|
ENGINE_NODE_INTELLIGENCE_SECRET_FILE = ENGINE_NODE_INTELLIGENCE_RUNTIME_DIR / "auth-token"
|
|
ENGINE_NODE_INTELLIGENCE_RELEASES_DIR = ENGINE_NODE_INTELLIGENCE_RUNTIME_DIR / "releases"
|
|
ENGINE_NODE_INTELLIGENCE_SECRET_CONTAINER_PATH = (
|
|
"/run/nodedc-secrets/engine-node-intelligence-auth-token"
|
|
)
|
|
ENGINE_NODE_INTELLIGENCE_SOURCE_REL = "nodedc-source/server/nodeIntelligence"
|
|
ENGINE_NODE_INTELLIGENCE_GATEWAY_REL = "nodedc-source/server/routes/engineAgentGateway.js"
|
|
ENGINE_NODE_INTELLIGENCE_SERVICE_ROOT_REL = "nodedc-source/services/node-intelligence"
|
|
ENGINE_NODE_INTELLIGENCE_OVERRIDE_REL = (
|
|
f"{ENGINE_NODE_INTELLIGENCE_SERVICE_ROOT_REL}/docker-compose.immutable-runtime.yml"
|
|
)
|
|
ENGINE_NODE_INTELLIGENCE_DESCRIPTOR_REL = (
|
|
f"{ENGINE_NODE_INTELLIGENCE_SERVICE_ROOT_REL}/activation.json"
|
|
)
|
|
ENGINE_NODE_INTELLIGENCE_IMAGE_ARCHIVE_REL = (
|
|
f"{ENGINE_NODE_INTELLIGENCE_SERVICE_ROOT_REL}/image/engine-node-intelligence.tar"
|
|
)
|
|
ENGINE_NODE_INTELLIGENCE_PREDECESSOR_GATEWAY_SHA256 = (
|
|
"6b8c80fa997ef7c438d199a6ee942c5e37aebc4057667af417897fa636131db4"
|
|
)
|
|
ENGINE_NODE_INTELLIGENCE_PREDECESSOR_COMPOSE_SHA256 = (
|
|
"258cebb64ff1943c939655cc55bdce00fc5c4dced67ec291d84d6df066ace50e"
|
|
)
|
|
ENGINE_NODE_INTELLIGENCE_ARTIFACT_ENTRIES = (
|
|
ENGINE_NODE_INTELLIGENCE_SOURCE_REL,
|
|
ENGINE_NODE_INTELLIGENCE_GATEWAY_REL,
|
|
ENGINE_NODE_INTELLIGENCE_SERVICE_ROOT_REL,
|
|
)
|
|
ENGINE_NODE_INTELLIGENCE_ROLLBACK_ENTRIES = (
|
|
ENGINE_NODE_INTELLIGENCE_GATEWAY_REL,
|
|
ENGINE_NODE_INTELLIGENCE_SERVICE_ROOT_REL,
|
|
)
|
|
ENGINE_MCP_CONTROL_PLANE_DESCRIPTOR_REL = ENGINE_NODE_INTELLIGENCE_DESCRIPTOR_REL
|
|
ENGINE_MCP_CONTROL_PLANE_ARTIFACT_ENTRIES = (
|
|
"nodedc-source/server/assets/engine-agent-npm/bin/nodedc-engine-codex-agent.mjs",
|
|
"nodedc-source/server/assets/engine-agent-npm/package.json",
|
|
"nodedc-source/server/assets/nodedc-engine-codex-agent-0.1.4.tgz",
|
|
"nodedc-source/server/dataProductPublishGrant/providerCatalog.js",
|
|
"nodedc-source/server/dataProductPublishGrant/signedDataPlaneClient.js",
|
|
"nodedc-source/server/dataProductReadGrant/acceptance.js",
|
|
"nodedc-source/server/dataProductReadGrant/service.js",
|
|
"nodedc-source/server/dataProductReadGrant/store.js",
|
|
"nodedc-source/server/engineAgents/store.js",
|
|
"nodedc-source/server/nodeIntelligence/upstreamProjection.js",
|
|
ENGINE_NODE_INTELLIGENCE_GATEWAY_REL,
|
|
"nodedc-source/server/routes/n8n.js",
|
|
"nodedc-source/server/routes/ndcAgentMcp.js",
|
|
ENGINE_DATA_PRODUCT_READ_GRANT_OVERRIDE_REL,
|
|
"nodedc-source/server/dataProductPublishGrant/service.js",
|
|
"nodedc-source/server/dataProductPublishGrant/store.js",
|
|
ENGINE_MCP_CONTROL_PLANE_DESCRIPTOR_REL,
|
|
)
|
|
ENGINE_MCP_CONTROL_PLANE_PREDECESSOR_SHA256 = {
|
|
ENGINE_NODE_INTELLIGENCE_GATEWAY_REL: "604a75ad3b9b463cea5e578760c8a23592e2e00b8acfc0099730a347e27bf4e8",
|
|
"nodedc-source/server/routes/ndcAgentMcp.js": "534e2c85e1faecc72a00da7ad32d0584ee09b6bd89eeec2221c3b23d80e1e962",
|
|
"nodedc-source/server/routes/n8n.js": "de6e3c76740af86e2eaeedede140b5ee54eb526f03db270324d7a4fe513f6817",
|
|
"nodedc-source/server/dataProductPublishGrant/providerCatalog.js": "a96d3695fdb3ed7012b9041182f37b09e32d1dfa0dbb391d8db2d7d25a39d64d",
|
|
"nodedc-source/server/dataProductPublishGrant/signedDataPlaneClient.js": "1a26728ac69c5ef3fdce1ce1154a6ce330f72286b6e07825796a246070d15bbf",
|
|
"nodedc-source/server/engineAgents/store.js": "2e62654c2dc12905efcc83a9dff45a818dd7b47924a10600160835c4416540e9",
|
|
"nodedc-source/server/nodeIntelligence/upstreamProjection.js": "946353ed8582c5d96e08361603425cc45eaf231f730ae46a75028d3bef352ee9",
|
|
"nodedc-source/server/assets/engine-agent-npm/bin/nodedc-engine-codex-agent.mjs": "7d3d7a9bc48614e6cd238199caa384073096b7ec8273703833681151b464ef17",
|
|
"nodedc-source/server/assets/engine-agent-npm/package.json": "9f299b20db4855b1bb4a9bd2e1095dfd059cd90ec82e936884a380aa41249e28",
|
|
"nodedc-source/server/dataProductPublishGrant/service.js": "85ded82d707e0ef5b1ad739b5a9be24c1432e2dd9e5051108739f09daf20f47c",
|
|
"nodedc-source/server/dataProductPublishGrant/store.js": "ef2a5e6bfad9f0b1710db006a0d30d01cd9bcaf06524b0e9f975dd771efe3952",
|
|
}
|
|
ENGINE_MCP_CONTROL_PLANE_TARGET_SHA256 = {
|
|
ENGINE_NODE_INTELLIGENCE_GATEWAY_REL: "96c726dab5cf1341f74e6e1095d518058ca320e0dd5738e25bdbe75db1f4fc15",
|
|
"nodedc-source/server/routes/ndcAgentMcp.js": "534e2c85e1faecc72a00da7ad32d0584ee09b6bd89eeec2221c3b23d80e1e962",
|
|
"nodedc-source/server/routes/n8n.js": "f293a7794405badbabd2bf7ef088f96fe1167d9e249f05cbfc8af280a0d3e8f8",
|
|
"nodedc-source/server/dataProductPublishGrant/providerCatalog.js": "901b8fad80018ce177b34ced804b39cb140a47e831414057f484296b373c651d",
|
|
"nodedc-source/server/dataProductPublishGrant/signedDataPlaneClient.js": "c2a13d5eb49937fe70ec6451d0465cac54c19c7fae44448db099079473ec02fc",
|
|
"nodedc-source/server/dataProductReadGrant/acceptance.js": "202dbe575b2f2e1c584aa7e6e99e38fa84f12496feb454e3dbd3f98e2d0396dd",
|
|
"nodedc-source/server/dataProductReadGrant/service.js": "65b8cdd6b603333bac1feb303e9791feb1e9da39dc9b5bfc3df3961273b0052e",
|
|
"nodedc-source/server/dataProductReadGrant/store.js": "c3fcdc59a794f57d92e3d2ac713ee28341347cebd25364c4060beaa3dc2151de",
|
|
"nodedc-source/server/engineAgents/store.js": "debd351fe0b8c72b33b8c79909b8f339cbaf061b970576f3ae72df52ebaa211f",
|
|
"nodedc-source/server/nodeIntelligence/upstreamProjection.js": "761a874b102a938bc6018159ddacdaac71ad6ae08e9f0f8d7f3b58a0165a5131",
|
|
"nodedc-source/server/assets/engine-agent-npm/bin/nodedc-engine-codex-agent.mjs": "521098de69fe288a56bd4158c849c1055ba24be08e19f7a4111618d0e8138445",
|
|
"nodedc-source/server/assets/engine-agent-npm/package.json": "cbe113e9b10bb84b9ccbffa3e261908e58a8430c11abe2cf4fd5304741b04597",
|
|
"nodedc-source/server/assets/nodedc-engine-codex-agent-0.1.4.tgz": "e74c0136d346f904b42589d75cb11a952b4d2f21153f1b057fba28e9acf4f95f",
|
|
ENGINE_DATA_PRODUCT_READ_GRANT_OVERRIDE_REL: "952df0cb2ac477e644f5f3a9d64b4872ce1da33356eeab0bec17dcb2931cf653",
|
|
"nodedc-source/server/dataProductPublishGrant/service.js": "ded3832c9677345a988448ae8e69cef254fd9bf39d2de20cffa295c1feedcd7c",
|
|
"nodedc-source/server/dataProductPublishGrant/store.js": "a92303b2732e21f68c1ac732fa26e4983cbadf3515741cee983b916a283d754c",
|
|
}
|
|
ENGINE_MCP_CONTROL_PLANE_NEW_PATHS = (
|
|
"nodedc-source/server/assets/nodedc-engine-codex-agent-0.1.4.tgz",
|
|
"nodedc-source/server/dataProductReadGrant/acceptance.js",
|
|
"nodedc-source/server/dataProductReadGrant/service.js",
|
|
"nodedc-source/server/dataProductReadGrant/store.js",
|
|
ENGINE_DATA_PRODUCT_READ_GRANT_OVERRIDE_REL,
|
|
)
|
|
ENGINE_MCP_ONTOLOGY_SDK_ARTIFACT_ENTRIES = (
|
|
"nodedc-source/server/assets/engine-agent-npm/bin/nodedc-engine-codex-agent.mjs",
|
|
"nodedc-source/server/assets/engine-agent-npm/package.json",
|
|
"nodedc-source/server/assets/nodedc-engine-codex-agent-0.1.5.tgz",
|
|
"nodedc-source/server/assets/provider-packages/v1/catalog.json",
|
|
"nodedc-source/server/dataProductPublishGrant/providerCatalog.js",
|
|
"nodedc-source/server/engineAgents/store.js",
|
|
ENGINE_NODE_INTELLIGENCE_GATEWAY_REL,
|
|
ENGINE_MCP_CONTROL_PLANE_DESCRIPTOR_REL,
|
|
)
|
|
ENGINE_MCP_ONTOLOGY_SDK_PREDECESSOR_SHA256 = {
|
|
"nodedc-source/server/assets/engine-agent-npm/bin/nodedc-engine-codex-agent.mjs": "521098de69fe288a56bd4158c849c1055ba24be08e19f7a4111618d0e8138445",
|
|
"nodedc-source/server/assets/engine-agent-npm/package.json": "cbe113e9b10bb84b9ccbffa3e261908e58a8430c11abe2cf4fd5304741b04597",
|
|
"nodedc-source/server/assets/provider-packages/v1/catalog.json": "30f89052b6557a2444550bd6e7eed30747b2d9b7ff3a22d079d9905fe0a04702",
|
|
"nodedc-source/server/dataProductPublishGrant/providerCatalog.js": "901b8fad80018ce177b34ced804b39cb140a47e831414057f484296b373c651d",
|
|
"nodedc-source/server/engineAgents/store.js": "debd351fe0b8c72b33b8c79909b8f339cbaf061b970576f3ae72df52ebaa211f",
|
|
ENGINE_NODE_INTELLIGENCE_GATEWAY_REL: "96c726dab5cf1341f74e6e1095d518058ca320e0dd5738e25bdbe75db1f4fc15",
|
|
}
|
|
ENGINE_MCP_ONTOLOGY_SDK_TARGET_SHA256 = {
|
|
"nodedc-source/server/assets/engine-agent-npm/bin/nodedc-engine-codex-agent.mjs": "adff3e474c914680f7d36b204d1dc03e69dc8065fff1f80b6713526cfc970137",
|
|
"nodedc-source/server/assets/engine-agent-npm/package.json": "0741647e4f7f58f69f3021d367484609a8e1eb7b8727d6ad9639bd9906b7f455",
|
|
"nodedc-source/server/assets/nodedc-engine-codex-agent-0.1.5.tgz": "72a1b2d41a12a298eaae63ba3334c7c66b6ca53de6a3f03a48607d4ff6da42fb",
|
|
"nodedc-source/server/assets/provider-packages/v1/catalog.json": "4800e1a1c2af5e4893b1a403c04e91f55689383457caff833edc9e35e8e7e37a",
|
|
"nodedc-source/server/dataProductPublishGrant/providerCatalog.js": "d5511a8bd3b4238af88a89537661c9ca4c0126bca7b5ad225985e27ccdb2c64c",
|
|
"nodedc-source/server/engineAgents/store.js": "4cd4bdd5958cfafee184e98a04fe12aa0c1cbe884326beaec63c99f9fff61285",
|
|
ENGINE_NODE_INTELLIGENCE_GATEWAY_REL: "25fa013ddbfd7d0c7ece8c792daec5f345062757c85eb56cfbff45bf1e812152",
|
|
}
|
|
ENGINE_MCP_ONTOLOGY_SDK_NEW_PATHS = (
|
|
"nodedc-source/server/assets/nodedc-engine-codex-agent-0.1.5.tgz",
|
|
)
|
|
ENGINE_MCP_AUTONOMY_PROVIDER_V5_ARTIFACT_ENTRIES = (
|
|
"nodedc-source/server/assets/engine-agent-npm/bin/nodedc-engine-codex-agent.mjs",
|
|
"nodedc-source/server/assets/engine-agent-npm/package.json",
|
|
"nodedc-source/server/assets/nodedc-engine-codex-agent-0.1.6.tgz",
|
|
"nodedc-source/server/assets/provider-packages/v1/catalog.json",
|
|
"nodedc-source/server/engineAgents/store.js",
|
|
ENGINE_NODE_INTELLIGENCE_GATEWAY_REL,
|
|
ENGINE_MCP_CONTROL_PLANE_DESCRIPTOR_REL,
|
|
)
|
|
ENGINE_MCP_AUTONOMY_PROVIDER_V5_PREDECESSOR_SHA256 = {
|
|
"nodedc-source/server/assets/engine-agent-npm/bin/nodedc-engine-codex-agent.mjs": "521098de69fe288a56bd4158c849c1055ba24be08e19f7a4111618d0e8138445",
|
|
"nodedc-source/server/assets/engine-agent-npm/package.json": "cbe113e9b10bb84b9ccbffa3e261908e58a8430c11abe2cf4fd5304741b04597",
|
|
"nodedc-source/server/assets/provider-packages/v1/catalog.json": "17f3e368f3264cbbd708965c9e1fd735aa974f15a1383bf88cd6d14a43dbf32d",
|
|
"nodedc-source/server/engineAgents/store.js": "debd351fe0b8c72b33b8c79909b8f339cbaf061b970576f3ae72df52ebaa211f",
|
|
ENGINE_NODE_INTELLIGENCE_GATEWAY_REL: "96c726dab5cf1341f74e6e1095d518058ca320e0dd5738e25bdbe75db1f4fc15",
|
|
}
|
|
ENGINE_MCP_AUTONOMY_PROVIDER_V5_TARGET_SHA256 = {
|
|
"nodedc-source/server/assets/engine-agent-npm/bin/nodedc-engine-codex-agent.mjs": "c15c9da4f90f44a4e9e12f3683127e906d614fb98e562faa0c939505c973e074",
|
|
"nodedc-source/server/assets/engine-agent-npm/package.json": "2ca8dcab0fa04bb1b21add1f75a9be61d5aa97753443ee1917302b4e0da5780a",
|
|
"nodedc-source/server/assets/nodedc-engine-codex-agent-0.1.6.tgz": "d007a81cb4e4af569b3c54d3869b0f60b5597e3b531bff232145fa1851d8572a",
|
|
"nodedc-source/server/assets/provider-packages/v1/catalog.json": "63e0741646197f0b1b3c64a4095e1bc8fb3a95ee6caf20b0293f89d869c9e620",
|
|
"nodedc-source/server/engineAgents/store.js": "4cd4bdd5958cfafee184e98a04fe12aa0c1cbe884326beaec63c99f9fff61285",
|
|
ENGINE_NODE_INTELLIGENCE_GATEWAY_REL: "5331f6dc8dc306f641370a2968eb025ee3e278e27ae9a217e4cfc2901fec369c",
|
|
}
|
|
ENGINE_MCP_AUTONOMY_PROVIDER_V5_NEW_PATHS = (
|
|
"nodedc-source/server/assets/nodedc-engine-codex-agent-0.1.6.tgz",
|
|
)
|
|
ENGINE_PROVIDER_SECURITY_CATALOG_REL = (
|
|
"nodedc-source/server/assets/provider-packages/v1/catalog.json"
|
|
)
|
|
ENGINE_PROVIDER_SECURITY_CATALOG_ARTIFACT_ENTRIES = (
|
|
ENGINE_PROVIDER_SECURITY_CATALOG_REL,
|
|
)
|
|
ENGINE_PROVIDER_SECURITY_CATALOG_PREDECESSOR_SHA256 = (
|
|
"1aac712a05e55137d69eedaf363969460ffe866288e8e18e35711967ab232f39"
|
|
)
|
|
ENGINE_PROVIDER_SECURITY_CATALOG_TARGET_SHA256 = (
|
|
"dd67d8b01091072a4e7be3820b9639c12f2f897ccbbd79208123564d10c1cd81"
|
|
)
|
|
ENGINE_CONTROL_PLANE_STATE_REL = "nodedc-control-plane"
|
|
ENGINE_PUBLISH_GRANT_STATE_REL = f"{ENGINE_CONTROL_PLANE_STATE_REL}/publish-grants"
|
|
ENGINE_READ_GRANT_STATE_REL = f"{ENGINE_CONTROL_PLANE_STATE_REL}/read-grants"
|
|
ENGINE_CONTROL_PLANE_STATE_PATH = Path("/volume2/nodedc-demo/nodedc-control-plane")
|
|
ENGINE_PUBLISH_GRANT_STATE_PATH = ENGINE_CONTROL_PLANE_STATE_PATH / "publish-grants"
|
|
ENGINE_READ_GRANT_STATE_PATH = ENGINE_CONTROL_PLANE_STATE_PATH / "read-grants"
|
|
ENGINE_EDP_PRIVATE_KEY_CONTAINER_PATH = "/run/nodedc-secrets/engine-edp-managed-provisioner-private.pem"
|
|
ENGINE_CONTROL_PLANE_CONTAINER_PATH = "/var/lib/nodedc-control-plane"
|
|
ENGINE_PUBLISH_GRANT_CONTAINER_PATH = "/var/lib/nodedc-control-plane/publish-grants"
|
|
ENGINE_READ_GRANT_CONTAINER_PATH = "/var/lib/nodedc-control-plane/read-grants"
|
|
EXTERNAL_DATA_PLANE_MANAGED_TRUST_CONTAINER_PATH = "/run/nodedc-trust/engine-managed-provisioner"
|
|
EXTERNAL_DATA_PLANE_INTERNAL_URL = "http://external-data-plane:18106"
|
|
PLATFORM_EXTERNAL_DATA_PLANE_COMPOSE_REL = "platform/docker-compose.external-data-plane.yml"
|
|
EXTERNAL_DATA_PLANE_DATABASE_SERVICE = "external-data-plane-postgres"
|
|
EXTERNAL_DATA_PLANE_SERVICE = "external-data-plane"
|
|
EXTERNAL_DATA_PLANE_IMAGE = "nodedc/external-data-plane:local"
|
|
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_NODE_MODULES_PATH = "/usr/local/lib/node_modules/n8n/node_modules"
|
|
ENGINE_N8N_ICON_SHA256 = "1c928fc996d8b82121e8f8e327d74b1dd21556c106de99c5e8d317d926c0ba17"
|
|
ENGINE_N8N_DARK_ICON_SHA256 = "ef3b8551da4ce04527736405afa9a220a2c76d047bd18f6f7124b9adb4216b0c"
|
|
ENGINE_N8N_RELEASE_CATALOG_JSON_SHA256 = {
|
|
"0.1.2-05e4b38b14b4a019": {
|
|
"nodes": "230f712230f7ee8debaa4b44a4358cc19c205bc43305d8ed89d5e3daac466506",
|
|
"credentials": "a26824ffc0e15db857c792153de3417febc0dbba4880380d6c8cd544b3c80f4d",
|
|
"meta": "caf7635420c61333e65f68350f5567217aed96fb51354b38764bcac2c24e7966",
|
|
},
|
|
"0.1.3-3354149b5245e39a": {
|
|
"nodes": "b0a5215699a6e691b8cfc505ab457d5632ef6d83adb3537520d0b60760ba16b2",
|
|
"credentials": "a26824ffc0e15db857c792153de3417febc0dbba4880380d6c8cd544b3c80f4d",
|
|
"meta": "b8aa60c0d919573626fa0694a1e4ae9ede015325a5d312e3e05d5ac293084aa7",
|
|
},
|
|
"0.1.4-59dc9f7882721d6a": {
|
|
"nodes": "b0a5215699a6e691b8cfc505ab457d5632ef6d83adb3537520d0b60760ba16b2",
|
|
"credentials": "af8ada839070a4c24b9b10981a751c2b2db651dced1392333d7a4ef13de4564e",
|
|
"meta": "8fadbc594c5d14b1d53ab69b677f00823949410b9a37f4e43ecc321e89d68983",
|
|
},
|
|
"0.1.5-3c8ae53f010d7c88": {
|
|
"nodes": "fb2ef4d38b9c071fae117a27381714658a1a19334b82fc79d64d13f27555fe5a",
|
|
"credentials": "af8ada839070a4c24b9b10981a751c2b2db651dced1392333d7a4ef13de4564e",
|
|
"meta": "b324592c38d518af8320d8540c7061e4684056ae88a676cf5e50c6eef03c0dd7",
|
|
},
|
|
"0.1.6-25cc2d7a52a0d0ee": {
|
|
"nodes": "fb2ef4d38b9c071fae117a27381714658a1a19334b82fc79d64d13f27555fe5a",
|
|
"credentials": "af8ada839070a4c24b9b10981a751c2b2db651dced1392333d7a4ef13de4564e",
|
|
"meta": "58adcb182e5f231a4798636a99887a1f214e3c6e1eb4c0dc2d9b2f3aa80a99ef",
|
|
},
|
|
}
|
|
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}$")
|
|
ENGINE_NODE_INTELLIGENCE_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",
|
|
"dist/credentials/NdcProviderRotatingAccessApi.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",
|
|
"ndcProviderRotatingAccessApi",
|
|
)
|
|
ENGINE_N8N_CREDENTIAL_TYPES_BY_RELEASE = {
|
|
"0.1.2-05e4b38b14b4a019": ENGINE_N8N_CREDENTIAL_TYPES[:3],
|
|
"0.1.3-3354149b5245e39a": ENGINE_N8N_CREDENTIAL_TYPES[:3],
|
|
"0.1.4-59dc9f7882721d6a": ENGINE_N8N_CREDENTIAL_TYPES,
|
|
"0.1.5-3c8ae53f010d7c88": ENGINE_N8N_CREDENTIAL_TYPES,
|
|
"0.1.6-25cc2d7a52a0d0ee": ENGINE_N8N_CREDENTIAL_TYPES,
|
|
}
|
|
ENGINE_CREDENTIAL_SINK_ARTIFACT_ENTRIES = (
|
|
"nodedc-source/server/credentialPolicies/ndcPrivateNode.js",
|
|
"nodedc-source/server/credentialSink",
|
|
"nodedc-source/server/index.js",
|
|
"nodedc-source/server/routes/engineAgentGateway.js",
|
|
"nodedc-source/server/routes/engineCredentialSink.js",
|
|
"nodedc-source/server/routes/n8n.js",
|
|
"nodedc-source/server/routes/ndcAgentMcp.js",
|
|
ENGINE_CREDENTIAL_BACKEND_OVERRIDE_TEMPLATE_REL,
|
|
)
|
|
ENGINE_DATA_PRODUCT_PUBLISH_GRANT_ARTIFACT_ENTRIES = (
|
|
"nodedc-source/server/assets/provider-packages/v1/catalog.json",
|
|
"nodedc-source/server/dataProductPublishGrant",
|
|
"nodedc-source/server/engineAgents/store.js",
|
|
"nodedc-source/server/routes/engineAgentGateway.js",
|
|
"nodedc-source/server/routes/n8n.js",
|
|
ENGINE_DATA_PRODUCT_PUBLISH_GRANT_OVERRIDE_REL,
|
|
)
|
|
ENGINE_DATA_PRODUCT_PUBLISH_GRANT_SOURCE_PREFIX = (
|
|
"nodedc-source/server/dataProductPublishGrant/"
|
|
)
|
|
ENGINE_DATA_PRODUCT_PUBLISH_GRANT_SOURCE_FILES = (
|
|
"acceptance.js",
|
|
"providerCatalog.js",
|
|
"service.js",
|
|
"signedDataPlaneClient.js",
|
|
"store.js",
|
|
)
|
|
ENGINE_PUBLISH_GRANT_COMPOSITE_PROVIDER_CATALOG_PREDECESSOR_SHA256 = (
|
|
"992159fc457ec76ce1f45aad337604c8a72b29252d44fbccda515f0fd6ea6428"
|
|
)
|
|
ENGINE_PUBLISH_GRANT_COMPOSITE_PROVIDER_CATALOG_TARGET_SHA256 = (
|
|
"9c931f9abfcadb5b34a8a854c2efb8fd79913e000eecf0967d1b7c500bf9a56a"
|
|
)
|
|
ENGINE_PUBLISH_GRANT_COMPOSITE_PROVIDER_CHANGED_PATHS = (
|
|
"nodedc-source/server/assets/provider-packages/v1/catalog.json",
|
|
"nodedc-source/server/dataProductPublishGrant/providerCatalog.js",
|
|
"nodedc-source/server/dataProductPublishGrant/service.js",
|
|
"nodedc-source/server/dataProductPublishGrant/store.js",
|
|
)
|
|
ENGINE_COMPOSITE_PROVIDER_V4_ARTIFACT_ENTRIES = (
|
|
"nodedc-source/server/assets/provider-packages/v1/catalog.json",
|
|
"nodedc-source/server/dataProductPublishGrant/providerCatalog.js",
|
|
"nodedc-source/server/dataProductPublishGrant/service.js",
|
|
"nodedc-source/server/dataProductPublishGrant/store.js",
|
|
)
|
|
ENGINE_COMPOSITE_PROVIDER_V4_PREDECESSOR_SHA256 = {
|
|
"nodedc-source/server/assets/provider-packages/v1/catalog.json": "992159fc457ec76ce1f45aad337604c8a72b29252d44fbccda515f0fd6ea6428",
|
|
"nodedc-source/server/dataProductPublishGrant/providerCatalog.js": "901b8fad80018ce177b34ced804b39cb140a47e831414057f484296b373c651d",
|
|
# Publish Grant 20260718-004 was applied after MCP Control Plane
|
|
# 20260718-003 and is therefore the authoritative installed predecessor.
|
|
"nodedc-source/server/dataProductPublishGrant/service.js": "6a417b25b080c05b40c771df4e2dca163491af2c9083ff73dc7e54ad3b360241",
|
|
"nodedc-source/server/dataProductPublishGrant/store.js": "a92303b2732e21f68c1ac732fa26e4983cbadf3515741cee983b916a283d754c",
|
|
}
|
|
ENGINE_COMPOSITE_PROVIDER_V4_TARGET_SHA256 = {
|
|
"nodedc-source/server/assets/provider-packages/v1/catalog.json": "9c931f9abfcadb5b34a8a854c2efb8fd79913e000eecf0967d1b7c500bf9a56a",
|
|
"nodedc-source/server/dataProductPublishGrant/providerCatalog.js": "689c6fbf695e582d983159973a21142787d1b19bb1d343da4c62c03092ac291f",
|
|
"nodedc-source/server/dataProductPublishGrant/service.js": "83f045f4e0f332644310172ed51bb652d808bf04155b11c02e46bdffc95f7220",
|
|
"nodedc-source/server/dataProductPublishGrant/store.js": "98662da6acd0489a9cae4b726eb61ce89d9b2c788b2ea95433e9c4f02930c095",
|
|
}
|
|
ENGINE_PROVIDER_ROTATING_SLOT_ARTIFACT_ENTRIES = (
|
|
"nodedc-source/server/assets/provider-packages/v1/catalog.json",
|
|
"nodedc-source/server/dataProductPublishGrant/providerCatalog.js",
|
|
)
|
|
ENGINE_PROVIDER_ROTATING_SLOT_PREDECESSOR_SHA256 = {
|
|
"nodedc-source/server/assets/provider-packages/v1/catalog.json": "9c931f9abfcadb5b34a8a854c2efb8fd79913e000eecf0967d1b7c500bf9a56a",
|
|
"nodedc-source/server/dataProductPublishGrant/providerCatalog.js": "689c6fbf695e582d983159973a21142787d1b19bb1d343da4c62c03092ac291f",
|
|
}
|
|
ENGINE_PROVIDER_ROTATING_SLOT_TARGET_SHA256 = {
|
|
"nodedc-source/server/assets/provider-packages/v1/catalog.json": "17f3e368f3264cbbd708965c9e1fd735aa974f15a1383bf88cd6d14a43dbf32d",
|
|
"nodedc-source/server/dataProductPublishGrant/providerCatalog.js": "f6cf5f4de9e57f87fb904e2136a9f34d494e1ffc289aec3ed9ed788b5583a062",
|
|
}
|
|
ENGINE_PROVIDER_AUTHORITY_DIAGNOSTICS_ARTIFACT_ENTRIES = (
|
|
"nodedc-source/server/dataProductPublishGrant/providerCatalog.js",
|
|
)
|
|
ENGINE_PROVIDER_AUTHORITY_DIAGNOSTICS_PREDECESSOR_SHA256 = {
|
|
"nodedc-source/server/dataProductPublishGrant/providerCatalog.js": "f6cf5f4de9e57f87fb904e2136a9f34d494e1ffc289aec3ed9ed788b5583a062",
|
|
}
|
|
ENGINE_PROVIDER_AUTHORITY_DIAGNOSTICS_TARGET_SHA256 = {
|
|
"nodedc-source/server/dataProductPublishGrant/providerCatalog.js": "1a14299167ebe17efd677fa3c59b84c80e12d6846bac6f40f9bcae0729ab22c6",
|
|
}
|
|
ENGINE_DEPTTRANS_ZONE_AUTHORITY_V1_ARTIFACT_ENTRIES = (
|
|
"nodedc-source/server/assets/provider-packages/v1/catalog.json",
|
|
"nodedc-source/server/assets/provider-packages/v1/depttrans-zone-authority-v1.json",
|
|
"nodedc-source/server/dataProductPublishGrant/providerCatalog.js",
|
|
"nodedc-source/server/dataProductPublishGrant/service.js",
|
|
"nodedc-source/server/dataProductPublishGrant/store.js",
|
|
)
|
|
ENGINE_DEPTTRANS_ZONE_AUTHORITY_V1_PREDECESSOR_SHA256 = {
|
|
"nodedc-source/server/assets/provider-packages/v1/catalog.json": "63e0741646197f0b1b3c64a4095e1bc8fb3a95ee6caf20b0293f89d869c9e620",
|
|
"nodedc-source/server/dataProductPublishGrant/providerCatalog.js": "1a14299167ebe17efd677fa3c59b84c80e12d6846bac6f40f9bcae0729ab22c6",
|
|
"nodedc-source/server/dataProductPublishGrant/service.js": "83f045f4e0f332644310172ed51bb652d808bf04155b11c02e46bdffc95f7220",
|
|
"nodedc-source/server/dataProductPublishGrant/store.js": "98662da6acd0489a9cae4b726eb61ce89d9b2c788b2ea95433e9c4f02930c095",
|
|
}
|
|
ENGINE_DEPTTRANS_ZONE_AUTHORITY_V1_TARGET_SHA256 = {
|
|
"nodedc-source/server/assets/provider-packages/v1/catalog.json": "1aac712a05e55137d69eedaf363969460ffe866288e8e18e35711967ab232f39",
|
|
"nodedc-source/server/assets/provider-packages/v1/depttrans-zone-authority-v1.json": "1482032178b4816e599df570face9b1dfa8ae1fa2943ac8f941c561e8cb9aa9d",
|
|
"nodedc-source/server/dataProductPublishGrant/providerCatalog.js": "e4c216b0affd89ddbd48abcec86febd48eb12a4c56507851daeb47f2ccd60c9a",
|
|
"nodedc-source/server/dataProductPublishGrant/service.js": "408836564e9a422eb5e648cc24d764f4bfdf7f83d93306742e8615fa8186a642",
|
|
"nodedc-source/server/dataProductPublishGrant/store.js": "ace5971d0772e9a9499f0849e6b754e3677cc2ca3080e573a12e26acf5a6c0c0",
|
|
}
|
|
ENGINE_DEPTTRANS_ZONE_AUTHORITY_V1_NEW_PATHS = (
|
|
"nodedc-source/server/assets/provider-packages/v1/depttrans-zone-authority-v1.json",
|
|
)
|
|
ENGINE_PROVIDER_TARGET_HOST_POLICY_ARTIFACT_ENTRIES = (
|
|
"nodedc-source/server/routes/n8n.js",
|
|
)
|
|
ENGINE_PROVIDER_TARGET_HOST_POLICY_PREDECESSOR_SHA256 = {
|
|
"nodedc-source/server/routes/n8n.js": "f293a7794405badbabd2bf7ef088f96fe1167d9e249f05cbfc8af280a0d3e8f8",
|
|
}
|
|
ENGINE_PROVIDER_TARGET_HOST_POLICY_TARGET_SHA256 = {
|
|
"nodedc-source/server/routes/n8n.js": "9bc3638e271102abec91bb80413329befb89d60c0e4dc0548f0dd11e93220d0a",
|
|
}
|
|
ENGINE_AGENT_FULL_GRANT_MIGRATION_ARTIFACT_ENTRIES = (
|
|
ENGINE_AGENT_FULL_GRANT_MIGRATION_STORE_REL,
|
|
)
|
|
ENGINE_DATA_PRODUCT_PUBLISH_GRANT_PREDECESSOR_SHA256 = {
|
|
"docker-compose.yml": ENGINE_DATA_PRODUCT_PUBLISH_GRANT_PREDECESSOR_COMPOSE_SHA256,
|
|
"nodedc-source/server/credentialPolicies/ndcPrivateNode.js": "723874a02dc7b8a68b22ff2304431cfe64f28933cedf5f1e6cda79b2e1cf704a",
|
|
"nodedc-source/server/credentialSink/core.js": "9f0facc41fd398fcd955cffdd486abb126cdcd756c87ffde667fcbe00e2c41d3",
|
|
"nodedc-source/server/credentialSink/requestAuth.js": "f8c9237c3e6f4219dee0d4f956d6e97f76f5bd7ba8a6fd0b4fb21aceffa38138",
|
|
"nodedc-source/server/credentialSink/store.js": "c78dc285a973b6acd8a2330f0310935ad08720b2905454492f292d235abf12c0",
|
|
"nodedc-source/server/credentialSink/vendor/engine-credential-sink.mjs": "b4800eead9bf94793ff1280d06e34aad6b37ef055a9d7f8d83d7fb5f9bd66d8b",
|
|
"nodedc-source/server/index.js": "b2b790b02839570d967a2ca68b00e2724485a99389ac9b3a589a1b22302a36b8",
|
|
"nodedc-source/server/routes/engineAgentGateway.js": "604a75ad3b9b463cea5e578760c8a23592e2e00b8acfc0099730a347e27bf4e8",
|
|
"nodedc-source/server/routes/engineCredentialSink.js": "9cbb69dbc8cbe6181cd5b0170fe9c4d717b0a173866ca98cba3e3766c0bab94e",
|
|
"nodedc-source/server/routes/n8n.js": "783d822e2457d82e890f43bc00c7e33822077dc2841f0511a89ecb210fd36d48",
|
|
"nodedc-source/server/routes/ndcAgentMcp.js": "534e2c85e1faecc72a00da7ad32d0584ee09b6bd89eeec2221c3b23d80e1e962",
|
|
ENGINE_CREDENTIAL_BACKEND_OVERRIDE_TEMPLATE_REL: "944fa64b08255eb8207b93fd327aebb98ecd9400d37d25fcfa8e3a040ee44afe",
|
|
}
|
|
ENGINE_DATA_PRODUCT_PUBLISH_GRANT_INSTALLED_FOUNDATION_SHA256 = {
|
|
**ENGINE_DATA_PRODUCT_PUBLISH_GRANT_PREDECESSOR_SHA256,
|
|
# This exact successor was installed by the later restart-safe-auth
|
|
# transition; initial Publish-grant installation still uses the original
|
|
# predecessor map above.
|
|
"nodedc-source/server/index.js": "0ac408e0e9a7bc5c8e13a00afc957b982b065e2bc414919839e0b0a11aa05ba4",
|
|
}
|
|
ENGINE_CREDENTIAL_SINK_CONTRACT_SHA256 = "b4800eead9bf94793ff1280d06e34aad6b37ef055a9d7f8d83d7fb5f9bd66d8b"
|
|
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
|
|
|
|
|
|
class ReconciliationRequired(DeployError):
|
|
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, RUNTIME_DIR):
|
|
path.mkdir(parents=True, exist_ok=True)
|
|
|
|
for path in (STATE_DIR, BACKUPS_DIR, TMP_DIR, RUNTIME_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 resolve_openssl_binary():
|
|
candidate = shutil.which("openssl")
|
|
if not candidate:
|
|
die("openssl is required to provision the Engine credential issuer")
|
|
path = Path(candidate).resolve()
|
|
try:
|
|
path_stat = path.lstat()
|
|
except FileNotFoundError:
|
|
die("resolved openssl binary is missing")
|
|
if (not stat.S_ISREG(path_stat.st_mode)
|
|
or path_stat.st_uid != 0
|
|
or path_stat.st_mode & (stat.S_IWGRP | stat.S_IWOTH)):
|
|
die(f"openssl binary is unsafe: {path}")
|
|
return path
|
|
|
|
|
|
def run_openssl(arguments, label):
|
|
result = subprocess.run(
|
|
[str(resolve_openssl_binary()), *arguments],
|
|
check=False,
|
|
stdin=subprocess.DEVNULL,
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
timeout=30,
|
|
)
|
|
if result.returncode != 0:
|
|
die(f"openssl {label} failed")
|
|
|
|
|
|
def validate_ed25519_public_key_file(path):
|
|
try:
|
|
value = path.read_text(encoding="ascii")
|
|
except (OSError, UnicodeDecodeError):
|
|
die(f"Engine credential issuer public key is unreadable: {path}")
|
|
match = re.fullmatch(
|
|
r"-----BEGIN PUBLIC KEY-----\n([A-Za-z0-9+/=\n]+)-----END PUBLIC KEY-----\n?",
|
|
value,
|
|
)
|
|
if not match:
|
|
die("Engine credential issuer public key PEM format mismatch")
|
|
try:
|
|
der = base64.b64decode(match.group(1).replace("\n", ""), validate=True)
|
|
except ValueError:
|
|
die("Engine credential issuer public key base64 is invalid")
|
|
# SubjectPublicKeyInfo for Ed25519 is the fixed RFC 8410 algorithm header
|
|
# plus one 32-byte public key. This rejects accidental RSA/EC key drift
|
|
# without printing either private or public key material.
|
|
if len(der) != 44 or not der.startswith(bytes.fromhex("302a300506032b6570032100")):
|
|
die("Engine credential issuer key is not Ed25519")
|
|
|
|
|
|
def fsync_file(path):
|
|
descriptor = os.open(str(path), os.O_RDONLY)
|
|
try:
|
|
os.fsync(descriptor)
|
|
finally:
|
|
os.close(descriptor)
|
|
|
|
|
|
def ensure_engine_credential_issuer_keypair():
|
|
# The private issuer never enters an artifact, Compose environment or
|
|
# runner output. The Engine receives only its matching public key through
|
|
# the already persistent nodedc-data mount.
|
|
ensure_external_data_plane_provisioner_secret()
|
|
state_dir = ENGINE_CREDENTIAL_SINK_STATE_DIR
|
|
try:
|
|
state_stat = state_dir.lstat()
|
|
except FileNotFoundError:
|
|
state_dir.mkdir(parents=True, exist_ok=False)
|
|
state_stat = state_dir.lstat()
|
|
if stat.S_ISLNK(state_stat.st_mode) or not stat.S_ISDIR(state_stat.st_mode):
|
|
die(f"Engine credential sink state directory is unsafe: {state_dir}")
|
|
os.chown(state_dir, 0, 0)
|
|
state_dir.chmod(0o700)
|
|
|
|
private_key = ENGINE_CREDENTIAL_PROVISIONER_PRIVATE_KEY_FILE
|
|
public_key = ENGINE_CREDENTIAL_SINK_PUBLIC_KEY_FILE
|
|
private_exists = private_key.exists() or private_key.is_symlink()
|
|
public_exists = public_key.exists() or public_key.is_symlink()
|
|
if not private_exists and public_exists:
|
|
die("Engine credential issuer public key exists without its private key")
|
|
|
|
if private_exists:
|
|
private_stat = private_key.lstat()
|
|
if (stat.S_ISLNK(private_stat.st_mode)
|
|
or not stat.S_ISREG(private_stat.st_mode)
|
|
or private_stat.st_uid != EXTERNAL_DATA_PLANE_RUNTIME_UID
|
|
or private_stat.st_gid != EXTERNAL_DATA_PLANE_RUNTIME_GID
|
|
or stat.S_IMODE(private_stat.st_mode) != 0o400
|
|
or private_stat.st_size < 64
|
|
or private_stat.st_size > 1024):
|
|
die(f"Engine credential issuer private key is unsafe: {private_key}")
|
|
run_openssl(["pkey", "-in", str(private_key), "-noout"], "private key validation")
|
|
else:
|
|
private_tmp = private_key.with_name(f".{private_key.name}.{os.getpid()}.{time.time_ns()}.tmp")
|
|
descriptor = None
|
|
try:
|
|
descriptor = os.open(str(private_tmp), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
os.close(descriptor)
|
|
descriptor = None
|
|
run_openssl(["genpkey", "-algorithm", "ED25519", "-out", str(private_tmp)], "Ed25519 key generation")
|
|
os.chown(private_tmp, EXTERNAL_DATA_PLANE_RUNTIME_UID, EXTERNAL_DATA_PLANE_RUNTIME_GID)
|
|
private_tmp.chmod(0o400)
|
|
fsync_file(private_tmp)
|
|
os.replace(private_tmp, private_key)
|
|
fsync_directory(private_key.parent)
|
|
finally:
|
|
if descriptor is not None:
|
|
os.close(descriptor)
|
|
if private_tmp.exists():
|
|
private_tmp.unlink()
|
|
|
|
derived_public = state_dir / f".{public_key.name}.{os.getpid()}.{time.time_ns()}.tmp"
|
|
descriptor = None
|
|
try:
|
|
descriptor = os.open(str(derived_public), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
os.close(descriptor)
|
|
descriptor = None
|
|
run_openssl(["pkey", "-in", str(private_key), "-pubout", "-out", str(derived_public)], "public key derivation")
|
|
validate_ed25519_public_key_file(derived_public)
|
|
if public_exists:
|
|
public_stat = public_key.lstat()
|
|
if (stat.S_ISLNK(public_stat.st_mode)
|
|
or not stat.S_ISREG(public_stat.st_mode)
|
|
or public_stat.st_uid != 0
|
|
or public_stat.st_gid != 0
|
|
or stat.S_IMODE(public_stat.st_mode) != 0o444
|
|
or public_stat.st_size < 64
|
|
or public_stat.st_size > 1024):
|
|
die(f"Engine credential issuer public key is unsafe: {public_key}")
|
|
validate_ed25519_public_key_file(public_key)
|
|
if derived_public.read_bytes() != public_key.read_bytes():
|
|
die("Engine credential issuer keypair mismatch")
|
|
else:
|
|
os.chown(derived_public, 0, 0)
|
|
derived_public.chmod(0o444)
|
|
fsync_file(derived_public)
|
|
os.replace(derived_public, public_key)
|
|
fsync_directory(state_dir)
|
|
finally:
|
|
if descriptor is not None:
|
|
os.close(descriptor)
|
|
if derived_public.exists():
|
|
derived_public.unlink()
|
|
return "reused" if private_exists else "created"
|
|
def ensure_engine_edp_managed_provisioner_keypair():
|
|
# Engine is the sole holder of the signing key. EDP receives only the
|
|
# matching public key from a separate trust directory. Neither file is
|
|
# copied into an artifact, shared .env, n8n container, or L2 graph.
|
|
openssl = shutil.which("openssl")
|
|
if not openssl:
|
|
die("openssl is required to manage the Engine EDP signing key")
|
|
|
|
try:
|
|
secret_parent_stat = MAP_GATEWAY_SECRET_DIR.lstat()
|
|
except FileNotFoundError:
|
|
MAP_GATEWAY_SECRET_DIR.mkdir(parents=True, exist_ok=False)
|
|
secret_parent_stat = MAP_GATEWAY_SECRET_DIR.lstat()
|
|
if stat.S_ISLNK(secret_parent_stat.st_mode) or not stat.S_ISDIR(secret_parent_stat.st_mode):
|
|
die("Engine EDP signing key parent directory is unsafe")
|
|
os.chown(MAP_GATEWAY_SECRET_DIR, 0, MAP_GATEWAY_RUNTIME_GID)
|
|
MAP_GATEWAY_SECRET_DIR.chmod(0o710)
|
|
|
|
try:
|
|
private_dir_stat = ENGINE_EDP_MANAGED_PROVISIONER_SECRET_DIR.lstat()
|
|
except FileNotFoundError:
|
|
ENGINE_EDP_MANAGED_PROVISIONER_SECRET_DIR.mkdir(parents=False, exist_ok=False)
|
|
private_dir_stat = ENGINE_EDP_MANAGED_PROVISIONER_SECRET_DIR.lstat()
|
|
if stat.S_ISLNK(private_dir_stat.st_mode) or not stat.S_ISDIR(private_dir_stat.st_mode):
|
|
die("Engine EDP signing key directory is unsafe")
|
|
os.chown(ENGINE_EDP_MANAGED_PROVISIONER_SECRET_DIR, 0, 0)
|
|
ENGINE_EDP_MANAGED_PROVISIONER_SECRET_DIR.chmod(0o700)
|
|
|
|
trust_parent = ENGINE_EDP_MANAGED_PROVISIONER_TRUST_DIR.parent
|
|
try:
|
|
trust_parent_stat = trust_parent.lstat()
|
|
except FileNotFoundError:
|
|
trust_parent.mkdir(parents=True, exist_ok=False)
|
|
trust_parent_stat = trust_parent.lstat()
|
|
if stat.S_ISLNK(trust_parent_stat.st_mode) or not stat.S_ISDIR(trust_parent_stat.st_mode):
|
|
die("Engine EDP trust parent directory is unsafe")
|
|
os.chown(trust_parent, 0, EXTERNAL_DATA_PLANE_RUNTIME_GID)
|
|
trust_parent.chmod(0o710)
|
|
|
|
try:
|
|
trust_dir_stat = ENGINE_EDP_MANAGED_PROVISIONER_TRUST_DIR.lstat()
|
|
except FileNotFoundError:
|
|
ENGINE_EDP_MANAGED_PROVISIONER_TRUST_DIR.mkdir(parents=False, exist_ok=False)
|
|
trust_dir_stat = ENGINE_EDP_MANAGED_PROVISIONER_TRUST_DIR.lstat()
|
|
if stat.S_ISLNK(trust_dir_stat.st_mode) or not stat.S_ISDIR(trust_dir_stat.st_mode):
|
|
die("Engine EDP trust directory is unsafe")
|
|
os.chown(ENGINE_EDP_MANAGED_PROVISIONER_TRUST_DIR, 0, EXTERNAL_DATA_PLANE_RUNTIME_GID)
|
|
ENGINE_EDP_MANAGED_PROVISIONER_TRUST_DIR.chmod(0o550)
|
|
|
|
try:
|
|
private_stat = ENGINE_EDP_MANAGED_PROVISIONER_PRIVATE_KEY_FILE.lstat()
|
|
except FileNotFoundError:
|
|
private_stat = None
|
|
try:
|
|
public_stat = ENGINE_EDP_MANAGED_PROVISIONER_PUBLIC_KEY_FILE.lstat()
|
|
except FileNotFoundError:
|
|
public_stat = None
|
|
# A public-only state is ambiguous: replacing its missing private half
|
|
# would silently rotate trust. Fail before creating anything so recovery is
|
|
# an explicit operator decision. Private-only is recoverable by derivation.
|
|
if private_stat is None and public_stat is not None:
|
|
die("Engine EDP public key exists without its private key")
|
|
|
|
private_created = False
|
|
if private_stat is None:
|
|
temporary_private = (
|
|
ENGINE_EDP_MANAGED_PROVISIONER_SECRET_DIR
|
|
/ f".private-key.{os.getpid()}.{time.time_ns()}.tmp"
|
|
)
|
|
try:
|
|
subprocess.run(
|
|
[openssl, "genpkey", "-algorithm", "ED25519", "-out", str(temporary_private)],
|
|
check=True,
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
)
|
|
temporary_stat = temporary_private.lstat()
|
|
if (stat.S_ISLNK(temporary_stat.st_mode) or not stat.S_ISREG(temporary_stat.st_mode)
|
|
or temporary_stat.st_size < 80 or temporary_stat.st_size > 8192):
|
|
die("generated Engine EDP private key is invalid")
|
|
os.chown(temporary_private, 0, 0)
|
|
temporary_private.chmod(0o400)
|
|
with temporary_private.open("rb") as handle:
|
|
os.fsync(handle.fileno())
|
|
os.replace(temporary_private, ENGINE_EDP_MANAGED_PROVISIONER_PRIVATE_KEY_FILE)
|
|
fsync_directory(ENGINE_EDP_MANAGED_PROVISIONER_SECRET_DIR)
|
|
private_created = True
|
|
except (OSError, subprocess.CalledProcessError) as error:
|
|
die(f"failed to generate Engine EDP signing key: {type(error).__name__}")
|
|
finally:
|
|
if temporary_private.exists():
|
|
temporary_private.unlink()
|
|
private_stat = ENGINE_EDP_MANAGED_PROVISIONER_PRIVATE_KEY_FILE.lstat()
|
|
|
|
if (stat.S_ISLNK(private_stat.st_mode) or not stat.S_ISREG(private_stat.st_mode)
|
|
or private_stat.st_uid != 0 or private_stat.st_gid != 0
|
|
or stat.S_IMODE(private_stat.st_mode) != 0o400
|
|
or private_stat.st_size < 80 or private_stat.st_size > 8192):
|
|
die("Engine EDP private key has unsafe ownership, mode, or size")
|
|
|
|
try:
|
|
derived = subprocess.run(
|
|
[openssl, "pkey", "-in", str(ENGINE_EDP_MANAGED_PROVISIONER_PRIVATE_KEY_FILE), "-pubout"],
|
|
check=True,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
).stdout
|
|
except (OSError, subprocess.CalledProcessError) as error:
|
|
die(f"Engine EDP private key validation failed: {type(error).__name__}")
|
|
if (not derived.startswith(b"-----BEGIN PUBLIC KEY-----\n")
|
|
or not derived.rstrip().endswith(b"-----END PUBLIC KEY-----")
|
|
or len(derived) > 8192):
|
|
die("derived Engine EDP public key is invalid")
|
|
try:
|
|
derived_der = subprocess.run(
|
|
[
|
|
openssl,
|
|
"pkey",
|
|
"-in",
|
|
str(ENGINE_EDP_MANAGED_PROVISIONER_PRIVATE_KEY_FILE),
|
|
"-pubout",
|
|
"-outform",
|
|
"DER",
|
|
],
|
|
check=True,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
).stdout
|
|
except (OSError, subprocess.CalledProcessError) as error:
|
|
die(f"Engine EDP private key type validation failed: {type(error).__name__}")
|
|
# RFC 8410 Ed25519 SubjectPublicKeyInfo is exactly 44 bytes and carries the
|
|
# OID 1.3.101.112. Reject RSA/EC keys before either runtime can mount them.
|
|
if (len(derived_der) != 44
|
|
or not derived_der.startswith(bytes.fromhex("302a300506032b6570032100"))):
|
|
die("Engine EDP signing key must be Ed25519")
|
|
|
|
if public_stat is not None:
|
|
if (stat.S_ISLNK(public_stat.st_mode) or not stat.S_ISREG(public_stat.st_mode)
|
|
or public_stat.st_uid != 0
|
|
or public_stat.st_gid != EXTERNAL_DATA_PLANE_RUNTIME_GID
|
|
or stat.S_IMODE(public_stat.st_mode) != 0o440
|
|
or public_stat.st_size < 80 or public_stat.st_size > 8192):
|
|
die("Engine EDP public key has unsafe ownership, mode, or size")
|
|
installed = ENGINE_EDP_MANAGED_PROVISIONER_PUBLIC_KEY_FILE.read_bytes()
|
|
if installed != derived:
|
|
die("Engine EDP public key does not match the installed private key")
|
|
return "created" if private_created else "reused"
|
|
|
|
temporary_public = (
|
|
ENGINE_EDP_MANAGED_PROVISIONER_TRUST_DIR
|
|
/ f".public-key.{os.getpid()}.{time.time_ns()}.tmp"
|
|
)
|
|
descriptor = None
|
|
try:
|
|
descriptor = os.open(str(temporary_public), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o440)
|
|
os.write(descriptor, derived)
|
|
os.fsync(descriptor)
|
|
os.fchown(descriptor, 0, EXTERNAL_DATA_PLANE_RUNTIME_GID)
|
|
os.fchmod(descriptor, 0o440)
|
|
os.close(descriptor)
|
|
descriptor = None
|
|
os.replace(temporary_public, ENGINE_EDP_MANAGED_PROVISIONER_PUBLIC_KEY_FILE)
|
|
fsync_directory(ENGINE_EDP_MANAGED_PROVISIONER_TRUST_DIR)
|
|
finally:
|
|
if descriptor is not None:
|
|
os.close(descriptor)
|
|
if temporary_public.exists():
|
|
temporary_public.unlink()
|
|
return "created"
|
|
return "created"
|
|
|
|
|
|
def ensure_foundry_edp_managed_provisioner_keypair():
|
|
# Foundry receives its own signing identity. It must never reuse or read
|
|
# the Engine private key; EDP trusts the matching public key separately.
|
|
openssl = shutil.which("openssl")
|
|
if not openssl:
|
|
die("openssl is required to manage the Foundry EDP signing key")
|
|
|
|
try:
|
|
secret_parent_stat = MAP_GATEWAY_SECRET_DIR.lstat()
|
|
except FileNotFoundError:
|
|
MAP_GATEWAY_SECRET_DIR.mkdir(parents=True, exist_ok=False)
|
|
secret_parent_stat = MAP_GATEWAY_SECRET_DIR.lstat()
|
|
if stat.S_ISLNK(secret_parent_stat.st_mode) or not stat.S_ISDIR(secret_parent_stat.st_mode):
|
|
die("Foundry EDP signing key parent directory is unsafe")
|
|
os.chown(MAP_GATEWAY_SECRET_DIR, 0, MAP_GATEWAY_RUNTIME_GID)
|
|
MAP_GATEWAY_SECRET_DIR.chmod(0o710)
|
|
|
|
try:
|
|
private_dir_stat = FOUNDRY_EDP_MANAGED_PROVISIONER_SECRET_DIR.lstat()
|
|
except FileNotFoundError:
|
|
FOUNDRY_EDP_MANAGED_PROVISIONER_SECRET_DIR.mkdir(parents=False, exist_ok=False)
|
|
private_dir_stat = FOUNDRY_EDP_MANAGED_PROVISIONER_SECRET_DIR.lstat()
|
|
if stat.S_ISLNK(private_dir_stat.st_mode) or not stat.S_ISDIR(private_dir_stat.st_mode):
|
|
die("Foundry EDP signing key directory is unsafe")
|
|
os.chown(FOUNDRY_EDP_MANAGED_PROVISIONER_SECRET_DIR, 0, 0)
|
|
FOUNDRY_EDP_MANAGED_PROVISIONER_SECRET_DIR.chmod(0o700)
|
|
|
|
trust_parent = FOUNDRY_EDP_MANAGED_PROVISIONER_TRUST_DIR.parent
|
|
try:
|
|
trust_parent_stat = trust_parent.lstat()
|
|
except FileNotFoundError:
|
|
trust_parent.mkdir(parents=True, exist_ok=False)
|
|
trust_parent_stat = trust_parent.lstat()
|
|
if stat.S_ISLNK(trust_parent_stat.st_mode) or not stat.S_ISDIR(trust_parent_stat.st_mode):
|
|
die("Foundry EDP trust parent directory is unsafe")
|
|
os.chown(trust_parent, 0, EXTERNAL_DATA_PLANE_RUNTIME_GID)
|
|
trust_parent.chmod(0o710)
|
|
|
|
try:
|
|
trust_dir_stat = FOUNDRY_EDP_MANAGED_PROVISIONER_TRUST_DIR.lstat()
|
|
except FileNotFoundError:
|
|
FOUNDRY_EDP_MANAGED_PROVISIONER_TRUST_DIR.mkdir(parents=False, exist_ok=False)
|
|
trust_dir_stat = FOUNDRY_EDP_MANAGED_PROVISIONER_TRUST_DIR.lstat()
|
|
if stat.S_ISLNK(trust_dir_stat.st_mode) or not stat.S_ISDIR(trust_dir_stat.st_mode):
|
|
die("Foundry EDP trust directory is unsafe")
|
|
os.chown(FOUNDRY_EDP_MANAGED_PROVISIONER_TRUST_DIR, 0, EXTERNAL_DATA_PLANE_RUNTIME_GID)
|
|
FOUNDRY_EDP_MANAGED_PROVISIONER_TRUST_DIR.chmod(0o550)
|
|
|
|
try:
|
|
private_stat = FOUNDRY_EDP_MANAGED_PROVISIONER_PRIVATE_KEY_FILE.lstat()
|
|
except FileNotFoundError:
|
|
private_stat = None
|
|
try:
|
|
public_stat = FOUNDRY_EDP_MANAGED_PROVISIONER_PUBLIC_KEY_FILE.lstat()
|
|
except FileNotFoundError:
|
|
public_stat = None
|
|
if private_stat is None and public_stat is not None:
|
|
die("Foundry EDP public key exists without its private key")
|
|
|
|
private_created = False
|
|
if private_stat is None:
|
|
temporary_private = (
|
|
FOUNDRY_EDP_MANAGED_PROVISIONER_SECRET_DIR
|
|
/ f".private-key.{os.getpid()}.{time.time_ns()}.tmp"
|
|
)
|
|
try:
|
|
subprocess.run(
|
|
[openssl, "genpkey", "-algorithm", "ED25519", "-out", str(temporary_private)],
|
|
check=True,
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
)
|
|
temporary_stat = temporary_private.lstat()
|
|
if (stat.S_ISLNK(temporary_stat.st_mode) or not stat.S_ISREG(temporary_stat.st_mode)
|
|
or temporary_stat.st_size < 80 or temporary_stat.st_size > 8192):
|
|
die("generated Foundry EDP private key is invalid")
|
|
os.chown(temporary_private, 0, 0)
|
|
temporary_private.chmod(0o400)
|
|
with temporary_private.open("rb") as handle:
|
|
os.fsync(handle.fileno())
|
|
os.replace(temporary_private, FOUNDRY_EDP_MANAGED_PROVISIONER_PRIVATE_KEY_FILE)
|
|
fsync_directory(FOUNDRY_EDP_MANAGED_PROVISIONER_SECRET_DIR)
|
|
private_created = True
|
|
except (OSError, subprocess.CalledProcessError) as error:
|
|
die(f"failed to generate Foundry EDP signing key: {type(error).__name__}")
|
|
finally:
|
|
if temporary_private.exists():
|
|
temporary_private.unlink()
|
|
private_stat = FOUNDRY_EDP_MANAGED_PROVISIONER_PRIVATE_KEY_FILE.lstat()
|
|
|
|
if (stat.S_ISLNK(private_stat.st_mode) or not stat.S_ISREG(private_stat.st_mode)
|
|
or private_stat.st_uid != 0 or private_stat.st_gid != 0
|
|
or stat.S_IMODE(private_stat.st_mode) != 0o400
|
|
or private_stat.st_size < 80 or private_stat.st_size > 8192):
|
|
die("Foundry EDP private key has unsafe ownership, mode, or size")
|
|
|
|
try:
|
|
derived = subprocess.run(
|
|
[openssl, "pkey", "-in", str(FOUNDRY_EDP_MANAGED_PROVISIONER_PRIVATE_KEY_FILE), "-pubout"],
|
|
check=True,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
).stdout
|
|
derived_der = subprocess.run(
|
|
[
|
|
openssl,
|
|
"pkey",
|
|
"-in",
|
|
str(FOUNDRY_EDP_MANAGED_PROVISIONER_PRIVATE_KEY_FILE),
|
|
"-pubout",
|
|
"-outform",
|
|
"DER",
|
|
],
|
|
check=True,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
).stdout
|
|
except (OSError, subprocess.CalledProcessError) as error:
|
|
die(f"Foundry EDP private key validation failed: {type(error).__name__}")
|
|
if (not derived.startswith(b"-----BEGIN PUBLIC KEY-----\n")
|
|
or not derived.rstrip().endswith(b"-----END PUBLIC KEY-----")
|
|
or len(derived) > 8192
|
|
or len(derived_der) != 44
|
|
or not derived_der.startswith(bytes.fromhex("302a300506032b6570032100"))):
|
|
die("Foundry EDP signing key must be Ed25519")
|
|
|
|
if public_stat is not None:
|
|
if (stat.S_ISLNK(public_stat.st_mode) or not stat.S_ISREG(public_stat.st_mode)
|
|
or public_stat.st_uid != 0
|
|
or public_stat.st_gid != EXTERNAL_DATA_PLANE_RUNTIME_GID
|
|
or stat.S_IMODE(public_stat.st_mode) != 0o440
|
|
or public_stat.st_size < 80 or public_stat.st_size > 8192):
|
|
die("Foundry EDP public key has unsafe ownership, mode, or size")
|
|
if FOUNDRY_EDP_MANAGED_PROVISIONER_PUBLIC_KEY_FILE.read_bytes() != derived:
|
|
die("Foundry EDP public key does not match the installed private key")
|
|
return "created" if private_created else "reused"
|
|
|
|
temporary_public = (
|
|
FOUNDRY_EDP_MANAGED_PROVISIONER_TRUST_DIR
|
|
/ f".public-key.{os.getpid()}.{time.time_ns()}.tmp"
|
|
)
|
|
descriptor = None
|
|
try:
|
|
descriptor = os.open(str(temporary_public), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o440)
|
|
os.write(descriptor, derived)
|
|
os.fsync(descriptor)
|
|
os.fchown(descriptor, 0, EXTERNAL_DATA_PLANE_RUNTIME_GID)
|
|
os.fchmod(descriptor, 0o440)
|
|
os.close(descriptor)
|
|
descriptor = None
|
|
os.replace(temporary_public, FOUNDRY_EDP_MANAGED_PROVISIONER_PUBLIC_KEY_FILE)
|
|
fsync_directory(FOUNDRY_EDP_MANAGED_PROVISIONER_TRUST_DIR)
|
|
finally:
|
|
if descriptor is not None:
|
|
os.close(descriptor)
|
|
if temporary_public.exists():
|
|
temporary_public.unlink()
|
|
return "created"
|
|
|
|
|
|
def ensure_engine_data_product_grant_private_state(include_reader=False):
|
|
# Compose must never create this bind source on our behalf: Docker's normal
|
|
# 0755 directory would either expose control-plane metadata or be rejected
|
|
# by the Engine's fail-closed PublishGrantStore.
|
|
engine_root = component_root("engine")
|
|
try:
|
|
engine_root_stat = engine_root.lstat()
|
|
except FileNotFoundError:
|
|
die("Engine root is missing before private state preparation")
|
|
if stat.S_ISLNK(engine_root_stat.st_mode) or not stat.S_ISDIR(engine_root_stat.st_mode):
|
|
die("Engine root is unsafe before private state preparation")
|
|
|
|
state_paths = [
|
|
(engine_root / ENGINE_CONTROL_PLANE_STATE_REL, "Engine control-plane state"),
|
|
(engine_root / ENGINE_PUBLISH_GRANT_STATE_REL, "Engine Publish grant state"),
|
|
]
|
|
if include_reader:
|
|
state_paths.append((
|
|
engine_root / ENGINE_READ_GRANT_STATE_REL,
|
|
"Engine Read grant state",
|
|
))
|
|
for state_path, label in state_paths:
|
|
try:
|
|
state_stat = state_path.lstat()
|
|
except FileNotFoundError:
|
|
state_path.mkdir(parents=False, exist_ok=False, mode=0o700)
|
|
state_stat = state_path.lstat()
|
|
if stat.S_ISLNK(state_stat.st_mode) or not stat.S_ISDIR(state_stat.st_mode):
|
|
die(f"{label} directory is unsafe: {state_path}")
|
|
os.chown(state_path, 0, 0)
|
|
state_path.chmod(0o700)
|
|
state_stat = state_path.lstat()
|
|
if (state_stat.st_uid != 0 or state_stat.st_gid != 0
|
|
or stat.S_IMODE(state_stat.st_mode) != 0o700):
|
|
die(f"{label} ownership or mode is unsafe: {state_path}")
|
|
|
|
|
|
def ensure_engine_publish_grant_private_state():
|
|
# Frozen compatibility seam for the established Publish transition.
|
|
return ensure_engine_data_product_grant_private_state(include_reader=False)
|
|
|
|
|
|
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(f"python={sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}")
|
|
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 == ENGINE_CREDENTIAL_BACKEND_OVERRIDE_TEMPLATE_REL:
|
|
return True
|
|
if rel == ENGINE_DATA_PRODUCT_PUBLISH_GRANT_OVERRIDE_REL:
|
|
return True
|
|
if rel == ENGINE_DATA_PRODUCT_READ_GRANT_OVERRIDE_REL:
|
|
return True
|
|
if rel == ENGINE_NODE_INTELLIGENCE_SERVICE_ROOT_REL or rel.startswith(
|
|
ENGINE_NODE_INTELLIGENCE_SERVICE_ROOT_REL + "/"
|
|
):
|
|
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 engine_n8n_credential_types_for_release(release_id):
|
|
expected = ENGINE_N8N_CREDENTIAL_TYPES_BY_RELEASE.get(release_id)
|
|
if expected is None:
|
|
die("Engine n8n credential catalog release is not registered")
|
|
return expected
|
|
|
|
|
|
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"],
|
|
}
|
|
for key, expected in exact_common.items():
|
|
if descriptor.get(key) != expected:
|
|
die(f"Engine n8n transition {key} mismatch")
|
|
if action == "activate":
|
|
expected_current = descriptor.get("expectedCurrent")
|
|
if (
|
|
expected_current != "verified_inactive"
|
|
and expected_current not in ENGINE_N8N_RELEASE_CATALOG_JSON_SHA256
|
|
):
|
|
die("Engine n8n activation expected-current release is not registered")
|
|
if expected_current == release_id:
|
|
die("Engine n8n activation target already equals expected current")
|
|
if descriptor.get("rollbackBaseline") != expected_current:
|
|
die("Engine n8n activation rollback baseline mismatch")
|
|
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_for_release(release_id)
|
|
):
|
|
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("rollbackBaseline") != "verified_inactive":
|
|
die("Engine n8n inactive rollback baseline 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_for_release(descriptor["releaseId"]))
|
|
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) != 385 + len(expected_credential_types):
|
|
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")
|
|
release_hashes = ENGINE_N8N_RELEASE_CATALOG_JSON_SHA256.get(descriptor["releaseId"])
|
|
if release_hashes is None:
|
|
die("Engine n8n activation release catalog is not registered")
|
|
expected_catalog_hashes = (
|
|
(nodes, release_hashes["nodes"], "node"),
|
|
(credentials, release_hashes["credentials"], "credential"),
|
|
(meta, release_hashes["meta"], "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 is_engine_node_intelligence_transition(component, entries):
|
|
if component != "engine" or entries is None:
|
|
return False
|
|
return tuple(entries) in (
|
|
ENGINE_NODE_INTELLIGENCE_ARTIFACT_ENTRIES,
|
|
ENGINE_NODE_INTELLIGENCE_ROLLBACK_ENTRIES,
|
|
)
|
|
|
|
|
|
def expected_engine_node_intelligence_compose_override():
|
|
return "\n".join((
|
|
"services:",
|
|
f" {ENGINE_NODE_INTELLIGENCE_SERVICE}:",
|
|
f" image: {ENGINE_NODE_INTELLIGENCE_IMAGE}",
|
|
" pull_policy: never",
|
|
" restart: unless-stopped",
|
|
f' user: "{ENGINE_NODE_INTELLIGENCE_RUNTIME_UID}:{ENGINE_NODE_INTELLIGENCE_RUNTIME_GID}"',
|
|
" read_only: true",
|
|
" environment:",
|
|
" HOME: /tmp",
|
|
" NODE_ENV: production",
|
|
" MCP_MODE: http",
|
|
' PORT: "3000"',
|
|
f" AUTH_TOKEN_FILE: {ENGINE_NODE_INTELLIGENCE_SECRET_CONTAINER_PATH}",
|
|
" NODE_DB_PATH: /app/data/nodes.db",
|
|
' REBUILD_ON_START: "false"',
|
|
' N8N_MCP_TELEMETRY_DISABLED: "true"',
|
|
" LOG_LEVEL: warn",
|
|
" volumes:",
|
|
" - type: bind",
|
|
f" source: {ENGINE_NODE_INTELLIGENCE_SECRET_FILE}",
|
|
f" target: {ENGINE_NODE_INTELLIGENCE_SECRET_CONTAINER_PATH}",
|
|
" read_only: true",
|
|
" expose:",
|
|
' - "3000"',
|
|
" tmpfs:",
|
|
" - /tmp:mode=1777",
|
|
" - /app/logs:mode=0755",
|
|
" cap_drop:",
|
|
" - ALL",
|
|
" security_opt:",
|
|
" - no-new-privileges:true",
|
|
" healthcheck:",
|
|
" test:",
|
|
" - CMD-SHELL",
|
|
" - curl -fsS http://127.0.0.1:3000/health >/dev/null",
|
|
" interval: 15s",
|
|
" timeout: 5s",
|
|
" retries: 10",
|
|
" start_period: 30s",
|
|
"",
|
|
" nodedc-backend:",
|
|
" depends_on:",
|
|
f" {ENGINE_NODE_INTELLIGENCE_SERVICE}:",
|
|
" condition: service_healthy",
|
|
" environment:",
|
|
f" ENGINE_NODE_INTELLIGENCE_MCP_URL: http://{ENGINE_NODE_INTELLIGENCE_SERVICE}:3000/mcp",
|
|
f" ENGINE_NODE_INTELLIGENCE_AUTH_TOKEN_FILE: {ENGINE_NODE_INTELLIGENCE_SECRET_CONTAINER_PATH}",
|
|
f" ENGINE_NODE_INTELLIGENCE_ASSET_ID: nodedc-node-intelligence@{ENGINE_NODE_INTELLIGENCE_RELEASE_ID.replace('-', '+', 1)}",
|
|
" volumes:",
|
|
" - type: bind",
|
|
f" source: {ENGINE_NODE_INTELLIGENCE_SECRET_FILE}",
|
|
f" target: {ENGINE_NODE_INTELLIGENCE_SECRET_CONTAINER_PATH}",
|
|
" read_only: true",
|
|
"",
|
|
))
|
|
|
|
|
|
def read_engine_node_intelligence_descriptor(path, label="Engine node-intelligence descriptor"):
|
|
descriptor = read_strict_json(path, label)
|
|
require_exact_json_keys(
|
|
descriptor,
|
|
(
|
|
"schemaVersion",
|
|
"action",
|
|
"releaseId",
|
|
"expectedCurrent",
|
|
"upstream",
|
|
"image",
|
|
"source",
|
|
"predecessor",
|
|
),
|
|
label,
|
|
)
|
|
if descriptor.get("schemaVersion") != "nodedc.engine-node-intelligence-transition/v1":
|
|
die("Engine node-intelligence transition schema mismatch")
|
|
action = descriptor.get("action")
|
|
if action not in ("activate", "rollback-inactive"):
|
|
die("Engine node-intelligence transition action mismatch")
|
|
if descriptor.get("releaseId") != ENGINE_NODE_INTELLIGENCE_RELEASE_ID:
|
|
die("Engine node-intelligence release mismatch")
|
|
require_exact_json_keys(
|
|
descriptor.get("upstream"),
|
|
("package", "version", "commit"),
|
|
f"{label} upstream",
|
|
)
|
|
if descriptor["upstream"] != {
|
|
"package": "n8n-mcp",
|
|
"version": "2.33.2",
|
|
"commit": ENGINE_NODE_INTELLIGENCE_UPSTREAM_COMMIT,
|
|
}:
|
|
die("Engine node-intelligence upstream pin mismatch")
|
|
require_exact_json_keys(
|
|
descriptor.get("predecessor"),
|
|
("gatewaySha256", "composeSha256", "backendRuntime"),
|
|
f"{label} predecessor",
|
|
)
|
|
predecessor = descriptor["predecessor"]
|
|
if predecessor.get("composeSha256") != ENGINE_NODE_INTELLIGENCE_PREDECESSOR_COMPOSE_SHA256:
|
|
die("Engine node-intelligence predecessor Compose mismatch")
|
|
if predecessor.get("backendRuntime") != "verified-derived-retry":
|
|
die("Engine node-intelligence backend predecessor mismatch")
|
|
|
|
if action == "activate":
|
|
if descriptor.get("expectedCurrent") != "inactive":
|
|
die("Engine node-intelligence activation expected-current mismatch")
|
|
if predecessor.get("gatewaySha256") != ENGINE_NODE_INTELLIGENCE_PREDECESSOR_GATEWAY_SHA256:
|
|
die("Engine node-intelligence activation gateway predecessor mismatch")
|
|
require_exact_json_keys(
|
|
descriptor.get("image"),
|
|
(
|
|
"tag",
|
|
"archiveRelativePath",
|
|
"archiveSha256",
|
|
"configSha256",
|
|
"architecture",
|
|
"os",
|
|
),
|
|
f"{label} image",
|
|
)
|
|
image = descriptor["image"]
|
|
if (
|
|
image.get("tag") != ENGINE_NODE_INTELLIGENCE_IMAGE
|
|
or image.get("archiveRelativePath") != ENGINE_NODE_INTELLIGENCE_IMAGE_ARCHIVE_REL
|
|
or not re.fullmatch(r"[a-f0-9]{64}", str(image.get("archiveSha256") or ""))
|
|
or not re.fullmatch(r"[a-f0-9]{64}", str(image.get("configSha256") or ""))
|
|
or image.get("architecture") != "amd64"
|
|
or image.get("os") != "linux"
|
|
):
|
|
die("Engine node-intelligence image descriptor mismatch")
|
|
require_exact_json_keys(
|
|
descriptor.get("source"),
|
|
(
|
|
"gatewaySha256",
|
|
"catalogSha256",
|
|
"upstreamClientSha256",
|
|
"upstreamProjectionSha256",
|
|
"composeOverrideSha256",
|
|
"readmeSha256",
|
|
),
|
|
f"{label} source",
|
|
)
|
|
for value in descriptor["source"].values():
|
|
if not re.fullmatch(r"[a-f0-9]{64}", str(value or "")):
|
|
die("Engine node-intelligence source digest is invalid")
|
|
else:
|
|
if descriptor.get("expectedCurrent") != ENGINE_NODE_INTELLIGENCE_RELEASE_ID:
|
|
die("Engine node-intelligence rollback expected-current mismatch")
|
|
require_exact_json_keys(
|
|
descriptor.get("image"),
|
|
("tag", "configSha256", "architecture", "os"),
|
|
f"{label} image",
|
|
)
|
|
image = descriptor["image"]
|
|
if (
|
|
image.get("tag") != ENGINE_NODE_INTELLIGENCE_IMAGE
|
|
or not re.fullmatch(r"[a-f0-9]{64}", str(image.get("configSha256") or ""))
|
|
or image.get("architecture") != "amd64"
|
|
or image.get("os") != "linux"
|
|
):
|
|
die("Engine node-intelligence rollback image descriptor mismatch")
|
|
require_exact_json_keys(
|
|
descriptor.get("source"),
|
|
("gatewaySha256", "readmeSha256"),
|
|
f"{label} source",
|
|
)
|
|
if descriptor["source"].get("gatewaySha256") != ENGINE_NODE_INTELLIGENCE_PREDECESSOR_GATEWAY_SHA256:
|
|
die("Engine node-intelligence rollback gateway target mismatch")
|
|
if any(
|
|
not re.fullmatch(r"[a-f0-9]{64}", str(value or ""))
|
|
for value in descriptor["source"].values()
|
|
):
|
|
die("Engine node-intelligence rollback source digest is invalid")
|
|
if not re.fullmatch(r"[a-f0-9]{64}", str(predecessor.get("gatewaySha256") or "")):
|
|
die("Engine node-intelligence rollback predecessor gateway is invalid")
|
|
return descriptor
|
|
|
|
|
|
def validate_engine_node_intelligence_image_archive(path, descriptor):
|
|
image = descriptor["image"]
|
|
try:
|
|
archive_stat = path.lstat()
|
|
except FileNotFoundError:
|
|
die("Engine node-intelligence image archive is missing")
|
|
if stat.S_ISLNK(archive_stat.st_mode) or not stat.S_ISREG(archive_stat.st_mode):
|
|
die("Engine node-intelligence image archive is unsafe")
|
|
if archive_stat.st_size < 1024 or archive_stat.st_size > MAX_ARTIFACT_BYTES:
|
|
die("Engine node-intelligence image archive size is invalid")
|
|
if sha256_file(path) != image["archiveSha256"]:
|
|
die("Engine node-intelligence image archive sha256 mismatch")
|
|
|
|
names = set()
|
|
manifest_raw = None
|
|
config_raw = None
|
|
total = 0
|
|
try:
|
|
with tarfile.open(path, "r:") as archive:
|
|
members = archive.getmembers()
|
|
if not members or len(members) > MAX_MEMBER_COUNT:
|
|
die("Engine node-intelligence image archive member count is invalid")
|
|
for member in members:
|
|
validate_posix_path(member.name.rstrip("/"))
|
|
if member.name in names:
|
|
die(f"Engine node-intelligence image archive duplicate member: {member.name}")
|
|
names.add(member.name)
|
|
if not (member.isfile() or member.isdir()):
|
|
die(f"Engine node-intelligence image archive special member: {member.name}")
|
|
if member.isdir():
|
|
continue
|
|
if member.size > MAX_FILE_BYTES:
|
|
die(f"Engine node-intelligence image archive member too large: {member.name}")
|
|
total += member.size
|
|
if total > MAX_ARTIFACT_BYTES:
|
|
die("Engine node-intelligence image archive expanded size is too large")
|
|
source = archive.extractfile(member)
|
|
if source is None:
|
|
die(f"Engine node-intelligence image archive member unreadable: {member.name}")
|
|
data = source.read(MAX_FILE_BYTES + 1)
|
|
if len(data) != member.size:
|
|
die(f"Engine node-intelligence image archive member size mismatch: {member.name}")
|
|
if member.name.startswith("blobs/sha256/"):
|
|
digest = member.name.rsplit("/", 1)[-1]
|
|
if not re.fullmatch(r"[a-f0-9]{64}", digest):
|
|
die("Engine node-intelligence image blob name is invalid")
|
|
if hashlib.sha256(data).hexdigest() != digest:
|
|
die("Engine node-intelligence image blob digest mismatch")
|
|
elif member.name not in ("index.json", "manifest.json", "oci-layout"):
|
|
die(f"Engine node-intelligence image archive unexpected member: {member.name}")
|
|
if member.name == "manifest.json":
|
|
manifest_raw = data
|
|
if manifest_raw is None:
|
|
die("Engine node-intelligence image manifest is missing")
|
|
try:
|
|
manifest = json.loads(manifest_raw.decode("utf-8"))
|
|
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
die("Engine node-intelligence image manifest is invalid")
|
|
if not isinstance(manifest, list) or len(manifest) != 1:
|
|
die("Engine node-intelligence image manifest exact set mismatch")
|
|
record = manifest[0]
|
|
if not isinstance(record, dict) or set(record) != {"Config", "RepoTags", "Layers"}:
|
|
die("Engine node-intelligence image manifest record mismatch")
|
|
if record.get("RepoTags") != [ENGINE_NODE_INTELLIGENCE_IMAGE]:
|
|
die("Engine node-intelligence image tag mismatch")
|
|
config_name = record.get("Config")
|
|
expected_config_name = f"blobs/sha256/{image['configSha256']}"
|
|
if config_name != expected_config_name:
|
|
die("Engine node-intelligence image config digest mismatch")
|
|
try:
|
|
config_member = archive.getmember(config_name)
|
|
config_file = archive.extractfile(config_member)
|
|
config_raw = config_file.read(MAX_FILE_BYTES + 1) if config_file else None
|
|
except KeyError:
|
|
config_raw = None
|
|
except (tarfile.TarError, OSError):
|
|
die("Engine node-intelligence image archive cannot be read")
|
|
if config_raw is None:
|
|
die("Engine node-intelligence image config is missing")
|
|
try:
|
|
config = json.loads(config_raw.decode("utf-8"))
|
|
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
die("Engine node-intelligence image config is invalid")
|
|
if config.get("architecture") != "amd64" or config.get("os") != "linux":
|
|
die("Engine node-intelligence image platform mismatch")
|
|
runtime_config = config.get("config") or {}
|
|
labels = runtime_config.get("Labels") or {}
|
|
if labels.get("org.opencontainers.image.revision") != ENGINE_NODE_INTELLIGENCE_UPSTREAM_COMMIT:
|
|
die("Engine node-intelligence image revision label mismatch")
|
|
if runtime_config.get("Entrypoint") != ["/usr/local/bin/docker-entrypoint.sh"]:
|
|
die("Engine node-intelligence image entrypoint mismatch")
|
|
if runtime_config.get("Cmd") != ["node", "dist/mcp/index.js"]:
|
|
die("Engine node-intelligence image command mismatch")
|
|
env = runtime_config.get("Env") or []
|
|
if any(
|
|
str(value).startswith(("AUTH_TOKEN=", "N8N_API_URL=", "N8N_API_KEY="))
|
|
for value in env
|
|
):
|
|
die("Engine node-intelligence image contains runtime authority")
|
|
return image["configSha256"]
|
|
|
|
|
|
def validate_engine_node_intelligence_transition(payload_dir, entries):
|
|
service_root = payload_dir / ENGINE_NODE_INTELLIGENCE_SERVICE_ROOT_REL
|
|
descriptor = read_engine_node_intelligence_descriptor(
|
|
service_root / "activation.json"
|
|
)
|
|
expected_entries = (
|
|
ENGINE_NODE_INTELLIGENCE_ARTIFACT_ENTRIES
|
|
if descriptor["action"] == "activate"
|
|
else ENGINE_NODE_INTELLIGENCE_ROLLBACK_ENTRIES
|
|
)
|
|
if tuple(entries) != expected_entries:
|
|
die("Engine node-intelligence files.txt exact set/order mismatch")
|
|
gateway = payload_dir / ENGINE_NODE_INTELLIGENCE_GATEWAY_REL
|
|
if sha256_file(gateway) != descriptor["source"]["gatewaySha256"]:
|
|
die("Engine node-intelligence gateway sha256 mismatch")
|
|
readme = service_root / "README.md"
|
|
if sha256_file(readme) != descriptor["source"]["readmeSha256"]:
|
|
die("Engine node-intelligence README sha256 mismatch")
|
|
|
|
if descriptor["action"] == "activate":
|
|
expected_service_files = {
|
|
"README.md",
|
|
"activation.json",
|
|
"docker-compose.immutable-runtime.yml",
|
|
"image/engine-node-intelligence.tar",
|
|
}
|
|
actual_service_files = {
|
|
child.relative_to(service_root).as_posix()
|
|
for child in service_root.rglob("*")
|
|
if child.is_file()
|
|
}
|
|
if actual_service_files != expected_service_files:
|
|
die("Engine node-intelligence service payload file set mismatch")
|
|
source_root = payload_dir / ENGINE_NODE_INTELLIGENCE_SOURCE_REL
|
|
expected_source_files = {
|
|
"catalog.js",
|
|
"upstreamMcpClient.js",
|
|
"upstreamProjection.js",
|
|
}
|
|
actual_source_files = {
|
|
child.relative_to(source_root).as_posix()
|
|
for child in source_root.rglob("*")
|
|
if child.is_file()
|
|
}
|
|
if actual_source_files != expected_source_files:
|
|
die("Engine node-intelligence source file set mismatch")
|
|
source_digests = {
|
|
"catalogSha256": sha256_file(source_root / "catalog.js"),
|
|
"upstreamClientSha256": sha256_file(source_root / "upstreamMcpClient.js"),
|
|
"upstreamProjectionSha256": sha256_file(source_root / "upstreamProjection.js"),
|
|
}
|
|
if any(descriptor["source"][key] != value for key, value in source_digests.items()):
|
|
die("Engine node-intelligence source digest mismatch")
|
|
override = service_root / "docker-compose.immutable-runtime.yml"
|
|
try:
|
|
override_text = override.read_text(encoding="utf-8")
|
|
except (OSError, UnicodeDecodeError):
|
|
die("Engine node-intelligence Compose override is unreadable")
|
|
if override_text != expected_engine_node_intelligence_compose_override():
|
|
die("Engine node-intelligence Compose override mismatch")
|
|
if sha256_file(override) != descriptor["source"]["composeOverrideSha256"]:
|
|
die("Engine node-intelligence Compose override sha256 mismatch")
|
|
gateway_text = gateway.read_text(encoding="utf-8")
|
|
for required in (
|
|
"engine_get_node_intelligence_status",
|
|
"engine_get_node_guidance",
|
|
"engine_validate_node_configuration",
|
|
"engine_validate_l2_deep",
|
|
):
|
|
if required not in gateway_text:
|
|
die(f"Engine node-intelligence gateway contract missing: {required}")
|
|
if not any(
|
|
version in gateway_text
|
|
for version in (
|
|
"const ENGINE_AGENT_MCP_VERSION = '0.3.0'",
|
|
"const ENGINE_AGENT_MCP_VERSION = '0.4.0'",
|
|
"const ENGINE_AGENT_MCP_VERSION = '0.5.0'",
|
|
"const ENGINE_AGENT_MCP_VERSION = '0.6.0'",
|
|
)
|
|
):
|
|
die("Engine node-intelligence gateway MCP version is not registered")
|
|
validate_engine_node_intelligence_image_archive(
|
|
payload_dir / ENGINE_NODE_INTELLIGENCE_IMAGE_ARCHIVE_REL,
|
|
descriptor,
|
|
)
|
|
else:
|
|
expected_service_files = {"README.md", "activation.json"}
|
|
actual_service_files = {
|
|
child.relative_to(service_root).as_posix()
|
|
for child in service_root.rglob("*")
|
|
if child.is_file()
|
|
}
|
|
if actual_service_files != expected_service_files:
|
|
die("Engine node-intelligence rollback payload file set mismatch")
|
|
if sha256_file(gateway) != ENGINE_NODE_INTELLIGENCE_PREDECESSOR_GATEWAY_SHA256:
|
|
die("Engine node-intelligence rollback gateway baseline mismatch")
|
|
return descriptor
|
|
|
|
|
|
def is_engine_mcp_control_plane_slice(component, entries):
|
|
return (
|
|
component == "engine"
|
|
and entries is not None
|
|
and tuple(entries) == ENGINE_MCP_CONTROL_PLANE_ARTIFACT_ENTRIES
|
|
)
|
|
|
|
|
|
def validate_engine_mcp_control_plane_payload(payload_dir, entries):
|
|
if tuple(entries) != ENGINE_MCP_CONTROL_PLANE_ARTIFACT_ENTRIES:
|
|
die("Engine MCP control-plane files.txt exact set/order mismatch")
|
|
for rel, expected_sha256 in ENGINE_MCP_CONTROL_PLANE_TARGET_SHA256.items():
|
|
path = payload_dir / rel
|
|
if path.is_symlink() or not path.is_file() or sha256_file(path) != expected_sha256:
|
|
die(f"Engine MCP control-plane target sha256 mismatch: {rel}")
|
|
|
|
gateway = (payload_dir / ENGINE_NODE_INTELLIGENCE_GATEWAY_REL).read_text(encoding="utf-8")
|
|
graph_route = (payload_dir / "nodedc-source/server/routes/ndcAgentMcp.js").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
installer = (
|
|
payload_dir
|
|
/ "nodedc-source/server/assets/engine-agent-npm/bin/nodedc-engine-codex-agent.mjs"
|
|
).read_text(encoding="utf-8")
|
|
package = read_strict_json(
|
|
payload_dir / "nodedc-source/server/assets/engine-agent-npm/package.json",
|
|
"Engine MCP Codex installer package",
|
|
)
|
|
if package.get("name") != "@nodedc/engine-codex-agent" or package.get("version") != "0.1.4":
|
|
die("Engine MCP Codex installer package identity mismatch")
|
|
if any(
|
|
marker not in gateway
|
|
for marker in (
|
|
"const ENGINE_AGENT_MCP_VERSION = '0.5.0'",
|
|
"engine_open_l2_change_session",
|
|
"engine_get_l2_change_session",
|
|
"engine_close_l2_change_session",
|
|
"never_infer_expiry",
|
|
"three_identical_failures_without_new_evidence",
|
|
"engine_plan_data_product_read_grant",
|
|
"engine_apply_data_product_read_grant",
|
|
"engine_accept_data_product_read_grant",
|
|
"engine_rollback_data_product_read_grant",
|
|
)
|
|
):
|
|
die("Engine MCP bounded-change-session contract is incomplete")
|
|
if any(
|
|
marker not in graph_route
|
|
for marker in (
|
|
"update_node_no_effect",
|
|
"subworkflow_post_write_equality_failed",
|
|
"verifiedWrite: true",
|
|
"graphDigest: expectedDigest",
|
|
)
|
|
):
|
|
die("Engine MCP graph post-write equality contract is incomplete")
|
|
if any(
|
|
marker not in installer
|
|
for marker in (
|
|
"Do not interrupt the user after ordinary recoverable errors",
|
|
"Never infer that a provider or managed capability token expired",
|
|
"Three identical failures with no new evidence",
|
|
"engine_plan_data_product_*_grant",
|
|
"durable until an explicit rollback or revoke",
|
|
)
|
|
):
|
|
die("Engine MCP Codex bounded-autonomy policy is incomplete")
|
|
combined = "\n".join((gateway, graph_route, installer)).lower()
|
|
if "gelios" in combined or "robot2b" in combined:
|
|
die("provider identity is forbidden in Engine MCP control-plane source")
|
|
|
|
archive_path = payload_dir / "nodedc-source/server/assets/nodedc-engine-codex-agent-0.1.4.tgz"
|
|
expected_members = {
|
|
"package/bin/nodedc-engine-codex-agent.mjs": (
|
|
payload_dir
|
|
/ "nodedc-source/server/assets/engine-agent-npm/bin/nodedc-engine-codex-agent.mjs"
|
|
).read_bytes(),
|
|
"package/package.json": (
|
|
payload_dir / "nodedc-source/server/assets/engine-agent-npm/package.json"
|
|
).read_bytes(),
|
|
}
|
|
observed_members = {}
|
|
try:
|
|
with tarfile.open(archive_path, "r:gz") as archive:
|
|
for member in archive:
|
|
if not (member.isfile() or member.isdir()) or member.name.startswith("/"):
|
|
die("Engine MCP Codex installer archive member is unsafe")
|
|
if member.isfile():
|
|
source = archive.extractfile(member)
|
|
if source is None:
|
|
die("Engine MCP Codex installer archive member is unreadable")
|
|
observed_members[member.name] = source.read(MAX_FILE_BYTES + 1)
|
|
except (OSError, tarfile.TarError):
|
|
die("Engine MCP Codex installer archive is invalid")
|
|
if observed_members != expected_members:
|
|
die("Engine MCP Codex installer archive/source equality mismatch")
|
|
|
|
n8n_route = (payload_dir / "nodedc-source/server/routes/n8n.js").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
if "engineDataProductReadGrantN8nAdapter" not in n8n_route:
|
|
die("Engine MCP managed reader native adapter is missing")
|
|
if "ndcDataProductReaderApi" not in n8n_route or "ndc_edprb_" not in n8n_route:
|
|
die("Engine MCP managed reader credential contract is incomplete")
|
|
if "read_grant_must_be_durable" not in n8n_route:
|
|
die("Engine MCP managed reader durable lifecycle is missing")
|
|
reader_service = (
|
|
payload_dir / "nodedc-source/server/dataProductReadGrant/service.js"
|
|
).read_text(encoding="utf-8")
|
|
if (
|
|
"readerGrantLifetime: 'explicit-revoke'" not in reader_service
|
|
or "expiresAt: null" not in reader_service
|
|
or "ENGINE_READ_GRANT_TTL_DAYS" in reader_service
|
|
):
|
|
die("Engine MCP managed reader explicit-revoke lifecycle is incomplete")
|
|
publish_service = (
|
|
payload_dir / "nodedc-source/server/dataProductPublishGrant/service.js"
|
|
).read_text(encoding="utf-8")
|
|
if (
|
|
"writerGrantLifetime: 'explicit-revoke'" not in publish_service
|
|
or "migrateCurrentGrantToDurable" not in publish_service
|
|
or "expiresAt: null" not in publish_service
|
|
or "ENGINE_PUBLISH_GRANT_TTL_DAYS" in publish_service
|
|
):
|
|
die("Engine MCP managed writer explicit-revoke lifecycle is incomplete")
|
|
reader_override = payload_dir / ENGINE_DATA_PRODUCT_READ_GRANT_OVERRIDE_REL
|
|
try:
|
|
reader_override_text = reader_override.read_text(encoding="utf-8")
|
|
except (OSError, UnicodeDecodeError):
|
|
die("Engine MCP managed reader runtime override is unreadable")
|
|
if reader_override_text != expected_engine_data_product_read_grant_override():
|
|
die("Engine MCP managed reader runtime override mismatch")
|
|
agent_store = (payload_dir / "nodedc-source/server/engineAgents/store.js").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
for scope in (
|
|
"'engine:l2:data-product-read-grant:plan'",
|
|
"'engine:l2:data-product-read-grant:write'",
|
|
):
|
|
if scope not in agent_store:
|
|
die(f"Engine MCP managed reader scope is missing: {scope}")
|
|
|
|
descriptor = read_engine_node_intelligence_descriptor(
|
|
payload_dir / ENGINE_MCP_CONTROL_PLANE_DESCRIPTOR_REL,
|
|
"Engine MCP control-plane node-intelligence descriptor",
|
|
)
|
|
if (
|
|
descriptor.get("action") != "activate"
|
|
or descriptor["source"].get("gatewaySha256")
|
|
!= ENGINE_MCP_CONTROL_PLANE_TARGET_SHA256[ENGINE_NODE_INTELLIGENCE_GATEWAY_REL]
|
|
or descriptor["source"].get("upstreamProjectionSha256")
|
|
!= ENGINE_MCP_CONTROL_PLANE_TARGET_SHA256[
|
|
"nodedc-source/server/nodeIntelligence/upstreamProjection.js"
|
|
]
|
|
):
|
|
die("Engine MCP control-plane descriptor target mismatch")
|
|
return descriptor
|
|
|
|
|
|
def preflight_engine_mcp_control_plane_predecessor(payload_dir):
|
|
candidate = validate_engine_mcp_control_plane_payload(
|
|
payload_dir,
|
|
ENGINE_MCP_CONTROL_PLANE_ARTIFACT_ENTRIES,
|
|
)
|
|
installed = current_engine_node_intelligence_descriptor()
|
|
validate_installed_engine_node_intelligence_source(installed)
|
|
if installed is None or installed.get("action") != "activate":
|
|
die("Engine MCP control-plane requires active node intelligence")
|
|
expected_candidate = json.loads(json.dumps(installed))
|
|
expected_candidate["source"]["gatewaySha256"] = (
|
|
ENGINE_MCP_CONTROL_PLANE_TARGET_SHA256[ENGINE_NODE_INTELLIGENCE_GATEWAY_REL]
|
|
)
|
|
expected_candidate["source"]["upstreamProjectionSha256"] = (
|
|
ENGINE_MCP_CONTROL_PLANE_TARGET_SHA256[
|
|
"nodedc-source/server/nodeIntelligence/upstreamProjection.js"
|
|
]
|
|
)
|
|
if candidate != expected_candidate:
|
|
die("Engine MCP control-plane descriptor crosses node-intelligence source boundary")
|
|
root = component_root("engine")
|
|
for rel, expected_sha256 in ENGINE_MCP_CONTROL_PLANE_PREDECESSOR_SHA256.items():
|
|
path = root / rel
|
|
if path.is_symlink() or not path.is_file() or sha256_file(path) != expected_sha256:
|
|
die(f"Engine MCP control-plane predecessor drift detected: {rel}")
|
|
for rel in ENGINE_MCP_CONTROL_PLANE_NEW_PATHS:
|
|
path = root / rel
|
|
if path.exists() or path.is_symlink():
|
|
die(f"Engine MCP control-plane new path already exists: {rel}")
|
|
backend = preflight_engine_credential_backend_runtime()
|
|
if backend["mode"] != "verified-derived-retry":
|
|
die("Engine MCP control-plane requires the active immutable backend runtime")
|
|
return {
|
|
"descriptor": candidate,
|
|
"predecessor_gateway_sha256": installed["source"]["gatewaySha256"],
|
|
"target_gateway_sha256": candidate["source"]["gatewaySha256"],
|
|
"backend_mode": backend["mode"],
|
|
}
|
|
|
|
|
|
def accept_engine_mcp_control_plane_runtime():
|
|
root = component_root("engine")
|
|
descriptor = validate_engine_mcp_control_plane_payload(
|
|
root,
|
|
ENGINE_MCP_CONTROL_PLANE_ARTIFACT_ENTRIES,
|
|
)
|
|
installed = current_engine_node_intelligence_descriptor()
|
|
if installed != descriptor:
|
|
die("Engine MCP control-plane installed descriptor equality failed")
|
|
validate_installed_engine_node_intelligence_source(installed)
|
|
backend = preflight_engine_credential_backend_runtime()
|
|
if backend["mode"] != "verified-derived-retry":
|
|
die("Engine MCP control-plane backend runtime acceptance failed")
|
|
live = run_engine_backend_probe(
|
|
(
|
|
"node",
|
|
"--input-type=module",
|
|
"-e",
|
|
"""
|
|
const module=await import('file:///app/server/routes/engineAgentGateway.js');
|
|
const store=await import('file:///app/server/engineAgents/store.js');
|
|
const names=new Set(module.engineAgentTools.map((tool)=>tool.name));
|
|
const required=['engine_plan_data_product_read_grant','engine_apply_data_product_read_grant','engine_accept_data_product_read_grant','engine_rollback_data_product_read_grant'];
|
|
const scopes=['engine:l2:data-product-read-grant:plan','engine:l2:data-product-read-grant:write'];
|
|
if(!required.every((name)=>names.has(name))||!scopes.every((scope)=>store.ENGINE_AGENT_SCOPES.includes(scope)))process.exit(2);
|
|
process.stdout.write('engine-mcp-reader-grant:0.5.0:'+required.length+':'+scopes.length);
|
|
""".strip(),
|
|
),
|
|
"Engine MCP managed reader capability",
|
|
container_id=engine_backend_container_id(),
|
|
)
|
|
if live != "engine-mcp-reader-grant:0.5.0:4:2":
|
|
die("Engine MCP managed reader live capability acceptance mismatch")
|
|
return {
|
|
"gateway_sha256": descriptor["source"]["gatewaySha256"],
|
|
"backend_mode": backend["mode"],
|
|
"reader_grant": live,
|
|
}
|
|
|
|
|
|
def is_engine_mcp_ontology_sdk_slice(component, entries):
|
|
return (
|
|
component == "engine"
|
|
and entries is not None
|
|
and tuple(entries) == ENGINE_MCP_ONTOLOGY_SDK_ARTIFACT_ENTRIES
|
|
)
|
|
|
|
|
|
def validate_engine_mcp_ontology_sdk_payload(payload_dir, entries):
|
|
if tuple(entries) != ENGINE_MCP_ONTOLOGY_SDK_ARTIFACT_ENTRIES:
|
|
die("Engine MCP Ontology/SDK files.txt exact set/order mismatch")
|
|
for rel, expected_sha256 in ENGINE_MCP_ONTOLOGY_SDK_TARGET_SHA256.items():
|
|
path = payload_dir / rel
|
|
if path.is_symlink() or not path.is_file() or sha256_file(path) != expected_sha256:
|
|
die(f"Engine MCP Ontology/SDK target sha256 mismatch: {rel}")
|
|
|
|
gateway = (payload_dir / ENGINE_NODE_INTELLIGENCE_GATEWAY_REL).read_text(
|
|
encoding="utf-8"
|
|
)
|
|
installer_path = (
|
|
payload_dir
|
|
/ "nodedc-source/server/assets/engine-agent-npm/bin/nodedc-engine-codex-agent.mjs"
|
|
)
|
|
installer = installer_path.read_text(encoding="utf-8")
|
|
package_path = payload_dir / "nodedc-source/server/assets/engine-agent-npm/package.json"
|
|
package = read_strict_json(package_path, "Engine MCP Codex installer package")
|
|
if package.get("name") != "@nodedc/engine-codex-agent" or package.get("version") != "0.1.5":
|
|
die("Engine MCP Ontology/SDK installer package identity mismatch")
|
|
if any(
|
|
marker not in gateway
|
|
for marker in (
|
|
"const ENGINE_AGENT_MCP_VERSION = '0.6.0'",
|
|
"authenticateEngineAgentOntologyToken",
|
|
"apiRouter.post('/ontology-mcp'",
|
|
"process.env.NODEDC_INTERNAL_ACCESS_TOKEN",
|
|
"serverName: 'nodedc-engine-agent'",
|
|
"serverName: 'nodedc_ontology'",
|
|
)
|
|
):
|
|
die("Engine MCP separate Ontology gateway contract is incomplete")
|
|
if any(
|
|
marker not in installer
|
|
for marker in (
|
|
"const ENGINE_SERVER_NAME = 'nodedc-engine-agent'",
|
|
"const ONTOLOGY_SERVER_NAME = 'nodedc_ontology'",
|
|
"Ontology tools/catalog are never embedded or multiplexed into Engine MCP",
|
|
"version: '0.1.5'",
|
|
)
|
|
):
|
|
die("Engine MCP separate Ontology installer contract is incomplete")
|
|
|
|
archive_path = payload_dir / "nodedc-source/server/assets/nodedc-engine-codex-agent-0.1.5.tgz"
|
|
expected_members = {
|
|
"package/bin/nodedc-engine-codex-agent.mjs": installer_path.read_bytes(),
|
|
"package/package.json": package_path.read_bytes(),
|
|
}
|
|
observed_members = {}
|
|
try:
|
|
with tarfile.open(archive_path, "r:gz") as archive:
|
|
for member in archive:
|
|
if not (member.isfile() or member.isdir()) or member.name.startswith("/"):
|
|
die("Engine MCP Ontology/SDK installer archive member is unsafe")
|
|
if member.isfile():
|
|
source = archive.extractfile(member)
|
|
if source is None:
|
|
die("Engine MCP Ontology/SDK installer archive member is unreadable")
|
|
observed_members[member.name] = source.read(MAX_FILE_BYTES + 1)
|
|
except (OSError, tarfile.TarError):
|
|
die("Engine MCP Ontology/SDK installer archive is invalid")
|
|
if observed_members != expected_members:
|
|
die("Engine MCP Ontology/SDK installer archive/source equality mismatch")
|
|
|
|
catalog = read_strict_json(
|
|
payload_dir / "nodedc-source/server/assets/provider-packages/v1/catalog.json",
|
|
"Engine provider security catalog",
|
|
)
|
|
packages = catalog.get("packages") if isinstance(catalog, dict) else None
|
|
if not isinstance(packages, list) or len(packages) != 1:
|
|
die("Engine provider security catalog must contain one active package")
|
|
provider = packages[0]
|
|
capabilities = provider.get("capabilities") if isinstance(provider, dict) else None
|
|
capability = capabilities[0] if isinstance(capabilities, list) and len(capabilities) == 1 else None
|
|
request = capability.get("request") if isinstance(capability, dict) else None
|
|
if (
|
|
provider.get("id") != "gelios.provider.v2"
|
|
or provider.get("version") != "2.0.0"
|
|
or (provider.get("providerCredential") or {}).get("credentialType") != "httpQueryAuth"
|
|
or not isinstance(request, dict)
|
|
or request.get("method") != "GET"
|
|
or request.get("url") != "https://admin.geliospro.com/sdk/?svc=get_units¶ms=%7B%7D"
|
|
):
|
|
die("Engine Gelios SDK/query-auth security projection mismatch")
|
|
resolver = (
|
|
payload_dir / "nodedc-source/server/dataProductPublishGrant/providerCatalog.js"
|
|
).read_text(encoding="utf-8")
|
|
if "new Set(['httpBearerAuth', 'httpQueryAuth'])" not in resolver:
|
|
die("Engine provider credential type allowlist mismatch")
|
|
store = (payload_dir / "nodedc-source/server/engineAgents/store.js").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
if any(
|
|
marker not in store
|
|
for marker in (
|
|
"const STORE_VERSION = 3",
|
|
"ontologyTokenHash",
|
|
"`ndc_eao_${",
|
|
"authenticateEngineAgentOntologyToken",
|
|
)
|
|
):
|
|
die("Engine separate Ontology token store migration is incomplete")
|
|
|
|
descriptor = read_engine_node_intelligence_descriptor(
|
|
payload_dir / ENGINE_MCP_CONTROL_PLANE_DESCRIPTOR_REL,
|
|
"Engine MCP Ontology/SDK node-intelligence descriptor",
|
|
)
|
|
if (
|
|
descriptor.get("action") != "activate"
|
|
or descriptor["source"].get("gatewaySha256")
|
|
!= ENGINE_MCP_ONTOLOGY_SDK_TARGET_SHA256[ENGINE_NODE_INTELLIGENCE_GATEWAY_REL]
|
|
or descriptor["source"].get("upstreamProjectionSha256")
|
|
!= ENGINE_MCP_CONTROL_PLANE_TARGET_SHA256[
|
|
"nodedc-source/server/nodeIntelligence/upstreamProjection.js"
|
|
]
|
|
):
|
|
die("Engine MCP Ontology/SDK descriptor target mismatch")
|
|
return descriptor
|
|
|
|
|
|
def preflight_engine_mcp_ontology_sdk_predecessor(payload_dir):
|
|
candidate = validate_engine_mcp_ontology_sdk_payload(
|
|
payload_dir,
|
|
ENGINE_MCP_ONTOLOGY_SDK_ARTIFACT_ENTRIES,
|
|
)
|
|
installed = current_engine_node_intelligence_descriptor()
|
|
validate_installed_engine_node_intelligence_source(installed)
|
|
if installed is None or installed.get("action") != "activate":
|
|
die("Engine MCP Ontology/SDK requires active node intelligence")
|
|
expected_candidate = json.loads(json.dumps(installed))
|
|
expected_candidate["source"]["gatewaySha256"] = (
|
|
ENGINE_MCP_ONTOLOGY_SDK_TARGET_SHA256[ENGINE_NODE_INTELLIGENCE_GATEWAY_REL]
|
|
)
|
|
if candidate != expected_candidate:
|
|
die("Engine MCP Ontology/SDK descriptor crosses node-intelligence source boundary")
|
|
root = component_root("engine")
|
|
for rel, expected_sha256 in ENGINE_MCP_ONTOLOGY_SDK_PREDECESSOR_SHA256.items():
|
|
path = root / rel
|
|
if path.is_symlink() or not path.is_file() or sha256_file(path) != expected_sha256:
|
|
die(f"Engine MCP Ontology/SDK predecessor drift detected: {rel}")
|
|
for rel in ENGINE_MCP_ONTOLOGY_SDK_NEW_PATHS:
|
|
path = root / rel
|
|
if path.exists() or path.is_symlink():
|
|
die(f"Engine MCP Ontology/SDK new path already exists: {rel}")
|
|
backend = preflight_engine_credential_backend_runtime()
|
|
if backend["mode"] != "verified-derived-retry":
|
|
die("Engine MCP Ontology/SDK requires the active immutable backend runtime")
|
|
return {
|
|
"descriptor": candidate,
|
|
"predecessor_gateway_sha256": installed["source"]["gatewaySha256"],
|
|
"target_gateway_sha256": candidate["source"]["gatewaySha256"],
|
|
"backend_mode": backend["mode"],
|
|
}
|
|
|
|
|
|
def accept_engine_mcp_ontology_sdk_runtime():
|
|
root = component_root("engine")
|
|
descriptor = validate_engine_mcp_ontology_sdk_payload(
|
|
root,
|
|
ENGINE_MCP_ONTOLOGY_SDK_ARTIFACT_ENTRIES,
|
|
)
|
|
installed = current_engine_node_intelligence_descriptor()
|
|
if installed != descriptor:
|
|
die("Engine MCP Ontology/SDK installed descriptor equality failed")
|
|
validate_installed_engine_node_intelligence_source(installed)
|
|
backend = preflight_engine_credential_backend_runtime()
|
|
if backend["mode"] != "verified-derived-retry":
|
|
die("Engine MCP Ontology/SDK backend runtime acceptance failed")
|
|
live = run_engine_backend_probe(
|
|
(
|
|
"node",
|
|
"--input-type=module",
|
|
"-e",
|
|
"""
|
|
const gateway=await import('file:///app/server/routes/engineAgentGateway.js');
|
|
const store=await import('file:///app/server/engineAgents/store.js');
|
|
const catalog=JSON.parse(await (await import('node:fs/promises')).readFile('/app/server/assets/provider-packages/v1/catalog.json','utf8'));
|
|
const provider=catalog.packages?.[0];
|
|
if(gateway.ENGINE_AGENT_MCP_VERSION!=='0.6.0')process.exit(2);
|
|
if(typeof store.authenticateEngineAgentOntologyToken!=='function')process.exit(3);
|
|
if(provider?.id!=='gelios.provider.v2'||provider?.providerCredential?.credentialType!=='httpQueryAuth')process.exit(4);
|
|
process.stdout.write('engine-mcp-ontology-sdk:0.6.0:0.1.5:gelios.provider.v2:httpQueryAuth');
|
|
""".strip(),
|
|
),
|
|
"Engine MCP Ontology/SDK capability",
|
|
container_id=engine_backend_container_id(),
|
|
)
|
|
expected = "engine-mcp-ontology-sdk:0.6.0:0.1.5:gelios.provider.v2:httpQueryAuth"
|
|
if live != expected:
|
|
die("Engine MCP Ontology/SDK live capability acceptance mismatch")
|
|
return {
|
|
"gateway_sha256": descriptor["source"]["gatewaySha256"],
|
|
"backend_mode": backend["mode"],
|
|
"capability": live,
|
|
}
|
|
|
|
|
|
def is_engine_mcp_autonomy_provider_v5_slice(component, entries):
|
|
return (
|
|
component == "engine"
|
|
and entries is not None
|
|
and tuple(entries) == ENGINE_MCP_AUTONOMY_PROVIDER_V5_ARTIFACT_ENTRIES
|
|
)
|
|
|
|
|
|
def validate_engine_mcp_autonomy_provider_v5_payload(payload_dir, entries):
|
|
if tuple(entries) != ENGINE_MCP_AUTONOMY_PROVIDER_V5_ARTIFACT_ENTRIES:
|
|
die("Engine MCP autonomy/provider v5 files.txt exact set/order mismatch")
|
|
for rel, expected_sha256 in ENGINE_MCP_AUTONOMY_PROVIDER_V5_TARGET_SHA256.items():
|
|
path = payload_dir / rel
|
|
if path.is_symlink() or not path.is_file() or sha256_file(path) != expected_sha256:
|
|
die(f"Engine MCP autonomy/provider v5 target sha256 mismatch: {rel}")
|
|
|
|
gateway = (payload_dir / ENGINE_NODE_INTELLIGENCE_GATEWAY_REL).read_text(
|
|
encoding="utf-8"
|
|
)
|
|
installer_path = (
|
|
payload_dir
|
|
/ "nodedc-source/server/assets/engine-agent-npm/bin/nodedc-engine-codex-agent.mjs"
|
|
)
|
|
installer = installer_path.read_text(encoding="utf-8")
|
|
package_path = payload_dir / "nodedc-source/server/assets/engine-agent-npm/package.json"
|
|
package = read_strict_json(package_path, "Engine MCP Codex installer package")
|
|
if package.get("name") != "@nodedc/engine-codex-agent" or package.get("version") != "0.1.6":
|
|
die("Engine MCP autonomy/provider v5 installer package identity mismatch")
|
|
if any(
|
|
marker not in gateway
|
|
for marker in (
|
|
"const ENGINE_AGENT_MCP_VERSION = '0.6.0'",
|
|
"authenticateEngineAgentOntologyToken",
|
|
"apiRouter.post('/ontology-mcp'",
|
|
"'nodedc-engine-codex-agent-0.1.6.tgz'",
|
|
)
|
|
):
|
|
die("Engine MCP autonomy/provider v5 gateway contract is incomplete")
|
|
if any(
|
|
marker not in installer
|
|
for marker in (
|
|
"MCP tool availability establishes capability authority",
|
|
"the user's requested objective establishes intent scope",
|
|
"machine safety barrier, not a permission ceremony",
|
|
"Never repeat an identical read, validation, apply or run",
|
|
"Three identical failures with no new evidence or state change are a critical stop",
|
|
"const ONTOLOGY_SERVER_NAME = 'nodedc_ontology'",
|
|
"version: '0.1.6'",
|
|
)
|
|
) or "only with explicit user confirmation" in installer:
|
|
die("Engine MCP capability-scoped autonomy policy is incomplete")
|
|
|
|
archive_path = payload_dir / "nodedc-source/server/assets/nodedc-engine-codex-agent-0.1.6.tgz"
|
|
expected_members = {
|
|
"package/bin/nodedc-engine-codex-agent.mjs": installer_path.read_bytes(),
|
|
"package/package.json": package_path.read_bytes(),
|
|
}
|
|
observed_members = {}
|
|
try:
|
|
with tarfile.open(archive_path, "r:gz") as archive:
|
|
for member in archive:
|
|
if not (member.isfile() or member.isdir()) or member.name.startswith("/"):
|
|
die("Engine MCP autonomy/provider v5 installer archive member is unsafe")
|
|
if member.isfile():
|
|
source = archive.extractfile(member)
|
|
if source is None:
|
|
die("Engine MCP autonomy/provider v5 installer archive member is unreadable")
|
|
observed_members[member.name] = source.read(MAX_FILE_BYTES + 1)
|
|
except (OSError, tarfile.TarError):
|
|
die("Engine MCP autonomy/provider v5 installer archive is invalid")
|
|
if observed_members != expected_members:
|
|
die("Engine MCP autonomy/provider v5 installer archive/source equality mismatch")
|
|
|
|
catalog = read_strict_json(
|
|
payload_dir / "nodedc-source/server/assets/provider-packages/v1/catalog.json",
|
|
"Engine provider security catalog",
|
|
)
|
|
packages = catalog.get("packages") if isinstance(catalog, dict) else None
|
|
if (
|
|
catalog.get("schemaVersion") != "nodedc.engine.provider-security-catalog/v1"
|
|
or not isinstance(packages, list)
|
|
or [item.get("id") for item in packages if isinstance(item, dict)]
|
|
!= ["gelios.provider.v4", "gelios.provider.v5"]
|
|
):
|
|
die("Engine Gelios provider v4/v5 catalog identity mismatch")
|
|
expected_products = {
|
|
"gelios.provider.v4": "fleet.positions.current.v3",
|
|
"gelios.provider.v5": "fleet.positions.current.v4",
|
|
}
|
|
expected_requests = [
|
|
("gelios.monitoring_config.current.read", "https://api.geliospro.com/api/v1/users/me/monitoring-config"),
|
|
("gelios.units.current.read", "https://api.geliospro.com/api/v1/units?incltrip=true"),
|
|
]
|
|
for provider in packages:
|
|
provider_id = provider.get("id")
|
|
credential = provider.get("providerCredential") or {}
|
|
capabilities = provider.get("capabilities")
|
|
observed_requests = [
|
|
(item.get("id"), (item.get("request") or {}).get("url"))
|
|
for item in capabilities
|
|
] if isinstance(capabilities, list) else None
|
|
if (
|
|
provider_id not in expected_products
|
|
or credential.get("credentialType") != "ndcProviderRotatingAccessApi"
|
|
or credential.get("authModeId") != "gelios.rest-rotating-bearer.v3"
|
|
or observed_requests != expected_requests
|
|
or any(item.get("dataProductIds") != [expected_products[provider_id]] for item in capabilities)
|
|
):
|
|
die("Engine Gelios provider v4/v5 security projection mismatch")
|
|
|
|
store = (payload_dir / "nodedc-source/server/engineAgents/store.js").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
if any(
|
|
marker not in store
|
|
for marker in (
|
|
"const STORE_VERSION = 3",
|
|
"ontologyTokenHash",
|
|
"authenticateEngineAgentOntologyToken",
|
|
)
|
|
):
|
|
die("Engine MCP autonomy/provider v5 Ontology token store migration is incomplete")
|
|
|
|
descriptor = read_engine_node_intelligence_descriptor(
|
|
payload_dir / ENGINE_MCP_CONTROL_PLANE_DESCRIPTOR_REL,
|
|
"Engine MCP autonomy/provider v5 node-intelligence descriptor",
|
|
)
|
|
if (
|
|
descriptor.get("action") != "activate"
|
|
or descriptor["source"].get("gatewaySha256")
|
|
!= ENGINE_MCP_AUTONOMY_PROVIDER_V5_TARGET_SHA256[ENGINE_NODE_INTELLIGENCE_GATEWAY_REL]
|
|
or descriptor["source"].get("upstreamProjectionSha256")
|
|
!= ENGINE_MCP_CONTROL_PLANE_TARGET_SHA256[
|
|
"nodedc-source/server/nodeIntelligence/upstreamProjection.js"
|
|
]
|
|
):
|
|
die("Engine MCP autonomy/provider v5 descriptor target mismatch")
|
|
return descriptor
|
|
|
|
|
|
def preflight_engine_mcp_autonomy_provider_v5_predecessor(payload_dir):
|
|
candidate = validate_engine_mcp_autonomy_provider_v5_payload(
|
|
payload_dir,
|
|
ENGINE_MCP_AUTONOMY_PROVIDER_V5_ARTIFACT_ENTRIES,
|
|
)
|
|
installed = current_engine_node_intelligence_descriptor()
|
|
validate_installed_engine_node_intelligence_source(installed)
|
|
if installed is None or installed.get("action") != "activate":
|
|
die("Engine MCP autonomy/provider v5 requires active node intelligence")
|
|
expected_candidate = json.loads(json.dumps(installed))
|
|
expected_candidate["source"]["gatewaySha256"] = (
|
|
ENGINE_MCP_AUTONOMY_PROVIDER_V5_TARGET_SHA256[ENGINE_NODE_INTELLIGENCE_GATEWAY_REL]
|
|
)
|
|
if candidate != expected_candidate:
|
|
die("Engine MCP autonomy/provider v5 crosses node-intelligence source boundary")
|
|
root = component_root("engine")
|
|
for rel, expected_sha256 in ENGINE_MCP_AUTONOMY_PROVIDER_V5_PREDECESSOR_SHA256.items():
|
|
path = root / rel
|
|
if path.is_symlink() or not path.is_file() or sha256_file(path) != expected_sha256:
|
|
die(f"Engine MCP autonomy/provider v5 predecessor drift detected: {rel}")
|
|
for rel in ENGINE_MCP_AUTONOMY_PROVIDER_V5_NEW_PATHS:
|
|
path = root / rel
|
|
if path.exists() or path.is_symlink():
|
|
die(f"Engine MCP autonomy/provider v5 new path already exists: {rel}")
|
|
backend = preflight_engine_credential_backend_runtime()
|
|
if backend["mode"] != "verified-derived-retry":
|
|
die("Engine MCP autonomy/provider v5 requires the active immutable backend runtime")
|
|
return {
|
|
"descriptor": candidate,
|
|
"predecessor_gateway_sha256": installed["source"]["gatewaySha256"],
|
|
"target_gateway_sha256": candidate["source"]["gatewaySha256"],
|
|
"backend_mode": backend["mode"],
|
|
}
|
|
|
|
|
|
def engine_mcp_autonomy_provider_v5_installed_state():
|
|
root = component_root("engine")
|
|
target = all(
|
|
(root / rel).is_file() and not (root / rel).is_symlink()
|
|
and sha256_file(root / rel) == expected
|
|
for rel, expected in ENGINE_MCP_AUTONOMY_PROVIDER_V5_TARGET_SHA256.items()
|
|
)
|
|
descriptor = current_engine_node_intelligence_descriptor()
|
|
if descriptor is not None:
|
|
validate_installed_engine_node_intelligence_source(descriptor)
|
|
if target and descriptor and descriptor.get("source", {}).get("gatewaySha256") == (
|
|
ENGINE_MCP_AUTONOMY_PROVIDER_V5_TARGET_SHA256[ENGINE_NODE_INTELLIGENCE_GATEWAY_REL]
|
|
):
|
|
return "target"
|
|
predecessor = all(
|
|
(root / rel).is_file() and not (root / rel).is_symlink()
|
|
and sha256_file(root / rel) == expected
|
|
for rel, expected in ENGINE_MCP_AUTONOMY_PROVIDER_V5_PREDECESSOR_SHA256.items()
|
|
)
|
|
new_paths_absent = all(
|
|
not (root / rel).exists() and not (root / rel).is_symlink()
|
|
for rel in ENGINE_MCP_AUTONOMY_PROVIDER_V5_NEW_PATHS
|
|
)
|
|
if predecessor and new_paths_absent and descriptor and descriptor.get("source", {}).get("gatewaySha256") == (
|
|
ENGINE_MCP_AUTONOMY_PROVIDER_V5_PREDECESSOR_SHA256[ENGINE_NODE_INTELLIGENCE_GATEWAY_REL]
|
|
):
|
|
return "predecessor"
|
|
die("Engine MCP autonomy/provider v5 installed state is neither target nor rollback predecessor")
|
|
|
|
|
|
def accept_engine_mcp_autonomy_provider_v5_runtime():
|
|
root = component_root("engine")
|
|
descriptor = validate_engine_mcp_autonomy_provider_v5_payload(
|
|
root,
|
|
ENGINE_MCP_AUTONOMY_PROVIDER_V5_ARTIFACT_ENTRIES,
|
|
)
|
|
installed = current_engine_node_intelligence_descriptor()
|
|
if installed != descriptor:
|
|
die("Engine MCP autonomy/provider v5 installed descriptor equality failed")
|
|
validate_installed_engine_node_intelligence_source(installed)
|
|
backend = preflight_engine_credential_backend_runtime()
|
|
if backend["mode"] != "verified-derived-retry":
|
|
die("Engine MCP autonomy/provider v5 backend runtime acceptance failed")
|
|
live = run_engine_backend_probe(
|
|
(
|
|
"node",
|
|
"--input-type=module",
|
|
"-e",
|
|
"""
|
|
const gateway=await import('file:///app/server/routes/engineAgentGateway.js');
|
|
const store=await import('file:///app/server/engineAgents/store.js');
|
|
const fs=await import('node:fs/promises');
|
|
const catalog=JSON.parse(await fs.readFile('/app/server/assets/provider-packages/v1/catalog.json','utf8'));
|
|
const pkg=JSON.parse(await fs.readFile('/app/server/assets/engine-agent-npm/package.json','utf8'));
|
|
const ids=catalog.packages?.map((item)=>item.id).join(',');
|
|
const target=catalog.packages?.find((item)=>item.id==='gelios.provider.v5');
|
|
if(gateway.ENGINE_AGENT_MCP_VERSION!=='0.6.0')process.exit(2);
|
|
if(typeof store.authenticateEngineAgentOntologyToken!=='function')process.exit(3);
|
|
if(pkg.version!=='0.1.6'||ids!=='gelios.provider.v4,gelios.provider.v5')process.exit(4);
|
|
if(target?.capabilities?.some((item)=>item.dataProductIds?.[0]!=='fleet.positions.current.v4'))process.exit(5);
|
|
process.stdout.write('engine-mcp-autonomy-provider-v5:0.6.0:0.1.6:'+ids);
|
|
""".strip(),
|
|
),
|
|
"Engine MCP autonomy/provider v5 capability",
|
|
container_id=engine_backend_container_id(),
|
|
)
|
|
expected = "engine-mcp-autonomy-provider-v5:0.6.0:0.1.6:gelios.provider.v4,gelios.provider.v5"
|
|
if live != expected:
|
|
die("Engine MCP autonomy/provider v5 live capability acceptance mismatch")
|
|
return {
|
|
"gateway_sha256": descriptor["source"]["gatewaySha256"],
|
|
"backend_mode": backend["mode"],
|
|
"capability": live,
|
|
}
|
|
|
|
|
|
def is_engine_provider_security_catalog_slice(component, entries):
|
|
return (
|
|
component == "engine"
|
|
and entries is not None
|
|
and tuple(entries) == ENGINE_PROVIDER_SECURITY_CATALOG_ARTIFACT_ENTRIES
|
|
)
|
|
|
|
|
|
def validate_engine_provider_security_catalog_payload(payload_dir, entries):
|
|
if tuple(entries) != ENGINE_PROVIDER_SECURITY_CATALOG_ARTIFACT_ENTRIES:
|
|
die("Engine provider security catalog files.txt exact set/order mismatch")
|
|
catalog_path = payload_dir / ENGINE_PROVIDER_SECURITY_CATALOG_REL
|
|
if (
|
|
catalog_path.is_symlink()
|
|
or not catalog_path.is_file()
|
|
or sha256_file(catalog_path) != ENGINE_PROVIDER_SECURITY_CATALOG_TARGET_SHA256
|
|
):
|
|
die("Engine provider security catalog target sha256 mismatch")
|
|
catalog = read_strict_json(catalog_path, "Engine provider security catalog")
|
|
packages = catalog.get("packages") if isinstance(catalog, dict) else None
|
|
package_ids = [item.get("id") for item in packages if isinstance(item, dict)] \
|
|
if isinstance(packages, list) else None
|
|
provider = next(
|
|
(
|
|
item for item in packages
|
|
if isinstance(item, dict) and item.get("id") == "gelios.provider.v7"
|
|
),
|
|
None,
|
|
) if isinstance(packages, list) else None
|
|
capabilities = provider.get("capabilities") if isinstance(provider, dict) else None
|
|
capability = capabilities[0] if isinstance(capabilities, list) and len(capabilities) == 1 else None
|
|
request = capability.get("request") if isinstance(capability, dict) else None
|
|
credential = provider.get("providerCredential") if isinstance(provider, dict) else None
|
|
publisher = provider.get("publisher") if isinstance(provider, dict) else None
|
|
if (
|
|
catalog.get("schemaVersion") != "nodedc.engine.provider-security-catalog/v1"
|
|
or package_ids != [
|
|
"gelios.provider.v1",
|
|
"gelios.provider.v4",
|
|
"gelios.provider.v5",
|
|
"moscow-department-of-transport.pmd-slow-zones.v1",
|
|
"gelios.provider.v7",
|
|
]
|
|
or not isinstance(provider, dict)
|
|
or provider.get("version") != "7.0.0"
|
|
or provider.get("providerId") != "gelios"
|
|
or credential != {
|
|
"authModeId": "gelios.rest-rotating-bearer.v3",
|
|
"credentialType": "ndcProviderRotatingAccessApi",
|
|
}
|
|
or not isinstance(capability, dict)
|
|
or capability.get("id") != "gelios.units.current.read"
|
|
or capability.get("classification") != "read"
|
|
or capability.get("status") != "implemented"
|
|
or request != {
|
|
"method": "GET",
|
|
"url": (
|
|
"https://api.geliospro.com/api/v1/units?incltrip=true&inclcntrs=true"
|
|
"&inclsnsrs=true&incllsv=true"
|
|
),
|
|
}
|
|
or capability.get("dataProductIds") != ["fleet.positions.current.v5"]
|
|
or publisher != {
|
|
"nodeType": "n8n-nodes-ndc.ndcDataProductPublish",
|
|
"credentialType": "ndcDataProductWriterApi",
|
|
}
|
|
):
|
|
die("Engine provider security catalog v7 telemetry projection mismatch")
|
|
return catalog
|
|
|
|
|
|
def preflight_engine_provider_security_catalog_predecessor():
|
|
catalog_path = component_root("engine") / ENGINE_PROVIDER_SECURITY_CATALOG_REL
|
|
try:
|
|
path_stat = catalog_path.lstat()
|
|
except FileNotFoundError:
|
|
die("Engine provider security catalog predecessor is missing")
|
|
if stat.S_ISLNK(path_stat.st_mode) or not stat.S_ISREG(path_stat.st_mode):
|
|
die("Engine provider security catalog predecessor is unsafe")
|
|
actual_sha256 = sha256_file(catalog_path)
|
|
if actual_sha256 != ENGINE_PROVIDER_SECURITY_CATALOG_PREDECESSOR_SHA256:
|
|
die(
|
|
"Engine provider security catalog predecessor drift detected: "
|
|
f"expected={ENGINE_PROVIDER_SECURITY_CATALOG_PREDECESSOR_SHA256} "
|
|
f"actual={actual_sha256}"
|
|
)
|
|
backend = preflight_engine_credential_backend_runtime()
|
|
if backend["mode"] != "verified-derived-retry":
|
|
die("Engine provider security catalog requires the active immutable backend")
|
|
return {"catalog_sha256": actual_sha256, "backend_mode": backend["mode"]}
|
|
|
|
|
|
def accept_engine_provider_security_catalog_runtime():
|
|
root = component_root("engine")
|
|
validate_engine_provider_security_catalog_payload(
|
|
root,
|
|
ENGINE_PROVIDER_SECURITY_CATALOG_ARTIFACT_ENTRIES,
|
|
)
|
|
live = run_engine_backend_probe(
|
|
(
|
|
"node",
|
|
"--input-type=module",
|
|
"-e",
|
|
"""
|
|
const module=await import('file:///app/server/dataProductPublishGrant/providerCatalog.js');
|
|
const catalog=await module.loadProviderSecurityCatalog();
|
|
const validation=module.validateProviderSecurityCatalog(catalog);
|
|
const provider=catalog.packages?.find((item)=>item.id==='gelios.provider.v7');
|
|
const capability=provider?.capabilities?.[0];
|
|
const slot='ndcProviderRotatingAccessApi';
|
|
const credential={id:'native-provider',name:'provider',nodeDcCredentialId:'provider-ref'};
|
|
const policy={kind:'httpRequest',allowedHosts:['api.geliospro.com'],requireHttps:true,disableRedirects:true};
|
|
const queryParameters={parameters:[{name:'incltrip',value:'true'},{name:'inclcntrs',value:'true'},{name:'inclsnsrs',value:'true'},{name:'incllsv',value:'true'}]};
|
|
const source={id:'units',data:{n8n:{id:'units',name:'units',type:'n8n-nodes-base.httpRequest',parameters:{method:'GET',url:'https://api.geliospro.com/api/v1/units',sendQuery:true,specifyQuery:'keypair',queryParameters},credentials:{[slot]:credential}},nodedcAgentCredentialPolicies:{[slot]:policy}}};
|
|
const publish={id:'publish',data:{n8n:{id:'publish',name:'publish',type:'n8n-nodes-ndc.ndcDataProductPublish',parameters:{dataProductId:'fleet.positions.current.v5'},credentials:{}}}};
|
|
const graph={nodes:[source,publish],edges:[{source:'units',target:'publish'}]};
|
|
const resolved=module.resolveProviderConnectionFromGraph({graph,targetNodeId:'publish',catalog});
|
|
const drift=structuredClone(graph);drift.nodes[0].data.n8n.parameters.queryParameters.parameters.pop();
|
|
const rejected=module.resolveProviderConnectionFromGraph({graph:drift,targetNodeId:'publish',catalog});
|
|
if(!validation.ok||provider?.providerCredential?.credentialType!==slot||capability?.request?.url!=='https://api.geliospro.com/api/v1/units?incltrip=true&inclcntrs=true&inclsnsrs=true&incllsv=true'||capability?.dataProductIds?.[0]!=='fleet.positions.current.v5'||!resolved.ok||resolved.descriptor?.packageId!=='gelios.provider.v7'||resolved.descriptor?.providerCredentialRef!=='provider-ref'||rejected.blockers?.join(',')!=='publish_grant_provider_request_not_exact')process.exit(2);
|
|
process.stdout.write('engine-provider-catalog:gelios.provider.v7:ndcProviderRotatingAccessApi:fleet.positions.current.v5');
|
|
""".strip(),
|
|
),
|
|
"Engine provider security catalog v7 telemetry",
|
|
container_id=engine_backend_container_id(),
|
|
)
|
|
expected = (
|
|
"engine-provider-catalog:gelios.provider.v7:"
|
|
"ndcProviderRotatingAccessApi:fleet.positions.current.v5"
|
|
)
|
|
if live != expected:
|
|
die("Engine provider security catalog live acceptance mismatch")
|
|
return {"catalog_sha256": ENGINE_PROVIDER_SECURITY_CATALOG_TARGET_SHA256, "live": live}
|
|
|
|
|
|
def accept_engine_composite_provider_runtime():
|
|
catalog_path = component_root("engine") / ENGINE_PROVIDER_SECURITY_CATALOG_REL
|
|
if (
|
|
catalog_path.is_symlink()
|
|
or not catalog_path.is_file()
|
|
or sha256_file(catalog_path)
|
|
!= ENGINE_PUBLISH_GRANT_COMPOSITE_PROVIDER_CATALOG_TARGET_SHA256
|
|
):
|
|
return None
|
|
live = run_engine_backend_probe(
|
|
(
|
|
"node",
|
|
"--input-type=module",
|
|
"-e",
|
|
"""
|
|
const module=await import('file:///app/server/dataProductPublishGrant/providerCatalog.js');
|
|
const catalog=await module.loadProviderSecurityCatalog();
|
|
const validation=module.validateProviderSecurityCatalog(catalog);
|
|
const credential={id:'native-provider',name:'provider',nodeDcCredentialId:'provider-ref'};
|
|
const policy={kind:'httpRequest',allowedHosts:['api.geliospro.com'],requireHttps:true,disableRedirects:true};
|
|
const slot='ndcProviderRotatingAccessApi';
|
|
const read=(id,url,parameters={})=>({id,data:{n8n:{id,name:id,type:'n8n-nodes-base.httpRequest',parameters:{method:'GET',url,...parameters},credentials:{[slot]:credential}},nodedcAgentCredentialPolicies:{[slot]:policy}}});
|
|
const query={sendQuery:true,specifyQuery:'keypair',queryParameters:{parameters:[{name:'incltrip',value:'true'}]}};
|
|
const graph={nodes:[read('monitoring','https://api.geliospro.com/api/v1/users/me/monitoring-config'),read('units','https://api.geliospro.com/api/v1/units',query),{id:'publish',data:{n8n:{id:'publish',name:'publish',type:'n8n-nodes-ndc.ndcDataProductPublish',parameters:{dataProductId:'fleet.positions.current.v3'},credentials:{}}}}],edges:[{source:'monitoring',target:'publish'},{source:'units',target:'publish'}]};
|
|
const resolved=module.resolveProviderConnectionFromGraph({graph,targetNodeId:'publish',catalog});
|
|
if(!validation.ok||catalog.packages?.[0]?.id!=='gelios.provider.v4'||catalog.packages?.[0]?.providerCredential?.credentialType!==slot||!resolved.ok||resolved.descriptor?.providerCredentialRef!=='provider-ref'||resolved.descriptor?.capabilityIds?.join(',')!=='gelios.monitoring_config.current.read,gelios.units.current.read'||resolved.descriptor?.providerRequestNodeIds?.join(',')!=='monitoring,units')process.exit(2);
|
|
process.stdout.write('engine-composite-provider:gelios.provider.v4:ndcProviderRotatingAccessApi:fleet.positions.current.v3:monitoring,units');
|
|
""".strip(),
|
|
),
|
|
"Engine composite provider authority",
|
|
container_id=engine_backend_container_id(),
|
|
)
|
|
expected = (
|
|
"engine-composite-provider:gelios.provider.v4:"
|
|
"ndcProviderRotatingAccessApi:fleet.positions.current.v3:monitoring,units"
|
|
)
|
|
if live != expected:
|
|
die("Engine composite provider live acceptance mismatch")
|
|
return {
|
|
"catalog_sha256": ENGINE_PUBLISH_GRANT_COMPOSITE_PROVIDER_CATALOG_TARGET_SHA256,
|
|
"live": live,
|
|
}
|
|
|
|
|
|
def accept_engine_provider_rotating_slot_runtime():
|
|
root = component_root("engine")
|
|
validate_engine_provider_rotating_slot_slice(
|
|
root,
|
|
ENGINE_PROVIDER_ROTATING_SLOT_ARTIFACT_ENTRIES,
|
|
)
|
|
live = run_engine_backend_probe(
|
|
(
|
|
"node",
|
|
"--input-type=module",
|
|
"-e",
|
|
"""
|
|
const module=await import('file:///app/server/dataProductPublishGrant/providerCatalog.js');
|
|
const catalog=await module.loadProviderSecurityCatalog();
|
|
const validation=module.validateProviderSecurityCatalog(catalog);
|
|
const credential={id:'native-provider',name:'provider',nodeDcCredentialId:'provider-ref'};
|
|
const policy={kind:'httpRequest',allowedHosts:['api.geliospro.com'],requireHttps:true,disableRedirects:true};
|
|
const slot='ndcProviderRotatingAccessApi';
|
|
const read=(id,url,parameters={})=>({id,data:{n8n:{id,name:id,type:'n8n-nodes-base.httpRequest',parameters:{method:'GET',url,...parameters},credentials:{[slot]:credential}},nodedcAgentCredentialPolicies:{[slot]:policy}}});
|
|
const query={sendQuery:true,specifyQuery:'keypair',queryParameters:{parameters:[{name:'incltrip',value:'true'}]}};
|
|
const graph={nodes:[read('monitoring','https://api.geliospro.com/api/v1/users/me/monitoring-config'),read('units','https://api.geliospro.com/api/v1/units',query),{id:'publish',data:{n8n:{id:'publish',name:'publish',type:'n8n-nodes-ndc.ndcDataProductPublish',parameters:{dataProductId:'fleet.positions.current.v3'},credentials:{}}}}],edges:[{source:'monitoring',target:'publish'},{source:'units',target:'publish'}]};
|
|
const resolved=module.resolveProviderConnectionFromGraph({graph,targetNodeId:'publish',catalog});
|
|
if(!validation.ok||catalog.packages?.[0]?.id!=='gelios.provider.v4'||catalog.packages?.[0]?.providerCredential?.credentialType!==slot||!resolved.ok||resolved.descriptor?.providerCredentialRef!=='provider-ref'||resolved.descriptor?.capabilityIds?.join(',')!=='gelios.monitoring_config.current.read,gelios.units.current.read'||resolved.descriptor?.providerRequestNodeIds?.join(',')!=='monitoring,units')process.exit(2);
|
|
process.stdout.write('engine-provider-rotating-slot:gelios.provider.v4:ndcProviderRotatingAccessApi:fleet.positions.current.v3:monitoring,units');
|
|
""".strip(),
|
|
),
|
|
"Engine provider rotating slot authority",
|
|
container_id=engine_backend_container_id(),
|
|
)
|
|
expected = (
|
|
"engine-provider-rotating-slot:gelios.provider.v4:"
|
|
"ndcProviderRotatingAccessApi:fleet.positions.current.v3:monitoring,units"
|
|
)
|
|
if live != expected:
|
|
die("Engine provider rotating slot live acceptance mismatch")
|
|
return {
|
|
"target_sha256": dict(ENGINE_PROVIDER_ROTATING_SLOT_TARGET_SHA256),
|
|
"live": live,
|
|
}
|
|
|
|
|
|
def accept_engine_provider_authority_diagnostics_runtime():
|
|
root = component_root("engine")
|
|
validate_engine_provider_authority_diagnostics_slice(
|
|
root,
|
|
ENGINE_PROVIDER_AUTHORITY_DIAGNOSTICS_ARTIFACT_ENTRIES,
|
|
)
|
|
live = run_engine_backend_probe(
|
|
(
|
|
"node",
|
|
"--input-type=module",
|
|
"-e",
|
|
"""
|
|
const module=await import('file:///app/server/dataProductPublishGrant/providerCatalog.js');
|
|
const catalog=await module.loadProviderSecurityCatalog();
|
|
const slot='ndcProviderRotatingAccessApi';
|
|
const credential={id:'native-provider',name:'provider',nodeDcCredentialId:'provider-ref'};
|
|
const policy={kind:'httpRequest',allowedHosts:['api.geliospro.com'],requireHttps:true,disableRedirects:true};
|
|
const read=(id,url,parameters={})=>({id,data:{n8n:{id,name:id,type:'n8n-nodes-base.httpRequest',parameters:{method:'GET',url,...parameters},credentials:{[slot]:credential}},nodedcAgentCredentialPolicies:{[slot]:structuredClone(policy)}}});
|
|
const query={sendQuery:true,specifyQuery:'keypair',queryParameters:{parameters:[{name:'incltrip',value:'true'}]}};
|
|
const graph={nodes:[read('monitoring','https://api.geliospro.com/api/v1/users/me/monitoring-config'),read('units','https://api.geliospro.com/api/v1/units',query),{id:'publish',data:{n8n:{id:'publish',name:'publish',type:'n8n-nodes-ndc.ndcDataProductPublish',parameters:{dataProductId:'fleet.positions.current.v3'},credentials:{}}}}],edges:[{source:'monitoring',target:'publish'},{source:'units',target:'publish'}]};
|
|
const valid=module.resolveProviderConnectionFromGraph({graph,targetNodeId:'publish',catalog});
|
|
const policyDrift=structuredClone(graph);policyDrift.nodes[0].data.nodedcAgentCredentialPolicies[slot].allowedHosts=['*.geliospro.com'];
|
|
const policyResult=module.resolveProviderConnectionFromGraph({graph:policyDrift,targetNodeId:'publish',catalog});
|
|
const bindingDrift=structuredClone(graph);delete bindingDrift.nodes[0].data.n8n.credentials[slot].id;
|
|
const bindingResult=module.resolveProviderConnectionFromGraph({graph:bindingDrift,targetNodeId:'publish',catalog});
|
|
if(!valid.ok||policyResult.blockers?.join(',')!=='publish_grant_provider_transport_policy_not_exact'||bindingResult.blockers?.join(',')!=='publish_grant_provider_credential_binding_invalid')process.exit(2);
|
|
process.stdout.write('engine-provider-authority-diagnostics:exact-reason-codes:v1');
|
|
""".strip(),
|
|
),
|
|
"Engine provider authority diagnostics",
|
|
container_id=engine_backend_container_id(),
|
|
)
|
|
expected = "engine-provider-authority-diagnostics:exact-reason-codes:v1"
|
|
if live != expected:
|
|
die("Engine provider authority diagnostics live acceptance mismatch")
|
|
return {
|
|
"target_sha256": dict(ENGINE_PROVIDER_AUTHORITY_DIAGNOSTICS_TARGET_SHA256),
|
|
"live": live,
|
|
}
|
|
|
|
|
|
def accept_engine_depttrans_zone_authority_v1_runtime():
|
|
root = component_root("engine")
|
|
validate_engine_depttrans_zone_authority_v1_slice(
|
|
root,
|
|
ENGINE_DEPTTRANS_ZONE_AUTHORITY_V1_ARTIFACT_ENTRIES,
|
|
)
|
|
live = run_engine_backend_probe(
|
|
(
|
|
"node",
|
|
"--input-type=module",
|
|
"-e",
|
|
"""
|
|
const module=await import('file:///app/server/dataProductPublishGrant/providerCatalog.js');
|
|
const catalog=await module.loadProviderSecurityCatalog();
|
|
const parameters={method:'GET',url:'http://map-gateway:18103/internal/zone-sources/v1/profiles/moscow-pmd-slow-zones/current',authentication:'none',sendQuery:false,sendHeaders:true,specifyHeaders:'keypair',headerParameters:{parameters:[{name:'Accept',value:'application/json'}]},sendBody:false,options:{response:{response:{fullResponse:false,responseFormat:'json'}},allowUnauthorizedCerts:false,redirect:{redirect:{followRedirects:false}},timeout:15000}};
|
|
const graph={nodes:[{id:'source',data:{n8n:{id:'source',name:'source',type:'n8n-nodes-base.httpRequest',parameters}}},{id:'publish',data:{n8n:{id:'publish',name:'publish',type:'n8n-nodes-ndc.ndcDataProductPublish',parameters:{dataProductId:'map.zones.current.v2',publishMode:'replace'},credentials:{}}}}],edges:[{source:'source',target:'publish'}]};
|
|
const resolved=module.resolveProviderConnectionFromGraph({graph,targetNodeId:'publish',catalog});
|
|
const drift=structuredClone(graph);drift.nodes[0].data.n8n.parameters.options.redirect.redirect.followRedirects=true;
|
|
const rejected=module.resolveProviderConnectionFromGraph({graph:drift,targetNodeId:'publish',catalog});
|
|
if(!module.validateProviderSecurityCatalog(catalog).ok||!resolved.ok||resolved.descriptor?.providerId!=='moscow-department-of-transport'||resolved.descriptor?.authorityBoundary!=='platform-service'||resolved.descriptor?.providerCredentialRef!==null||resolved.descriptor?.platformServiceIds?.join(',')!=='nodedc-map-gateway'||rejected.blockers?.join(',')!=='publish_grant_provider_request_not_exact')process.exit(2);
|
|
process.stdout.write('engine-depttrans-zone-authority:platform-service:map.zones.current.v2:nodedc-map-gateway');
|
|
""".strip(),
|
|
),
|
|
"Engine Depttrans zone authority v1",
|
|
container_id=engine_backend_container_id(),
|
|
)
|
|
expected = (
|
|
"engine-depttrans-zone-authority:platform-service:"
|
|
"map.zones.current.v2:nodedc-map-gateway"
|
|
)
|
|
if live != expected:
|
|
die("Engine Depttrans zone authority v1 live acceptance mismatch")
|
|
return {
|
|
"target_sha256": dict(ENGINE_DEPTTRANS_ZONE_AUTHORITY_V1_TARGET_SHA256),
|
|
"live": live,
|
|
}
|
|
|
|
|
|
def accept_engine_provider_target_host_policy_runtime():
|
|
root = component_root("engine")
|
|
validate_engine_provider_target_host_policy_slice(
|
|
root,
|
|
ENGINE_PROVIDER_TARGET_HOST_POLICY_ARTIFACT_ENTRIES,
|
|
)
|
|
live = run_engine_backend_probe(
|
|
(
|
|
"node",
|
|
"--input-type=module",
|
|
"-e",
|
|
"""
|
|
const module=await import('file:///app/server/routes/n8n.js');
|
|
const slot='ndcProviderRotatingAccessApi';
|
|
const entry={id:'provider',name:'provider',type:slot,nodeDcCredentialId:'provider-ref',data:{engineAgentAllowedHttpHosts:['*.geliospro.com','telemetry.example']}};
|
|
const target={type:'n8n-nodes-base.httpRequest',parameters:{url:'https://api.geliospro.com/api/v1/units'}};
|
|
const deniedTarget={type:'n8n-nodes-base.httpRequest',parameters:{url:'https://untrusted.example/collect'}};
|
|
const allowed=module.buildEngineAgentCredentialTransportPolicy(entry,{nodes:[]},slot,target);
|
|
const denied=module.buildEngineAgentCredentialTransportPolicy(entry,{nodes:[]},slot,deniedTarget);
|
|
if(!allowed.bindable||allowed.policy?.allowedHosts?.join(',')!=='api.geliospro.com'||allowed.policy?.requireHttps!==true||allowed.policy?.disableRedirects!==true||denied.bindable||denied.reason!=='credential_host_not_allowed')process.exit(2);
|
|
process.stdout.write('engine-provider-target-host-policy:exact-literal-host:v1');
|
|
""".strip(),
|
|
),
|
|
"Engine provider target host policy",
|
|
container_id=engine_backend_container_id(),
|
|
)
|
|
expected = "engine-provider-target-host-policy:exact-literal-host:v1"
|
|
if live != expected:
|
|
die("Engine provider target host policy live acceptance mismatch")
|
|
return {
|
|
"target_sha256": dict(ENGINE_PROVIDER_TARGET_HOST_POLICY_TARGET_SHA256),
|
|
"live": live,
|
|
}
|
|
|
|
|
|
def validate_no_lifecycle_scripts(payload_dir, label):
|
|
forbidden = ("preinstall", "install", "postinstall", "prepare", "prepack", "postpack")
|
|
for package_path in payload_dir.rglob("package.json"):
|
|
package = read_strict_json(package_path, f"{label} package.json", max_bytes=1024 * 1024)
|
|
scripts = package.get("scripts") or {}
|
|
if not isinstance(scripts, dict):
|
|
die(f"{label} package scripts must be an object")
|
|
for name in forbidden:
|
|
if name in scripts:
|
|
die(f"{label} lifecycle script is forbidden: {package_path.relative_to(payload_dir)}:{name}")
|
|
|
|
|
|
def expected_engine_credential_backend_override():
|
|
return "\n".join((
|
|
"services:",
|
|
" nodedc-backend:",
|
|
f" image: {ENGINE_CREDENTIAL_BACKEND_IMAGE}",
|
|
" pull_policy: never",
|
|
" environment:",
|
|
" HOME: /tmp",
|
|
" command:",
|
|
" - /bin/sh",
|
|
" - -lc",
|
|
" - |-|".replace("|-|", "|-"),
|
|
" set -eu",
|
|
" test \"$$(command -v node)\" = /usr/local/bin/node",
|
|
" command -v sqlite3 >/dev/null",
|
|
" command -v docker >/dev/null",
|
|
" docker compose version >/dev/null",
|
|
f" test \"$$(sha256sum /app/package-lock.json | cut -d ' ' -f 1)\" = {ENGINE_CREDENTIAL_BACKEND_PACKAGE_LOCK_SHA256}",
|
|
" test -d /app/node_modules",
|
|
" mkdir -p /app/server/data/api",
|
|
" if [ -d /seed-api ]; then",
|
|
" for d in cesium gelios overpass wind yandex; do",
|
|
" src=/seed-api/$$d",
|
|
" [ -d \"$$src\" ] || continue",
|
|
" dst=/app/server/data/api/$$d",
|
|
" mkdir -p \"$$dst\"",
|
|
" if [ \"$$NODEDC_API_SEED_MODE\" = force ]; then",
|
|
" cp -R \"$$src/.\" \"$$dst/\"",
|
|
" elif [ -z \"$$(ls -A \"$$dst\" 2>/dev/null)\" ]; then",
|
|
" cp -R \"$$src/.\" \"$$dst/\"",
|
|
" fi",
|
|
" done",
|
|
" fi",
|
|
" if [ \"$$NODEDC_DATA_SEED_MODE\" != off ] && [ \"$$NODEDC_DATA_SEED_MODE\" != none ]; then",
|
|
" for d in workflows n8n; do",
|
|
" src=/seed-data/$$d",
|
|
" dst=/app/server/data/$$d",
|
|
" [ -d \"$$src\" ] || continue",
|
|
" mkdir -p \"$$dst\"",
|
|
" if [ \"$$NODEDC_DATA_SEED_MODE\" = merge ] || [ \"$$NODEDC_DATA_SEED_MODE\" = force ]; then",
|
|
" cp -R \"$$src/.\" \"$$dst/\"",
|
|
" elif [ -z \"$$(ls -A \"$$dst\" 2>/dev/null)\" ]; then",
|
|
" cp -R \"$$src/.\" \"$$dst/\"",
|
|
" fi",
|
|
" done",
|
|
" fi",
|
|
" exec node server/index.js",
|
|
" volumes:",
|
|
" - ./nodedc-backend-node_modules:/app/node_modules:ro",
|
|
" tmpfs:",
|
|
" - /tmp:mode=1777",
|
|
" - /run:mode=0755",
|
|
" - /var/cache:mode=0755",
|
|
" - /var/log:mode=0755",
|
|
"",
|
|
))
|
|
|
|
|
|
def expected_engine_data_product_publish_grant_override():
|
|
return "\n".join((
|
|
"services:",
|
|
" nodedc-backend:",
|
|
' user: "0:0"',
|
|
" environment:",
|
|
f" ENGINE_DATA_PLANE_BASE_URL: {EXTERNAL_DATA_PLANE_INTERNAL_URL}",
|
|
f" ENGINE_EDP_MANAGED_PROVISIONER_PRIVATE_KEY_FILE: {ENGINE_EDP_PRIVATE_KEY_CONTAINER_PATH}",
|
|
f" ENGINE_CONTROL_PLANE_PUBLISH_GRANT_ROOT: {ENGINE_PUBLISH_GRANT_CONTAINER_PATH}",
|
|
" volumes:",
|
|
" - type: bind",
|
|
f" source: {ENGINE_PUBLISH_GRANT_STATE_PATH}",
|
|
f" target: {ENGINE_PUBLISH_GRANT_CONTAINER_PATH}",
|
|
" bind:",
|
|
" create_host_path: false",
|
|
" - type: bind",
|
|
f" source: {ENGINE_EDP_MANAGED_PROVISIONER_PRIVATE_KEY_FILE}",
|
|
f" target: {ENGINE_EDP_PRIVATE_KEY_CONTAINER_PATH}",
|
|
" read_only: true",
|
|
" bind:",
|
|
" create_host_path: false",
|
|
"",
|
|
))
|
|
|
|
|
|
def expected_engine_data_product_read_grant_override():
|
|
return "\n".join((
|
|
"services:",
|
|
" nodedc-backend:",
|
|
" environment:",
|
|
f" ENGINE_CONTROL_PLANE_READ_GRANT_ROOT: {ENGINE_READ_GRANT_CONTAINER_PATH}",
|
|
" volumes:",
|
|
" - type: bind",
|
|
f" source: {ENGINE_READ_GRANT_STATE_PATH}",
|
|
f" target: {ENGINE_READ_GRANT_CONTAINER_PATH}",
|
|
" bind:",
|
|
" create_host_path: false",
|
|
"",
|
|
))
|
|
|
|
|
|
def validate_engine_credential_sink_slice(payload_dir, entries):
|
|
if tuple(entries) != ENGINE_CREDENTIAL_SINK_ARTIFACT_ENTRIES:
|
|
die("Engine credential sink files.txt exact set/order mismatch")
|
|
index_text = (payload_dir / "nodedc-source/server/index.js").read_text(encoding="utf-8")
|
|
if "engineCredentialSink" not in index_text:
|
|
die("Engine credential sink router mount is missing")
|
|
vendor_contract = payload_dir / "nodedc-source/server/credentialSink/vendor/engine-credential-sink.mjs"
|
|
if sha256_file(vendor_contract) != ENGINE_CREDENTIAL_SINK_CONTRACT_SHA256:
|
|
die("Engine credential sink vendored contract sha256 mismatch")
|
|
override = payload_dir / ENGINE_CREDENTIAL_BACKEND_OVERRIDE_TEMPLATE_REL
|
|
try:
|
|
override_text = override.read_text(encoding="utf-8")
|
|
except (OSError, UnicodeDecodeError):
|
|
die("Engine credential immutable runtime override is unreadable")
|
|
if override_text != expected_engine_credential_backend_override():
|
|
die("Engine credential immutable runtime override mismatch")
|
|
if re.search(r"\b(?:apk|npm|yarn|pnpm)\b", override_text):
|
|
die("Engine credential mutable runtime setup is forbidden")
|
|
generic_roots = (
|
|
payload_dir / "nodedc-source/server/credentialSink",
|
|
payload_dir / "nodedc-source/server/credentialPolicies/ndcPrivateNode.js",
|
|
payload_dir / "nodedc-source/server/routes/engineCredentialSink.js",
|
|
)
|
|
for root in generic_roots:
|
|
candidates = [root] if root.is_file() else list(root.rglob("*"))
|
|
for path in candidates:
|
|
if not path.is_file() or path.suffix.lower() not in (".js", ".mjs", ".json"):
|
|
continue
|
|
try:
|
|
text_value = path.read_text(encoding="utf-8")
|
|
except (OSError, UnicodeDecodeError):
|
|
die(f"Engine credential sink source is unreadable: {path.relative_to(payload_dir)}")
|
|
if re.search(r"gelios|robot2b", text_value, re.IGNORECASE):
|
|
die(f"Engine credential sink provider logic is forbidden: {path.relative_to(payload_dir)}")
|
|
validate_no_lifecycle_scripts(payload_dir, "Engine credential sink")
|
|
|
|
|
|
def validate_engine_data_product_publish_grant_slice(payload_dir, entries):
|
|
if tuple(entries) != ENGINE_DATA_PRODUCT_PUBLISH_GRANT_ARTIFACT_ENTRIES:
|
|
die("Engine data product publish grant files.txt exact set/order mismatch")
|
|
|
|
override = payload_dir / ENGINE_DATA_PRODUCT_PUBLISH_GRANT_OVERRIDE_REL
|
|
try:
|
|
override_text = override.read_text(encoding="utf-8")
|
|
except (OSError, UnicodeDecodeError):
|
|
die("Engine data product publish grant runtime override is unreadable")
|
|
if override_text != expected_engine_data_product_publish_grant_override():
|
|
die("Engine data product publish grant runtime override mismatch")
|
|
|
|
n8n_route = (payload_dir / "nodedc-source/server/routes/n8n.js").read_text(encoding="utf-8")
|
|
if "from '../credentialSink/" in n8n_route:
|
|
die("Engine data product publish grant must not depend on the legacy sink")
|
|
for adapter in (
|
|
"engineCredentialSinkN8nAdapter",
|
|
"engineDataProductPublishGrantN8nAdapter",
|
|
):
|
|
if adapter not in n8n_route:
|
|
die(f"Engine data product publish grant adapter is missing: {adapter}")
|
|
|
|
gateway = (payload_dir / "nodedc-source/server/routes/engineAgentGateway.js").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
for tool in (
|
|
"engine_plan_data_product_publish_grant",
|
|
"engine_apply_data_product_publish_grant",
|
|
"engine_accept_data_product_publish_grant",
|
|
"engine_rollback_data_product_publish_grant",
|
|
):
|
|
if tool not in gateway:
|
|
die(f"Engine data product publish grant tool is missing: {tool}")
|
|
|
|
catalog_path = payload_dir / ENGINE_PROVIDER_SECURITY_CATALOG_REL
|
|
if sha256_file(catalog_path) == ENGINE_PUBLISH_GRANT_COMPOSITE_PROVIDER_CATALOG_TARGET_SHA256:
|
|
catalog = read_strict_json(catalog_path, "Engine composite provider security catalog")
|
|
packages = catalog.get("packages") if isinstance(catalog, dict) else None
|
|
provider = packages[0] if isinstance(packages, list) and len(packages) == 1 else None
|
|
capabilities = provider.get("capabilities") if isinstance(provider, dict) else None
|
|
if (
|
|
catalog.get("schemaVersion") != "nodedc.engine.provider-security-catalog/v1"
|
|
or not isinstance(provider, dict)
|
|
or provider.get("id") != "gelios.provider.v4"
|
|
or provider.get("version") != "4.0.0"
|
|
or provider.get("providerId") != "gelios"
|
|
or provider.get("providerCredential") != {
|
|
"authModeId": "gelios.rest-rotating-bearer.v3",
|
|
"credentialType": "httpBearerAuth",
|
|
}
|
|
or capabilities != [{
|
|
"id": "gelios.monitoring_config.current.read",
|
|
"classification": "read",
|
|
"status": "implemented",
|
|
"request": {
|
|
"method": "GET",
|
|
"url": "https://api.geliospro.com/api/v1/users/me/monitoring-config",
|
|
},
|
|
"dataProductIds": ["fleet.positions.current.v3"],
|
|
}, {
|
|
"id": "gelios.units.current.read",
|
|
"classification": "read",
|
|
"status": "implemented",
|
|
"request": {
|
|
"method": "GET",
|
|
"url": "https://api.geliospro.com/api/v1/units?incltrip=true",
|
|
},
|
|
"dataProductIds": ["fleet.positions.current.v3"],
|
|
}]
|
|
or provider.get("publisher") != {
|
|
"nodeType": "n8n-nodes-ndc.ndcDataProductPublish",
|
|
"credentialType": "ndcDataProductWriterApi",
|
|
}
|
|
):
|
|
die("Engine composite provider security catalog projection mismatch")
|
|
provider_catalog = (
|
|
payload_dir / "nodedc-source/server/dataProductPublishGrant/providerCatalog.js"
|
|
).read_text(encoding="utf-8")
|
|
publish_service = (
|
|
payload_dir / "nodedc-source/server/dataProductPublishGrant/service.js"
|
|
).read_text(encoding="utf-8")
|
|
publish_store = (
|
|
payload_dir / "nodedc-source/server/dataProductPublishGrant/store.js"
|
|
).read_text(encoding="utf-8")
|
|
if any(
|
|
marker not in provider_catalog
|
|
for marker in (
|
|
"function capabilityRequests(capability)",
|
|
"function exactHttpRequestUrl(n8n)",
|
|
"capabilityIds",
|
|
"providerRequestNodeIds",
|
|
"providerCredentialRefs.size !== 1",
|
|
)
|
|
):
|
|
die("Engine composite provider resolver contract mismatch")
|
|
if any(
|
|
marker not in publish_service
|
|
for marker in (
|
|
"capabilities: descriptor.capabilityIds",
|
|
"providerRequestNodeIds: descriptor.providerRequestNodeIds",
|
|
)
|
|
):
|
|
die("Engine composite provider plan projection mismatch")
|
|
if any(
|
|
marker not in publish_store
|
|
for marker in (
|
|
"Array.isArray(raw.capabilityIds)",
|
|
"Array.isArray(raw.providerRequestNodeIds)",
|
|
"new Set(descriptor.providerRequestNodeIds).size",
|
|
)
|
|
):
|
|
die("Engine composite provider durable descriptor mismatch")
|
|
validate_no_lifecycle_scripts(payload_dir, "Engine data product publish grant")
|
|
|
|
|
|
def validate_engine_composite_provider_v4_slice(payload_dir, entries):
|
|
if tuple(entries) != ENGINE_COMPOSITE_PROVIDER_V4_ARTIFACT_ENTRIES:
|
|
die("Engine composite provider v4 files.txt exact set/order mismatch")
|
|
for rel, expected_sha256 in ENGINE_COMPOSITE_PROVIDER_V4_TARGET_SHA256.items():
|
|
path = payload_dir / rel
|
|
try:
|
|
path_stat = path.lstat()
|
|
except FileNotFoundError:
|
|
die(f"Engine composite provider v4 target is missing: {rel}")
|
|
if (
|
|
stat.S_ISLNK(path_stat.st_mode)
|
|
or not stat.S_ISREG(path_stat.st_mode)
|
|
or sha256_file(path) != expected_sha256
|
|
):
|
|
die(f"Engine composite provider v4 target sha256 mismatch: {rel}")
|
|
|
|
catalog = read_strict_json(
|
|
payload_dir / ENGINE_PROVIDER_SECURITY_CATALOG_REL,
|
|
"Engine composite provider v4 catalog",
|
|
)
|
|
packages = catalog.get("packages") if isinstance(catalog, dict) else None
|
|
provider = packages[0] if isinstance(packages, list) and len(packages) == 1 else None
|
|
capabilities = provider.get("capabilities") if isinstance(provider, dict) else None
|
|
if (
|
|
catalog.get("schemaVersion") != "nodedc.engine.provider-security-catalog/v1"
|
|
or not isinstance(provider, dict)
|
|
or provider.get("id") != "gelios.provider.v4"
|
|
or provider.get("version") != "4.0.0"
|
|
or provider.get("providerCredential") != {
|
|
"authModeId": "gelios.rest-rotating-bearer.v3",
|
|
"credentialType": "httpBearerAuth",
|
|
}
|
|
or not isinstance(capabilities, list)
|
|
or [item.get("id") for item in capabilities if isinstance(item, dict)] != [
|
|
"gelios.monitoring_config.current.read",
|
|
"gelios.units.current.read",
|
|
]
|
|
or [item.get("dataProductIds") for item in capabilities if isinstance(item, dict)] != [
|
|
["fleet.positions.current.v3"],
|
|
["fleet.positions.current.v3"],
|
|
]
|
|
or [
|
|
(item.get("request") or {}).get("url")
|
|
for item in capabilities
|
|
if isinstance(item, dict)
|
|
] != [
|
|
"https://api.geliospro.com/api/v1/users/me/monitoring-config",
|
|
"https://api.geliospro.com/api/v1/units?incltrip=true",
|
|
]
|
|
):
|
|
die("Engine composite provider v4 projection mismatch")
|
|
|
|
source_markers = {
|
|
"nodedc-source/server/dataProductPublishGrant/providerCatalog.js": (
|
|
"function exactHttpRequestUrl(n8n)",
|
|
"const capabilityIds = productCapabilities.map",
|
|
"providerCredentialRefs.size !== 1",
|
|
),
|
|
"nodedc-source/server/dataProductPublishGrant/service.js": (
|
|
"capabilities: descriptor.capabilityIds",
|
|
"providerRequestNodeIds: descriptor.providerRequestNodeIds",
|
|
),
|
|
"nodedc-source/server/dataProductPublishGrant/store.js": (
|
|
"Array.isArray(raw.capabilityIds)",
|
|
"Array.isArray(raw.providerRequestNodeIds)",
|
|
),
|
|
}
|
|
for rel, markers in source_markers.items():
|
|
source = (payload_dir / rel).read_text(encoding="utf-8")
|
|
if any(marker not in source for marker in markers):
|
|
die(f"Engine composite provider v4 source contract mismatch: {rel}")
|
|
|
|
|
|
def validate_engine_provider_rotating_slot_slice(payload_dir, entries):
|
|
if tuple(entries) != ENGINE_PROVIDER_ROTATING_SLOT_ARTIFACT_ENTRIES:
|
|
die("Engine provider rotating slot files.txt exact set/order mismatch")
|
|
for rel, expected_sha256 in ENGINE_PROVIDER_ROTATING_SLOT_TARGET_SHA256.items():
|
|
path = payload_dir / rel
|
|
try:
|
|
path_stat = path.lstat()
|
|
except FileNotFoundError:
|
|
die(f"Engine provider rotating slot target is missing: {rel}")
|
|
if (
|
|
stat.S_ISLNK(path_stat.st_mode)
|
|
or not stat.S_ISREG(path_stat.st_mode)
|
|
or sha256_file(path) != expected_sha256
|
|
):
|
|
die(f"Engine provider rotating slot target sha256 mismatch: {rel}")
|
|
|
|
catalog = read_strict_json(
|
|
payload_dir / ENGINE_PROVIDER_SECURITY_CATALOG_REL,
|
|
"Engine provider rotating slot catalog",
|
|
)
|
|
packages = catalog.get("packages") if isinstance(catalog, dict) else None
|
|
provider = packages[0] if isinstance(packages, list) and len(packages) == 1 else None
|
|
if (
|
|
not isinstance(provider, dict)
|
|
or provider.get("id") != "gelios.provider.v4"
|
|
or provider.get("providerCredential") != {
|
|
"authModeId": "gelios.rest-rotating-bearer.v3",
|
|
"credentialType": "ndcProviderRotatingAccessApi",
|
|
}
|
|
):
|
|
die("Engine provider rotating slot catalog projection mismatch")
|
|
|
|
resolver = (
|
|
payload_dir / "nodedc-source/server/dataProductPublishGrant/providerCatalog.js"
|
|
).read_text(encoding="utf-8")
|
|
if (
|
|
"'ndcProviderRotatingAccessApi'" not in resolver
|
|
or "n8n.credentials?.[providerPackage.providerCredential.credentialType]" not in resolver
|
|
or "nodedcAgentCredentialPolicies?.[providerPackage.providerCredential.credentialType]" not in resolver
|
|
):
|
|
die("Engine provider rotating slot resolver contract mismatch")
|
|
|
|
|
|
def validate_engine_provider_authority_diagnostics_slice(payload_dir, entries):
|
|
if tuple(entries) != ENGINE_PROVIDER_AUTHORITY_DIAGNOSTICS_ARTIFACT_ENTRIES:
|
|
die("Engine provider authority diagnostics files.txt exact set/order mismatch")
|
|
for rel, expected_sha256 in ENGINE_PROVIDER_AUTHORITY_DIAGNOSTICS_TARGET_SHA256.items():
|
|
path = payload_dir / rel
|
|
try:
|
|
path_stat = path.lstat()
|
|
except FileNotFoundError:
|
|
die(f"Engine provider authority diagnostics target is missing: {rel}")
|
|
if (
|
|
stat.S_ISLNK(path_stat.st_mode)
|
|
or not stat.S_ISREG(path_stat.st_mode)
|
|
or sha256_file(path) != expected_sha256
|
|
):
|
|
die(f"Engine provider authority diagnostics target sha256 mismatch: {rel}")
|
|
source = (
|
|
payload_dir / "nodedc-source/server/dataProductPublishGrant/providerCatalog.js"
|
|
).read_text(encoding="utf-8")
|
|
required_markers = (
|
|
"publish_grant_provider_request_not_exact",
|
|
"publish_grant_provider_credential_binding_invalid",
|
|
"publish_grant_provider_transport_policy_not_exact",
|
|
"publish_grant_provider_request_path_unreachable",
|
|
"publish_grant_provider_credential_identity_mismatch",
|
|
)
|
|
if any(marker not in source for marker in required_markers):
|
|
die("Engine provider authority diagnostics reason-code contract mismatch")
|
|
|
|
|
|
def validate_engine_depttrans_zone_authority_v1_slice(payload_dir, entries):
|
|
if tuple(entries) != ENGINE_DEPTTRANS_ZONE_AUTHORITY_V1_ARTIFACT_ENTRIES:
|
|
die("Engine Depttrans zone authority v1 files.txt exact set/order mismatch")
|
|
for rel, expected_sha256 in ENGINE_DEPTTRANS_ZONE_AUTHORITY_V1_TARGET_SHA256.items():
|
|
path = payload_dir / rel
|
|
try:
|
|
path_stat = path.lstat()
|
|
except FileNotFoundError:
|
|
die(f"Engine Depttrans zone authority v1 target is missing: {rel}")
|
|
if (
|
|
stat.S_ISLNK(path_stat.st_mode)
|
|
or not stat.S_ISREG(path_stat.st_mode)
|
|
or sha256_file(path) != expected_sha256
|
|
):
|
|
die(f"Engine Depttrans zone authority v1 target sha256 mismatch: {rel}")
|
|
|
|
catalog = read_strict_json(
|
|
payload_dir / ENGINE_PROVIDER_SECURITY_CATALOG_REL,
|
|
"Engine Depttrans zone authority v1 catalog",
|
|
)
|
|
packages = catalog.get("packages") if isinstance(catalog, dict) else None
|
|
package_ids = [item.get("id") for item in packages if isinstance(item, dict)] \
|
|
if isinstance(packages, list) else None
|
|
provider = next(
|
|
(
|
|
item for item in packages
|
|
if isinstance(item, dict)
|
|
and item.get("id") == "moscow-department-of-transport.pmd-slow-zones.v1"
|
|
),
|
|
None,
|
|
) if isinstance(packages, list) else None
|
|
capability = provider.get("capabilities", [None])[0] if isinstance(provider, dict) else None
|
|
request = capability.get("request") if isinstance(capability, dict) else None
|
|
marker = read_strict_json(
|
|
payload_dir / "nodedc-source/server/assets/provider-packages/v1/depttrans-zone-authority-v1.json",
|
|
"Engine Depttrans zone authority v1 marker",
|
|
)
|
|
if (
|
|
catalog.get("schemaVersion") != "nodedc.engine.provider-security-catalog/v1"
|
|
or package_ids != [
|
|
"gelios.provider.v1",
|
|
"gelios.provider.v4",
|
|
"gelios.provider.v5",
|
|
"moscow-department-of-transport.pmd-slow-zones.v1",
|
|
]
|
|
or provider.get("providerId") != "moscow-department-of-transport"
|
|
or provider.get("version") != "1.0.0"
|
|
or "providerCredential" in provider
|
|
or capability.get("id") != "depttrans.pmd-slow-zones.current.read"
|
|
or capability.get("dataProductIds") != ["map.zones.current.v2"]
|
|
or request != {
|
|
"method": "GET",
|
|
"url": "http://map-gateway:18103/internal/zone-sources/v1/profiles/moscow-pmd-slow-zones/current",
|
|
"authorityBoundary": "platform-service",
|
|
"serviceId": "nodedc-map-gateway",
|
|
"network": "engine",
|
|
}
|
|
or marker != {
|
|
"schemaVersion": "nodedc.engine.platform-service-authority/v1",
|
|
"id": "moscow-department-of-transport.pmd-slow-zones.v1",
|
|
"providerId": "moscow-department-of-transport",
|
|
"authorityBoundary": "platform-service",
|
|
"serviceId": "nodedc-map-gateway",
|
|
"network": "engine",
|
|
"dataProductId": "map.zones.current.v2",
|
|
}
|
|
):
|
|
die("Engine Depttrans zone authority v1 catalog projection mismatch")
|
|
|
|
required_markers = {
|
|
"nodedc-source/server/dataProductPublishGrant/providerCatalog.js": (
|
|
"function platformServiceRequestIsExact",
|
|
"authorityBoundary === 'platform-service'",
|
|
"platformServiceIds",
|
|
),
|
|
"nodedc-source/server/dataProductPublishGrant/service.js": (
|
|
"pinned_platform_service_no_graph_credential",
|
|
"platformServiceIds: descriptor.platformServiceIds",
|
|
),
|
|
"nodedc-source/server/dataProductPublishGrant/store.js": (
|
|
"new Set(['provider-credential', 'platform-service'])",
|
|
"descriptor.authorityBoundary === 'platform-service'",
|
|
),
|
|
}
|
|
for rel, markers in required_markers.items():
|
|
source = (payload_dir / rel).read_text(encoding="utf-8")
|
|
if any(marker not in source for marker in markers):
|
|
die(f"Engine Depttrans zone authority v1 source contract mismatch: {rel}")
|
|
|
|
|
|
def validate_engine_provider_target_host_policy_slice(payload_dir, entries):
|
|
if tuple(entries) != ENGINE_PROVIDER_TARGET_HOST_POLICY_ARTIFACT_ENTRIES:
|
|
die("Engine provider target host policy files.txt exact set/order mismatch")
|
|
for rel, expected_sha256 in ENGINE_PROVIDER_TARGET_HOST_POLICY_TARGET_SHA256.items():
|
|
path = payload_dir / rel
|
|
try:
|
|
path_stat = path.lstat()
|
|
except FileNotFoundError:
|
|
die(f"Engine provider target host policy target is missing: {rel}")
|
|
if (
|
|
stat.S_ISLNK(path_stat.st_mode)
|
|
or not stat.S_ISREG(path_stat.st_mode)
|
|
or sha256_file(path) != expected_sha256
|
|
):
|
|
die(f"Engine provider target host policy target sha256 mismatch: {rel}")
|
|
source = (payload_dir / "nodedc-source/server/routes/n8n.js").read_text(encoding="utf-8")
|
|
required_markers = (
|
|
"const eligibleHosts = explicitHosts.length",
|
|
"allowedHosts: [targetHost]",
|
|
"credential_host_not_allowed",
|
|
)
|
|
if any(marker not in source for marker in required_markers):
|
|
die("Engine provider target host policy contract mismatch")
|
|
|
|
|
|
def validate_engine_agent_full_grant_migration_slice(payload_dir, entries):
|
|
if tuple(entries) != ENGINE_AGENT_FULL_GRANT_MIGRATION_ARTIFACT_ENTRIES:
|
|
die("Engine agent full grant migration files.txt exact set/order mismatch")
|
|
store_path = payload_dir / ENGINE_AGENT_FULL_GRANT_MIGRATION_STORE_REL
|
|
try:
|
|
store_source = store_path.read_text(encoding="utf-8")
|
|
except (OSError, UnicodeDecodeError):
|
|
die("Engine agent full grant migration store is unreadable")
|
|
if sha256_file(store_path) != ENGINE_AGENT_FULL_GRANT_MIGRATION_TARGET_SHA256:
|
|
die("Engine agent full grant migration target sha256 mismatch")
|
|
required = (
|
|
"const STORE_VERSION = 2",
|
|
"export const ENGINE_AGENT_FULL_DEVELOPER_PROFILE = 'full-developer'",
|
|
"export const ENGINE_AGENT_CUSTOM_PROFILE = 'custom'",
|
|
"const LEGACY_FULL_DEVELOPER_SCOPES = Object.freeze([",
|
|
"'engine:l2:data-product-publish-grant:plan'",
|
|
"'engine:l2:data-product-publish-grant:write'",
|
|
"const migrateLegacyFullDeveloper = sourceVersion === 1",
|
|
"LEGACY_FULL_DEVELOPER_SCOPES.every((scope) => scopes.includes(scope))",
|
|
"profile === ENGINE_AGENT_FULL_DEVELOPER_PROFILE ? [...ENGINE_AGENT_SCOPES] : scopes",
|
|
"throw new Error('engine_agent_store_version_unsupported')",
|
|
)
|
|
if any(value not in store_source for value in required):
|
|
die("Engine agent full grant migration contract mismatch")
|
|
if re.search(r"gelios|robot2b", store_source, re.IGNORECASE):
|
|
die("Engine agent full grant migration provider logic is forbidden")
|
|
|
|
|
|
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 current_engine_node_intelligence_descriptor():
|
|
path = component_root("engine") / ENGINE_NODE_INTELLIGENCE_DESCRIPTOR_REL
|
|
if not path.exists():
|
|
return None
|
|
if path.is_symlink() or not path.is_file():
|
|
die("installed Engine node-intelligence descriptor is unsafe")
|
|
return read_engine_node_intelligence_descriptor(
|
|
path,
|
|
"installed Engine node-intelligence descriptor",
|
|
)
|
|
|
|
|
|
def validate_installed_engine_node_intelligence_source(descriptor):
|
|
if descriptor is None or descriptor.get("action") != "activate":
|
|
die("active Engine node-intelligence descriptor is required")
|
|
root = component_root("engine")
|
|
source_root = root / ENGINE_NODE_INTELLIGENCE_SOURCE_REL
|
|
expected_source_files = {
|
|
"catalog.js": descriptor["source"]["catalogSha256"],
|
|
"upstreamMcpClient.js": descriptor["source"]["upstreamClientSha256"],
|
|
"upstreamProjection.js": descriptor["source"]["upstreamProjectionSha256"],
|
|
}
|
|
try:
|
|
source_stat = source_root.lstat()
|
|
except FileNotFoundError:
|
|
die("installed Engine node-intelligence source is missing")
|
|
if stat.S_ISLNK(source_stat.st_mode) or not stat.S_ISDIR(source_stat.st_mode):
|
|
die("installed Engine node-intelligence source is unsafe")
|
|
actual_files = {
|
|
child.relative_to(source_root).as_posix()
|
|
for child in source_root.rglob("*")
|
|
if child.is_file()
|
|
}
|
|
if actual_files != set(expected_source_files):
|
|
die("installed Engine node-intelligence source file set mismatch")
|
|
for rel, expected_sha256 in expected_source_files.items():
|
|
path = source_root / rel
|
|
if path.is_symlink() or sha256_file(path) != expected_sha256:
|
|
die(f"installed Engine node-intelligence source drift detected: {rel}")
|
|
gateway = root / ENGINE_NODE_INTELLIGENCE_GATEWAY_REL
|
|
if gateway.is_symlink() or sha256_file(gateway) != descriptor["source"]["gatewaySha256"]:
|
|
die("installed Engine node-intelligence gateway drift detected")
|
|
override = root / ENGINE_NODE_INTELLIGENCE_OVERRIDE_REL
|
|
try:
|
|
override_stat = override.lstat()
|
|
override_text = override.read_text(encoding="utf-8")
|
|
except (FileNotFoundError, OSError, UnicodeDecodeError):
|
|
die("installed Engine node-intelligence Compose override is missing")
|
|
if stat.S_ISLNK(override_stat.st_mode) or not stat.S_ISREG(override_stat.st_mode):
|
|
die("installed Engine node-intelligence Compose override is unsafe")
|
|
if (
|
|
override_text != expected_engine_node_intelligence_compose_override()
|
|
or sha256_file(override) != descriptor["source"]["composeOverrideSha256"]
|
|
):
|
|
die("installed Engine node-intelligence Compose override drift detected")
|
|
readme = root / ENGINE_NODE_INTELLIGENCE_SERVICE_ROOT_REL / "README.md"
|
|
if readme.is_symlink() or sha256_file(readme) != descriptor["source"]["readmeSha256"]:
|
|
die("installed Engine node-intelligence README drift detected")
|
|
return descriptor["source"]["gatewaySha256"]
|
|
|
|
|
|
def preflight_engine_node_intelligence_predecessor(payload_dir):
|
|
candidate = read_engine_node_intelligence_descriptor(
|
|
payload_dir / ENGINE_NODE_INTELLIGENCE_DESCRIPTOR_REL,
|
|
"candidate Engine node-intelligence descriptor",
|
|
)
|
|
root = component_root("engine")
|
|
compose_path = root / "docker-compose.yml"
|
|
gateway_path = root / ENGINE_NODE_INTELLIGENCE_GATEWAY_REL
|
|
for path, label in (
|
|
(compose_path, "Compose"),
|
|
(gateway_path, "gateway"),
|
|
):
|
|
try:
|
|
path_stat = path.lstat()
|
|
except FileNotFoundError:
|
|
die(f"Engine node-intelligence predecessor {label} is missing")
|
|
if stat.S_ISLNK(path_stat.st_mode) or not stat.S_ISREG(path_stat.st_mode):
|
|
die(f"Engine node-intelligence predecessor {label} is unsafe")
|
|
if sha256_file(compose_path) != candidate["predecessor"]["composeSha256"]:
|
|
die("Engine node-intelligence predecessor Compose drift detected")
|
|
|
|
current = current_engine_node_intelligence_descriptor()
|
|
current_state = (
|
|
current["releaseId"]
|
|
if current is not None and current["action"] == "activate"
|
|
else "inactive"
|
|
)
|
|
if current_state != candidate["expectedCurrent"]:
|
|
die(
|
|
"Engine node-intelligence predecessor state mismatch: "
|
|
f"expected={candidate['expectedCurrent']} actual={current_state}"
|
|
)
|
|
actual_gateway_sha256 = sha256_file(gateway_path)
|
|
if actual_gateway_sha256 != candidate["predecessor"]["gatewaySha256"]:
|
|
die(
|
|
"Engine node-intelligence predecessor gateway drift detected: "
|
|
f"expected={candidate['predecessor']['gatewaySha256']} "
|
|
f"actual={actual_gateway_sha256}"
|
|
)
|
|
if candidate["action"] == "activate":
|
|
if current is not None and current["action"] == "activate":
|
|
die("Engine node-intelligence activation requires an inactive predecessor")
|
|
override = root / ENGINE_NODE_INTELLIGENCE_OVERRIDE_REL
|
|
if override.exists() or override.is_symlink():
|
|
die("inactive Engine node-intelligence predecessor still has a Compose override")
|
|
else:
|
|
validate_installed_engine_node_intelligence_source(current)
|
|
|
|
backend = preflight_engine_credential_backend_runtime()
|
|
if backend["mode"] != "verified-derived-retry":
|
|
die("Engine node-intelligence requires the active immutable backend")
|
|
return {
|
|
"action": candidate["action"],
|
|
"current_state": current_state,
|
|
"gateway_sha256": actual_gateway_sha256,
|
|
"backend_mode": backend["mode"],
|
|
"descriptor": candidate,
|
|
}
|
|
|
|
|
|
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)
|
|
touches_node_intelligence = any(
|
|
rel == ENGINE_NODE_INTELLIGENCE_SOURCE_REL
|
|
or rel.startswith(ENGINE_NODE_INTELLIGENCE_SOURCE_REL + "/")
|
|
or rel == ENGINE_NODE_INTELLIGENCE_SERVICE_ROOT_REL
|
|
or rel.startswith(ENGINE_NODE_INTELLIGENCE_SERVICE_ROOT_REL + "/")
|
|
for rel in entries
|
|
)
|
|
if (
|
|
touches_node_intelligence
|
|
and not is_engine_node_intelligence_transition(manifest["component"], entries)
|
|
and not is_engine_mcp_control_plane_slice(manifest["component"], entries)
|
|
and not is_engine_mcp_ontology_sdk_slice(manifest["component"], entries)
|
|
and not is_engine_mcp_autonomy_provider_v5_slice(manifest["component"], entries)
|
|
):
|
|
die("Engine node-intelligence payload requires the canonical transition")
|
|
if is_engine_node_intelligence_transition(manifest["component"], entries):
|
|
validate_engine_node_intelligence_transition(payload_dir, entries)
|
|
if is_engine_mcp_control_plane_slice(manifest["component"], entries):
|
|
validate_engine_mcp_control_plane_payload(payload_dir, entries)
|
|
if is_engine_mcp_ontology_sdk_slice(manifest["component"], entries):
|
|
validate_engine_mcp_ontology_sdk_payload(payload_dir, entries)
|
|
if is_engine_mcp_autonomy_provider_v5_slice(manifest["component"], entries):
|
|
validate_engine_mcp_autonomy_provider_v5_payload(payload_dir, entries)
|
|
if is_engine_composite_provider_v4_slice(manifest["component"], entries):
|
|
validate_engine_composite_provider_v4_slice(payload_dir, entries)
|
|
if is_engine_provider_rotating_slot_slice(manifest["component"], entries):
|
|
validate_engine_provider_rotating_slot_slice(payload_dir, entries)
|
|
if is_engine_provider_authority_diagnostics_slice(manifest["component"], entries):
|
|
validate_engine_provider_authority_diagnostics_slice(payload_dir, entries)
|
|
if is_engine_depttrans_zone_authority_v1_slice(manifest["component"], entries):
|
|
validate_engine_depttrans_zone_authority_v1_slice(payload_dir, entries)
|
|
if is_engine_provider_target_host_policy_slice(manifest["component"], entries):
|
|
validate_engine_provider_target_host_policy_slice(payload_dir, entries)
|
|
if is_engine_provider_security_catalog_slice(manifest["component"], entries):
|
|
validate_engine_provider_security_catalog_payload(payload_dir, entries)
|
|
if touches_engine_credential_sink(manifest["component"], entries):
|
|
validate_engine_credential_sink_slice(payload_dir, entries)
|
|
if (
|
|
not is_engine_mcp_control_plane_slice(manifest["component"], entries)
|
|
and not is_engine_mcp_ontology_sdk_slice(manifest["component"], entries)
|
|
and not is_engine_mcp_autonomy_provider_v5_slice(manifest["component"], entries)
|
|
and not is_engine_composite_provider_v4_slice(manifest["component"], entries)
|
|
and not is_engine_provider_rotating_slot_slice(manifest["component"], entries)
|
|
and not is_engine_provider_authority_diagnostics_slice(manifest["component"], entries)
|
|
and not is_engine_depttrans_zone_authority_v1_slice(manifest["component"], entries)
|
|
and not is_engine_provider_target_host_policy_slice(manifest["component"], entries)
|
|
and (
|
|
touches_engine_data_product_publish_grant(entries)
|
|
or ENGINE_DATA_PRODUCT_PUBLISH_GRANT_OVERRIDE_REL in entries
|
|
)
|
|
):
|
|
validate_engine_data_product_publish_grant_slice(payload_dir, entries)
|
|
elif (
|
|
not is_engine_mcp_control_plane_slice(manifest["component"], entries)
|
|
and not is_engine_mcp_ontology_sdk_slice(manifest["component"], entries)
|
|
and not is_engine_mcp_autonomy_provider_v5_slice(manifest["component"], entries)
|
|
and ENGINE_AGENT_FULL_GRANT_MIGRATION_STORE_REL in entries
|
|
):
|
|
validate_engine_agent_full_grant_migration_slice(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 touches_engine_credential_sink(component, entries):
|
|
if component != "engine" or entries is None:
|
|
return False
|
|
return any(
|
|
rel == "nodedc-source/server/credentialSink"
|
|
or rel.startswith("nodedc-source/server/credentialSink/")
|
|
or rel == "nodedc-source/server/routes/engineCredentialSink.js"
|
|
for rel in entries
|
|
)
|
|
|
|
|
|
# Frozen compatibility predicate retained because the last successful Engine
|
|
# credential runtime canon includes it in run_compose(). The corresponding
|
|
# Platform service is not an allowed artifact path and has no deploy branch.
|
|
def touches_engine_credential_provisioner(component, entries):
|
|
return False
|
|
|
|
|
|
def touches_external_data_plane_files(entries):
|
|
runtime_contract_files = {
|
|
"platform/packages/external-provider-contract/package.json",
|
|
"platform/packages/external-provider-contract/src/contract-version.mjs",
|
|
"platform/packages/external-provider-contract/src/data-plane.mjs",
|
|
"platform/packages/external-provider-contract/src/data-product.mjs",
|
|
"platform/packages/external-provider-contract/src/intake-batch.mjs",
|
|
"platform/packages/external-provider-contract/src/index.mjs",
|
|
"platform/packages/external-provider-contract/src/sensitive-field-policy.mjs",
|
|
}
|
|
return any(
|
|
rel == "platform/services/external-data-plane"
|
|
or rel.startswith("platform/services/external-data-plane/")
|
|
or rel in runtime_contract_files
|
|
or rel == "platform/docker-compose.external-data-plane.yml"
|
|
for rel in entries
|
|
)
|
|
|
|
|
|
def touches_engine_data_product_publish_grant(entries):
|
|
if entries is None:
|
|
return False
|
|
return any(
|
|
rel == "nodedc-source/server/dataProductPublishGrant"
|
|
or rel.startswith("nodedc-source/server/dataProductPublishGrant/")
|
|
for rel in entries
|
|
)
|
|
|
|
|
|
def is_engine_data_product_publish_grant_slice(component, entries):
|
|
return (
|
|
component == "engine"
|
|
and entries is not None
|
|
and tuple(entries) == ENGINE_DATA_PRODUCT_PUBLISH_GRANT_ARTIFACT_ENTRIES
|
|
)
|
|
|
|
|
|
def is_engine_composite_provider_v4_slice(component, entries):
|
|
return (
|
|
component == "engine"
|
|
and entries is not None
|
|
and tuple(entries) == ENGINE_COMPOSITE_PROVIDER_V4_ARTIFACT_ENTRIES
|
|
)
|
|
|
|
|
|
def is_engine_provider_rotating_slot_slice(component, entries):
|
|
return (
|
|
component == "engine"
|
|
and entries is not None
|
|
and tuple(entries) == ENGINE_PROVIDER_ROTATING_SLOT_ARTIFACT_ENTRIES
|
|
)
|
|
|
|
|
|
def is_engine_provider_authority_diagnostics_slice(component, entries):
|
|
return (
|
|
component == "engine"
|
|
and entries is not None
|
|
and tuple(entries) == ENGINE_PROVIDER_AUTHORITY_DIAGNOSTICS_ARTIFACT_ENTRIES
|
|
)
|
|
|
|
|
|
def is_engine_depttrans_zone_authority_v1_slice(component, entries):
|
|
return (
|
|
component == "engine"
|
|
and entries is not None
|
|
and tuple(entries) == ENGINE_DEPTTRANS_ZONE_AUTHORITY_V1_ARTIFACT_ENTRIES
|
|
)
|
|
|
|
|
|
def is_engine_provider_target_host_policy_slice(component, entries):
|
|
return (
|
|
component == "engine"
|
|
and entries is not None
|
|
and tuple(entries) == ENGINE_PROVIDER_TARGET_HOST_POLICY_ARTIFACT_ENTRIES
|
|
)
|
|
|
|
|
|
def is_engine_agent_full_grant_migration_slice(component, entries):
|
|
return (
|
|
component == "engine"
|
|
and entries is not None
|
|
and tuple(entries) == ENGINE_AGENT_FULL_GRANT_MIGRATION_ARTIFACT_ENTRIES
|
|
)
|
|
|
|
|
|
def collect_engine_data_product_publish_grant_files(root, entries, label):
|
|
files = {}
|
|
for rel in entries:
|
|
path = root / rel
|
|
try:
|
|
path_stat = path.lstat()
|
|
except FileNotFoundError:
|
|
die(f"{label} file is missing: {rel}")
|
|
if stat.S_ISLNK(path_stat.st_mode):
|
|
die(f"{label} path is unsafe: {rel}")
|
|
if stat.S_ISREG(path_stat.st_mode):
|
|
files[rel] = sha256_file(path)
|
|
continue
|
|
if not stat.S_ISDIR(path_stat.st_mode):
|
|
die(f"{label} path is unsafe: {rel}")
|
|
for child in sorted(path.rglob("*")):
|
|
child_rel = child.relative_to(root).as_posix()
|
|
child_stat = child.lstat()
|
|
if stat.S_ISLNK(child_stat.st_mode):
|
|
die(f"{label} path is unsafe: {child_rel}")
|
|
if stat.S_ISDIR(child_stat.st_mode):
|
|
continue
|
|
if not stat.S_ISREG(child_stat.st_mode):
|
|
die(f"{label} path is unsafe: {child_rel}")
|
|
files[child_rel] = sha256_file(child)
|
|
return files
|
|
|
|
|
|
def validate_installed_engine_data_product_publish_grant_foundation(root):
|
|
# The original credential-sink baseline remains immutable. The two route
|
|
# files below became Publish-grant-owned when the initial transition was
|
|
# applied, so their current contract is validated semantically instead of
|
|
# comparing them to their pre-transition bytes.
|
|
publish_owned_predecessors = {
|
|
"nodedc-source/server/routes/engineAgentGateway.js",
|
|
"nodedc-source/server/routes/n8n.js",
|
|
}
|
|
for rel, expected_sha256 in ENGINE_DATA_PRODUCT_PUBLISH_GRANT_INSTALLED_FOUNDATION_SHA256.items():
|
|
if rel in publish_owned_predecessors:
|
|
continue
|
|
path = root / rel
|
|
try:
|
|
path_stat = path.lstat()
|
|
except FileNotFoundError:
|
|
die(f"installed Engine data product publish grant foundation is missing: {rel}")
|
|
if stat.S_ISLNK(path_stat.st_mode) or not stat.S_ISREG(path_stat.st_mode):
|
|
die(f"installed Engine data product publish grant foundation is unsafe: {rel}")
|
|
actual_sha256 = sha256_file(path)
|
|
if actual_sha256 != expected_sha256:
|
|
die(
|
|
"installed Engine data product publish grant foundation drift detected: "
|
|
f"path={rel} expected={expected_sha256} actual={actual_sha256}"
|
|
)
|
|
|
|
grant_dir = root / "nodedc-source/server/dataProductPublishGrant"
|
|
try:
|
|
grant_stat = grant_dir.lstat()
|
|
except FileNotFoundError:
|
|
die("installed Engine data product publish grant source is missing")
|
|
if stat.S_ISLNK(grant_stat.st_mode) or not stat.S_ISDIR(grant_stat.st_mode):
|
|
die("installed Engine data product publish grant source is unsafe")
|
|
installed_names = []
|
|
for child in grant_dir.iterdir():
|
|
child_stat = child.lstat()
|
|
if stat.S_ISLNK(child_stat.st_mode) or not stat.S_ISREG(child_stat.st_mode):
|
|
die(f"installed Engine data product publish grant source is unsafe: {child.name}")
|
|
installed_names.append(child.name)
|
|
if tuple(sorted(installed_names)) != ENGINE_DATA_PRODUCT_PUBLISH_GRANT_SOURCE_FILES:
|
|
die("installed Engine data product publish grant source set mismatch")
|
|
|
|
required_files = (
|
|
"nodedc-source/server/assets/provider-packages/v1/catalog.json",
|
|
"nodedc-source/server/engineAgents/store.js",
|
|
"nodedc-source/server/routes/engineAgentGateway.js",
|
|
"nodedc-source/server/routes/n8n.js",
|
|
ENGINE_DATA_PRODUCT_PUBLISH_GRANT_OVERRIDE_REL,
|
|
)
|
|
for rel in required_files:
|
|
path = root / rel
|
|
try:
|
|
path_stat = path.lstat()
|
|
except FileNotFoundError:
|
|
die(f"installed Engine data product publish grant file is missing: {rel}")
|
|
if stat.S_ISLNK(path_stat.st_mode) or not stat.S_ISREG(path_stat.st_mode):
|
|
die(f"installed Engine data product publish grant file is unsafe: {rel}")
|
|
|
|
try:
|
|
override_text = (root / ENGINE_DATA_PRODUCT_PUBLISH_GRANT_OVERRIDE_REL).read_text(
|
|
encoding="utf-8"
|
|
)
|
|
gateway = (root / "nodedc-source/server/routes/engineAgentGateway.js").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
n8n_route = (root / "nodedc-source/server/routes/n8n.js").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
except (OSError, UnicodeDecodeError):
|
|
die("installed Engine data product publish grant source is unreadable")
|
|
if override_text != expected_engine_data_product_publish_grant_override():
|
|
die("installed Engine data product publish grant override drift detected")
|
|
if "from '../credentialSink/" in n8n_route:
|
|
die("installed Engine data product publish grant depends on the legacy sink")
|
|
for adapter in (
|
|
"engineCredentialSinkN8nAdapter",
|
|
"engineDataProductPublishGrantN8nAdapter",
|
|
):
|
|
if adapter not in n8n_route:
|
|
die(f"installed Engine data product publish grant adapter is missing: {adapter}")
|
|
for tool in (
|
|
"engine_plan_data_product_publish_grant",
|
|
"engine_apply_data_product_publish_grant",
|
|
"engine_accept_data_product_publish_grant",
|
|
"engine_rollback_data_product_publish_grant",
|
|
):
|
|
if tool not in gateway:
|
|
die(f"installed Engine data product publish grant tool is missing: {tool}")
|
|
|
|
return sha256_file(root / "docker-compose.yml")
|
|
|
|
|
|
def preflight_engine_data_product_publish_grant_predecessor(payload_dir=None):
|
|
root = component_root("engine")
|
|
installed_override = root / ENGINE_DATA_PRODUCT_PUBLISH_GRANT_OVERRIDE_REL
|
|
if installed_override.exists() or installed_override.is_symlink():
|
|
compose_sha256 = validate_installed_engine_data_product_publish_grant_foundation(root)
|
|
if payload_dir is None:
|
|
die("Engine data product publish grant update candidate is required")
|
|
installed_files = collect_engine_data_product_publish_grant_files(
|
|
root,
|
|
ENGINE_DATA_PRODUCT_PUBLISH_GRANT_ARTIFACT_ENTRIES,
|
|
"installed Engine data product publish grant",
|
|
)
|
|
candidate_files = collect_engine_data_product_publish_grant_files(
|
|
payload_dir,
|
|
ENGINE_DATA_PRODUCT_PUBLISH_GRANT_ARTIFACT_ENTRIES,
|
|
"candidate Engine data product publish grant",
|
|
)
|
|
if set(installed_files) != set(candidate_files):
|
|
die("Engine data product publish grant update file set mismatch")
|
|
changed_paths = tuple(sorted(
|
|
rel
|
|
for rel, candidate_sha256 in candidate_files.items()
|
|
if installed_files[rel] != candidate_sha256
|
|
))
|
|
if not changed_paths:
|
|
die("Engine data product publish grant update contains no source change")
|
|
forbidden_changes = tuple(
|
|
rel
|
|
for rel in changed_paths
|
|
if (
|
|
not rel.startswith(ENGINE_DATA_PRODUCT_PUBLISH_GRANT_SOURCE_PREFIX)
|
|
and rel != ENGINE_PROVIDER_SECURITY_CATALOG_REL
|
|
)
|
|
)
|
|
if forbidden_changes:
|
|
die(
|
|
"Engine data product publish grant update crosses its source boundary: "
|
|
f"path={forbidden_changes[0]}"
|
|
)
|
|
if ENGINE_PROVIDER_SECURITY_CATALOG_REL in changed_paths:
|
|
installed_catalog_sha256 = installed_files[ENGINE_PROVIDER_SECURITY_CATALOG_REL]
|
|
candidate_catalog_sha256 = candidate_files[ENGINE_PROVIDER_SECURITY_CATALOG_REL]
|
|
if (
|
|
installed_catalog_sha256
|
|
!= ENGINE_PUBLISH_GRANT_COMPOSITE_PROVIDER_CATALOG_PREDECESSOR_SHA256
|
|
or candidate_catalog_sha256
|
|
!= ENGINE_PUBLISH_GRANT_COMPOSITE_PROVIDER_CATALOG_TARGET_SHA256
|
|
):
|
|
die("Engine composite provider catalog transition mismatch")
|
|
if changed_paths != ENGINE_PUBLISH_GRANT_COMPOSITE_PROVIDER_CHANGED_PATHS:
|
|
die("Engine composite provider update exact changed path set mismatch")
|
|
mode = "installed-composite-provider-update"
|
|
else:
|
|
mode = "installed-source-update"
|
|
return {
|
|
"mode": mode,
|
|
"compose_sha256": compose_sha256,
|
|
"changed_paths": changed_paths,
|
|
}
|
|
|
|
actual = {}
|
|
for rel, expected_sha256 in ENGINE_DATA_PRODUCT_PUBLISH_GRANT_PREDECESSOR_SHA256.items():
|
|
path = root / rel
|
|
try:
|
|
path_stat = path.lstat()
|
|
except FileNotFoundError:
|
|
die(f"Engine data product publish grant predecessor file is missing: {rel}")
|
|
if stat.S_ISLNK(path_stat.st_mode) or not stat.S_ISREG(path_stat.st_mode):
|
|
die(f"Engine data product publish grant predecessor file is unsafe: {rel}")
|
|
actual_sha256 = sha256_file(path)
|
|
if actual_sha256 != expected_sha256:
|
|
die(
|
|
"Engine data product publish grant predecessor drift detected: "
|
|
f"path={rel} expected={expected_sha256} actual={actual_sha256}"
|
|
)
|
|
actual[rel] = actual_sha256
|
|
return {
|
|
"mode": "credential-sink-initial-transition",
|
|
"compose_sha256": actual["docker-compose.yml"],
|
|
"changed_paths": (),
|
|
}
|
|
|
|
|
|
def preflight_engine_composite_provider_v4_predecessor():
|
|
root = component_root("engine")
|
|
actual = {}
|
|
for rel, expected_sha256 in ENGINE_COMPOSITE_PROVIDER_V4_PREDECESSOR_SHA256.items():
|
|
path = root / rel
|
|
try:
|
|
path_stat = path.lstat()
|
|
except FileNotFoundError:
|
|
die(f"Engine composite provider v4 predecessor is missing: {rel}")
|
|
if stat.S_ISLNK(path_stat.st_mode) or not stat.S_ISREG(path_stat.st_mode):
|
|
die(f"Engine composite provider v4 predecessor is unsafe: {rel}")
|
|
actual_sha256 = sha256_file(path)
|
|
if actual_sha256 != expected_sha256:
|
|
die(
|
|
"Engine composite provider v4 predecessor drift detected: "
|
|
f"path={rel} expected={expected_sha256} actual={actual_sha256}"
|
|
)
|
|
actual[rel] = actual_sha256
|
|
backend = preflight_engine_credential_backend_runtime()
|
|
if backend["mode"] != "verified-derived-retry":
|
|
die("Engine composite provider v4 requires the active immutable backend")
|
|
return {
|
|
"mode": "exact-composite-provider-v3-to-v4",
|
|
"predecessor_sha256": actual,
|
|
"target_sha256": dict(ENGINE_COMPOSITE_PROVIDER_V4_TARGET_SHA256),
|
|
"backend_mode": backend["mode"],
|
|
}
|
|
|
|
|
|
def preflight_engine_provider_rotating_slot_predecessor():
|
|
root = component_root("engine")
|
|
actual = {}
|
|
for rel, expected_sha256 in ENGINE_PROVIDER_ROTATING_SLOT_PREDECESSOR_SHA256.items():
|
|
path = root / rel
|
|
try:
|
|
path_stat = path.lstat()
|
|
except FileNotFoundError:
|
|
die(f"Engine provider rotating slot predecessor is missing: {rel}")
|
|
if stat.S_ISLNK(path_stat.st_mode) or not stat.S_ISREG(path_stat.st_mode):
|
|
die(f"Engine provider rotating slot predecessor is unsafe: {rel}")
|
|
actual_sha256 = sha256_file(path)
|
|
if actual_sha256 != expected_sha256:
|
|
die(
|
|
"Engine provider rotating slot predecessor drift detected: "
|
|
f"path={rel} expected={expected_sha256} actual={actual_sha256}"
|
|
)
|
|
actual[rel] = actual_sha256
|
|
backend = preflight_engine_credential_backend_runtime()
|
|
if backend["mode"] != "verified-derived-retry":
|
|
die("Engine provider rotating slot requires the active immutable backend")
|
|
return {
|
|
"mode": "exact-gelios-v4-credential-slot-alignment",
|
|
"predecessor_sha256": actual,
|
|
"target_sha256": dict(ENGINE_PROVIDER_ROTATING_SLOT_TARGET_SHA256),
|
|
"backend_mode": backend["mode"],
|
|
}
|
|
|
|
|
|
def preflight_engine_provider_authority_diagnostics_predecessor():
|
|
root = component_root("engine")
|
|
actual = {}
|
|
for rel, expected_sha256 in ENGINE_PROVIDER_AUTHORITY_DIAGNOSTICS_PREDECESSOR_SHA256.items():
|
|
path = root / rel
|
|
try:
|
|
path_stat = path.lstat()
|
|
except FileNotFoundError:
|
|
die(f"Engine provider authority diagnostics predecessor is missing: {rel}")
|
|
if stat.S_ISLNK(path_stat.st_mode) or not stat.S_ISREG(path_stat.st_mode):
|
|
die(f"Engine provider authority diagnostics predecessor is unsafe: {rel}")
|
|
actual_sha256 = sha256_file(path)
|
|
if actual_sha256 != expected_sha256:
|
|
die(
|
|
"Engine provider authority diagnostics predecessor drift detected: "
|
|
f"path={rel} expected={expected_sha256} actual={actual_sha256}"
|
|
)
|
|
actual[rel] = actual_sha256
|
|
backend = preflight_engine_credential_backend_runtime()
|
|
if backend["mode"] != "verified-derived-retry":
|
|
die("Engine provider authority diagnostics requires the active immutable backend")
|
|
return {
|
|
"mode": "exact-provider-authority-reason-codes",
|
|
"predecessor_sha256": actual,
|
|
"target_sha256": dict(ENGINE_PROVIDER_AUTHORITY_DIAGNOSTICS_TARGET_SHA256),
|
|
"backend_mode": backend["mode"],
|
|
}
|
|
|
|
|
|
def preflight_engine_depttrans_zone_authority_v1_predecessor():
|
|
root = component_root("engine")
|
|
actual = {}
|
|
for rel, expected_sha256 in ENGINE_DEPTTRANS_ZONE_AUTHORITY_V1_PREDECESSOR_SHA256.items():
|
|
path = root / rel
|
|
try:
|
|
path_stat = path.lstat()
|
|
except FileNotFoundError:
|
|
die(f"Engine Depttrans zone authority v1 predecessor is missing: {rel}")
|
|
if stat.S_ISLNK(path_stat.st_mode) or not stat.S_ISREG(path_stat.st_mode):
|
|
die(f"Engine Depttrans zone authority v1 predecessor is unsafe: {rel}")
|
|
actual_sha256 = sha256_file(path)
|
|
if actual_sha256 != expected_sha256:
|
|
die(
|
|
"Engine Depttrans zone authority v1 predecessor drift detected: "
|
|
f"path={rel} expected={expected_sha256} actual={actual_sha256}"
|
|
)
|
|
actual[rel] = actual_sha256
|
|
for rel in ENGINE_DEPTTRANS_ZONE_AUTHORITY_V1_NEW_PATHS:
|
|
path = root / rel
|
|
if path.exists() or path.is_symlink():
|
|
die(f"Engine Depttrans zone authority v1 new path already exists: {rel}")
|
|
backend = preflight_engine_credential_backend_runtime()
|
|
if backend["mode"] != "verified-derived-retry":
|
|
die("Engine Depttrans zone authority v1 requires the active immutable backend")
|
|
return {
|
|
"mode": "exact-platform-service-authority-v1",
|
|
"predecessor_sha256": actual,
|
|
"target_sha256": dict(ENGINE_DEPTTRANS_ZONE_AUTHORITY_V1_TARGET_SHA256),
|
|
"backend_mode": backend["mode"],
|
|
}
|
|
|
|
|
|
def print_engine_depttrans_zone_authority_v1_plan(preflight):
|
|
print("engine_provider_authority_transition=platform-service-v1")
|
|
print("engine_provider_package=moscow-department-of-transport.pmd-slow-zones.v1")
|
|
print("engine_provider_id=moscow-department-of-transport")
|
|
print("engine_provider_authority_boundary=platform-service")
|
|
print("engine_provider_platform_service=nodedc-map-gateway")
|
|
print("engine_provider_platform_network=engine")
|
|
print("engine_data_product=map.zones.current.v2")
|
|
print("provider_credential_values=preserved")
|
|
predecessor_sha256 = preflight["predecessor_sha256"]
|
|
target_sha256 = preflight["target_sha256"]
|
|
for changed_path in ENGINE_DEPTTRANS_ZONE_AUTHORITY_V1_ARTIFACT_ENTRIES:
|
|
print(f"engine_depttrans_zone_authority_changed_path={changed_path}")
|
|
if changed_path in predecessor_sha256:
|
|
print(
|
|
f"engine_depttrans_zone_authority_predecessor_sha256[{changed_path}]="
|
|
f"{predecessor_sha256[changed_path]}"
|
|
)
|
|
elif changed_path in ENGINE_DEPTTRANS_ZONE_AUTHORITY_V1_NEW_PATHS:
|
|
print(
|
|
f"engine_depttrans_zone_authority_predecessor_state[{changed_path}]="
|
|
"absent"
|
|
)
|
|
else:
|
|
die(
|
|
"Engine Depttrans zone authority v1 plan has no predecessor state: "
|
|
f"{changed_path}"
|
|
)
|
|
if changed_path not in target_sha256:
|
|
die(
|
|
"Engine Depttrans zone authority v1 plan has no target sha256: "
|
|
f"{changed_path}"
|
|
)
|
|
print(
|
|
f"engine_depttrans_zone_authority_target_sha256[{changed_path}]="
|
|
f"{target_sha256[changed_path]}"
|
|
)
|
|
print(f"backend_current_barrier={preflight['backend_mode']}")
|
|
print("backend_force_recreate=yes")
|
|
print("backend_pull=never")
|
|
print("l2_graph=untouched")
|
|
print("n8n_l1=untouched")
|
|
print("engine_ui=untouched")
|
|
print("engine_databases=untouched")
|
|
print("mcp_nginx=untouched")
|
|
|
|
|
|
def preflight_engine_provider_target_host_policy_predecessor():
|
|
root = component_root("engine")
|
|
actual = {}
|
|
for rel, expected_sha256 in ENGINE_PROVIDER_TARGET_HOST_POLICY_PREDECESSOR_SHA256.items():
|
|
path = root / rel
|
|
try:
|
|
path_stat = path.lstat()
|
|
except FileNotFoundError:
|
|
die(f"Engine provider target host policy predecessor is missing: {rel}")
|
|
if stat.S_ISLNK(path_stat.st_mode) or not stat.S_ISREG(path_stat.st_mode):
|
|
die(f"Engine provider target host policy predecessor is unsafe: {rel}")
|
|
actual_sha256 = sha256_file(path)
|
|
if actual_sha256 != expected_sha256:
|
|
die(
|
|
"Engine provider target host policy predecessor drift detected: "
|
|
f"path={rel} expected={expected_sha256} actual={actual_sha256}"
|
|
)
|
|
actual[rel] = actual_sha256
|
|
backend = preflight_engine_credential_backend_runtime()
|
|
if backend["mode"] != "verified-derived-retry":
|
|
die("Engine provider target host policy requires the active immutable backend")
|
|
return {
|
|
"mode": "exact-provider-literal-target-host",
|
|
"predecessor_sha256": actual,
|
|
"target_sha256": dict(ENGINE_PROVIDER_TARGET_HOST_POLICY_TARGET_SHA256),
|
|
"backend_mode": backend["mode"],
|
|
}
|
|
|
|
|
|
def preflight_engine_agent_full_grant_migration_predecessor():
|
|
root = component_root("engine")
|
|
store_path = root / ENGINE_AGENT_FULL_GRANT_MIGRATION_STORE_REL
|
|
try:
|
|
store_stat = store_path.lstat()
|
|
except FileNotFoundError:
|
|
die("Engine agent full grant migration predecessor is missing")
|
|
if stat.S_ISLNK(store_stat.st_mode) or not stat.S_ISREG(store_stat.st_mode):
|
|
die("Engine agent full grant migration predecessor is unsafe")
|
|
actual_sha256 = sha256_file(store_path)
|
|
if actual_sha256 != ENGINE_AGENT_FULL_GRANT_MIGRATION_PREDECESSOR_SHA256:
|
|
die(
|
|
"Engine agent full grant migration predecessor drift detected: "
|
|
f"expected={ENGINE_AGENT_FULL_GRANT_MIGRATION_PREDECESSOR_SHA256} "
|
|
f"actual={actual_sha256}"
|
|
)
|
|
|
|
publish_override = root / ENGINE_DATA_PRODUCT_PUBLISH_GRANT_OVERRIDE_REL
|
|
try:
|
|
publish_stat = publish_override.lstat()
|
|
publish_text = publish_override.read_text(encoding="utf-8")
|
|
except (FileNotFoundError, OSError, UnicodeDecodeError):
|
|
die("Engine agent full grant migration requires the installed Publish overlay")
|
|
if (
|
|
stat.S_ISLNK(publish_stat.st_mode)
|
|
or not stat.S_ISREG(publish_stat.st_mode)
|
|
or publish_text != expected_engine_data_product_publish_grant_override()
|
|
):
|
|
die("Engine agent full grant migration Publish overlay mismatch")
|
|
return actual_sha256
|
|
|
|
|
|
def is_platform_provider_catalog_only(entries):
|
|
prefix = "platform/packages/external-provider-contract/providers/"
|
|
return bool(entries) and all(rel.startswith(prefix) for rel in entries)
|
|
|
|
|
|
def component_services(component, entries=None):
|
|
if (
|
|
is_engine_mcp_control_plane_slice(component, entries)
|
|
or is_engine_mcp_ontology_sdk_slice(component, entries)
|
|
or is_engine_mcp_autonomy_provider_v5_slice(component, entries)
|
|
or is_engine_composite_provider_v4_slice(component, entries)
|
|
or is_engine_provider_rotating_slot_slice(component, entries)
|
|
or is_engine_provider_authority_diagnostics_slice(component, entries)
|
|
or is_engine_depttrans_zone_authority_v1_slice(component, entries)
|
|
or is_engine_provider_target_host_policy_slice(component, entries)
|
|
or is_engine_provider_security_catalog_slice(component, entries)
|
|
):
|
|
# This slice updates only the existing Engine backend control plane.
|
|
# Node intelligence keeps the same immutable sidecar image and n8n/L1
|
|
# retain their current generations.
|
|
return ("nodedc-backend",)
|
|
|
|
if is_engine_node_intelligence_transition(component, entries):
|
|
if tuple(entries) == ENGINE_NODE_INTELLIGENCE_ARTIFACT_ENTRIES:
|
|
return (ENGINE_NODE_INTELLIGENCE_SERVICE, "nodedc-backend")
|
|
return ("nodedc-backend",)
|
|
|
|
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 touches_engine_credential_sink(component, entries):
|
|
# The sink is served by the existing Engine backend. It has no frontend
|
|
# or n8n process code, so preserve those generations during this slice.
|
|
return ("nodedc-backend",)
|
|
if is_engine_data_product_publish_grant_slice(component, entries):
|
|
# Publish-grant source and its fixed runtime overlay are backend-owned.
|
|
# n8n, the UI and every database retain their existing generations.
|
|
return ("nodedc-backend",)
|
|
if is_engine_agent_full_grant_migration_slice(component, entries):
|
|
# This one-time compatibility migration changes only Engine Agent
|
|
# authorization normalization inside the already-active backend.
|
|
return ("nodedc-backend",)
|
|
|
|
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:
|
|
if is_platform_provider_catalog_only(entries):
|
|
return ()
|
|
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 = touches_external_data_plane_files(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:
|
|
# The Timescale/PostgreSQL service is durable infrastructure. Both
|
|
# contract and managed EDP changes recreate only the EDP
|
|
# application process.
|
|
add("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 engine_backend_immutable_runtime_is_current():
|
|
metadata = validate_engine_backend_activation_marker()
|
|
compose_project = component_compose_root("engine").name
|
|
result = subprocess.run(
|
|
[
|
|
str(DOCKER), "container", "ls", "-a",
|
|
"--filter", f"label=com.docker.compose.project={compose_project}",
|
|
"--filter", "label=com.docker.compose.service=nodedc-backend",
|
|
"--format", "{{.ID}}",
|
|
],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
container_ids = [line.strip() for line in result.stdout.splitlines() if line.strip()]
|
|
if (result.returncode != 0
|
|
or len(container_ids) != 1
|
|
or not re.fullmatch(r"[a-f0-9]{12,64}", container_ids[0])):
|
|
die("Engine backend immutable runtime topology is unproven")
|
|
containers = docker_json(
|
|
["container", "inspect", container_ids[0]],
|
|
"Engine backend immutable runtime container inspect",
|
|
)
|
|
container = containers[0] if isinstance(containers, list) and len(containers) == 1 else None
|
|
image = inspect_engine_backend_derived_image(metadata)
|
|
return (
|
|
isinstance(container, dict)
|
|
and container.get("Image") == image.get("Id")
|
|
and (container.get("Config") or {}).get("Image") == ENGINE_CREDENTIAL_BACKEND_IMAGE
|
|
)
|
|
|
|
|
|
def component_compose_files(component, allow_prepared_engine_backend=False):
|
|
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)
|
|
if (ENGINE_CREDENTIAL_BACKEND_ACTIVATION_FILE.exists()
|
|
or ENGINE_CREDENTIAL_BACKEND_ACTIVATION_FILE.is_symlink()):
|
|
validate_engine_backend_activation_marker()
|
|
if (allow_prepared_engine_backend
|
|
or engine_backend_immutable_runtime_is_current()):
|
|
files.append(ENGINE_CREDENTIAL_BACKEND_OVERRIDE_FILE)
|
|
publish_grant_override = root / ENGINE_DATA_PRODUCT_PUBLISH_GRANT_OVERRIDE_REL
|
|
if publish_grant_override.exists() or publish_grant_override.is_symlink():
|
|
if publish_grant_override.is_symlink() or not publish_grant_override.is_file():
|
|
die("installed Engine data product publish grant override is unsafe")
|
|
try:
|
|
publish_grant_override_text = publish_grant_override.read_text(encoding="utf-8")
|
|
except (OSError, UnicodeDecodeError):
|
|
die("installed Engine data product publish grant override cannot be read")
|
|
if publish_grant_override_text != expected_engine_data_product_publish_grant_override():
|
|
die("installed Engine data product publish grant override drift detected")
|
|
# Canonical order is immutable: base, active L2 extension, credential
|
|
# sink, then the additive Publish grant overlay.
|
|
files.append(publish_grant_override)
|
|
read_grant_override = root / ENGINE_DATA_PRODUCT_READ_GRANT_OVERRIDE_REL
|
|
if read_grant_override.exists() or read_grant_override.is_symlink():
|
|
if read_grant_override.is_symlink() or not read_grant_override.is_file():
|
|
die("installed Engine data product read grant override is unsafe")
|
|
try:
|
|
read_grant_override_text = read_grant_override.read_text(encoding="utf-8")
|
|
except (OSError, UnicodeDecodeError):
|
|
die("installed Engine data product read grant override cannot be read")
|
|
if read_grant_override_text != expected_engine_data_product_read_grant_override():
|
|
die("installed Engine data product read grant override drift detected")
|
|
# Read authority is additive and ordered after the writer/private-key overlay.
|
|
files.append(read_grant_override)
|
|
node_intelligence_descriptor = current_engine_node_intelligence_descriptor()
|
|
if (
|
|
node_intelligence_descriptor is not None
|
|
and node_intelligence_descriptor["action"] == "activate"
|
|
):
|
|
validate_installed_engine_node_intelligence_source(
|
|
node_intelligence_descriptor
|
|
)
|
|
files.append(root / ENGINE_NODE_INTELLIGENCE_OVERRIDE_REL)
|
|
return tuple(files)
|
|
files = COMPONENTS[component].get("compose_files", ())
|
|
if component == "platform":
|
|
# The EDP overlay is optional until its first dedicated artifact lands.
|
|
# Once installed (or after a candidate has been copied), it participates
|
|
# in every authoritative Compose operation.
|
|
files = tuple(
|
|
path
|
|
for path in files
|
|
if path.name != Path(PLATFORM_EXTERNAL_DATA_PLANE_COMPOSE_REL).name or path.is_file()
|
|
)
|
|
return 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 = touches_external_data_plane_files(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 inspect_local_image(image_ref, label):
|
|
images = docker_json(["image", "inspect", image_ref], f"{label} inspect")
|
|
if not isinstance(images, list) or len(images) != 1 or not isinstance(images[0], dict):
|
|
die(f"{label} 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)
|
|
or image.get("Architecture") != "amd64"
|
|
or image.get("Os") != "linux"):
|
|
die(f"{label} identity/platform mismatch")
|
|
return image_id
|
|
|
|
|
|
def inspect_optional_local_image(image_ref, label):
|
|
result = subprocess.run(
|
|
[str(DOCKER), "image", "inspect", image_ref],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
if result.returncode != 0:
|
|
return None
|
|
try:
|
|
images = json.loads(result.stdout)
|
|
except json.JSONDecodeError:
|
|
die(f"{label} returned invalid JSON")
|
|
if not isinstance(images, list) or len(images) != 1 or not isinstance(images[0], dict):
|
|
die(f"{label} 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)
|
|
or image.get("Architecture") != "amd64"
|
|
or image.get("Os") != "linux"):
|
|
die(f"{label} identity/platform mismatch")
|
|
return image_id
|
|
|
|
|
|
def ensure_engine_node_intelligence_secret():
|
|
try:
|
|
runtime_stat = ENGINE_NODE_INTELLIGENCE_RUNTIME_DIR.lstat()
|
|
except FileNotFoundError:
|
|
ENGINE_NODE_INTELLIGENCE_RUNTIME_DIR.mkdir(parents=False, exist_ok=False)
|
|
runtime_stat = ENGINE_NODE_INTELLIGENCE_RUNTIME_DIR.lstat()
|
|
if stat.S_ISLNK(runtime_stat.st_mode) or not stat.S_ISDIR(runtime_stat.st_mode):
|
|
die("Engine node-intelligence runtime directory is unsafe")
|
|
os.chown(
|
|
ENGINE_NODE_INTELLIGENCE_RUNTIME_DIR,
|
|
0,
|
|
ENGINE_NODE_INTELLIGENCE_RUNTIME_GID,
|
|
)
|
|
ENGINE_NODE_INTELLIGENCE_RUNTIME_DIR.chmod(0o710)
|
|
|
|
try:
|
|
secret_stat = ENGINE_NODE_INTELLIGENCE_SECRET_FILE.lstat()
|
|
except FileNotFoundError:
|
|
value = secrets.token_urlsafe(48)
|
|
temporary = ENGINE_NODE_INTELLIGENCE_RUNTIME_DIR / (
|
|
f".{ENGINE_NODE_INTELLIGENCE_SECRET_FILE.name}."
|
|
f"{os.getpid()}.{time.time_ns()}.tmp"
|
|
)
|
|
descriptor = None
|
|
try:
|
|
descriptor = os.open(
|
|
str(temporary),
|
|
os.O_WRONLY | os.O_CREAT | os.O_EXCL,
|
|
0o440,
|
|
)
|
|
os.write(descriptor, f"{value}\n".encode("ascii"))
|
|
os.fsync(descriptor)
|
|
os.fchown(descriptor, 0, ENGINE_NODE_INTELLIGENCE_RUNTIME_GID)
|
|
os.fchmod(descriptor, 0o440)
|
|
os.close(descriptor)
|
|
descriptor = None
|
|
os.replace(temporary, ENGINE_NODE_INTELLIGENCE_SECRET_FILE)
|
|
fsync_directory(ENGINE_NODE_INTELLIGENCE_RUNTIME_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)
|
|
or secret_stat.st_uid != 0
|
|
or secret_stat.st_gid != ENGINE_NODE_INTELLIGENCE_RUNTIME_GID
|
|
or stat.S_IMODE(secret_stat.st_mode) != 0o440
|
|
or secret_stat.st_size < 49
|
|
or secret_stat.st_size > 512
|
|
):
|
|
die("Engine node-intelligence secret file is unsafe")
|
|
try:
|
|
value = ENGINE_NODE_INTELLIGENCE_SECRET_FILE.read_text(
|
|
encoding="ascii"
|
|
).strip()
|
|
except (OSError, UnicodeDecodeError):
|
|
die("Engine node-intelligence secret file is unreadable")
|
|
if not ENGINE_NODE_INTELLIGENCE_SECRET_RE.fullmatch(value):
|
|
die("Engine node-intelligence secret file has invalid format")
|
|
return "reused"
|
|
|
|
|
|
def inspect_engine_node_intelligence_image(descriptor, required=True):
|
|
result = subprocess.run(
|
|
[str(DOCKER), "image", "inspect", ENGINE_NODE_INTELLIGENCE_IMAGE],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
if result.returncode != 0:
|
|
if not required:
|
|
return None
|
|
die("Engine node-intelligence image is missing")
|
|
try:
|
|
images = json.loads(result.stdout)
|
|
except json.JSONDecodeError:
|
|
die("Engine node-intelligence image inspect returned invalid JSON")
|
|
if not isinstance(images, list) or len(images) != 1 or not isinstance(images[0], dict):
|
|
die("Engine node-intelligence image inspect shape mismatch")
|
|
image = images[0]
|
|
image_id = image.get("Id")
|
|
config = image.get("Config") or {}
|
|
labels = config.get("Labels") or {}
|
|
repo_tags = image.get("RepoTags") or []
|
|
if (
|
|
not isinstance(image_id, str)
|
|
or not re.fullmatch(r"sha256:[a-f0-9]{64}", image_id)
|
|
or image.get("Architecture") != descriptor["image"]["architecture"]
|
|
or image.get("Os") != descriptor["image"]["os"]
|
|
or ENGINE_NODE_INTELLIGENCE_IMAGE not in repo_tags
|
|
or not isinstance(labels, dict)
|
|
or labels.get("org.opencontainers.image.revision")
|
|
!= ENGINE_NODE_INTELLIGENCE_UPSTREAM_COMMIT
|
|
or config.get("Entrypoint") != ["/usr/local/bin/docker-entrypoint.sh"]
|
|
or config.get("Cmd") != ["node", "dist/mcp/index.js"]
|
|
):
|
|
die("Engine node-intelligence image identity mismatch")
|
|
env = config.get("Env") or []
|
|
if any(
|
|
str(value).startswith(("AUTH_TOKEN=", "N8N_API_URL=", "N8N_API_KEY="))
|
|
for value in env
|
|
):
|
|
die("Engine node-intelligence image contains runtime authority")
|
|
return image
|
|
|
|
|
|
def install_engine_node_intelligence_image(descriptor):
|
|
if descriptor.get("action") != "activate":
|
|
die("Engine node-intelligence image install requires activation")
|
|
source_archive = (
|
|
component_root("engine") / ENGINE_NODE_INTELLIGENCE_IMAGE_ARCHIVE_REL
|
|
)
|
|
validate_engine_node_intelligence_image_archive(source_archive, descriptor)
|
|
|
|
try:
|
|
releases_stat = ENGINE_NODE_INTELLIGENCE_RELEASES_DIR.lstat()
|
|
except FileNotFoundError:
|
|
ENGINE_NODE_INTELLIGENCE_RELEASES_DIR.mkdir(parents=False, exist_ok=False)
|
|
releases_stat = ENGINE_NODE_INTELLIGENCE_RELEASES_DIR.lstat()
|
|
if stat.S_ISLNK(releases_stat.st_mode) or not stat.S_ISDIR(releases_stat.st_mode):
|
|
die("Engine node-intelligence releases directory is unsafe")
|
|
os.chown(ENGINE_NODE_INTELLIGENCE_RELEASES_DIR, 0, 0)
|
|
ENGINE_NODE_INTELLIGENCE_RELEASES_DIR.chmod(0o500)
|
|
|
|
release_dir = (
|
|
ENGINE_NODE_INTELLIGENCE_RELEASES_DIR / descriptor["releaseId"]
|
|
)
|
|
try:
|
|
release_stat = release_dir.lstat()
|
|
except FileNotFoundError:
|
|
ENGINE_NODE_INTELLIGENCE_RELEASES_DIR.chmod(0o700)
|
|
try:
|
|
release_dir.mkdir(mode=0o500, exist_ok=False)
|
|
finally:
|
|
ENGINE_NODE_INTELLIGENCE_RELEASES_DIR.chmod(0o500)
|
|
release_stat = release_dir.lstat()
|
|
if (
|
|
stat.S_ISLNK(release_stat.st_mode)
|
|
or not stat.S_ISDIR(release_stat.st_mode)
|
|
or release_stat.st_uid != 0
|
|
):
|
|
die("Engine node-intelligence release directory is unsafe")
|
|
os.chown(release_dir, 0, 0)
|
|
release_dir.chmod(0o500)
|
|
|
|
release_archive = release_dir / "engine-node-intelligence.tar"
|
|
if release_archive.exists() or release_archive.is_symlink():
|
|
release_archive_stat = release_archive.lstat()
|
|
if (
|
|
stat.S_ISLNK(release_archive_stat.st_mode)
|
|
or not stat.S_ISREG(release_archive_stat.st_mode)
|
|
or release_archive_stat.st_uid != 0
|
|
or release_archive_stat.st_gid != 0
|
|
or stat.S_IMODE(release_archive_stat.st_mode) != 0o400
|
|
or sha256_file(release_archive) != descriptor["image"]["archiveSha256"]
|
|
):
|
|
die("Engine node-intelligence sealed release collision")
|
|
else:
|
|
release_dir.chmod(0o700)
|
|
temporary = release_dir / (
|
|
f".engine-node-intelligence.tar.{os.getpid()}.{time.time_ns()}.tmp"
|
|
)
|
|
try:
|
|
with source_archive.open("rb") as source, temporary.open("xb") as output:
|
|
shutil.copyfileobj(source, output, 1024 * 1024)
|
|
output.flush()
|
|
os.fsync(output.fileno())
|
|
os.chown(temporary, 0, 0)
|
|
temporary.chmod(0o400)
|
|
if sha256_file(temporary) != descriptor["image"]["archiveSha256"]:
|
|
die("Engine node-intelligence sealed release copy mismatch")
|
|
os.replace(temporary, release_archive)
|
|
fsync_directory(release_dir)
|
|
finally:
|
|
if temporary.exists():
|
|
temporary.unlink()
|
|
release_dir.chmod(0o500)
|
|
|
|
load = subprocess.run(
|
|
[str(DOCKER), "image", "load", "--input", str(release_archive)],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=600,
|
|
)
|
|
if load.returncode != 0:
|
|
die("Engine node-intelligence offline image load failed")
|
|
image = inspect_engine_node_intelligence_image(descriptor)
|
|
return image["Id"]
|
|
|
|
|
|
def engine_backend_container_id():
|
|
result = subprocess.run(
|
|
[*compose_base_cmd("engine"), "ps", "-q", "nodedc-backend"],
|
|
cwd=str(component_compose_root("engine")),
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
container_ids = [line.strip() for line in result.stdout.splitlines() if line.strip()]
|
|
if (result.returncode != 0
|
|
or len(container_ids) != 1
|
|
or not re.fullmatch(r"[a-f0-9]{12,64}", container_ids[0])):
|
|
die("Engine backend topology mismatch")
|
|
return container_ids[0]
|
|
|
|
|
|
def run_engine_backend_probe(arguments, label, container_id=None, image_ref=None):
|
|
if (container_id is None) == (image_ref is None):
|
|
die(f"{label} probe target mismatch")
|
|
if container_id is not None:
|
|
cmd = [str(DOCKER), "exec", container_id, *arguments]
|
|
else:
|
|
cmd = [
|
|
str(DOCKER), "run", "--rm", "--network", "none",
|
|
"--tmpfs", "/tmp:mode=1777", "--env", "HOME=/tmp",
|
|
image_ref, *arguments,
|
|
]
|
|
result = subprocess.run(
|
|
cmd,
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=90,
|
|
)
|
|
value = result.stdout.strip()
|
|
if (result.returncode != 0
|
|
or result.stderr.strip()
|
|
or not value
|
|
or len(value) > 512
|
|
or any(ord(character) < 0x20 and character not in "\t" for character in value)):
|
|
die(f"Engine backend {label} probe failed")
|
|
return value
|
|
|
|
|
|
def engine_backend_tool_versions(container_id=None, image_ref=None):
|
|
versions = {
|
|
"node": run_engine_backend_probe(("node", "--version"), "node", container_id, image_ref),
|
|
"sqlite": run_engine_backend_probe(("sqlite3", "--version"), "sqlite", container_id, image_ref),
|
|
"docker": run_engine_backend_probe(("docker", "--version"), "docker", container_id, image_ref),
|
|
"compose": run_engine_backend_probe(
|
|
("docker", "compose", "version"), "Docker Compose", container_id, image_ref
|
|
),
|
|
}
|
|
if (not re.fullmatch(r"v20\.[0-9]+\.[0-9]+", versions["node"])
|
|
or not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+(?:\s+.*)?", versions["sqlite"])
|
|
or not re.fullmatch(r"Docker version [0-9]+\.[0-9]+\.[0-9]+, build [A-Za-z0-9._-]+", versions["docker"])
|
|
or not re.fullmatch(r"Docker Compose version v?[0-9]+\.[0-9]+\.[0-9]+(?:[-+][A-Za-z0-9._-]+)?", versions["compose"])):
|
|
die("Engine backend runtime tool version format mismatch")
|
|
return versions
|
|
|
|
|
|
def validate_engine_backend_mounts(container, node_modules_read_only):
|
|
expected = {
|
|
"/app": ("bind", True),
|
|
"/app/deploy/docker-compose.yml": ("bind", False),
|
|
"/seed-api": ("bind", False),
|
|
"/seed-data": ("bind", False),
|
|
"/app/node_modules": ("bind", not node_modules_read_only),
|
|
"/app/server/data": ("bind", True),
|
|
"/app/server/storage": ("bind", True),
|
|
"/app/server/logs": ("bind", True),
|
|
"/var/run/docker.sock": ("bind", True),
|
|
}
|
|
mounts = container.get("Mounts")
|
|
if not isinstance(mounts, list):
|
|
die("Engine backend mount inventory is missing")
|
|
actual = {}
|
|
node_modules_source = None
|
|
for mount in mounts:
|
|
if not isinstance(mount, dict) or not isinstance(mount.get("Destination"), str):
|
|
die("Engine backend mount inventory is invalid")
|
|
destination = mount["Destination"]
|
|
if destination in actual:
|
|
die(f"Engine backend duplicate mount destination: {destination}")
|
|
actual[destination] = (mount.get("Type"), bool(mount.get("RW")))
|
|
if destination == "/app/node_modules":
|
|
node_modules_source = mount.get("Source")
|
|
if actual != expected:
|
|
die("Engine backend mount barrier mismatch")
|
|
if (node_modules_source != str(ENGINE_CREDENTIAL_BACKEND_NODE_MODULES_DIR)
|
|
or not ENGINE_CREDENTIAL_BACKEND_NODE_MODULES_DIR.is_absolute()):
|
|
die("Engine backend node_modules mount source mismatch")
|
|
|
|
|
|
def validate_engine_backend_mounts_for_installed_runtime(
|
|
container,
|
|
node_modules_read_only,
|
|
):
|
|
publish_override = (
|
|
component_root("engine")
|
|
/ ENGINE_DATA_PRODUCT_PUBLISH_GRANT_OVERRIDE_REL
|
|
)
|
|
publish_installed = publish_override.exists() or publish_override.is_symlink()
|
|
if not publish_installed:
|
|
# The legacy credential runtime remains byte-for-byte authoritative
|
|
# until the exact Publish overlay has been installed.
|
|
return validate_engine_backend_mounts(container, node_modules_read_only)
|
|
|
|
if publish_override.is_symlink() or not publish_override.is_file():
|
|
die("installed Engine data product publish grant override is unsafe")
|
|
try:
|
|
publish_override_text = publish_override.read_text(encoding="utf-8")
|
|
except (OSError, UnicodeDecodeError):
|
|
die("installed Engine data product publish grant override cannot be read")
|
|
if publish_override_text != expected_engine_data_product_publish_grant_override():
|
|
die("installed Engine data product publish grant override drift detected")
|
|
|
|
expected_publish_mounts = {
|
|
ENGINE_PUBLISH_GRANT_CONTAINER_PATH: (
|
|
"bind",
|
|
True,
|
|
str(ENGINE_PUBLISH_GRANT_STATE_PATH),
|
|
),
|
|
ENGINE_EDP_PRIVATE_KEY_CONTAINER_PATH: (
|
|
"bind",
|
|
False,
|
|
str(ENGINE_EDP_MANAGED_PROVISIONER_PRIVATE_KEY_FILE),
|
|
),
|
|
}
|
|
read_override = component_root("engine") / ENGINE_DATA_PRODUCT_READ_GRANT_OVERRIDE_REL
|
|
read_installed = read_override.exists() or read_override.is_symlink()
|
|
if read_installed:
|
|
if read_override.is_symlink() or not read_override.is_file():
|
|
die("installed Engine data product read grant override is unsafe")
|
|
try:
|
|
read_override_text = read_override.read_text(encoding="utf-8")
|
|
except (OSError, UnicodeDecodeError):
|
|
die("installed Engine data product read grant override cannot be read")
|
|
if read_override_text != expected_engine_data_product_read_grant_override():
|
|
die("installed Engine data product read grant override drift detected")
|
|
expected_publish_mounts[ENGINE_READ_GRANT_CONTAINER_PATH] = (
|
|
"bind",
|
|
True,
|
|
str(ENGINE_READ_GRANT_STATE_PATH),
|
|
)
|
|
mounts = container.get("Mounts")
|
|
if not isinstance(mounts, list):
|
|
die("Engine backend mount inventory is missing")
|
|
baseline_mounts = []
|
|
observed_publish_mounts = set()
|
|
for mount in mounts:
|
|
if not isinstance(mount, dict) or not isinstance(mount.get("Destination"), str):
|
|
die("Engine backend mount inventory is invalid")
|
|
destination = mount["Destination"]
|
|
expected = expected_publish_mounts.get(destination)
|
|
if expected is None:
|
|
baseline_mounts.append(mount)
|
|
continue
|
|
if destination in observed_publish_mounts:
|
|
die(f"Engine backend duplicate mount destination: {destination}")
|
|
actual = (
|
|
mount.get("Type"),
|
|
bool(mount.get("RW")),
|
|
mount.get("Source"),
|
|
)
|
|
if actual != expected:
|
|
die(f"Engine backend Publish mount barrier mismatch: {destination}")
|
|
observed_publish_mounts.add(destination)
|
|
if observed_publish_mounts != set(expected_publish_mounts):
|
|
die("Engine backend Publish mount inventory mismatch")
|
|
|
|
projected = dict(container)
|
|
projected["Mounts"] = baseline_mounts
|
|
return validate_engine_backend_mounts(projected, node_modules_read_only)
|
|
|
|
|
|
def validate_engine_backend_node_intelligence_mounts_for_installed_runtime(
|
|
container,
|
|
node_modules_read_only,
|
|
):
|
|
descriptor = current_engine_node_intelligence_descriptor()
|
|
active = descriptor is not None and descriptor["action"] == "activate"
|
|
if not active:
|
|
# The pre-existing runtime guard remains authoritative when this
|
|
# independent overlay is inactive. Any stray secret mount therefore
|
|
# still fails its exact baseline inventory.
|
|
return validate_engine_backend_mounts_for_installed_runtime(
|
|
container,
|
|
node_modules_read_only,
|
|
)
|
|
|
|
validate_installed_engine_node_intelligence_source(descriptor)
|
|
mounts = container.get("Mounts")
|
|
if not isinstance(mounts, list):
|
|
die("Engine backend mount inventory is missing")
|
|
baseline_mounts = []
|
|
observed = 0
|
|
for mount in mounts:
|
|
if not isinstance(mount, dict) or not isinstance(mount.get("Destination"), str):
|
|
die("Engine backend mount inventory is invalid")
|
|
if mount["Destination"] != ENGINE_NODE_INTELLIGENCE_SECRET_CONTAINER_PATH:
|
|
baseline_mounts.append(mount)
|
|
continue
|
|
observed += 1
|
|
actual = (
|
|
mount.get("Type"),
|
|
bool(mount.get("RW")),
|
|
mount.get("Source"),
|
|
)
|
|
expected = (
|
|
"bind",
|
|
False,
|
|
str(ENGINE_NODE_INTELLIGENCE_SECRET_FILE),
|
|
)
|
|
if observed != 1 or actual != expected:
|
|
die("Engine backend node-intelligence mount barrier mismatch")
|
|
if observed != 1:
|
|
die("Engine backend node-intelligence mount inventory mismatch")
|
|
|
|
# Remove only the independently proven additive mount, then delegate the
|
|
# remaining inventory to the existing Publish-aware guard and ultimately
|
|
# the byte-protected legacy baseline validator.
|
|
projected = dict(container)
|
|
projected["Mounts"] = baseline_mounts
|
|
return validate_engine_backend_mounts_for_installed_runtime(
|
|
projected,
|
|
node_modules_read_only,
|
|
)
|
|
|
|
|
|
def validate_engine_backend_dependencies(container_id):
|
|
package_path = component_root("engine") / "nodedc-source/package.json"
|
|
try:
|
|
package_stat = package_path.lstat()
|
|
except FileNotFoundError:
|
|
die("Engine backend package.json is missing")
|
|
if stat.S_ISLNK(package_stat.st_mode) or not stat.S_ISREG(package_stat.st_mode):
|
|
die("Engine backend package.json is unsafe")
|
|
package = read_strict_json(package_path, "Engine backend package.json", max_bytes=2 * 1024 * 1024)
|
|
dependencies = package.get("dependencies")
|
|
if not isinstance(dependencies, dict) or not dependencies:
|
|
die("Engine backend dependency manifest is invalid")
|
|
names = sorted(dependencies)
|
|
if any(not re.fullmatch(r"(?:@[a-z0-9._-]+/)?[a-z0-9._-]+", name) for name in names):
|
|
die("Engine backend dependency name is unsafe")
|
|
script = (
|
|
"const names=" + json.dumps(names, separators=(",", ":"))
|
|
+ ";for(const name of names)require.resolve(name,{paths:['/app']});"
|
|
+ "process.stdout.write('dependencies-ok')"
|
|
)
|
|
result = subprocess.run(
|
|
[str(DOCKER), "exec", "--workdir", "/app", container_id, "node", "-e", script],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=120,
|
|
)
|
|
if result.returncode != 0 or result.stderr.strip() or result.stdout.strip() != "dependencies-ok":
|
|
die("Engine backend mounted dependency barrier mismatch")
|
|
|
|
|
|
def engine_backend_node_modules_tree_sha256(container_id):
|
|
script = """
|
|
const crypto=require('crypto');
|
|
const fs=require('fs');
|
|
const path=require('path');
|
|
const root='/app/node_modules';
|
|
const hash=crypto.createHash('sha256');
|
|
const lock=JSON.parse(fs.readFileSync('/app/package-lock.json','utf8'));
|
|
if(lock.lockfileVersion!==3||!lock.packages||typeof lock.packages!=='object')throw new Error('lock-invalid');
|
|
const expected=new Map(Object.entries(lock.packages).filter(([key,value])=>key.startsWith('node_modules/')&&value&&typeof value==='object'));
|
|
function secure(stat,label,{symlink=false}={}){
|
|
if(stat.uid!==0||stat.gid!==0)throw new Error('owner:'+label);
|
|
if(!symlink&&(stat.mode&0o022)!==0)throw new Error('writable:'+label);
|
|
}
|
|
secure(fs.lstatSync(root),'root');
|
|
function packageVersion(packageRoot,key){
|
|
const manifest=JSON.parse(fs.readFileSync(path.join(packageRoot,'package.json'),'utf8'));
|
|
const wanted=expected.get(key);
|
|
if(!wanted||String(manifest.version||'')!==String(wanted.version||''))throw new Error('package-version:'+key);
|
|
}
|
|
function inspectPackageDirectory(nodeModulesRoot,relativePrefix){
|
|
const names=fs.readdirSync(nodeModulesRoot).sort((a,b)=>Buffer.from(a).compare(Buffer.from(b)));
|
|
const roots=[];
|
|
for(const name of names){
|
|
if(name==='.bin'||name==='.package-lock.json')continue;
|
|
const candidate=path.join(nodeModulesRoot,name);
|
|
const stat=fs.lstatSync(candidate);
|
|
if(name.startsWith('@')){
|
|
if(!stat.isDirectory())throw new Error('scope-not-directory');
|
|
for(const child of fs.readdirSync(candidate).sort((a,b)=>Buffer.from(a).compare(Buffer.from(b)))){
|
|
roots.push([path.join(candidate,child),relativePrefix+name+'/'+child]);
|
|
}
|
|
}else{
|
|
roots.push([candidate,relativePrefix+name]);
|
|
}
|
|
}
|
|
for(const [packageRoot,key] of roots){
|
|
const stat=fs.lstatSync(packageRoot);
|
|
if(!stat.isDirectory()||stat.isSymbolicLink())throw new Error('package-boundary:'+key);
|
|
packageVersion(packageRoot,key);
|
|
const nested=path.join(packageRoot,'node_modules');
|
|
if(fs.existsSync(nested))inspectPackageDirectory(nested,key+'/node_modules/');
|
|
}
|
|
}
|
|
inspectPackageDirectory(root,'node_modules/');
|
|
for(const [key,value] of expected){
|
|
const target=path.join('/app',key);
|
|
if(!fs.existsSync(target)&&value.optional!==true)throw new Error('package-missing:'+key);
|
|
if(fs.existsSync(target))packageVersion(target,key);
|
|
}
|
|
function visit(relative){
|
|
const absolute=path.join(root,relative);
|
|
const names=fs.readdirSync(absolute).sort((a,b)=>Buffer.from(a).compare(Buffer.from(b)));
|
|
for(const name of names){
|
|
if(name.includes('\\0')||name==='.'||name==='..')throw new Error('unsafe-name');
|
|
const child=relative?relative+'/'+name:name;
|
|
const target=path.join(root,child);
|
|
const stat=fs.lstatSync(target);
|
|
const mode=(stat.mode&0o7777).toString(8);
|
|
if(stat.isDirectory()){
|
|
secure(stat,child);
|
|
hash.update('d\\0'+child+'\\0'+mode+'\\0');
|
|
visit(child);
|
|
}else if(stat.isFile()){
|
|
secure(stat,child);
|
|
hash.update('f\\0'+child+'\\0'+mode+'\\0'+stat.size+'\\0');
|
|
hash.update(fs.readFileSync(target));
|
|
}else if(stat.isSymbolicLink()){
|
|
secure(stat,child,{symlink:true});
|
|
const link=fs.readlinkSync(target);
|
|
if(path.isAbsolute(link))throw new Error('absolute-link:'+child);
|
|
const resolved=path.resolve(path.dirname(target),link);
|
|
if(resolved!==root&&!resolved.startsWith(root+'/'))throw new Error('escaping-link:'+child);
|
|
const real=fs.realpathSync(target);
|
|
if(real!==root&&!real.startsWith(root+'/'))throw new Error('escaping-real-link:'+child);
|
|
hash.update('l\\0'+child+'\\0'+mode+'\\0'+link+'\\0');
|
|
}else{
|
|
throw new Error('special-file');
|
|
}
|
|
}
|
|
}
|
|
visit('');
|
|
process.stdout.write('sha256:'+hash.digest('hex'));
|
|
""".strip()
|
|
result = subprocess.run(
|
|
[str(DOCKER), "exec", "--workdir", "/app", container_id, "node", "-e", script],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=300,
|
|
)
|
|
value = result.stdout.strip()
|
|
if (result.returncode != 0
|
|
or result.stderr.strip()
|
|
or not re.fullmatch(r"sha256:[a-f0-9]{64}", value)):
|
|
die("Engine backend node_modules tree proof failed")
|
|
return value
|
|
|
|
|
|
def validate_runtime_owned_file(path, mode, label, min_bytes=1, max_bytes=1024 * 1024 * 1024):
|
|
try:
|
|
path_stat = path.lstat()
|
|
except FileNotFoundError:
|
|
die(f"{label} is missing")
|
|
if (stat.S_ISLNK(path_stat.st_mode)
|
|
or not stat.S_ISREG(path_stat.st_mode)
|
|
or path_stat.st_uid != 0
|
|
or path_stat.st_gid != 0
|
|
or stat.S_IMODE(path_stat.st_mode) != mode
|
|
or path_stat.st_size < min_bytes
|
|
or path_stat.st_size > max_bytes):
|
|
die(f"{label} is unsafe")
|
|
return path_stat
|
|
|
|
|
|
def validate_engine_backend_runtime_override_file():
|
|
validate_runtime_owned_file(
|
|
ENGINE_CREDENTIAL_BACKEND_OVERRIDE_FILE,
|
|
0o600,
|
|
"Engine backend immutable runtime override",
|
|
max_bytes=64 * 1024,
|
|
)
|
|
try:
|
|
value = ENGINE_CREDENTIAL_BACKEND_OVERRIDE_FILE.read_text(encoding="utf-8")
|
|
except (OSError, UnicodeDecodeError):
|
|
die("Engine backend immutable runtime override is unreadable")
|
|
if value != expected_engine_credential_backend_override():
|
|
die("Engine backend immutable runtime override drift detected")
|
|
return sha256_file(ENGINE_CREDENTIAL_BACKEND_OVERRIDE_FILE)
|
|
|
|
|
|
def validate_engine_backend_activation_marker():
|
|
validate_runtime_owned_file(
|
|
ENGINE_CREDENTIAL_BACKEND_ACTIVATION_FILE,
|
|
0o600,
|
|
"Engine backend immutable runtime activation marker",
|
|
max_bytes=128,
|
|
)
|
|
validate_engine_backend_runtime_override_file()
|
|
metadata = read_engine_backend_runtime_metadata()
|
|
validate_runtime_owned_file(
|
|
ENGINE_CREDENTIAL_BACKEND_ROOTFS_FILE,
|
|
0o600,
|
|
"Engine backend canonical rootfs",
|
|
max_bytes=MAX_PAYLOAD_BYTES,
|
|
)
|
|
expected = f"sha256:{sha256_file(ENGINE_CREDENTIAL_BACKEND_METADATA_FILE)}\n"
|
|
try:
|
|
actual = ENGINE_CREDENTIAL_BACKEND_ACTIVATION_FILE.read_text(encoding="ascii")
|
|
except (OSError, UnicodeDecodeError):
|
|
die("Engine backend immutable runtime activation marker is unreadable")
|
|
if (actual != expected
|
|
or sha256_file(ENGINE_CREDENTIAL_BACKEND_ROOTFS_FILE) != metadata["rootfsSha256"]):
|
|
die("Engine backend immutable runtime activation marker mismatch")
|
|
inspect_engine_backend_derived_image(metadata)
|
|
return metadata
|
|
|
|
|
|
def quarantine_engine_backend_partial_runtime():
|
|
runtime_exists = (
|
|
ENGINE_CREDENTIAL_BACKEND_RUNTIME_DIR.exists()
|
|
or ENGINE_CREDENTIAL_BACKEND_RUNTIME_DIR.is_symlink()
|
|
)
|
|
if not runtime_exists:
|
|
return "absent"
|
|
ensure_root_runtime_directory(ENGINE_CREDENTIAL_BACKEND_RUNTIME_DIR)
|
|
present = [
|
|
path for path in (
|
|
ENGINE_CREDENTIAL_BACKEND_ACTIVATION_FILE,
|
|
ENGINE_CREDENTIAL_BACKEND_OVERRIDE_FILE,
|
|
ENGINE_CREDENTIAL_BACKEND_METADATA_FILE,
|
|
ENGINE_CREDENTIAL_BACKEND_ROOTFS_FILE,
|
|
)
|
|
if path.exists() or path.is_symlink()
|
|
]
|
|
if not present:
|
|
return "empty"
|
|
failed_root = ENGINE_CREDENTIAL_BACKEND_RUNTIME_DIR / "failed"
|
|
ensure_root_runtime_directory(failed_root)
|
|
quarantine = failed_root / f"{stamp()}-{os.getpid()}-{time.time_ns()}"
|
|
quarantine.mkdir(mode=0o700)
|
|
os.chown(quarantine, 0, 0)
|
|
# Move the activation marker first. Once this succeeds, no subsequent
|
|
# Engine Compose command can consume the incomplete override.
|
|
ordered = sorted(
|
|
present,
|
|
key=lambda path: path != ENGINE_CREDENTIAL_BACKEND_ACTIVATION_FILE,
|
|
)
|
|
for path in ordered:
|
|
os.replace(path, quarantine / path.name)
|
|
fsync_directory(ENGINE_CREDENTIAL_BACKEND_RUNTIME_DIR)
|
|
fsync_directory(quarantine)
|
|
return "quarantined"
|
|
|
|
|
|
def read_engine_backend_runtime_metadata():
|
|
validate_runtime_owned_file(
|
|
ENGINE_CREDENTIAL_BACKEND_METADATA_FILE,
|
|
0o600,
|
|
"Engine backend immutable runtime metadata",
|
|
max_bytes=64 * 1024,
|
|
)
|
|
value = read_strict_json(
|
|
ENGINE_CREDENTIAL_BACKEND_METADATA_FILE,
|
|
"Engine backend immutable runtime metadata",
|
|
max_bytes=64 * 1024,
|
|
)
|
|
expected_keys = {
|
|
"schemaVersion", "release", "sourceContainerId", "sourceImageId",
|
|
"derivedImageId", "rootfsSha256", "packageLockSha256",
|
|
"nodeModulesTreeSha256", "overrideSha256", "tools",
|
|
}
|
|
if (not isinstance(value, dict)
|
|
or set(value) != expected_keys
|
|
or value.get("schemaVersion") != "nodedc.engine-backend-immutable-runtime/v1"
|
|
or value.get("release") != "credential-sink-20260716-001"
|
|
or not re.fullmatch(r"[a-f0-9]{12,64}", str(value.get("sourceContainerId")))
|
|
or not re.fullmatch(r"sha256:[a-f0-9]{64}", str(value.get("sourceImageId")))
|
|
or not re.fullmatch(r"sha256:[a-f0-9]{64}", str(value.get("derivedImageId")))
|
|
or not re.fullmatch(r"[a-f0-9]{64}", str(value.get("rootfsSha256")))
|
|
or value.get("packageLockSha256") != ENGINE_CREDENTIAL_BACKEND_PACKAGE_LOCK_SHA256
|
|
or not re.fullmatch(r"sha256:[a-f0-9]{64}", str(value.get("nodeModulesTreeSha256")))
|
|
or not re.fullmatch(r"[a-f0-9]{64}", str(value.get("overrideSha256")))
|
|
or not isinstance(value.get("tools"), dict)
|
|
or set(value["tools"]) != {"node", "sqlite", "docker", "compose"}):
|
|
die("Engine backend immutable runtime metadata mismatch")
|
|
return value
|
|
|
|
|
|
def inspect_engine_backend_derived_image(
|
|
expected_metadata=None,
|
|
image_ref=ENGINE_CREDENTIAL_BACKEND_IMAGE,
|
|
expected_source_image_id=None,
|
|
expected_rootfs_sha256=None,
|
|
):
|
|
images = docker_json(
|
|
["image", "inspect", image_ref],
|
|
"Engine backend immutable image inspect",
|
|
)
|
|
if not isinstance(images, list) or len(images) != 1 or not isinstance(images[0], dict):
|
|
die("Engine backend immutable image inspect shape mismatch")
|
|
image = images[0]
|
|
image_id = image.get("Id")
|
|
labels = (image.get("Config") or {}).get("Labels") or {}
|
|
if (not isinstance(image_id, str)
|
|
or not re.fullmatch(r"sha256:[a-f0-9]{64}", image_id)
|
|
or image.get("Architecture") != "amd64"
|
|
or image.get("Os") != "linux"
|
|
or not isinstance(labels, dict)
|
|
or labels.get("org.nodedc.release") != "credential-sink-20260716-001"
|
|
or labels.get("org.nodedc.package-lock-sha256") != ENGINE_CREDENTIAL_BACKEND_PACKAGE_LOCK_SHA256
|
|
or not re.fullmatch(r"sha256:[a-f0-9]{64}", str(labels.get("org.nodedc.source-image-id")))
|
|
or not re.fullmatch(r"[a-f0-9]{64}", str(labels.get("org.nodedc.rootfs-sha256")))):
|
|
die("Engine backend immutable image identity mismatch")
|
|
if (expected_source_image_id is not None
|
|
and labels["org.nodedc.source-image-id"] != expected_source_image_id):
|
|
die("Engine backend immutable image source collision")
|
|
if (expected_rootfs_sha256 is not None
|
|
and labels["org.nodedc.rootfs-sha256"] != expected_rootfs_sha256):
|
|
die("Engine backend immutable image rootfs collision")
|
|
if expected_metadata is not None and (
|
|
image_id != expected_metadata["derivedImageId"]
|
|
or labels["org.nodedc.source-image-id"] != expected_metadata["sourceImageId"]
|
|
or labels["org.nodedc.rootfs-sha256"] != expected_metadata["rootfsSha256"]
|
|
):
|
|
die("Engine backend immutable image metadata drift detected")
|
|
return image
|
|
|
|
|
|
def ensure_engine_backend_release_image(
|
|
build_context,
|
|
source_image_id,
|
|
rootfs_sha256,
|
|
expected_tools,
|
|
):
|
|
target_id = inspect_optional_local_image(
|
|
ENGINE_CREDENTIAL_BACKEND_IMAGE,
|
|
"Engine backend immutable release image",
|
|
)
|
|
if target_id is not None:
|
|
image = inspect_engine_backend_derived_image(
|
|
image_ref=ENGINE_CREDENTIAL_BACKEND_IMAGE,
|
|
expected_source_image_id=source_image_id,
|
|
expected_rootfs_sha256=rootfs_sha256,
|
|
)
|
|
if (image.get("Id") != target_id
|
|
or engine_backend_tool_versions(
|
|
image_ref=ENGINE_CREDENTIAL_BACKEND_IMAGE,
|
|
) != expected_tools):
|
|
die("Engine backend immutable release image collision")
|
|
return image
|
|
|
|
build_ref = f"nodedc/deploy-build:engine-backend-{rootfs_sha256[:16]}"
|
|
build_id = inspect_optional_local_image(
|
|
build_ref,
|
|
"Engine backend immutable build image",
|
|
)
|
|
if build_id is None:
|
|
build = subprocess.run(
|
|
[
|
|
str(DOCKER), "build", "--no-cache", "--network", "none", "--pull=false",
|
|
"-f", "Dockerfile", "-t", build_ref, ".",
|
|
],
|
|
cwd=str(build_context),
|
|
check=False,
|
|
timeout=600,
|
|
)
|
|
if build.returncode != 0:
|
|
die("Engine backend immutable image build failed")
|
|
build_id = inspect_optional_local_image(
|
|
build_ref,
|
|
"Engine backend immutable built image",
|
|
)
|
|
if build_id is None:
|
|
die("Engine backend immutable built image is missing")
|
|
|
|
build_image = inspect_engine_backend_derived_image(
|
|
image_ref=build_ref,
|
|
expected_source_image_id=source_image_id,
|
|
expected_rootfs_sha256=rootfs_sha256,
|
|
)
|
|
if (build_image.get("Id") != build_id
|
|
or engine_backend_tool_versions(image_ref=build_ref) != expected_tools):
|
|
die("Engine backend immutable build image collision")
|
|
|
|
# The deploy lock serializes canonical applies. Recheck the release tag
|
|
# immediately before assigning it so an existing versioned identity is
|
|
# never intentionally moved to a different image.
|
|
target_id = inspect_optional_local_image(
|
|
ENGINE_CREDENTIAL_BACKEND_IMAGE,
|
|
"Engine backend immutable release image recheck",
|
|
)
|
|
if target_id is not None:
|
|
image = inspect_engine_backend_derived_image(
|
|
image_ref=ENGINE_CREDENTIAL_BACKEND_IMAGE,
|
|
expected_source_image_id=source_image_id,
|
|
expected_rootfs_sha256=rootfs_sha256,
|
|
)
|
|
if image.get("Id") != build_id:
|
|
die("Engine backend immutable release image collision")
|
|
return image
|
|
tag = subprocess.run(
|
|
[str(DOCKER), "image", "tag", build_id, ENGINE_CREDENTIAL_BACKEND_IMAGE],
|
|
check=False,
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
)
|
|
if tag.returncode != 0:
|
|
die("Engine backend immutable release image tag failed")
|
|
image = inspect_engine_backend_derived_image(
|
|
image_ref=ENGINE_CREDENTIAL_BACKEND_IMAGE,
|
|
expected_source_image_id=source_image_id,
|
|
expected_rootfs_sha256=rootfs_sha256,
|
|
)
|
|
if (image.get("Id") != build_id
|
|
or engine_backend_tool_versions(
|
|
image_ref=ENGINE_CREDENTIAL_BACKEND_IMAGE,
|
|
) != expected_tools):
|
|
die("Engine backend immutable release image verification failed")
|
|
return image
|
|
|
|
|
|
def preflight_engine_credential_backend_runtime():
|
|
base_image_id = inspect_local_image(
|
|
ENGINE_CREDENTIAL_BACKEND_BASE_IMAGE,
|
|
"Engine backend base image",
|
|
)
|
|
package_lock = component_root("engine") / "nodedc-source/package-lock.json"
|
|
try:
|
|
lock_stat = package_lock.lstat()
|
|
except FileNotFoundError:
|
|
die("Engine backend package-lock.json is missing")
|
|
if (stat.S_ISLNK(lock_stat.st_mode)
|
|
or not stat.S_ISREG(lock_stat.st_mode)
|
|
or sha256_file(package_lock) != ENGINE_CREDENTIAL_BACKEND_PACKAGE_LOCK_SHA256):
|
|
die("Engine backend package-lock barrier mismatch")
|
|
try:
|
|
node_modules_stat = ENGINE_CREDENTIAL_BACKEND_NODE_MODULES_DIR.lstat()
|
|
except FileNotFoundError:
|
|
die("Engine backend node_modules host source is missing")
|
|
if (stat.S_ISLNK(node_modules_stat.st_mode)
|
|
or not stat.S_ISDIR(node_modules_stat.st_mode)
|
|
or node_modules_stat.st_uid != 0
|
|
or node_modules_stat.st_gid != 0
|
|
or node_modules_stat.st_mode & (stat.S_IWGRP | stat.S_IWOTH)):
|
|
die("Engine backend node_modules host source is unsafe")
|
|
|
|
container_id = engine_backend_container_id()
|
|
containers = docker_json(["container", "inspect", container_id], "Engine backend container inspect")
|
|
if not isinstance(containers, list) or len(containers) != 1 or not isinstance(containers[0], dict):
|
|
die("Engine backend container inspect shape mismatch")
|
|
container = containers[0]
|
|
state = container.get("State") or {}
|
|
started_at = state.get("StartedAt")
|
|
initial_restart_count = int(container.get("RestartCount") or 0)
|
|
if (state.get("Status") != "running"
|
|
or (state.get("Health") or {}).get("Status") != "healthy"
|
|
or not isinstance(started_at, str)
|
|
or not re.fullmatch(r"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9:.]+Z", started_at)):
|
|
die("Engine backend source container is not stable and healthy")
|
|
config_image = (container.get("Config") or {}).get("Image")
|
|
current_image_id = container.get("Image")
|
|
validate_engine_backend_node_intelligence_mounts_for_installed_runtime(
|
|
container,
|
|
node_modules_read_only=config_image == ENGINE_CREDENTIAL_BACKEND_IMAGE,
|
|
)
|
|
if config_image == ENGINE_CREDENTIAL_BACKEND_BASE_IMAGE and current_image_id == base_image_id:
|
|
runtime_presence = {
|
|
"activation": ENGINE_CREDENTIAL_BACKEND_ACTIVATION_FILE.exists()
|
|
or ENGINE_CREDENTIAL_BACKEND_ACTIVATION_FILE.is_symlink(),
|
|
"override": ENGINE_CREDENTIAL_BACKEND_OVERRIDE_FILE.exists()
|
|
or ENGINE_CREDENTIAL_BACKEND_OVERRIDE_FILE.is_symlink(),
|
|
"metadata": ENGINE_CREDENTIAL_BACKEND_METADATA_FILE.exists()
|
|
or ENGINE_CREDENTIAL_BACKEND_METADATA_FILE.is_symlink(),
|
|
"rootfs": ENGINE_CREDENTIAL_BACKEND_ROOTFS_FILE.exists()
|
|
or ENGINE_CREDENTIAL_BACKEND_ROOTFS_FILE.is_symlink(),
|
|
}
|
|
if all(runtime_presence.values()):
|
|
validate_engine_backend_activation_marker()
|
|
override_sha256 = validate_engine_backend_runtime_override_file()
|
|
metadata = read_engine_backend_runtime_metadata()
|
|
rootfs_stat = validate_runtime_owned_file(
|
|
ENGINE_CREDENTIAL_BACKEND_ROOTFS_FILE,
|
|
0o600,
|
|
"Engine backend canonical rootfs",
|
|
max_bytes=MAX_PAYLOAD_BYTES,
|
|
)
|
|
image = inspect_engine_backend_derived_image(metadata)
|
|
if (rootfs_stat.st_size <= 0
|
|
or sha256_file(ENGINE_CREDENTIAL_BACKEND_ROOTFS_FILE) != metadata["rootfsSha256"]
|
|
or override_sha256 != metadata["overrideSha256"]
|
|
or metadata["sourceImageId"] != base_image_id
|
|
or metadata["derivedImageId"] != image.get("Id")):
|
|
die("Engine backend prepared immutable runtime drift detected")
|
|
mode = "verified-prepared-not-active"
|
|
elif any(runtime_presence.values()):
|
|
for name, path in (
|
|
("activation", ENGINE_CREDENTIAL_BACKEND_ACTIVATION_FILE),
|
|
("override", ENGINE_CREDENTIAL_BACKEND_OVERRIDE_FILE),
|
|
("metadata", ENGINE_CREDENTIAL_BACKEND_METADATA_FILE),
|
|
("rootfs", ENGINE_CREDENTIAL_BACKEND_ROOTFS_FILE),
|
|
):
|
|
if runtime_presence[name]:
|
|
validate_runtime_owned_file(
|
|
path,
|
|
0o600,
|
|
f"Engine backend partial runtime {name}",
|
|
max_bytes=MAX_PAYLOAD_BYTES,
|
|
)
|
|
mode = "recoverable-prepared-partial"
|
|
else:
|
|
mode = "verified-running-base"
|
|
source_image_id = base_image_id
|
|
elif config_image == ENGINE_CREDENTIAL_BACKEND_IMAGE:
|
|
override_sha256 = validate_engine_backend_runtime_override_file()
|
|
metadata = read_engine_backend_runtime_metadata()
|
|
image = inspect_engine_backend_derived_image(metadata)
|
|
if current_image_id != image.get("Id") or override_sha256 != metadata["overrideSha256"]:
|
|
die("Engine backend immutable runtime activation drift detected")
|
|
mode = "verified-derived-retry"
|
|
source_image_id = metadata["sourceImageId"]
|
|
else:
|
|
die("Engine backend current image barrier mismatch")
|
|
tools = engine_backend_tool_versions(container_id=container_id)
|
|
validate_engine_backend_dependencies(container_id)
|
|
node_modules_tree_sha256 = engine_backend_node_modules_tree_sha256(container_id)
|
|
if mode in ("verified-derived-retry", "verified-prepared-not-active"):
|
|
if (node_modules_tree_sha256 != metadata["nodeModulesTreeSha256"]
|
|
or tools != metadata["tools"]):
|
|
die("Engine backend prepared runtime source proof drift detected")
|
|
stable = docker_json(["container", "inspect", container_id], "Engine backend stability inspect")
|
|
stable_container = stable[0] if isinstance(stable, list) and len(stable) == 1 else None
|
|
stable_state = stable_container.get("State") if isinstance(stable_container, dict) else None
|
|
if (not isinstance(stable_state, dict)
|
|
or stable_state.get("Status") != "running"
|
|
or (stable_state.get("Health") or {}).get("Status") != "healthy"
|
|
or stable_state.get("StartedAt") != started_at
|
|
or int(stable_container.get("RestartCount") or 0) != initial_restart_count):
|
|
die("Engine backend restarted during immutable runtime preflight")
|
|
return {
|
|
"base_image_id": base_image_id,
|
|
"container_id": container_id,
|
|
"current_image_id": current_image_id,
|
|
"mode": mode,
|
|
"source_image_id": source_image_id,
|
|
"node_modules_tree_sha256": node_modules_tree_sha256,
|
|
"tools": tools,
|
|
}
|
|
|
|
|
|
def normalize_rootfs_member_name(name):
|
|
if not isinstance(name, str) or not name or name.startswith("/") or "\\" in name:
|
|
die("Engine backend export member path is unsafe")
|
|
while name.startswith("./"):
|
|
name = name[2:]
|
|
parts = name.split("/")
|
|
if not name or any(not part or part in (".", "..") for part in parts):
|
|
die("Engine backend export member path is unsafe")
|
|
if any(part.startswith("._") or part == "__MACOSX" for part in parts):
|
|
die("Engine backend export contains AppleDouble metadata")
|
|
return "/".join(parts)
|
|
|
|
|
|
def engine_backend_rootfs_member_allowed(name, is_directory):
|
|
excluded = (
|
|
"etc/hosts", "etc/hostname", "etc/resolv.conf", "etc/mtab",
|
|
"etc/environment", "etc/profile", "etc/profile.d", "etc/ssl/private",
|
|
"etc/ssh/ssh_host", "etc/machine-id", "etc/docker", "etc/containerd",
|
|
"usr/local/etc", "usr/local/lib/node_modules",
|
|
)
|
|
if any(name == value or name.startswith(f"{value}/") for value in excluded):
|
|
return False
|
|
basename = name.rsplit("/", 1)[-1].lower()
|
|
if (basename in (".env", "id_rsa", "id_ed25519")
|
|
or basename.startswith(".env.")
|
|
or basename.endswith((".p12", ".pfx"))):
|
|
die(f"Engine backend export secret-like path rejected: {name}")
|
|
if any(name == root or name.startswith(f"{root}/") for root in ("bin", "sbin", "lib", "usr", "etc")):
|
|
return True
|
|
if name in ("var", "var/lib"):
|
|
return is_directory
|
|
return name == "var/lib/apk" or name.startswith("var/lib/apk/")
|
|
|
|
|
|
def resolve_rootfs_link(name, linkname):
|
|
if not isinstance(linkname, str) or not linkname or "\\" in linkname:
|
|
die(f"Engine backend export link target is unsafe: {name}")
|
|
parts = [] if linkname.startswith("/") else name.split("/")[:-1]
|
|
for part in linkname.lstrip("/").split("/"):
|
|
if part in ("", "."):
|
|
continue
|
|
if part == "..":
|
|
if not parts:
|
|
die(f"Engine backend export link escapes root: {name}")
|
|
parts.pop()
|
|
else:
|
|
parts.append(part)
|
|
target = "/".join(parts)
|
|
if not target or not engine_backend_rootfs_member_allowed(target, False):
|
|
die(f"Engine backend export link leaves safe roots: {name}")
|
|
return linkname
|
|
|
|
|
|
ENGINE_BACKEND_ROOTFS_OMITTED_TOOLCHAIN_LINKS = {
|
|
"usr/local/bin/corepack": re.compile(r"^\.\./lib/node_modules/corepack/dist/corepack\.js$"),
|
|
"usr/local/bin/npm": re.compile(r"^\.\./lib/node_modules/npm/bin/npm-cli\.js$"),
|
|
"usr/local/bin/npx": re.compile(r"^\.\./lib/node_modules/npm/bin/npx-cli\.js$"),
|
|
"usr/local/bin/yarn": re.compile(r"^/opt/yarn-v[0-9]+\.[0-9]+\.[0-9]+/bin/yarn$"),
|
|
"usr/local/bin/yarnpkg": re.compile(r"^/opt/yarn-v[0-9]+\.[0-9]+\.[0-9]+/bin/yarnpkg$"),
|
|
}
|
|
|
|
|
|
def engine_backend_rootfs_toolchain_link_is_omitted(name, linkname):
|
|
expected = ENGINE_BACKEND_ROOTFS_OMITTED_TOOLCHAIN_LINKS.get(name)
|
|
if expected is None:
|
|
return False
|
|
if not isinstance(linkname, str) or not expected.fullmatch(linkname):
|
|
die(f"Engine backend export omitted toolchain link target mismatch: {name}")
|
|
return True
|
|
|
|
|
|
ENGINE_BACKEND_ROOTFS_SECRET_PATTERNS = (
|
|
re.compile(br"-----BEGIN (?:OPENSSH |RSA |EC )?PRIVATE KEY-----"),
|
|
re.compile(br"ndc_(?:edpwb|edprb|fndbg|edppr)_[A-Za-z0-9_-]{8,}"),
|
|
re.compile(br"AKIA[0-9A-Z]{16}"),
|
|
re.compile(br"gh[pousr]_[A-Za-z0-9]{20,}"),
|
|
re.compile(br"xox[baprs]-[A-Za-z0-9-]{20,}"),
|
|
)
|
|
|
|
|
|
def spool_and_scan_rootfs_file(source, name, expected_size):
|
|
if expected_size < 0 or expected_size > MAX_FILE_BYTES:
|
|
die(f"Engine backend export member size rejected: {name}")
|
|
temporary = tempfile.SpooledTemporaryFile(max_size=8 * 1024 * 1024)
|
|
total = 0
|
|
carry = b""
|
|
while True:
|
|
chunk = source.read(1024 * 1024)
|
|
if not chunk:
|
|
break
|
|
total += len(chunk)
|
|
if total > MAX_FILE_BYTES:
|
|
temporary.close()
|
|
die(f"Engine backend export member too large: {name}")
|
|
sample = carry + chunk
|
|
if any(pattern.search(sample) for pattern in ENGINE_BACKEND_ROOTFS_SECRET_PATTERNS):
|
|
temporary.close()
|
|
die(f"Engine backend export secret material rejected: {name}")
|
|
carry = sample[-256:]
|
|
temporary.write(chunk)
|
|
if total != expected_size:
|
|
temporary.close()
|
|
die(f"Engine backend export member size mismatch: {name}")
|
|
temporary.seek(0)
|
|
return temporary
|
|
|
|
|
|
def build_canonical_engine_backend_rootfs(source_tar, destination_tar):
|
|
total_bytes = 0
|
|
with tarfile.open(source_tar, "r:*") as source:
|
|
members = []
|
|
seen = set()
|
|
source_members = {}
|
|
for member in source.getmembers():
|
|
name = normalize_rootfs_member_name(member.name)
|
|
if name in seen:
|
|
die(f"Engine backend export duplicate member: {name}")
|
|
seen.add(name)
|
|
source_members[name] = member
|
|
if not engine_backend_rootfs_member_allowed(name, member.isdir()):
|
|
continue
|
|
if not (member.isdir() or member.isfile() or member.issym() or member.islnk()):
|
|
die(f"Engine backend export special member rejected: {name}")
|
|
if (member.issym()
|
|
and engine_backend_rootfs_toolchain_link_is_omitted(name, member.linkname)):
|
|
continue
|
|
members.append((name, member))
|
|
members.sort(key=lambda item: item[0])
|
|
|
|
with destination_tar.open("xb") as raw_output:
|
|
with tarfile.open(fileobj=raw_output, mode="w", format=tarfile.GNU_FORMAT) as output:
|
|
for name, member in members:
|
|
info = tarfile.TarInfo(name)
|
|
info.uid = member.uid
|
|
info.gid = member.gid
|
|
info.uname = ""
|
|
info.gname = ""
|
|
info.mode = member.mode & 0o7777
|
|
info.mtime = 0
|
|
if member.isdir():
|
|
info.type = tarfile.DIRTYPE
|
|
info.size = 0
|
|
output.addfile(info)
|
|
continue
|
|
if member.issym():
|
|
info.type = tarfile.SYMTYPE
|
|
info.linkname = resolve_rootfs_link(name, member.linkname)
|
|
info.size = 0
|
|
output.addfile(info)
|
|
continue
|
|
if member.islnk():
|
|
target_name = normalize_rootfs_member_name(member.linkname.lstrip("/"))
|
|
target = source_members.get(target_name)
|
|
if (target is None
|
|
or not target.isfile()
|
|
or not engine_backend_rootfs_member_allowed(target_name, False)):
|
|
die(f"Engine backend export hardlink target mismatch: {name}")
|
|
expected_size = target.size
|
|
source_member = target
|
|
else:
|
|
expected_size = member.size
|
|
source_member = member
|
|
source_file = source.extractfile(source_member)
|
|
if source_file is None:
|
|
die(f"Engine backend export member is unreadable: {name}")
|
|
with source_file:
|
|
staged = spool_and_scan_rootfs_file(source_file, name, expected_size)
|
|
with staged:
|
|
info.type = tarfile.REGTYPE
|
|
info.size = expected_size
|
|
output.addfile(info, staged)
|
|
total_bytes += expected_size
|
|
if total_bytes > MAX_PAYLOAD_BYTES:
|
|
die("Engine backend canonical rootfs exceeds size limit")
|
|
destination_tar.chmod(0o600)
|
|
return sha256_file(destination_tar)
|
|
|
|
|
|
def atomic_write_root_file(path, data, mode=0o600):
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
directory_stat = path.parent.lstat()
|
|
if (stat.S_ISLNK(directory_stat.st_mode)
|
|
or not stat.S_ISDIR(directory_stat.st_mode)
|
|
or directory_stat.st_uid != 0
|
|
or directory_stat.st_gid != 0
|
|
or stat.S_IMODE(directory_stat.st_mode) != 0o700):
|
|
die(f"runtime directory is unsafe: {path.parent}")
|
|
temporary = path.with_name(f".{path.name}.{os.getpid()}.{time.time_ns()}.tmp")
|
|
descriptor = None
|
|
try:
|
|
descriptor = os.open(str(temporary), os.O_WRONLY | os.O_CREAT | os.O_EXCL, mode)
|
|
os.write(descriptor, data)
|
|
os.fsync(descriptor)
|
|
os.fchown(descriptor, 0, 0)
|
|
os.fchmod(descriptor, mode)
|
|
os.close(descriptor)
|
|
descriptor = None
|
|
os.replace(temporary, path)
|
|
fsync_directory(path.parent)
|
|
finally:
|
|
if descriptor is not None:
|
|
os.close(descriptor)
|
|
if temporary.exists():
|
|
temporary.unlink()
|
|
|
|
|
|
def ensure_root_runtime_directory(path):
|
|
try:
|
|
path_stat = path.lstat()
|
|
except FileNotFoundError:
|
|
path.mkdir(parents=True, exist_ok=False)
|
|
path_stat = path.lstat()
|
|
if stat.S_ISLNK(path_stat.st_mode) or not stat.S_ISDIR(path_stat.st_mode):
|
|
die(f"runtime directory is unsafe: {path}")
|
|
os.chown(path, 0, 0)
|
|
path.chmod(0o700)
|
|
|
|
|
|
def prepare_engine_credential_backend_runtime():
|
|
preflight = preflight_engine_credential_backend_runtime()
|
|
installed_template = component_root("engine") / ENGINE_CREDENTIAL_BACKEND_OVERRIDE_TEMPLATE_REL
|
|
try:
|
|
template_stat = installed_template.lstat()
|
|
template = installed_template.read_text(encoding="utf-8")
|
|
except (FileNotFoundError, OSError, UnicodeDecodeError):
|
|
die("installed Engine backend immutable runtime template is unreadable")
|
|
if (stat.S_ISLNK(template_stat.st_mode)
|
|
or not stat.S_ISREG(template_stat.st_mode)
|
|
or template != expected_engine_credential_backend_override()):
|
|
die("installed Engine backend immutable runtime template mismatch")
|
|
|
|
ensure_root_runtime_directory(ENGINE_CREDENTIAL_BACKEND_RUNTIME_DIR)
|
|
if preflight["mode"] in ("verified-derived-retry", "verified-prepared-not-active"):
|
|
validate_engine_backend_activation_marker()
|
|
validate_engine_backend_runtime_override_file()
|
|
metadata = read_engine_backend_runtime_metadata()
|
|
image = inspect_engine_backend_derived_image(metadata)
|
|
if engine_backend_tool_versions(image_ref=ENGINE_CREDENTIAL_BACKEND_IMAGE) != metadata["tools"]:
|
|
die("Engine backend immutable runtime tool drift detected")
|
|
return metadata
|
|
if preflight["mode"] == "recoverable-prepared-partial":
|
|
quarantine_engine_backend_partial_runtime()
|
|
return prepare_engine_credential_backend_runtime()
|
|
|
|
with tempfile.TemporaryDirectory(prefix="engine-backend-runtime-", dir=TMP_DIR) as temporary_name:
|
|
temporary = Path(temporary_name)
|
|
raw_export = temporary / "container-export.tar"
|
|
canonical_rootfs = temporary / "canonical-rootfs.tar"
|
|
export = subprocess.run(
|
|
[str(DOCKER), "container", "export", "--output", str(raw_export), preflight["container_id"]],
|
|
check=False,
|
|
stdin=subprocess.DEVNULL,
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
timeout=300,
|
|
)
|
|
if export.returncode != 0:
|
|
die("Engine backend container export failed")
|
|
rootfs_sha256 = build_canonical_engine_backend_rootfs(raw_export, canonical_rootfs)
|
|
raw_export.unlink()
|
|
dockerfile = "\n".join((
|
|
"FROM scratch",
|
|
"ADD canonical-rootfs.tar /",
|
|
"LABEL org.nodedc.release=\"credential-sink-20260716-001\"",
|
|
f"LABEL org.nodedc.source-image-id=\"{preflight['source_image_id']}\"",
|
|
f"LABEL org.nodedc.rootfs-sha256=\"{rootfs_sha256}\"",
|
|
f"LABEL org.nodedc.package-lock-sha256=\"{ENGINE_CREDENTIAL_BACKEND_PACKAGE_LOCK_SHA256}\"",
|
|
"WORKDIR /app",
|
|
"CMD [\"/usr/local/bin/node\",\"server/index.js\"]",
|
|
"",
|
|
))
|
|
(temporary / "Dockerfile").write_text(dockerfile, encoding="utf-8")
|
|
(temporary / "Dockerfile").chmod(0o600)
|
|
image = ensure_engine_backend_release_image(
|
|
temporary,
|
|
preflight["source_image_id"],
|
|
rootfs_sha256,
|
|
preflight["tools"],
|
|
)
|
|
tools = preflight["tools"]
|
|
|
|
os.chown(canonical_rootfs, 0, 0)
|
|
canonical_rootfs.chmod(0o600)
|
|
os.replace(canonical_rootfs, ENGINE_CREDENTIAL_BACKEND_ROOTFS_FILE)
|
|
fsync_directory(ENGINE_CREDENTIAL_BACKEND_RUNTIME_DIR)
|
|
|
|
atomic_write_root_file(
|
|
ENGINE_CREDENTIAL_BACKEND_OVERRIDE_FILE,
|
|
template.encode("utf-8"),
|
|
)
|
|
override_sha256 = validate_engine_backend_runtime_override_file()
|
|
metadata = {
|
|
"schemaVersion": "nodedc.engine-backend-immutable-runtime/v1",
|
|
"release": "credential-sink-20260716-001",
|
|
"sourceContainerId": preflight["container_id"],
|
|
"sourceImageId": preflight["source_image_id"],
|
|
"derivedImageId": image["Id"],
|
|
"rootfsSha256": rootfs_sha256,
|
|
"packageLockSha256": ENGINE_CREDENTIAL_BACKEND_PACKAGE_LOCK_SHA256,
|
|
"nodeModulesTreeSha256": preflight["node_modules_tree_sha256"],
|
|
"overrideSha256": override_sha256,
|
|
"tools": tools,
|
|
}
|
|
atomic_write_root_file(
|
|
ENGINE_CREDENTIAL_BACKEND_METADATA_FILE,
|
|
(json.dumps(metadata, ensure_ascii=False, indent=2, sort_keys=True) + "\n").encode("utf-8"),
|
|
)
|
|
read_engine_backend_runtime_metadata()
|
|
atomic_write_root_file(
|
|
ENGINE_CREDENTIAL_BACKEND_ACTIVATION_FILE,
|
|
f"sha256:{sha256_file(ENGINE_CREDENTIAL_BACKEND_METADATA_FILE)}\n".encode("ascii"),
|
|
)
|
|
validate_engine_backend_activation_marker()
|
|
return metadata
|
|
|
|
|
|
def preflight_engine_credential_sink_ready():
|
|
url = "http://127.0.0.1:3001/internal/engine-credential-sink/v1/health"
|
|
try:
|
|
request = urllib.request.Request(url, headers={"Cache-Control": "no-store"})
|
|
with NO_REDIRECT_OPENER.open(request, timeout=10) as response:
|
|
if response.status != 200:
|
|
die(f"Engine credential sink preflight failed: HTTP {response.status}")
|
|
body = response.read(16 * 1024)
|
|
except urllib.error.HTTPError as exc:
|
|
die(f"Engine credential sink preflight failed: HTTP {exc.code}")
|
|
except Exception as exc:
|
|
die(f"Engine credential sink preflight failed: {type(exc).__name__}")
|
|
try:
|
|
value = json.loads(body)
|
|
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
die("Engine credential sink preflight returned invalid JSON")
|
|
if (value.get("ok") is not True
|
|
or value.get("ready") is not True
|
|
or value.get("service") != "NDC Engine Credential Sink"
|
|
or value.get("issuer") != {
|
|
"serviceId": "platform.engine-credential-provisioner",
|
|
"keyId": ENGINE_CREDENTIAL_PROVISIONER_KEY_ID,
|
|
}):
|
|
die("Engine credential sink preflight identity/readiness mismatch")
|
|
|
|
|
|
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_package_loader_probe_script():
|
|
# LoadNodesAndCredentials.init() in n8n 2.3.2 initializes NODE_PATH before
|
|
# it loads community packages. A standalone `node -e` probe must reproduce
|
|
# that image-owned module-resolution step or peer imports such as
|
|
# n8n-workflow fail even though the real n8n loader succeeds.
|
|
return (
|
|
"process.env.NODE_PATH='"
|
|
+ ENGINE_N8N_NODE_MODULES_PATH
|
|
+ "';require('module').Module._initPaths();"
|
|
"const {PackageDirectoryLoader}=require('"
|
|
+ ENGINE_N8N_NODE_MODULES_PATH
|
|
+ "/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(error=>{process.stderr.write(String(error&&error.code||'PROBE_ERROR'));process.exit(1)});"
|
|
)
|
|
|
|
|
|
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 = engine_n8n_package_loader_probe_script()
|
|
result = subprocess.run(
|
|
[str(DOCKER), "exec", container_id, "node", "-e", script],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=120,
|
|
)
|
|
if result.returncode != 0:
|
|
raw_category = (result.stderr or "").strip().upper()
|
|
category = raw_category if re.fullmatch(r"[A-Z0-9_]{1,64}", raw_category) else "PROBE_ERROR"
|
|
die(f"Engine n8n package-loader probe failed: category={category}")
|
|
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_for_release(current_state))
|
|
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}"
|
|
)
|
|
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_for_release(descriptor["releaseId"]))
|
|
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)
|
|
publish_grant_preflight = None
|
|
node_intelligence_preflight = None
|
|
mcp_control_plane_preflight = None
|
|
mcp_ontology_sdk_preflight = None
|
|
mcp_autonomy_provider_v5_preflight = None
|
|
composite_provider_v4_preflight = None
|
|
provider_rotating_slot_preflight = None
|
|
provider_authority_diagnostics_preflight = None
|
|
depttrans_zone_authority_v1_preflight = None
|
|
provider_target_host_policy_preflight = None
|
|
provider_catalog_preflight = None
|
|
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,
|
|
)
|
|
if is_engine_data_product_publish_grant_slice(manifest["component"], entries):
|
|
publish_grant_preflight = (
|
|
preflight_engine_data_product_publish_grant_predecessor(payload_dir)
|
|
)
|
|
if is_engine_node_intelligence_transition(manifest["component"], entries):
|
|
node_intelligence_preflight = preflight_engine_node_intelligence_predecessor(
|
|
payload_dir
|
|
)
|
|
if is_engine_mcp_control_plane_slice(manifest["component"], entries):
|
|
mcp_control_plane_preflight = preflight_engine_mcp_control_plane_predecessor(
|
|
payload_dir
|
|
)
|
|
if is_engine_mcp_ontology_sdk_slice(manifest["component"], entries):
|
|
mcp_ontology_sdk_preflight = preflight_engine_mcp_ontology_sdk_predecessor(
|
|
payload_dir
|
|
)
|
|
if is_engine_mcp_autonomy_provider_v5_slice(manifest["component"], entries):
|
|
mcp_autonomy_provider_v5_preflight = (
|
|
preflight_engine_mcp_autonomy_provider_v5_predecessor(payload_dir)
|
|
)
|
|
if is_engine_composite_provider_v4_slice(manifest["component"], entries):
|
|
composite_provider_v4_preflight = preflight_engine_composite_provider_v4_predecessor()
|
|
if is_engine_provider_rotating_slot_slice(manifest["component"], entries):
|
|
provider_rotating_slot_preflight = preflight_engine_provider_rotating_slot_predecessor()
|
|
if is_engine_provider_authority_diagnostics_slice(manifest["component"], entries):
|
|
provider_authority_diagnostics_preflight = (
|
|
preflight_engine_provider_authority_diagnostics_predecessor()
|
|
)
|
|
if is_engine_depttrans_zone_authority_v1_slice(manifest["component"], entries):
|
|
depttrans_zone_authority_v1_preflight = (
|
|
preflight_engine_depttrans_zone_authority_v1_predecessor()
|
|
)
|
|
if is_engine_provider_target_host_policy_slice(manifest["component"], entries):
|
|
provider_target_host_policy_preflight = (
|
|
preflight_engine_provider_target_host_policy_predecessor()
|
|
)
|
|
if is_engine_provider_security_catalog_slice(manifest["component"], entries):
|
|
provider_catalog_preflight = preflight_engine_provider_security_catalog_predecessor()
|
|
|
|
component = manifest["component"]
|
|
root = component_root(component)
|
|
compose_root = component_compose_root(component)
|
|
services = component_services(component, entries)
|
|
builds = component_builds(component, entries)
|
|
touches_publish_grant = is_engine_data_product_publish_grant_slice(
|
|
component,
|
|
entries,
|
|
)
|
|
touches_composite_provider_v4 = is_engine_composite_provider_v4_slice(
|
|
component,
|
|
entries,
|
|
)
|
|
touches_provider_rotating_slot = is_engine_provider_rotating_slot_slice(
|
|
component,
|
|
entries,
|
|
)
|
|
touches_provider_authority_diagnostics = is_engine_provider_authority_diagnostics_slice(
|
|
component,
|
|
entries,
|
|
)
|
|
touches_depttrans_zone_authority_v1 = is_engine_depttrans_zone_authority_v1_slice(
|
|
component,
|
|
entries,
|
|
)
|
|
touches_provider_target_host_policy = is_engine_provider_target_host_policy_slice(
|
|
component,
|
|
entries,
|
|
)
|
|
touches_agent_grant_migration = is_engine_agent_full_grant_migration_slice(
|
|
component,
|
|
entries,
|
|
)
|
|
publish_grant_predecessor_sha256 = None
|
|
agent_grant_migration_predecessor_sha256 = None
|
|
credential_backend_preflight = None
|
|
if (
|
|
touches_engine_credential_sink(component, entries)
|
|
or touches_publish_grant
|
|
or touches_composite_provider_v4
|
|
or touches_provider_rotating_slot
|
|
or touches_provider_authority_diagnostics
|
|
or touches_depttrans_zone_authority_v1
|
|
or touches_provider_target_host_policy
|
|
or touches_agent_grant_migration
|
|
):
|
|
credential_backend_preflight = preflight_engine_credential_backend_runtime()
|
|
if touches_publish_grant:
|
|
if publish_grant_preflight is None:
|
|
die("Engine data product publish grant preflight is missing")
|
|
publish_grant_predecessor_sha256 = publish_grant_preflight["compose_sha256"]
|
|
if credential_backend_preflight["mode"] != "verified-derived-retry":
|
|
die("Engine data product publish grant requires the active immutable credential backend")
|
|
if touches_composite_provider_v4:
|
|
if composite_provider_v4_preflight is None:
|
|
die("Engine composite provider v4 preflight is missing")
|
|
if credential_backend_preflight["mode"] != "verified-derived-retry":
|
|
die("Engine composite provider v4 requires the active immutable credential backend")
|
|
if touches_provider_rotating_slot:
|
|
if provider_rotating_slot_preflight is None:
|
|
die("Engine provider rotating slot preflight is missing")
|
|
if credential_backend_preflight["mode"] != "verified-derived-retry":
|
|
die("Engine provider rotating slot requires the active immutable credential backend")
|
|
if touches_provider_authority_diagnostics:
|
|
if provider_authority_diagnostics_preflight is None:
|
|
die("Engine provider authority diagnostics preflight is missing")
|
|
if credential_backend_preflight["mode"] != "verified-derived-retry":
|
|
die("Engine provider authority diagnostics requires the active immutable credential backend")
|
|
if touches_depttrans_zone_authority_v1:
|
|
if depttrans_zone_authority_v1_preflight is None:
|
|
die("Engine Depttrans zone authority v1 preflight is missing")
|
|
if credential_backend_preflight["mode"] != "verified-derived-retry":
|
|
die("Engine Depttrans zone authority v1 requires the active immutable credential backend")
|
|
if touches_provider_target_host_policy:
|
|
if provider_target_host_policy_preflight is None:
|
|
die("Engine provider target host policy preflight is missing")
|
|
if credential_backend_preflight["mode"] != "verified-derived-retry":
|
|
die("Engine provider target host policy requires the active immutable credential backend")
|
|
if touches_agent_grant_migration:
|
|
agent_grant_migration_predecessor_sha256 = (
|
|
preflight_engine_agent_full_grant_migration_predecessor()
|
|
)
|
|
if credential_backend_preflight["mode"] != "verified-derived-retry":
|
|
die("Engine agent full grant migration requires the active immutable credential backend")
|
|
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 node_intelligence_preflight:
|
|
descriptor = node_intelligence_preflight["descriptor"]
|
|
print(f"node_intelligence_transition={descriptor['action']}")
|
|
print(f"node_intelligence_release={descriptor['releaseId']}")
|
|
print(f"node_intelligence_current_state={node_intelligence_preflight['current_state']}")
|
|
print(f"node_intelligence_upstream_commit={descriptor['upstream']['commit']}")
|
|
print(f"node_intelligence_image={descriptor['image']['tag']}")
|
|
print(f"node_intelligence_image_config_sha256={descriptor['image']['configSha256']}")
|
|
if descriptor["action"] == "activate":
|
|
print(f"node_intelligence_image_archive_sha256={descriptor['image']['archiveSha256']}")
|
|
print("node_intelligence_build=offline-image-load")
|
|
print("node_intelligence_pull=never")
|
|
print(f"node_intelligence_secret=runner-managed:{ENGINE_NODE_INTELLIGENCE_SECRET_FILE}")
|
|
print("node_intelligence_host_ports=none")
|
|
print(f"predecessor_gateway_sha256={node_intelligence_preflight['gateway_sha256']}")
|
|
print(f"backend_current_barrier={node_intelligence_preflight['backend_mode']}")
|
|
if mcp_control_plane_preflight:
|
|
print("engine_mcp_transition=managed-reader-grant+validator-reconciliation")
|
|
print("engine_mcp_version=0.5.0")
|
|
print("engine_mcp_installer=0.1.4")
|
|
print("engine_mcp_reader_grant=plan+apply+accept+rollback")
|
|
print(
|
|
"predecessor_gateway_sha256="
|
|
f"{mcp_control_plane_preflight['predecessor_gateway_sha256']}"
|
|
)
|
|
print(
|
|
"target_gateway_sha256="
|
|
f"{mcp_control_plane_preflight['target_gateway_sha256']}"
|
|
)
|
|
print(f"backend_current_barrier={mcp_control_plane_preflight['backend_mode']}")
|
|
print("node_intelligence_image=preserved")
|
|
print("n8n_l1=untouched")
|
|
if mcp_ontology_sdk_preflight:
|
|
print("engine_mcp_transition=separate-ontology+gelios-sdk-query-auth")
|
|
print("engine_mcp_version=0.6.0")
|
|
print("engine_mcp_installer=0.1.5")
|
|
print("engine_mcp_ontology=separate-read-only-proxy")
|
|
print("engine_provider_package=gelios.provider.v2")
|
|
print("engine_provider_credential=httpQueryAuth")
|
|
print(
|
|
"predecessor_gateway_sha256="
|
|
f"{mcp_ontology_sdk_preflight['predecessor_gateway_sha256']}"
|
|
)
|
|
print(
|
|
"target_gateway_sha256="
|
|
f"{mcp_ontology_sdk_preflight['target_gateway_sha256']}"
|
|
)
|
|
print(f"backend_current_barrier={mcp_ontology_sdk_preflight['backend_mode']}")
|
|
print("node_intelligence_image=preserved")
|
|
print("n8n_l1=untouched")
|
|
if mcp_autonomy_provider_v5_preflight:
|
|
print("engine_mcp_transition=capability-scoped-autonomy+provider-v5")
|
|
print("engine_mcp_version=0.6.0")
|
|
print("engine_mcp_installer=0.1.6")
|
|
print("engine_mcp_authority=mcp-capability-intersect-user-objective")
|
|
print("engine_mcp_plan=machine-safety-barrier")
|
|
print("engine_mcp_retry_stop=three-identical-failures-without-new-evidence")
|
|
print("engine_provider_packages=gelios.provider.v4,gelios.provider.v5")
|
|
print("engine_data_product=fleet.positions.current.v4")
|
|
print(
|
|
"predecessor_gateway_sha256="
|
|
f"{mcp_autonomy_provider_v5_preflight['predecessor_gateway_sha256']}"
|
|
)
|
|
print(
|
|
"target_gateway_sha256="
|
|
f"{mcp_autonomy_provider_v5_preflight['target_gateway_sha256']}"
|
|
)
|
|
print(f"backend_current_barrier={mcp_autonomy_provider_v5_preflight['backend_mode']}")
|
|
print("node_intelligence_image=preserved")
|
|
print("n8n_l1=untouched")
|
|
if provider_catalog_preflight:
|
|
print("engine_provider_catalog_transition=gelios-telemetry-authority-v7")
|
|
print("engine_provider_package=gelios.provider.v7")
|
|
print("engine_provider_credential=ndcProviderRotatingAccessApi")
|
|
print(
|
|
"engine_provider_endpoint=https://api.geliospro.com/api/v1/units?"
|
|
"incltrip=true&inclcntrs=true&inclsnsrs=true&incllsv=true"
|
|
)
|
|
print("engine_data_product=fleet.positions.current.v5")
|
|
print(
|
|
"predecessor_catalog_sha256="
|
|
f"{provider_catalog_preflight['catalog_sha256']}"
|
|
)
|
|
print(f"target_catalog_sha256={ENGINE_PROVIDER_SECURITY_CATALOG_TARGET_SHA256}")
|
|
print(f"backend_current_barrier={provider_catalog_preflight['backend_mode']}")
|
|
print("n8n_l1=untouched")
|
|
print("engine_ui=untouched")
|
|
print("engine_databases=untouched")
|
|
print("mcp_nginx=untouched")
|
|
if composite_provider_v4_preflight:
|
|
print("engine_composite_provider_transition=exact-v3-to-v4")
|
|
print("engine_provider_package=gelios.provider.v4")
|
|
print(
|
|
"engine_provider_capabilities="
|
|
"gelios.monitoring_config.current.read,gelios.units.current.read"
|
|
)
|
|
print("engine_provider_requests=monitoring-config,units?incltrip=true")
|
|
print("engine_data_product=fleet.positions.current.v3")
|
|
for changed_path in ENGINE_COMPOSITE_PROVIDER_V4_ARTIFACT_ENTRIES:
|
|
print(f"engine_composite_provider_changed_path={changed_path}")
|
|
print(
|
|
f"engine_composite_provider_predecessor_sha256[{changed_path}]="
|
|
f"{composite_provider_v4_preflight['predecessor_sha256'][changed_path]}"
|
|
)
|
|
print(
|
|
f"engine_composite_provider_target_sha256[{changed_path}]="
|
|
f"{composite_provider_v4_preflight['target_sha256'][changed_path]}"
|
|
)
|
|
print(f"backend_current_barrier={composite_provider_v4_preflight['backend_mode']}")
|
|
print("provider_credential_values=preserved")
|
|
print("backend_force_recreate=yes")
|
|
print("backend_pull=never")
|
|
print("n8n_l1=untouched")
|
|
print("engine_ui=untouched")
|
|
print("engine_databases=untouched")
|
|
if provider_rotating_slot_preflight:
|
|
print("engine_provider_credential_transition=exact-active-slot-alignment")
|
|
print("engine_provider_package=gelios.provider.v4")
|
|
print("engine_provider_auth_mode=gelios.rest-rotating-bearer.v3")
|
|
print("engine_provider_credential_slot=ndcProviderRotatingAccessApi")
|
|
print("engine_data_product=fleet.positions.current.v3")
|
|
for changed_path in ENGINE_PROVIDER_ROTATING_SLOT_ARTIFACT_ENTRIES:
|
|
print(f"engine_provider_rotating_slot_changed_path={changed_path}")
|
|
print(
|
|
f"engine_provider_rotating_slot_predecessor_sha256[{changed_path}]="
|
|
f"{provider_rotating_slot_preflight['predecessor_sha256'][changed_path]}"
|
|
)
|
|
print(
|
|
f"engine_provider_rotating_slot_target_sha256[{changed_path}]="
|
|
f"{provider_rotating_slot_preflight['target_sha256'][changed_path]}"
|
|
)
|
|
print(f"backend_current_barrier={provider_rotating_slot_preflight['backend_mode']}")
|
|
print("provider_credential_values=preserved")
|
|
print("backend_force_recreate=yes")
|
|
print("backend_pull=never")
|
|
print("l2_graph=untouched")
|
|
print("n8n_l1=untouched")
|
|
print("engine_ui=untouched")
|
|
print("engine_databases=untouched")
|
|
if provider_authority_diagnostics_preflight:
|
|
print("engine_provider_authority_transition=exact-safe-reason-codes")
|
|
print("engine_provider_package=gelios.provider.v4")
|
|
print("engine_data_product=fleet.positions.current.v3")
|
|
print("provider_credential_values=preserved")
|
|
for changed_path in ENGINE_PROVIDER_AUTHORITY_DIAGNOSTICS_ARTIFACT_ENTRIES:
|
|
print(f"engine_provider_authority_changed_path={changed_path}")
|
|
print(
|
|
f"engine_provider_authority_predecessor_sha256[{changed_path}]="
|
|
f"{provider_authority_diagnostics_preflight['predecessor_sha256'][changed_path]}"
|
|
)
|
|
print(
|
|
f"engine_provider_authority_target_sha256[{changed_path}]="
|
|
f"{provider_authority_diagnostics_preflight['target_sha256'][changed_path]}"
|
|
)
|
|
print(f"backend_current_barrier={provider_authority_diagnostics_preflight['backend_mode']}")
|
|
print("backend_force_recreate=yes")
|
|
print("backend_pull=never")
|
|
print("l2_graph=untouched")
|
|
print("n8n_l1=untouched")
|
|
print("engine_ui=untouched")
|
|
print("engine_databases=untouched")
|
|
if depttrans_zone_authority_v1_preflight:
|
|
print_engine_depttrans_zone_authority_v1_plan(
|
|
depttrans_zone_authority_v1_preflight
|
|
)
|
|
if provider_target_host_policy_preflight:
|
|
print("engine_provider_transport_transition=exact-literal-target-host")
|
|
print("engine_provider_package=gelios.provider.v4")
|
|
print("engine_data_product=fleet.positions.current.v3")
|
|
print("provider_credential_values=preserved")
|
|
for changed_path in ENGINE_PROVIDER_TARGET_HOST_POLICY_ARTIFACT_ENTRIES:
|
|
print(f"engine_provider_target_host_policy_changed_path={changed_path}")
|
|
print(
|
|
f"engine_provider_target_host_policy_predecessor_sha256[{changed_path}]="
|
|
f"{provider_target_host_policy_preflight['predecessor_sha256'][changed_path]}"
|
|
)
|
|
print(
|
|
f"engine_provider_target_host_policy_target_sha256[{changed_path}]="
|
|
f"{provider_target_host_policy_preflight['target_sha256'][changed_path]}"
|
|
)
|
|
print(f"backend_current_barrier={provider_target_host_policy_preflight['backend_mode']}")
|
|
print("backend_force_recreate=yes")
|
|
print("backend_pull=never")
|
|
print("l2_graph=untouched")
|
|
print("n8n_l1=untouched")
|
|
print("engine_ui=untouched")
|
|
print("engine_databases=untouched")
|
|
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(f"n8n_rollback_baseline={transition_descriptor['rollbackBaseline']}")
|
|
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 touches_external_data_plane_files(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}")
|
|
print(f"runtime_private_key=runner-managed:{FOUNDRY_EDP_MANAGED_PROVISIONER_PRIVATE_KEY_FILE}")
|
|
print(f"runtime_public_trust=runner-managed:{FOUNDRY_EDP_MANAGED_PROVISIONER_PUBLIC_KEY_FILE}")
|
|
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}")
|
|
print(f"runtime_public_trust=runner-managed:{FOUNDRY_EDP_MANAGED_PROVISIONER_PUBLIC_KEY_FILE}")
|
|
if touches_engine_credential_sink(component, entries):
|
|
print(f"runtime_identity_key_id={ENGINE_CREDENTIAL_PROVISIONER_KEY_ID}")
|
|
print(f"runtime_private_key=runner-managed:{ENGINE_CREDENTIAL_PROVISIONER_PRIVATE_KEY_FILE}")
|
|
print(f"runtime_public_key=runner-managed:{ENGINE_CREDENTIAL_SINK_PUBLIC_KEY_FILE}")
|
|
if touches_engine_credential_sink(component, entries):
|
|
print(f"backend_source_container_id={credential_backend_preflight['container_id']}")
|
|
print(f"backend_source_image_id={credential_backend_preflight['source_image_id']}")
|
|
print(f"backend_current_image_id={credential_backend_preflight['current_image_id']}")
|
|
print(f"backend_current_barrier={credential_backend_preflight['mode']}")
|
|
print(f"backend_derived_image={ENGINE_CREDENTIAL_BACKEND_IMAGE}")
|
|
print(f"backend_package_lock_sha256={ENGINE_CREDENTIAL_BACKEND_PACKAGE_LOCK_SHA256}")
|
|
print(f"backend_node_modules_tree_sha256={credential_backend_preflight['node_modules_tree_sha256']}")
|
|
print("backend_rootfs=safe-roots:/bin,/sbin,/lib,/usr,/etc,/var/lib/apk")
|
|
print("backend_build_network=none")
|
|
print("backend_pull=never")
|
|
print("backend_force_recreate=yes")
|
|
if touches_publish_grant or touches_external_data_plane:
|
|
print(f"runtime_private_key=runner-managed:{ENGINE_EDP_MANAGED_PROVISIONER_PRIVATE_KEY_FILE}")
|
|
print(f"runtime_public_trust=runner-managed:{ENGINE_EDP_MANAGED_PROVISIONER_PUBLIC_KEY_FILE}")
|
|
if touches_publish_grant:
|
|
print(f"publish_grant_transition={publish_grant_preflight['mode']}")
|
|
for changed_path in publish_grant_preflight["changed_paths"]:
|
|
print(f"publish_grant_changed_path={changed_path}")
|
|
print(f"predecessor_compose_sha256={publish_grant_predecessor_sha256}")
|
|
print(f"backend_current_barrier={credential_backend_preflight['mode']}")
|
|
print(f"runtime_private_state=runner-managed:{component_root('engine') / ENGINE_PUBLISH_GRANT_STATE_REL}")
|
|
if publish_grant_preflight["mode"] == "installed-composite-provider-update":
|
|
print("engine_provider_package=gelios.provider.v4")
|
|
print("engine_provider_requests=monitoring-config,units?incltrip=true")
|
|
print("engine_data_product=fleet.positions.current.v3")
|
|
print(
|
|
"predecessor_catalog_sha256="
|
|
f"{ENGINE_PUBLISH_GRANT_COMPOSITE_PROVIDER_CATALOG_PREDECESSOR_SHA256}"
|
|
)
|
|
print(
|
|
"target_catalog_sha256="
|
|
f"{ENGINE_PUBLISH_GRANT_COMPOSITE_PROVIDER_CATALOG_TARGET_SHA256}"
|
|
)
|
|
print("provider_credential_values=preserved")
|
|
if touches_agent_grant_migration:
|
|
print(f"predecessor_store_sha256={agent_grant_migration_predecessor_sha256}")
|
|
print(f"target_store_sha256={ENGINE_AGENT_FULL_GRANT_MIGRATION_TARGET_SHA256}")
|
|
print(f"backend_current_barrier={credential_backend_preflight['mode']}")
|
|
print("agent_grant_migration=v1-full-bundle-to-v2-named-full-developer-profile")
|
|
print("backend_pull=never")
|
|
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 validate_backup_partition(entries, existing, missing, label):
|
|
entry_set = set(entries)
|
|
existing_set = set(existing)
|
|
missing_set = set(missing)
|
|
if len(entry_set) != len(entries):
|
|
die(f"{label} entries contain duplicates")
|
|
if len(existing_set) != len(existing) or len(missing_set) != len(missing):
|
|
die(f"{label} backup path lists contain duplicates")
|
|
if existing_set & missing_set or existing_set | missing_set != entry_set:
|
|
die(f"{label} backup entry set mismatch")
|
|
for rel in entries:
|
|
if any(rel != other and rel.startswith(other.rstrip("/") + "/") for other in entries):
|
|
die(f"{label} entries overlap")
|
|
return existing_set, missing_set
|
|
|
|
|
|
def materialize_backup_tree(backup_archive, restore_root, existing):
|
|
seen_names = set()
|
|
seen_roots = set()
|
|
directory_modes = []
|
|
member_count = 0
|
|
payload_bytes = 0
|
|
with tarfile.open(backup_archive, "r:gz") as archive:
|
|
for member in archive:
|
|
member_count += 1
|
|
if member_count > MAX_MEMBER_COUNT:
|
|
die("Platform rollback backup has too many members")
|
|
validate_posix_path(member.name)
|
|
if member.name in seen_names:
|
|
die("Platform rollback backup contains duplicate members")
|
|
seen_names.add(member.name)
|
|
owners = [
|
|
rel
|
|
for rel in existing
|
|
if member.name == rel or member.name.startswith(rel.rstrip("/") + "/")
|
|
]
|
|
if len(owners) != 1:
|
|
die("Platform rollback backup contains an unexpected member")
|
|
if member.name == owners[0]:
|
|
seen_roots.add(owners[0])
|
|
if not (member.isfile() or member.isdir()):
|
|
die("Platform rollback backup contains an unsupported member")
|
|
if member.mode & (stat.S_ISUID | stat.S_ISGID):
|
|
die("Platform rollback backup contains a privileged member")
|
|
|
|
target = tar_name_to_path(restore_root, member.name)
|
|
if member.isdir():
|
|
target.mkdir(parents=True, exist_ok=True)
|
|
target.chmod(0o755)
|
|
directory_modes.append((target, stat.S_IMODE(member.mode) & 0o777))
|
|
continue
|
|
|
|
if member.size > MAX_FILE_BYTES:
|
|
die("Platform rollback backup member is too large")
|
|
payload_bytes += member.size
|
|
if payload_bytes > MAX_PAYLOAD_BYTES:
|
|
die("Platform rollback backup is too large")
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
if target.exists():
|
|
die("Platform rollback backup member collision")
|
|
source = archive.extractfile(member)
|
|
if source is None:
|
|
die("Platform rollback backup member is unreadable")
|
|
with source, target.open("xb") as output:
|
|
shutil.copyfileobj(source, output, 1024 * 1024)
|
|
target.chmod(stat.S_IMODE(member.mode) & 0o777)
|
|
|
|
if seen_roots != set(existing):
|
|
die("Platform rollback backup root set mismatch")
|
|
for target, mode in sorted(directory_modes, key=lambda item: len(item[0].parts), reverse=True):
|
|
target.chmod(mode)
|
|
|
|
|
|
def restore_platform_overlay(root, backup_dir, entries, current_stamp):
|
|
existing = read_backup_path_list(backup_dir / "existing-files.txt")
|
|
missing = read_backup_path_list(backup_dir / "missing-files.txt")
|
|
existing_set, missing_set = validate_backup_partition(
|
|
entries,
|
|
existing,
|
|
missing,
|
|
"Platform rollback",
|
|
)
|
|
backup_archive = backup_dir / "source-before.tgz"
|
|
try:
|
|
backup_stat = backup_archive.lstat()
|
|
except FileNotFoundError:
|
|
die("Platform rollback backup archive is missing")
|
|
if stat.S_ISLNK(backup_stat.st_mode) or not stat.S_ISREG(backup_stat.st_mode):
|
|
die("Platform rollback backup archive is unsafe")
|
|
|
|
with tempfile.TemporaryDirectory(prefix="platform-rollback-", dir=TMP_DIR) as tmp:
|
|
restore_root = Path(tmp)
|
|
materialize_backup_tree(backup_archive, restore_root, existing_set)
|
|
for rel in entries:
|
|
destination = root / rel
|
|
destination_resolved = destination.resolve(strict=False)
|
|
if not is_relative_to(destination_resolved, root.resolve()):
|
|
die("Platform rollback target escaped component root")
|
|
if destination.is_symlink():
|
|
die("Platform rollback target is a symlink")
|
|
|
|
if rel in existing_set:
|
|
staged = restore_root / rel
|
|
rollback_stamp = f"{current_stamp}-platform-rollback"
|
|
if staged.is_dir():
|
|
replace_directory(staged, destination, rollback_stamp)
|
|
elif staged.is_file():
|
|
replace_file(staged, destination, rollback_stamp)
|
|
else:
|
|
die("Platform rollback staged path is missing")
|
|
continue
|
|
|
|
if rel not in missing_set or not destination.exists():
|
|
continue
|
|
if destination.is_dir():
|
|
shutil.rmtree(destination)
|
|
elif destination.is_file():
|
|
destination.unlink()
|
|
else:
|
|
die("Platform rollback target has unsupported type")
|
|
return len(entries)
|
|
|
|
|
|
def rollback_platform_apply(root, backup_dir, entries, current_stamp, runtime_started, applied_services):
|
|
existing = read_backup_path_list(backup_dir / "existing-files.txt")
|
|
missing = read_backup_path_list(backup_dir / "missing-files.txt")
|
|
existing_set, missing_set = validate_backup_partition(
|
|
entries,
|
|
existing,
|
|
missing,
|
|
"Platform runtime rollback",
|
|
)
|
|
|
|
edp_was_new = PLATFORM_EXTERNAL_DATA_PLANE_COMPOSE_REL in missing_set
|
|
candidate_cleanup_failed = False
|
|
if runtime_started and edp_was_new:
|
|
# Remove only candidate-only containers while its Compose overlay is
|
|
# still installed. Deliberately omit --volumes: rollback never deletes
|
|
# newly written data, even when the first EDP activation is rejected.
|
|
try:
|
|
candidate_services = tuple(
|
|
service
|
|
for service in applied_services
|
|
if service == EXTERNAL_DATA_PLANE_SERVICE
|
|
)
|
|
if not candidate_services:
|
|
die("Platform candidate-only EDP runtime selection is invalid")
|
|
stop_and_remove_compose_services(
|
|
"platform",
|
|
candidate_services,
|
|
)
|
|
except Exception:
|
|
candidate_cleanup_failed = True
|
|
|
|
restored_count = restore_platform_overlay(root, backup_dir, entries, current_stamp)
|
|
if candidate_cleanup_failed:
|
|
die("Platform candidate-only runtime cleanup failed after source restore")
|
|
if not runtime_started:
|
|
return f"source-restored-runtime-unchanged:{restored_count}"
|
|
|
|
baseline_entries = [rel for rel in entries if rel in existing_set]
|
|
if edp_was_new:
|
|
baseline_entries = [
|
|
rel
|
|
for rel in baseline_entries
|
|
if not touches_external_data_plane_files((rel,))
|
|
]
|
|
baseline_services = tuple(
|
|
service
|
|
for service in applied_services
|
|
if not edp_was_new
|
|
or service not in (EXTERNAL_DATA_PLANE_SERVICE, EXTERNAL_DATA_PLANE_DATABASE_SERVICE)
|
|
)
|
|
if baseline_services:
|
|
run_component_runtime("platform", baseline_entries, baseline_services)
|
|
if baseline_services == (EXTERNAL_DATA_PLANE_SERVICE,):
|
|
# The restored EDP may predate managed provisioning. Prove the
|
|
# stable baseline contract without requiring a candidate-only field.
|
|
healthcheck_url(external_data_plane_healthcheck(require_managed=False))
|
|
else:
|
|
run_healthchecks("platform", baseline_entries, baseline_services)
|
|
return f"source+runtime-restored:{restored_count}"
|
|
|
|
|
|
def rollback_engine_apply(root, backup_dir, entries, current_stamp, runtime_started, applied_services):
|
|
existing = read_backup_path_list(backup_dir / "existing-files.txt")
|
|
missing = read_backup_path_list(backup_dir / "missing-files.txt")
|
|
restore_entries = list(entries)
|
|
if "nginx-html" in existing:
|
|
restore_entries.append("nginx-html")
|
|
validate_backup_partition(
|
|
restore_entries,
|
|
existing,
|
|
missing,
|
|
"Engine runtime rollback",
|
|
)
|
|
restored_count = restore_platform_overlay(
|
|
root,
|
|
backup_dir,
|
|
restore_entries,
|
|
current_stamp,
|
|
)
|
|
if not runtime_started:
|
|
return f"source-restored-runtime-unchanged:{restored_count}"
|
|
run_component_runtime("engine", entries, applied_services)
|
|
run_healthchecks("engine", entries, applied_services)
|
|
return f"source+runtime-restored:{restored_count}"
|
|
|
|
|
|
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 restore_overlay_source(root, backup_dir, entries, 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) or existing & missing:
|
|
die("credential bridge rollback backup entry set mismatch")
|
|
|
|
with tempfile.TemporaryDirectory(prefix="credential-bridge-rollback-", dir=TMP_DIR) as tmp:
|
|
restore_root = Path(tmp)
|
|
names = set()
|
|
with tarfile.open(backup_dir / "source-before.tgz", "r:gz") as archive:
|
|
for member in archive:
|
|
validate_posix_path(member.name)
|
|
if member.name in names:
|
|
die(f"credential bridge rollback duplicate backup member: {member.name}")
|
|
names.add(member.name)
|
|
if not path_is_covered_by_files_list(member.name, existing):
|
|
die(f"credential bridge rollback unexpected backup member: {member.name}")
|
|
if not (member.isfile() or member.isdir()):
|
|
die(f"credential bridge rollback special backup member: {member.name}")
|
|
target = tar_name_to_path(restore_root, member.name)
|
|
if member.isdir():
|
|
target.mkdir(parents=True, exist_ok=True)
|
|
target.chmod(0o755)
|
|
continue
|
|
if member.size > MAX_FILE_BYTES:
|
|
die(f"credential bridge rollback backup member too large: {member.name}")
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
source = archive.extractfile(member)
|
|
if source is None:
|
|
die(f"credential bridge rollback unreadable backup member: {member.name}")
|
|
with source, target.open("xb") as output:
|
|
shutil.copyfileobj(source, output, 1024 * 1024)
|
|
target.chmod(0o644)
|
|
|
|
for rel in entries:
|
|
destination = root / rel
|
|
if rel in existing:
|
|
source = restore_root / rel
|
|
if not source.exists() or source.is_symlink():
|
|
die(f"credential bridge rollback source missing or unsafe: {rel}")
|
|
copy_payload_path(restore_root, root, rel, f"{current_stamp}-rollback")
|
|
continue
|
|
if not destination.exists() and not destination.is_symlink():
|
|
continue
|
|
if destination.is_symlink():
|
|
die(f"credential bridge rollback installed symlink rejected: {rel}")
|
|
retained = destination.with_name(f"{destination.name}.failed-{current_stamp}")
|
|
if retained.exists() or retained.is_symlink():
|
|
die(f"credential bridge rollback retained path already exists: {retained}")
|
|
destination.rename(retained)
|
|
|
|
|
|
def rollback_engine_credential_bridge(
|
|
component,
|
|
root,
|
|
backup_dir,
|
|
entries,
|
|
current_stamp,
|
|
engine_backend_recreated=False,
|
|
engine_backend_initial_mode=None,
|
|
):
|
|
if not touches_engine_credential_sink(component, entries):
|
|
die("credential bridge rollback called for unrelated artifact")
|
|
restore_overlay_source(root, backup_dir, entries, current_stamp)
|
|
if not engine_backend_recreated:
|
|
if engine_backend_initial_mode == "verified-derived-retry":
|
|
validate_engine_backend_activation_marker()
|
|
if not engine_backend_immutable_runtime_is_current():
|
|
raise ReconciliationRequired("engine_backend_prior_derived_runtime_unproven")
|
|
return "engine-sink-source-restored-active-runtime-preserved"
|
|
if engine_backend_initial_mode == "verified-prepared-not-active":
|
|
validate_engine_backend_activation_marker()
|
|
return "engine-sink-source-restored-prepared-runtime-preserved"
|
|
quarantine = quarantine_engine_backend_partial_runtime()
|
|
return f"engine-sink-source-restored-before-backend-recreate-{quarantine}"
|
|
rollback_entries = ("nodedc-source/server/index.js",)
|
|
# The first Compose call may have failed before replacing the base
|
|
# container. Original sink entries explicitly authorize the already proven
|
|
# prepared override for this rollback recreate.
|
|
run_compose("engine", ("nodedc-backend",), entries)
|
|
healthcheck_compose_service("engine", "nodedc-backend")
|
|
run_healthchecks("engine", rollback_entries)
|
|
runtime = preflight_engine_credential_backend_runtime()
|
|
if runtime["mode"] != "verified-derived-retry":
|
|
die("Engine backend immutable rollback runtime acceptance failed")
|
|
return "engine-sink-source-restored"
|
|
|
|
|
|
def rollback_engine_node_intelligence(
|
|
root,
|
|
backup_dir,
|
|
entries,
|
|
current_stamp,
|
|
runtime_started=False,
|
|
node_intelligence_service_stopped=False,
|
|
):
|
|
candidate_descriptor = current_engine_node_intelligence_descriptor()
|
|
candidate_active = (
|
|
candidate_descriptor is not None
|
|
and candidate_descriptor["action"] == "activate"
|
|
)
|
|
cleanup_failed = False
|
|
if runtime_started and candidate_active:
|
|
try:
|
|
stop_and_remove_compose_services(
|
|
"engine",
|
|
(ENGINE_NODE_INTELLIGENCE_SERVICE,),
|
|
)
|
|
except Exception:
|
|
cleanup_failed = True
|
|
|
|
restore_overlay_source(root, backup_dir, entries, current_stamp)
|
|
restored = current_engine_node_intelligence_descriptor()
|
|
restored_action = restored["action"] if restored is not None else "inactive"
|
|
if cleanup_failed:
|
|
die("Engine node-intelligence candidate runtime cleanup failed after source restore")
|
|
|
|
reconcile_runtime = runtime_started or node_intelligence_service_stopped
|
|
if not reconcile_runtime:
|
|
return f"source-restored-runtime-unchanged:{restored_action}"
|
|
|
|
if restored is not None and restored["action"] == "activate":
|
|
validate_installed_engine_node_intelligence_source(restored)
|
|
ensure_engine_node_intelligence_secret()
|
|
install_engine_node_intelligence_image(restored)
|
|
run_engine_node_intelligence_compose(
|
|
(ENGINE_NODE_INTELLIGENCE_SERVICE, "nodedc-backend"),
|
|
ENGINE_NODE_INTELLIGENCE_ARTIFACT_ENTRIES,
|
|
)
|
|
accept_engine_node_intelligence_runtime(restored)
|
|
return "active"
|
|
|
|
run_engine_node_intelligence_compose(
|
|
("nodedc-backend",),
|
|
ENGINE_NODE_INTELLIGENCE_ROLLBACK_ENTRIES,
|
|
)
|
|
healthcheck_compose_service("engine", "nodedc-backend")
|
|
healthcheck_url("http://127.0.0.1:3001/health")
|
|
backend = preflight_engine_credential_backend_runtime()
|
|
if backend["mode"] != "verified-derived-retry":
|
|
die("Engine node-intelligence rollback backend barrier failed")
|
|
assert_engine_node_intelligence_container_absent()
|
|
return restored_action
|
|
|
|
|
|
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, allow_prepared_engine_backend=False):
|
|
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,
|
|
allow_prepared_engine_backend=allow_prepared_engine_backend,
|
|
):
|
|
cmd.extend(["-f", str(compose_file)])
|
|
return cmd
|
|
|
|
|
|
def run_compose(component, services, entries=None):
|
|
compose_root = component_compose_root(component)
|
|
allow_prepared_engine_backend = touches_engine_credential_sink(component, entries)
|
|
cmd = [
|
|
*compose_base_cmd(
|
|
component,
|
|
allow_prepared_engine_backend=allow_prepared_engine_backend,
|
|
),
|
|
"up", "-d", "--force-recreate",
|
|
]
|
|
if (is_engine_n8n_transition(component, entries)
|
|
or touches_engine_credential_sink(component, entries)
|
|
or touches_engine_credential_provisioner(component, entries)
|
|
or (component == "engine" and ENGINE_CREDENTIAL_BACKEND_ACTIVATION_FILE.exists())):
|
|
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,
|
|
allow_prepared_engine_backend=allow_prepared_engine_backend,
|
|
),
|
|
"logs", "--no-color", "--tail=180", *services,
|
|
],
|
|
cwd=str(compose_root),
|
|
check=False,
|
|
)
|
|
raise
|
|
subprocess.run(
|
|
[
|
|
*compose_base_cmd(
|
|
component,
|
|
allow_prepared_engine_backend=allow_prepared_engine_backend,
|
|
),
|
|
"ps",
|
|
],
|
|
cwd=str(compose_root),
|
|
check=True,
|
|
)
|
|
|
|
|
|
def run_engine_node_intelligence_compose(services, entries):
|
|
if not is_engine_node_intelligence_transition("engine", entries):
|
|
die("Engine node-intelligence Compose called for unrelated artifact")
|
|
compose_root = component_compose_root("engine")
|
|
cmd = [
|
|
*compose_base_cmd("engine"),
|
|
"up",
|
|
"-d",
|
|
"--force-recreate",
|
|
"--pull",
|
|
"never",
|
|
"--no-deps",
|
|
*services,
|
|
]
|
|
try:
|
|
subprocess.run(cmd, cwd=str(compose_root), check=True)
|
|
except subprocess.CalledProcessError:
|
|
subprocess.run(
|
|
[
|
|
*compose_base_cmd("engine"),
|
|
"logs",
|
|
"--no-color",
|
|
"--tail=180",
|
|
*services,
|
|
],
|
|
cwd=str(compose_root),
|
|
check=False,
|
|
)
|
|
raise
|
|
subprocess.run(
|
|
[*compose_base_cmd("engine"), "ps"],
|
|
cwd=str(compose_root),
|
|
check=True,
|
|
)
|
|
|
|
|
|
def stop_and_remove_compose_services(component, services):
|
|
if not services:
|
|
return
|
|
compose_root = component_compose_root(component)
|
|
subprocess.run(
|
|
[*compose_base_cmd(component), "rm", "--stop", "--force", *services],
|
|
cwd=str(compose_root),
|
|
check=True,
|
|
)
|
|
|
|
|
|
def prepare_component_runtime(component, entries=None):
|
|
if is_engine_data_product_publish_grant_slice(component, entries):
|
|
ensure_engine_edp_managed_provisioner_keypair()
|
|
ensure_engine_publish_grant_private_state()
|
|
|
|
if is_engine_mcp_control_plane_slice(component, entries):
|
|
ensure_engine_edp_managed_provisioner_keypair()
|
|
ensure_engine_data_product_grant_private_state(include_reader=True)
|
|
|
|
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 is_engine_node_intelligence_transition(component, entries):
|
|
ensure_engine_node_intelligence_secret()
|
|
descriptor = current_engine_node_intelligence_descriptor()
|
|
if descriptor is None:
|
|
die("installed Engine node-intelligence descriptor is missing")
|
|
if descriptor["action"] == "activate":
|
|
install_engine_node_intelligence_image(descriptor)
|
|
return
|
|
|
|
if touches_engine_credential_sink(component, entries):
|
|
ensure_engine_credential_issuer_keypair()
|
|
prepare_engine_credential_backend_runtime()
|
|
return
|
|
|
|
if component == "module-foundry":
|
|
ensure_map_gateway_admin_secret()
|
|
ensure_foundry_edp_managed_provisioner_keypair()
|
|
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 = touches_external_data_plane_files(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_engine_edp_managed_provisioner_keypair()
|
|
ensure_foundry_edp_managed_provisioner_keypair()
|
|
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 == "platform" and is_platform_provider_catalog_only(entries):
|
|
return
|
|
if component_artifact_only(component):
|
|
return
|
|
run_build(component, entries)
|
|
prepare_component_runtime(component, entries)
|
|
if is_engine_node_intelligence_transition(component, entries):
|
|
run_engine_node_intelligence_compose(services, entries)
|
|
else:
|
|
run_compose(component, services, entries)
|
|
|
|
|
|
def healthcheck_url(check):
|
|
headers = {}
|
|
expected_json = {}
|
|
if isinstance(check, dict):
|
|
url = check.get("url")
|
|
headers = check.get("headers") or {}
|
|
expected_json = check.get("expected_json") 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:
|
|
if expected_json:
|
|
raw = response.read(64 * 1024 + 1)
|
|
if len(raw) > 64 * 1024:
|
|
last_error = "health response too large"
|
|
continue
|
|
try:
|
|
payload = json.loads(raw.decode("utf-8"))
|
|
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
last_error = "health response is not json"
|
|
continue
|
|
if not isinstance(payload, dict) or any(
|
|
payload.get(key) != expected
|
|
for key, expected in expected_json.items()
|
|
):
|
|
last_error = "health response contract mismatch"
|
|
continue
|
|
return
|
|
last_error = f"HTTP {response.status}"
|
|
except urllib.error.HTTPError as exc:
|
|
if 200 <= exc.code < 400 and not expected_json:
|
|
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 external_data_plane_healthcheck(require_managed=True):
|
|
expected_json = {
|
|
"ok": True,
|
|
"service": "nodedc-external-data-plane",
|
|
"database": "ready",
|
|
}
|
|
if require_managed:
|
|
expected_json["managedWriterBindingProvisioning"] = "enabled"
|
|
server_source = (
|
|
component_root("platform")
|
|
/ "platform/services/external-data-plane/src/server.mjs"
|
|
)
|
|
try:
|
|
server_text = server_source.read_text(encoding="utf-8")
|
|
except (OSError, UnicodeDecodeError):
|
|
server_text = ""
|
|
if 'managedReaderBindingProvisioning: config.managedProvisionerApiEnabled' in server_text:
|
|
expected_json["managedReaderBindingProvisioning"] = "enabled"
|
|
if 'managedWriterBindingLifetime: config.managedProvisionerApiEnabled ? "explicit-revoke" : "disabled"' in server_text:
|
|
expected_json["managedWriterBindingLifetime"] = "explicit-revoke"
|
|
if 'managedReaderBindingLifetime: config.managedProvisionerApiEnabled ? "explicit-revoke" : "disabled"' in server_text:
|
|
expected_json["managedReaderBindingLifetime"] = "explicit-revoke"
|
|
if 'foundryReaderBindingProvisioning: config.foundryProvisionerApiEnabled ? "digest+server-resolved-source" : "disabled"' in server_text:
|
|
expected_json["foundryReaderBindingProvisioning"] = "digest+server-resolved-source"
|
|
expected_json["foundryReaderBindingLifetime"] = "explicit-revoke"
|
|
return {
|
|
"url": "http://127.0.0.1:18106/healthz",
|
|
"expected_json": expected_json,
|
|
}
|
|
|
|
|
|
def module_foundry_healthcheck():
|
|
expected_json = {
|
|
"status": "ok",
|
|
"service": "nodedc-module-foundry",
|
|
}
|
|
server_source = component_root("module-foundry") / "server/catalog-server.mjs"
|
|
try:
|
|
server_text = server_source.read_text(encoding="utf-8")
|
|
except (OSError, UnicodeDecodeError):
|
|
server_text = ""
|
|
if "dataProductConsumerProvisioner" in server_text:
|
|
expected_json["dataProductConsumerProvisioner"] = {
|
|
"configured": True,
|
|
"auth": "dedicated-ed25519-service-identity",
|
|
"sourceScope": "external-data-plane-resolved",
|
|
}
|
|
return {
|
|
"url": "http://172.22.0.222:9920/healthz",
|
|
"expected_json": expected_json,
|
|
}
|
|
|
|
|
|
def component_healthchecks(component, entries=None, services=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 ()
|
|
if is_engine_node_intelligence_transition(component, entries):
|
|
# The exact sidecar/backend topology and live MCP capability calls are
|
|
# accepted separately below. Keep the generic HTTP barrier scoped to
|
|
# the backend generation recreated by this transition.
|
|
return ("http://127.0.0.1:3001/health",)
|
|
if component == "platform" and entries is not None and is_platform_provider_catalog_only(entries):
|
|
return ()
|
|
if component == "module-foundry":
|
|
return (module_foundry_healthcheck(),)
|
|
if (
|
|
(
|
|
is_engine_data_product_publish_grant_slice(component, entries)
|
|
or is_engine_composite_provider_v4_slice(component, entries)
|
|
or is_engine_provider_rotating_slot_slice(component, entries)
|
|
or is_engine_provider_authority_diagnostics_slice(component, entries)
|
|
or is_engine_depttrans_zone_authority_v1_slice(component, entries)
|
|
or is_engine_provider_target_host_policy_slice(component, entries)
|
|
or is_engine_agent_full_grant_migration_slice(component, entries)
|
|
or is_engine_mcp_control_plane_slice(component, entries)
|
|
or is_engine_mcp_ontology_sdk_slice(component, entries)
|
|
or is_engine_mcp_autonomy_provider_v5_slice(component, entries)
|
|
or is_engine_provider_security_catalog_slice(component, entries)
|
|
)
|
|
and services is not None
|
|
):
|
|
selected_services = set(services)
|
|
checks = []
|
|
if "app" in selected_services:
|
|
checks.append("http://127.0.0.1:8080/")
|
|
if "nodedc-backend" in selected_services:
|
|
checks.append("http://127.0.0.1:3001/health")
|
|
if is_engine_agent_full_grant_migration_slice(component, entries):
|
|
checks.append("http://127.0.0.1:3001/internal/engine-credential-sink/v1/health")
|
|
return tuple(checks)
|
|
if component == "platform" and entries is not None:
|
|
selected_services = tuple(services) if services is not None else component_services(component, entries)
|
|
# A narrow EDP application deploy must prove only the service it
|
|
# recreated. Unrelated Platform routes retain independent lifecycle and
|
|
# must not turn a valid EDP deploy or rollback into a false failure.
|
|
if selected_services == (EXTERNAL_DATA_PLANE_SERVICE,):
|
|
return (external_data_plane_healthcheck(),)
|
|
checks = list(COMPONENTS[component].get("healthchecks", ()))
|
|
if touches_engine_credential_sink(component, entries):
|
|
checks.append("http://127.0.0.1:3001/internal/engine-credential-sink/v1/health")
|
|
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 = touches_external_data_plane_files(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(external_data_plane_healthcheck())
|
|
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 compose_service_container_id(component, service):
|
|
result = subprocess.run(
|
|
[*compose_base_cmd(component), "ps", "-q", service],
|
|
cwd=str(component_compose_root(component)),
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
container_ids = [line.strip() for line in result.stdout.splitlines() if line.strip()]
|
|
if (
|
|
result.returncode != 0
|
|
or len(container_ids) != 1
|
|
or not re.fullmatch(r"[a-f0-9]{12,64}", container_ids[0])
|
|
):
|
|
die(f"Compose service container lookup failed: {service}")
|
|
return container_ids[0]
|
|
|
|
|
|
def healthcheck_compose_service(component, service):
|
|
healthcheck_container(compose_service_container_id(component, service))
|
|
|
|
|
|
def container_environment(container, label):
|
|
environment = {}
|
|
for raw in (container.get("Config") or {}).get("Env") or []:
|
|
if not isinstance(raw, str) or "=" not in raw:
|
|
die(f"{label} environment inventory is invalid")
|
|
key, value = raw.split("=", 1)
|
|
if not key or key in environment:
|
|
die(f"{label} environment inventory is invalid")
|
|
environment[key] = value
|
|
return environment
|
|
|
|
|
|
def validate_engine_node_intelligence_secret_mount(container, label):
|
|
matches = [
|
|
mount
|
|
for mount in (container.get("Mounts") or [])
|
|
if isinstance(mount, dict)
|
|
and mount.get("Destination") == ENGINE_NODE_INTELLIGENCE_SECRET_CONTAINER_PATH
|
|
]
|
|
if len(matches) != 1:
|
|
die(f"{label} secret mount inventory mismatch")
|
|
mount = matches[0]
|
|
if (
|
|
mount.get("Type") != "bind"
|
|
or mount.get("Source") != str(ENGINE_NODE_INTELLIGENCE_SECRET_FILE)
|
|
or mount.get("RW") is not False
|
|
):
|
|
die(f"{label} secret mount boundary mismatch")
|
|
|
|
|
|
def assert_engine_node_intelligence_container_absent():
|
|
result = subprocess.run(
|
|
[
|
|
str(DOCKER),
|
|
"container",
|
|
"ls",
|
|
"--all",
|
|
"--filter",
|
|
f"label=com.docker.compose.service={ENGINE_NODE_INTELLIGENCE_SERVICE}",
|
|
"--format",
|
|
"{{.ID}}",
|
|
],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
if result.returncode != 0 or result.stdout.strip():
|
|
die("Engine node-intelligence inactive runtime still has a sidecar container")
|
|
|
|
|
|
def engine_node_intelligence_live_probe_script():
|
|
return """
|
|
const {createNodeIntelligenceMcpClient}=await import('file:///app/server/nodeIntelligence/upstreamMcpClient.js');
|
|
const client=createNodeIntelligenceMcpClient();
|
|
const status=await client.status();
|
|
if(!status.ok||status.status!=='ready'||status.authSource!=='file'||status.server?.version!=='2.33.2'||!Object.values(status.capabilities||{}).every(Boolean))throw new Error('status');
|
|
const guidance=await client.callTool('get_node',{nodeType:'nodes-base.httpRequest',detail:'minimal'});
|
|
if(!guidance||typeof guidance!=='object')throw new Error('guidance');
|
|
const nodeValidation=await client.callTool('validate_node',{nodeType:'nodes-base.httpRequest',config:{url:'https://example.com'},mode:'minimal',profile:'ai-friendly'});
|
|
if(!nodeValidation||typeof nodeValidation!=='object'||nodeValidation.valid!==true)throw new Error('node-validation');
|
|
const workflowValidation=await client.callTool('validate_workflow',{workflow:{name:'NDC acceptance',nodes:[{id:'probe-1',name:'Start',type:'nodes-base.manualTrigger',typeVersion:1,position:[0,0],parameters:{}},{id:'probe-2',name:'Output',type:'nodes-base.set',typeVersion:3.4,position:[240,0],parameters:{}}],connections:{Start:{main:[[{node:'Output',type:'main',index:0}]]}},settings:{}},options:{validateNodes:true,validateConnections:true,validateExpressions:true,profile:'runtime'}});
|
|
if(!workflowValidation||typeof workflowValidation!=='object'||workflowValidation.valid!==true)throw new Error('workflow-validation');
|
|
process.stdout.write('node-intelligence-live:2.33.2:guidance+node+workflow');
|
|
""".strip()
|
|
|
|
|
|
def accept_engine_node_intelligence_runtime(descriptor):
|
|
if descriptor is None or descriptor.get("action") != "activate":
|
|
die("Engine node-intelligence activation descriptor is required")
|
|
validate_installed_engine_node_intelligence_source(descriptor)
|
|
ensure_engine_node_intelligence_secret()
|
|
image = inspect_engine_node_intelligence_image(descriptor)
|
|
|
|
sidecar_id = compose_service_container_id(
|
|
"engine",
|
|
ENGINE_NODE_INTELLIGENCE_SERVICE,
|
|
)
|
|
backend_id = compose_service_container_id("engine", "nodedc-backend")
|
|
healthcheck_container(sidecar_id)
|
|
healthcheck_container(backend_id)
|
|
sidecar = inspect_container(sidecar_id)
|
|
backend = inspect_container(backend_id)
|
|
|
|
sidecar_config = sidecar.get("Config") or {}
|
|
sidecar_host = sidecar.get("HostConfig") or {}
|
|
sidecar_environment = container_environment(
|
|
sidecar,
|
|
"Engine node-intelligence sidecar",
|
|
)
|
|
if (
|
|
sidecar.get("Image") != image["Id"]
|
|
or sidecar_config.get("Image") != ENGINE_NODE_INTELLIGENCE_IMAGE
|
|
or sidecar_config.get("User")
|
|
!= f"{ENGINE_NODE_INTELLIGENCE_RUNTIME_UID}:{ENGINE_NODE_INTELLIGENCE_RUNTIME_GID}"
|
|
or sidecar_host.get("ReadonlyRootfs") is not True
|
|
or sidecar_host.get("PortBindings") not in (None, {})
|
|
or set(sidecar_host.get("CapDrop") or []) != {"ALL"}
|
|
or "no-new-privileges:true" not in (sidecar_host.get("SecurityOpt") or [])
|
|
or sidecar_environment.get("AUTH_TOKEN_FILE")
|
|
!= ENGINE_NODE_INTELLIGENCE_SECRET_CONTAINER_PATH
|
|
or sidecar_environment.get("NODE_DB_PATH") != "/app/data/nodes.db"
|
|
or sidecar_environment.get("REBUILD_ON_START") != "false"
|
|
or sidecar_environment.get("N8N_MCP_TELEMETRY_DISABLED") != "true"
|
|
):
|
|
die("Engine node-intelligence sidecar runtime contract mismatch")
|
|
if any(
|
|
key in sidecar_environment
|
|
for key in ("AUTH_TOKEN", "N8N_API_URL", "N8N_API_KEY")
|
|
):
|
|
die("Engine node-intelligence sidecar runtime authority leak")
|
|
validate_engine_node_intelligence_secret_mount(
|
|
sidecar,
|
|
"Engine node-intelligence sidecar",
|
|
)
|
|
|
|
backend_environment = container_environment(backend, "Engine backend")
|
|
if (
|
|
backend_environment.get("ENGINE_NODE_INTELLIGENCE_MCP_URL")
|
|
!= f"http://{ENGINE_NODE_INTELLIGENCE_SERVICE}:3000/mcp"
|
|
or backend_environment.get("ENGINE_NODE_INTELLIGENCE_AUTH_TOKEN_FILE")
|
|
!= ENGINE_NODE_INTELLIGENCE_SECRET_CONTAINER_PATH
|
|
or backend_environment.get("ENGINE_NODE_INTELLIGENCE_ASSET_ID")
|
|
!= f"nodedc-node-intelligence@{ENGINE_NODE_INTELLIGENCE_RELEASE_ID.replace('-', '+', 1)}"
|
|
or "ENGINE_NODE_INTELLIGENCE_AUTH_TOKEN" in backend_environment
|
|
):
|
|
die("Engine backend node-intelligence boundary mismatch")
|
|
validate_engine_node_intelligence_secret_mount(
|
|
backend,
|
|
"Engine backend node-intelligence",
|
|
)
|
|
|
|
result = run_engine_backend_probe(
|
|
(
|
|
"node",
|
|
"--input-type=module",
|
|
"-e",
|
|
engine_node_intelligence_live_probe_script(),
|
|
),
|
|
"node-intelligence live capability",
|
|
container_id=backend_id,
|
|
)
|
|
expected = "node-intelligence-live:2.33.2:guidance+node+workflow"
|
|
if result != expected:
|
|
die("Engine node-intelligence live capability acceptance mismatch")
|
|
backend_runtime = preflight_engine_credential_backend_runtime()
|
|
if backend_runtime["mode"] != "verified-derived-retry":
|
|
die("Engine node-intelligence backend immutable runtime acceptance failed")
|
|
return result
|
|
|
|
|
|
def accept_engine_agent_full_grant_migration_runtime():
|
|
expected_scopes = (
|
|
"engine:l2:context:read",
|
|
"engine:l2:graph:read",
|
|
"engine:l2:node-schema:read",
|
|
"engine:l2:graph:plan",
|
|
"engine:l2:graph:write",
|
|
"engine:l2:data-product-publish-grant:plan",
|
|
"engine:l2:data-product-publish-grant:write",
|
|
"engine:l2:validate",
|
|
"engine:l2:runtime:read",
|
|
"engine:l2:deploy-run",
|
|
"engine:l2:execution:stop",
|
|
)
|
|
script = """
|
|
import crypto from 'node:crypto';
|
|
import fs from 'node:fs';
|
|
const file='/app/server/engineAgents/store.js';
|
|
const digest=crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
const module=await import('file:///app/server/engineAgents/store.js');
|
|
const expected=JSON.parse(process.argv[1]);
|
|
if(digest!==process.argv[2]||JSON.stringify(module.ENGINE_AGENT_SCOPES)!==JSON.stringify(expected)||module.ENGINE_AGENT_FULL_DEVELOPER_PROFILE!=='full-developer')process.exit(2);
|
|
process.stdout.write('store-v2-full-developer:'+digest);
|
|
""".strip()
|
|
result = run_engine_backend_probe(
|
|
(
|
|
"node",
|
|
"--input-type=module",
|
|
"-e",
|
|
script,
|
|
json.dumps(expected_scopes, separators=(",", ":")),
|
|
ENGINE_AGENT_FULL_GRANT_MIGRATION_TARGET_SHA256,
|
|
),
|
|
"agent full grant migration",
|
|
container_id=engine_backend_container_id(),
|
|
)
|
|
expected = f"store-v2-full-developer:{ENGINE_AGENT_FULL_GRANT_MIGRATION_TARGET_SHA256}"
|
|
if result != expected:
|
|
die("Engine agent full grant migration runtime acceptance mismatch")
|
|
return result
|
|
|
|
|
|
def run_healthchecks(component, entries=None, services=None):
|
|
if component == "platform" and entries is not None and is_platform_provider_catalog_only(entries):
|
|
return
|
|
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
|
|
if is_engine_node_intelligence_transition(component, entries):
|
|
descriptor = current_engine_node_intelligence_descriptor()
|
|
if descriptor is None:
|
|
die(
|
|
"installed Engine node-intelligence transition descriptor "
|
|
"is missing during acceptance"
|
|
)
|
|
if descriptor["action"] == "activate":
|
|
accept_engine_node_intelligence_runtime(descriptor)
|
|
healthcheck_url("http://127.0.0.1:3001/health")
|
|
return
|
|
healthcheck_compose_service("engine", "nodedc-backend")
|
|
healthcheck_url("http://127.0.0.1:3001/health")
|
|
backend = preflight_engine_credential_backend_runtime()
|
|
if backend["mode"] != "verified-derived-retry":
|
|
die("Engine node-intelligence rollback backend acceptance failed")
|
|
assert_engine_node_intelligence_container_absent()
|
|
return
|
|
touches_publish_grant = is_engine_data_product_publish_grant_slice(
|
|
component,
|
|
entries,
|
|
)
|
|
touches_composite_provider_v4 = is_engine_composite_provider_v4_slice(
|
|
component,
|
|
entries,
|
|
)
|
|
touches_provider_rotating_slot = is_engine_provider_rotating_slot_slice(
|
|
component,
|
|
entries,
|
|
)
|
|
touches_provider_authority_diagnostics = is_engine_provider_authority_diagnostics_slice(
|
|
component,
|
|
entries,
|
|
)
|
|
touches_depttrans_zone_authority_v1 = is_engine_depttrans_zone_authority_v1_slice(
|
|
component,
|
|
entries,
|
|
)
|
|
touches_provider_target_host_policy = is_engine_provider_target_host_policy_slice(
|
|
component,
|
|
entries,
|
|
)
|
|
touches_agent_grant_migration = is_engine_agent_full_grant_migration_slice(
|
|
component,
|
|
entries,
|
|
)
|
|
touches_mcp_control_plane = is_engine_mcp_control_plane_slice(component, entries)
|
|
touches_mcp_ontology_sdk = is_engine_mcp_ontology_sdk_slice(component, entries)
|
|
touches_mcp_autonomy_provider_v5 = is_engine_mcp_autonomy_provider_v5_slice(component, entries)
|
|
touches_provider_catalog = is_engine_provider_security_catalog_slice(component, entries)
|
|
if (
|
|
touches_engine_credential_sink(component, entries)
|
|
or touches_publish_grant
|
|
or touches_composite_provider_v4
|
|
or touches_provider_rotating_slot
|
|
or touches_provider_authority_diagnostics
|
|
or touches_depttrans_zone_authority_v1
|
|
or touches_provider_target_host_policy
|
|
or touches_agent_grant_migration
|
|
or touches_mcp_control_plane
|
|
or touches_mcp_ontology_sdk
|
|
or touches_mcp_autonomy_provider_v5
|
|
or touches_provider_catalog
|
|
):
|
|
# The HTTP endpoint can become ready before Docker publishes the first
|
|
# successful health probe. Wait for the Compose health barrier before
|
|
# the strict immutable-runtime preflight, both on apply and retry.
|
|
healthcheck_compose_service("engine", "nodedc-backend")
|
|
for url in component_healthchecks(component, entries, services):
|
|
healthcheck_url(url)
|
|
if (
|
|
touches_engine_credential_sink(component, entries)
|
|
or touches_publish_grant
|
|
or touches_composite_provider_v4
|
|
or touches_provider_rotating_slot
|
|
or touches_provider_authority_diagnostics
|
|
or touches_depttrans_zone_authority_v1
|
|
or touches_provider_target_host_policy
|
|
or touches_agent_grant_migration
|
|
or touches_mcp_control_plane
|
|
or touches_mcp_ontology_sdk
|
|
or touches_mcp_autonomy_provider_v5
|
|
or touches_provider_catalog
|
|
):
|
|
runtime = preflight_engine_credential_backend_runtime()
|
|
if runtime["mode"] != "verified-derived-retry":
|
|
die("Engine backend immutable runtime activation acceptance failed")
|
|
if touches_agent_grant_migration:
|
|
accept_engine_agent_full_grant_migration_runtime()
|
|
if touches_mcp_control_plane:
|
|
accept_engine_mcp_control_plane_runtime()
|
|
if touches_mcp_ontology_sdk:
|
|
accept_engine_mcp_ontology_sdk_runtime()
|
|
if touches_mcp_autonomy_provider_v5:
|
|
state = engine_mcp_autonomy_provider_v5_installed_state()
|
|
if state == "target":
|
|
accept_engine_mcp_autonomy_provider_v5_runtime()
|
|
if touches_provider_catalog:
|
|
accept_engine_provider_security_catalog_runtime()
|
|
if touches_publish_grant:
|
|
accept_engine_composite_provider_runtime()
|
|
if touches_composite_provider_v4:
|
|
root = component_root("engine")
|
|
installed_sha256 = {
|
|
rel: sha256_file(root / rel)
|
|
for rel in ENGINE_COMPOSITE_PROVIDER_V4_ARTIFACT_ENTRIES
|
|
}
|
|
if installed_sha256 == ENGINE_COMPOSITE_PROVIDER_V4_TARGET_SHA256:
|
|
if accept_engine_composite_provider_runtime() is None:
|
|
die("Engine composite provider v4 live acceptance was not executed")
|
|
elif installed_sha256 != ENGINE_COMPOSITE_PROVIDER_V4_PREDECESSOR_SHA256:
|
|
die("Engine composite provider v4 installed source state is neither target nor rollback predecessor")
|
|
if touches_provider_rotating_slot:
|
|
root = component_root("engine")
|
|
installed_sha256 = {
|
|
rel: sha256_file(root / rel)
|
|
for rel in ENGINE_PROVIDER_ROTATING_SLOT_ARTIFACT_ENTRIES
|
|
}
|
|
if installed_sha256 == ENGINE_PROVIDER_ROTATING_SLOT_TARGET_SHA256:
|
|
accept_engine_provider_rotating_slot_runtime()
|
|
elif installed_sha256 != ENGINE_PROVIDER_ROTATING_SLOT_PREDECESSOR_SHA256:
|
|
die("Engine provider rotating slot installed state is neither target nor rollback predecessor")
|
|
if touches_provider_authority_diagnostics:
|
|
root = component_root("engine")
|
|
installed_sha256 = {
|
|
rel: sha256_file(root / rel)
|
|
for rel in ENGINE_PROVIDER_AUTHORITY_DIAGNOSTICS_ARTIFACT_ENTRIES
|
|
}
|
|
if installed_sha256 == ENGINE_PROVIDER_AUTHORITY_DIAGNOSTICS_TARGET_SHA256:
|
|
accept_engine_provider_authority_diagnostics_runtime()
|
|
elif installed_sha256 != ENGINE_PROVIDER_AUTHORITY_DIAGNOSTICS_PREDECESSOR_SHA256:
|
|
die("Engine provider authority diagnostics installed state is neither target nor rollback predecessor")
|
|
if touches_depttrans_zone_authority_v1:
|
|
root = component_root("engine")
|
|
target_state = all(
|
|
(root / rel).is_file()
|
|
and not (root / rel).is_symlink()
|
|
and sha256_file(root / rel) == expected
|
|
for rel, expected in ENGINE_DEPTTRANS_ZONE_AUTHORITY_V1_TARGET_SHA256.items()
|
|
)
|
|
predecessor_state = all(
|
|
(root / rel).is_file()
|
|
and not (root / rel).is_symlink()
|
|
and sha256_file(root / rel) == expected
|
|
for rel, expected in ENGINE_DEPTTRANS_ZONE_AUTHORITY_V1_PREDECESSOR_SHA256.items()
|
|
) and all(
|
|
not (root / rel).exists() and not (root / rel).is_symlink()
|
|
for rel in ENGINE_DEPTTRANS_ZONE_AUTHORITY_V1_NEW_PATHS
|
|
)
|
|
if target_state:
|
|
accept_engine_depttrans_zone_authority_v1_runtime()
|
|
elif not predecessor_state:
|
|
die("Engine Depttrans zone authority v1 installed state is neither target nor rollback predecessor")
|
|
if touches_provider_target_host_policy:
|
|
root = component_root("engine")
|
|
installed_sha256 = {
|
|
rel: sha256_file(root / rel)
|
|
for rel in ENGINE_PROVIDER_TARGET_HOST_POLICY_ARTIFACT_ENTRIES
|
|
}
|
|
if installed_sha256 == ENGINE_PROVIDER_TARGET_HOST_POLICY_TARGET_SHA256:
|
|
accept_engine_provider_target_host_policy_runtime()
|
|
elif installed_sha256 != ENGINE_PROVIDER_TARGET_HOST_POLICY_PREDECESSOR_SHA256:
|
|
die("Engine provider target host policy installed state is neither target nor rollback predecessor")
|
|
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
|
|
services = None
|
|
transition_descriptor = None
|
|
node_intelligence_descriptor = None
|
|
node_intelligence_service_stopped = False
|
|
apply_started = False
|
|
engine_backend_recreated = False
|
|
engine_backend_initial_mode = None
|
|
runtime_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 is_engine_data_product_publish_grant_slice(component, entries):
|
|
preflight_engine_data_product_publish_grant_predecessor(payload_dir)
|
|
publish_backend_preflight = preflight_engine_credential_backend_runtime()
|
|
if publish_backend_preflight["mode"] != "verified-derived-retry":
|
|
die("Engine data product publish grant requires the active immutable credential backend")
|
|
if is_engine_composite_provider_v4_slice(component, entries):
|
|
preflight_engine_composite_provider_v4_predecessor()
|
|
composite_backend_preflight = preflight_engine_credential_backend_runtime()
|
|
if composite_backend_preflight["mode"] != "verified-derived-retry":
|
|
die("Engine composite provider v4 requires the active immutable credential backend")
|
|
if is_engine_provider_rotating_slot_slice(component, entries):
|
|
preflight_engine_provider_rotating_slot_predecessor()
|
|
rotating_slot_backend_preflight = preflight_engine_credential_backend_runtime()
|
|
if rotating_slot_backend_preflight["mode"] != "verified-derived-retry":
|
|
die("Engine provider rotating slot requires the active immutable credential backend")
|
|
if is_engine_provider_authority_diagnostics_slice(component, entries):
|
|
preflight_engine_provider_authority_diagnostics_predecessor()
|
|
authority_diagnostics_backend_preflight = preflight_engine_credential_backend_runtime()
|
|
if authority_diagnostics_backend_preflight["mode"] != "verified-derived-retry":
|
|
die("Engine provider authority diagnostics requires the active immutable credential backend")
|
|
if is_engine_depttrans_zone_authority_v1_slice(component, entries):
|
|
preflight_engine_depttrans_zone_authority_v1_predecessor()
|
|
depttrans_authority_backend_preflight = preflight_engine_credential_backend_runtime()
|
|
if depttrans_authority_backend_preflight["mode"] != "verified-derived-retry":
|
|
die("Engine Depttrans zone authority v1 requires the active immutable credential backend")
|
|
if is_engine_provider_target_host_policy_slice(component, entries):
|
|
preflight_engine_provider_target_host_policy_predecessor()
|
|
target_host_policy_backend_preflight = preflight_engine_credential_backend_runtime()
|
|
if target_host_policy_backend_preflight["mode"] != "verified-derived-retry":
|
|
die("Engine provider target host policy requires the active immutable credential backend")
|
|
if is_engine_agent_full_grant_migration_slice(component, entries):
|
|
preflight_engine_agent_full_grant_migration_predecessor()
|
|
migration_backend_preflight = preflight_engine_credential_backend_runtime()
|
|
if migration_backend_preflight["mode"] != "verified-derived-retry":
|
|
die("Engine agent full grant migration requires the active immutable credential backend")
|
|
if is_engine_node_intelligence_transition(component, entries):
|
|
node_intelligence_preflight = preflight_engine_node_intelligence_predecessor(
|
|
payload_dir
|
|
)
|
|
node_intelligence_descriptor = node_intelligence_preflight["descriptor"]
|
|
if is_engine_mcp_control_plane_slice(component, entries):
|
|
preflight_engine_mcp_control_plane_predecessor(payload_dir)
|
|
if is_engine_mcp_ontology_sdk_slice(component, entries):
|
|
preflight_engine_mcp_ontology_sdk_predecessor(payload_dir)
|
|
if is_engine_mcp_autonomy_provider_v5_slice(component, entries):
|
|
preflight_engine_mcp_autonomy_provider_v5_predecessor(payload_dir)
|
|
if is_engine_provider_security_catalog_slice(component, entries):
|
|
preflight_engine_provider_security_catalog_predecessor()
|
|
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 touches_engine_credential_sink(component, entries):
|
|
engine_backend_initial_mode = preflight_engine_credential_backend_runtime()["mode"]
|
|
|
|
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)
|
|
if component == "platform" and touches_external_data_plane_files(entries):
|
|
validate_backup_partition(
|
|
entries,
|
|
read_backup_path_list(backup_dir / "existing-files.txt"),
|
|
read_backup_path_list(backup_dir / "missing-files.txt"),
|
|
"Platform pre-apply",
|
|
)
|
|
|
|
apply_started = True
|
|
if (
|
|
node_intelligence_descriptor is not None
|
|
and node_intelligence_descriptor["action"] == "rollback-inactive"
|
|
):
|
|
stop_and_remove_compose_services(
|
|
"engine",
|
|
(ENGINE_NODE_INTELLIGENCE_SERVICE,),
|
|
)
|
|
node_intelligence_service_stopped = 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)
|
|
|
|
if touches_engine_credential_sink(component, entries):
|
|
run_build(component, entries)
|
|
prepare_component_runtime(component, entries)
|
|
# From this point the exact immutable override/image are
|
|
# verified, so even an ambiguous partial Compose failure
|
|
# can be safely reconciled by recreating on that runtime.
|
|
engine_backend_recreated = True
|
|
run_compose(component, services, entries)
|
|
else:
|
|
# Mark the generic runtime before Compose so a partial
|
|
# candidate start is always eligible for its domain rollback.
|
|
runtime_started = bool(services)
|
|
run_component_runtime(component, entries, services)
|
|
run_healthchecks(component, entries, services)
|
|
|
|
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
|
|
):
|
|
if 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)
|
|
elif (
|
|
node_intelligence_descriptor is not None
|
|
and is_engine_node_intelligence_transition(component, entries)
|
|
):
|
|
try:
|
|
restored_action = rollback_engine_node_intelligence(
|
|
root,
|
|
backup_dir,
|
|
entries,
|
|
current_stamp,
|
|
runtime_started=runtime_started,
|
|
node_intelligence_service_stopped=node_intelligence_service_stopped,
|
|
)
|
|
rollback_status = f"ok:{restored_action}"
|
|
print(
|
|
f"engine-node-intelligence-automatic-rollback={rollback_status}",
|
|
file=sys.stderr,
|
|
)
|
|
except Exception as rollback_exc:
|
|
rollback_status = f"failed:{type(rollback_exc).__name__}"
|
|
print(
|
|
"engine-node-intelligence-automatic-rollback=failed",
|
|
file=sys.stderr,
|
|
)
|
|
elif touches_engine_credential_sink(component, entries):
|
|
try:
|
|
restored_action = rollback_engine_credential_bridge(
|
|
component,
|
|
root,
|
|
backup_dir,
|
|
entries,
|
|
current_stamp,
|
|
engine_backend_recreated=engine_backend_recreated,
|
|
engine_backend_initial_mode=engine_backend_initial_mode,
|
|
)
|
|
rollback_status = f"ok:{restored_action}"
|
|
print(f"engine-credential-automatic-rollback={rollback_status}", file=sys.stderr)
|
|
except ReconciliationRequired:
|
|
rollback_status = "deferred:reconciliation-required"
|
|
print("engine-credential-automatic-rollback=deferred:reconciliation-required", file=sys.stderr)
|
|
except Exception as rollback_exc:
|
|
rollback_status = f"failed:{type(rollback_exc).__name__}"
|
|
print("engine-credential-automatic-rollback=failed", file=sys.stderr)
|
|
elif (
|
|
(
|
|
is_engine_data_product_publish_grant_slice(component, entries)
|
|
or is_engine_composite_provider_v4_slice(component, entries)
|
|
or is_engine_provider_rotating_slot_slice(component, entries)
|
|
or is_engine_provider_authority_diagnostics_slice(component, entries)
|
|
or is_engine_depttrans_zone_authority_v1_slice(component, entries)
|
|
or is_engine_provider_target_host_policy_slice(component, entries)
|
|
or is_engine_agent_full_grant_migration_slice(component, entries)
|
|
or is_engine_mcp_control_plane_slice(component, entries)
|
|
or is_engine_mcp_ontology_sdk_slice(component, entries)
|
|
or is_engine_mcp_autonomy_provider_v5_slice(component, entries)
|
|
or is_engine_provider_security_catalog_slice(component, entries)
|
|
)
|
|
and services is not None
|
|
):
|
|
try:
|
|
restored_state = rollback_engine_apply(
|
|
root,
|
|
backup_dir,
|
|
entries,
|
|
current_stamp,
|
|
runtime_started,
|
|
services,
|
|
)
|
|
rollback_status = f"ok:engine-overlay:{restored_state}"
|
|
print(f"engine-automatic-rollback={rollback_status}", file=sys.stderr)
|
|
except Exception as rollback_exc:
|
|
rollback_status = f"failed:{type(rollback_exc).__name__}"
|
|
print("engine-automatic-rollback=failed", file=sys.stderr)
|
|
elif (
|
|
component == "platform"
|
|
and entries is not None
|
|
and services is not None
|
|
and touches_external_data_plane_files(entries)
|
|
):
|
|
try:
|
|
restored_state = rollback_platform_apply(
|
|
root,
|
|
backup_dir,
|
|
entries,
|
|
current_stamp,
|
|
runtime_started,
|
|
services,
|
|
)
|
|
rollback_status = f"ok:platform-overlay:{restored_state}"
|
|
print(f"platform-automatic-rollback={rollback_status}", file=sys.stderr)
|
|
except Exception as rollback_exc:
|
|
rollback_status = f"failed:{type(rollback_exc).__name__}"
|
|
print("platform-automatic-rollback=failed", file=sys.stderr)
|
|
failed_path = None
|
|
if artifact.exists() and rollback_status != "deferred:reconciliation-required":
|
|
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": (
|
|
"reconciliation-required"
|
|
if rollback_status == "deferred:reconciliation-required"
|
|
else "failed"
|
|
),
|
|
})
|
|
if backup_id:
|
|
print(f"backup={backup_id}", file=sys.stderr)
|
|
raise
|
|
|
|
|
|
def main(argv):
|
|
if sys.version_info < (3, 8):
|
|
die("Python 3.8 or newer is required")
|
|
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)
|