diff --git a/infra/.env.example b/infra/.env.example index 1fc4efd..dc0af65 100644 --- a/infra/.env.example +++ b/infra/.env.example @@ -75,10 +75,20 @@ EXTERNAL_DATA_PLANE_WRITER_BINDING_MAX_TTL_DAYS=90 EXTERNAL_DATA_PLANE_MAX_FUTURE_SKEW_SECONDS=300 EXTERNAL_DATA_PLANE_RETENTION_SWEEP_MS=3600000 EXTERNAL_DATA_PLANE_LEGACY_INTAKE_ENABLED=false -# The writer-provisioner secret is not an env value. The root-owned deploy -# runner keeps it in /volume1/docker/nodedc-platform/secrets/external-data-plane-provisioner/ -# and mounts it -# only into External Data Plane and the future dedicated Engine provisioner. +# Internal control-plane writer/reader binding issuance; keep false until the +# atomic NDC L2 ensure-grant operation is deployed. Legacy path returns a +# plaintext capability and must never be exposed to users. +EXTERNAL_DATA_PLANE_PROVISIONING_ENABLED=false +# Digest-only managed writer-binding ensure. It accepts only signed Engine +# service requests; the legacy provisioner bearer is deliberately invalid here. +# Keep false until the matching Engine private key is provisioned. The trust +# directory is mounted read-only into EDP and contains only `public-key.pem`. +EXTERNAL_DATA_PLANE_MANAGED_PROVISIONING_ENABLED=false +EXTERNAL_DATA_PLANE_MANAGED_PROVISIONER_SERVICE_ID=nodedc-engine +EXTERNAL_DATA_PLANE_MANAGED_PROVISIONER_KEY_ID=engine-edp-managed-provisioner-v1 +EXTERNAL_DATA_PLANE_MANAGED_PROVISIONER_AUDIENCE=nodedc-external-data-plane.managed-provisioning.v1 +EXTERNAL_DATA_PLANE_MANAGED_PROVISIONER_MAX_SKEW_SECONDS=60 +EXTERNAL_DATA_PLANE_MANAGED_PROVISIONER_REPLAY_CACHE_MAX_ENTRIES=10000 # notification core NOTIFICATION_PG_DB=nodedc_notifications @@ -119,8 +129,10 @@ AI_WORKSPACE_HUB_FALLBACK_URLS= AI_WORKSPACE_ONTOLOGY_MCP_PUBLIC_URL= ONTOLOGY_CORE_HOST_BIND=127.0.0.1:18104 -# Gelios Gateway — storage/read service. Provider credentials belong to the -# protected Engine Collector and are never configured in this service. +# Gelios Gateway — frozen legacy storage/read compatibility service. Provider +# credentials belong to the protected Engine Collector and are never configured +# in this service. Keep this contour reproducible; do not use it as a template +# for new providers. GELIOS_TIMESCALE_IMAGE=timescale/timescaledb-ha:pg16.14-ts2.28.2-all GELIOS_PG_DB=nodedc_gelios GELIOS_PG_USER=nodedc_gelios @@ -128,8 +140,6 @@ GELIOS_PG_PASS=change-me-generate-with-infra-scripts-init-dev-env # URL-encode reserved characters in GELIOS_PG_PASS when forming this URL. GELIOS_DATABASE_URL=postgresql://nodedc_gelios:change-me-generate-with-infra-scripts-init-dev-env@gelios-postgres:5432/nodedc_gelios GELIOS_GATEWAY_HOST_BIND=127.0.0.1:18105 -# Explicit tenant and connection identifiers are deployment configuration; -# do not encode a customer or pilot name in source defaults. GELIOS_TENANT_ID=replace-with-tenant-id GELIOS_CONNECTION_ID=gelios-connection-id # `allowlist` accepts only GELIOS_ALLOWED_UNIT_IDS. `all` accepts every unit diff --git a/infra/deploy-runner/build-engine-agent-full-grant-migration-artifact.mjs b/infra/deploy-runner/build-engine-agent-full-grant-migration-artifact.mjs new file mode 100644 index 0000000..c9b19a2 --- /dev/null +++ b/infra/deploy-runner/build-engine-agent-full-grant-migration-artifact.mjs @@ -0,0 +1,121 @@ +#!/usr/bin/env node +import { createHash } from 'node:crypto' +import { spawnSync } from 'node:child_process' +import { cp, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const here = dirname(fileURLToPath(import.meta.url)) +const platformRoot = resolve(here, '../..') +const workspaceRoot = resolve(platformRoot, '..') +const engineRoot = resolve(workspaceRoot, 'NODEDC_ENGINE_INFRA') +const artifactRoot = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || join(workspaceRoot, 'deploy-artifacts')) +const [patchId = '', ...extra] = process.argv.slice(2) +const storeRelativePath = 'nodedc-source/server/engineAgents/store.js' +const predecessorSha256 = '52daa43499d6d9a97fe7ffa891edb9212b7791e733f91dd3ca686d42739b7e9a' +const targetSha256 = '2e62654c2dc12905efcc83a9dff45a818dd7b47924a10600160835c4416540e9' +const previouslyIssuedPatchIds = new Set([ + 'engine-agent-full-grant-migration-20260717-001', +]) + +if (extra.length || !/^engine-agent-full-grant-migration-\d{8}-\d{3}$/.test(patchId)) { + throw new Error('usage: build-engine-agent-full-grant-migration-artifact.mjs ') +} +if (previouslyIssuedPatchIds.has(patchId)) { + throw new Error('engine_agent_full_grant_migration_patch_id_already_issued') +} + +const source = join(engineRoot, storeRelativePath) +const sourceInfo = await lstat(source) +if (sourceInfo.isSymbolicLink() || !sourceInfo.isFile()) throw new Error('engine_agent_store_source_unsafe') +const sourceBytes = await readFile(source) +if (digest(sourceBytes) !== targetSha256) throw new Error('engine_agent_store_target_sha256_mismatch') +const sourceText = sourceBytes.toString('utf8') +for (const required of [ + '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 (!sourceText.includes(required)) throw new Error(`engine_agent_store_contract_missing:${required}`) +} +if (/gelios|robot2b/i.test(sourceText)) throw new Error('engine_agent_store_provider_logic_forbidden') +run('node', ['--check', source]) + +await mkdir(artifactRoot, { recursive: true }) +const artifact = join(artifactRoot, `nodedc-${patchId}.tgz`) +await assertFresh(artifact) +const stage = await mkdtemp(join(tmpdir(), 'nodedc-engine-agent-grant-migration-')) +try { + const destination = join(stage, 'payload', storeRelativePath) + await mkdir(dirname(destination), { recursive: true }) + await cp(source, destination, { force: false, verbatimSymlinks: true }) + await writeFile( + join(stage, 'manifest.env'), + `id=${patchId}\ncomponent=engine\ntype=app-overlay\n`, + 'utf8', + ) + await writeFile(join(stage, 'files.txt'), `${storeRelativePath}\n`, 'utf8') + run('python3', ['-c', canonicalTarScript(), artifact, stage]) + const artifactBytes = await readFile(artifact) + console.log(JSON.stringify({ + ok: true, + patchId, + artifact, + sha256: digest(artifactBytes), + entries: [storeRelativePath], + predecessorSha256, + targetSha256, + services: ['nodedc-backend'], + excluded: ['n8n', 'app', 'databases', 'credentials', 'runtime-data'], + }, null, 2)) +} finally { + await rm(stage, { recursive: true, force: true }) +} + +async function assertFresh(target) { + try { + await lstat(target) + } catch (error) { + if (error?.code === 'ENOENT') return + throw error + } + throw new Error('engine_agent_full_grant_migration_artifact_already_exists') +} + +function canonicalTarScript() { + return [ + 'import gzip, io, pathlib, sys, tarfile', + 'root=pathlib.Path(sys.argv[2])', + "with open(sys.argv[1], 'wb') as out:", + " with gzip.GzipFile(filename='', mode='wb', fileobj=out, compresslevel=9, mtime=0) as gz:", + " with tarfile.open(fileobj=gz, mode='w', format=tarfile.PAX_FORMAT) as tar:", + " for top in ('manifest.env','files.txt','payload'):", + " p=root/top; paths=[p] + (sorted(p.rglob('*')) if p.is_dir() else [])", + ' for x in paths:', + " info=tar.gettarinfo(str(x), arcname=x.relative_to(root).as_posix())", + " info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644", + " with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info, src if info.isfile() else None)", + ].join('\n') +} + +function run(command, args) { + const result = spawnSync(command, args, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + maxBuffer: 16 * 1024 * 1024, + }) + if (result.status !== 0) throw new Error(`${command}_failed:${result.stderr || result.stdout}`) + return result +} + +function digest(bytes) { + return createHash('sha256').update(bytes).digest('hex') +} diff --git a/infra/deploy-runner/build-engine-credential-sink-recovery-artifact.mjs b/infra/deploy-runner/build-engine-credential-sink-recovery-artifact.mjs new file mode 100644 index 0000000..6dc72ba --- /dev/null +++ b/infra/deploy-runner/build-engine-credential-sink-recovery-artifact.mjs @@ -0,0 +1,156 @@ +#!/usr/bin/env node +import { createHash } from 'node:crypto' +import { spawnSync } from 'node:child_process' +import { lstat, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const here = dirname(fileURLToPath(import.meta.url)) +const platformRoot = resolve(here, '../..') +const workspaceRoot = resolve(platformRoot, '..') +const artifactRoot = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || join(workspaceRoot, 'deploy-artifacts')) +const sourceArtifact = resolve( + process.env.NODEDC_ENGINE_CREDENTIAL_SINK_RECOVERY_SOURCE + || '/Volumes/docker/nodedc-deploy/applied/nodedc-engine-credential-sink-20260716-001.tgz', +) +const [patchId = '', ...extra] = process.argv.slice(2) + +if (extra.length || !/^engine-credential-sink-recovery-\d{8}-\d{3}$/.test(patchId)) { + throw new Error('usage: build-engine-credential-sink-recovery-artifact.mjs ') +} + +const sourceArtifactSha256 = '0a96add05fe59db8f490927f66e07a84490474a7afb3ef7de51e1d6fd96f86a2' +const sourceManifest = 'id=engine-credential-sink-20260716-001\ncomponent=engine\ntype=app-overlay\n' +const entries = Object.freeze([ + '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', + 'nodedc-source/services/backend/credential-sink/docker-compose.immutable-runtime.yml', +]) +const payloadSha256 = Object.freeze({ + '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': 'e3450a4e1d5318dbac37627b67804d62804e27e2032c9e90478a1e739cea6d3d', + 'nodedc-source/server/routes/engineCredentialSink.js': '9cbb69dbc8cbe6181cd5b0170fe9c4d717b0a173866ca98cba3e3766c0bab94e', + 'nodedc-source/server/routes/n8n.js': '783d822e2457d82e890f43bc00c7e33822077dc2841f0511a89ecb210fd36d48', + 'nodedc-source/server/routes/ndcAgentMcp.js': 'fbb3342b1a617b956d5b3a6a40d5111aa107c1176b4ff89c37b104995bada081', + 'nodedc-source/services/backend/credential-sink/docker-compose.immutable-runtime.yml': '944fa64b08255eb8207b93fd327aebb98ecd9400d37d25fcfa8e3a040ee44afe', +}) + +const sourceInfo = await lstat(sourceArtifact) +if (sourceInfo.isSymbolicLink() || !sourceInfo.isFile()) { + throw new Error('engine_credential_sink_recovery_source_unsafe') +} +if (digest(await readFile(sourceArtifact)) !== sourceArtifactSha256) { + throw new Error('engine_credential_sink_recovery_source_sha256_mismatch') +} + +await mkdir(artifactRoot, { recursive: true }) +const artifact = join(artifactRoot, `nodedc-${patchId}.tgz`) +await assertArtifactTargetFresh(artifact) +const stage = await mkdtemp(join(tmpdir(), 'nodedc-engine-credential-sink-recovery-')) + +try { + run('python3', [ + '-c', verifiedExtractScript(), sourceArtifact, stage, + sourceArtifactSha256, sourceManifest, JSON.stringify(entries), JSON.stringify(payloadSha256), + ]) + await writeFile( + join(stage, 'manifest.env'), + `id=${patchId}\ncomponent=engine\ntype=app-overlay\n`, + 'utf8', + ) + await writeFile(join(stage, 'files.txt'), `${entries.join('\n')}\n`, 'utf8') + run('python3', ['-c', canonicalTarScript(), artifact, stage]) + console.log(JSON.stringify({ + ok: true, + patchId, + artifact, + sha256: digest(await readFile(artifact)), + sourceArtifact, + sourceArtifactSha256, + entries, + payloadSha256, + changed: ['manifest.env:id'], + }, null, 2)) +} finally { + await rm(stage, { recursive: true, force: true }) +} + +async function assertArtifactTargetFresh(target) { + try { + await lstat(target) + } catch (error) { + if (error?.code === 'ENOENT') return + throw error + } + throw new Error('engine_credential_sink_recovery_artifact_already_exists') +} + +function verifiedExtractScript() { + return [ + 'import hashlib,json,pathlib,sys,tarfile', + 'source=pathlib.Path(sys.argv[1]); stage=pathlib.Path(sys.argv[2])', + 'expected_archive=sys.argv[3]; expected_manifest=sys.argv[4].encode()', + 'entries=json.loads(sys.argv[5]); hashes=json.loads(sys.argv[6])', + "raw=source.read_bytes()", + "assert hashlib.sha256(raw).hexdigest()==expected_archive, 'source-sha256'", + "expected_files={'manifest.env','files.txt'}|{'payload/'+name for name in hashes}", + "expected_dirs={'payload'}", + "for name in hashes:", + " p=pathlib.PurePosixPath('payload/'+name)", + " expected_dirs.update(str(parent) for parent in p.parents if str(parent)!='.')", + "with tarfile.open(source,'r:gz') as tar:", + " members=tar.getmembers(); names={member.name for member in members}", + " assert names==expected_files|expected_dirs, 'source-member-set'", + " for member in members:", + " p=pathlib.PurePosixPath(member.name)", + " assert not p.is_absolute() and '..' not in p.parts and '\\\\' not in member.name, 'unsafe-member'", + " assert member.isdir() if member.name in expected_dirs else member.isfile(), 'unsafe-member-type'", + " assert tar.extractfile('manifest.env').read()==expected_manifest, 'source-manifest'", + " expected_list=('\\n'.join(entries)+'\\n').encode()", + " assert tar.extractfile('files.txt').read()==expected_list, 'source-files-list'", + " for name,wanted in hashes.items():", + " data=tar.extractfile('payload/'+name).read()", + " assert hashlib.sha256(data).hexdigest()==wanted, 'payload-sha256:'+name", + " target=stage/'payload'/name; target.parent.mkdir(parents=True,exist_ok=True); target.write_bytes(data)", + ].join('\n') +} + +function canonicalTarScript() { + return [ + 'import gzip,io,pathlib,sys,tarfile', + 'root=pathlib.Path(sys.argv[2])', + "with open(sys.argv[1],'wb') as out:", + " with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:", + " with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:", + " for top in ('manifest.env','files.txt','payload'):", + " p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])", + " for x in paths:", + " info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())", + " info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644", + " with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)", + ].join('\n') +} + +function run(command, args) { + const result = spawnSync(command, args, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + maxBuffer: 128 * 1024 * 1024, + }) + if (result.status !== 0) throw new Error(`${command}_failed:${result.stderr || result.stdout}`) +} + +function digest(bytes) { + return createHash('sha256').update(bytes).digest('hex') +} diff --git a/infra/deploy-runner/build-engine-data-product-publish-grant-artifact.mjs b/infra/deploy-runner/build-engine-data-product-publish-grant-artifact.mjs new file mode 100644 index 0000000..18b7c73 --- /dev/null +++ b/infra/deploy-runner/build-engine-data-product-publish-grant-artifact.mjs @@ -0,0 +1,252 @@ +#!/usr/bin/env node +import { createHash } from 'node:crypto' +import { spawnSync } from 'node:child_process' +import { cp, lstat, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join, relative, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const here = dirname(fileURLToPath(import.meta.url)) +const platformRoot = resolve(here, '../..') +const workspaceRoot = resolve(platformRoot, '..') +const engineRoot = resolve(workspaceRoot, 'NODEDC_ENGINE_INFRA') +const artifactRoot = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || join(workspaceRoot, 'deploy-artifacts')) +const [patchId = '', ...extra] = process.argv.slice(2) +const previouslyIssuedPatchIds = new Set([ + 'engine-data-product-publish-grant-20260716-001', + 'engine-data-product-publish-grant-20260717-001', + 'engine-data-product-publish-grant-20260717-002', +]) + +if (extra.length || !/^engine-data-product-publish-grant-\d{8}-\d{3}$/.test(patchId)) { + throw new Error('usage: build-engine-data-product-publish-grant-artifact.mjs ') +} +if (previouslyIssuedPatchIds.has(patchId)) throw new Error('engine_publish_grant_patch_id_already_issued') + +// Deliberately exclude frontend/dist, runtime data, tests, every credential +// value, and the already-installed credential-sink implementation. The sink +// remains mounted and byte-frozen as the predecessor domain; this artifact +// adds the exact Data Product Publish grant flow beside it. +const entries = Object.freeze([ + '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', + 'nodedc-source/services/backend/data-product-publish-grant/docker-compose.immutable-runtime.yml', +]) +const ignoredBasenames = new Set(['.DS_Store', '.git', 'node_modules']) +const credentialSinkPredecessorSha256 = Object.freeze({ + 'docker-compose.yml': '258cebb64ff1943c939655cc55bdce00fc5c4dced67ec291d84d6df066ace50e', + '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/engineCredentialSink.js': '9cbb69dbc8cbe6181cd5b0170fe9c4d717b0a173866ca98cba3e3766c0bab94e', + 'nodedc-source/server/routes/ndcAgentMcp.js': 'fbb3342b1a617b956d5b3a6a40d5111aa107c1176b4ff89c37b104995bada081', + 'nodedc-source/services/backend/credential-sink/docker-compose.immutable-runtime.yml': '944fa64b08255eb8207b93fd327aebb98ecd9400d37d25fcfa8e3a040ee44afe', +}) +const publishGrantRuntimeOverride = [ + 'services:', + ' nodedc-backend:', + ' user: "0:0"', + ' environment:', + ' ENGINE_DATA_PLANE_BASE_URL: http://external-data-plane:18106', + ' ENGINE_EDP_MANAGED_PROVISIONER_PRIVATE_KEY_FILE: /run/nodedc-secrets/engine-edp-managed-provisioner-private.pem', + ' ENGINE_CONTROL_PLANE_PUBLISH_GRANT_ROOT: /var/lib/nodedc-control-plane/publish-grants', + ' volumes:', + ' - type: bind', + ' source: /volume2/nodedc-demo/nodedc-control-plane/publish-grants', + ' target: /var/lib/nodedc-control-plane/publish-grants', + ' bind:', + ' create_host_path: false', + ' - type: bind', + ' source: /volume1/docker/nodedc-platform/secrets/engine-edp-managed-provisioner/private-key.pem', + ' target: /run/nodedc-secrets/engine-edp-managed-provisioner-private.pem', + ' read_only: true', + ' bind:', + ' create_host_path: false', + '', +].join('\n') + +await assertSourceBoundary() +await mkdir(artifactRoot, { recursive: true }) +const artifact = join(artifactRoot, `nodedc-${patchId}.tgz`) +await assertArtifactTargetFresh(artifact) +const stage = await mkdtemp(join(tmpdir(), 'nodedc-engine-publish-grant-artifact-')) +const payload = join(stage, 'payload') + +try { + await mkdir(payload, { recursive: true }) + for (const entry of entries) { + await copySafe(resolve(engineRoot, entry), join(payload, entry)) + } + await writeFile( + join(stage, 'manifest.env'), + `id=${patchId}\ncomponent=engine\ntype=app-overlay\n`, + 'utf8', + ) + await writeFile(join(stage, 'files.txt'), `${entries.join('\n')}\n`, 'utf8') + run('python3', ['-c', canonicalTarScript(), artifact, stage]) + const sha256 = digest(await readFile(artifact)) + console.log(JSON.stringify({ + ok: true, + patchId, + artifact, + sha256, + entries, + excluded: [ + 'docker-compose.yml', + 'nodedc-source/server/index.js', + 'nodedc-source/server/credentialPolicies/ndcPrivateNode.js', + 'nodedc-source/server/credentialSink', + 'nodedc-source/server/routes/engineCredentialSink.js', + 'nodedc-source/server/middleware/demoAccess.js', + 'nodedc-source/server/routes/ndcAgentMcp.js', + 'nodedc-source/server/data', + 'nodedc-source/dist', + 'nodedc-source/server/tests', + ], + preservedPredecessor: Object.keys(credentialSinkPredecessorSha256), + }, null, 2)) +} finally { + await rm(stage, { recursive: true, force: true }) +} + +async function assertSourceBoundary() { + for (const [relativePath, expectedSha256] of Object.entries(credentialSinkPredecessorSha256)) { + const bytes = await readFile(join(engineRoot, relativePath)) + if (digest(bytes) !== expectedSha256) { + throw new Error(`engine_credential_sink_predecessor_drift:${relativePath}`) + } + } + const runtimeOverridePath = join( + engineRoot, + 'nodedc-source/services/backend/data-product-publish-grant/docker-compose.immutable-runtime.yml', + ) + if (await readFile(runtimeOverridePath, 'utf8') !== publishGrantRuntimeOverride) { + throw new Error('engine_publish_grant_runtime_override_mismatch') + } + + const indexSource = await readFile(join(engineRoot, 'nodedc-source/server/index.js'), 'utf8') + if (!indexSource.includes("app.use('/api/engine-agent-mcp', engineAgentMcpRouter)")) { + throw new Error('engine_agent_mcp_mount_missing') + } + if ( + !indexSource.includes("import engineCredentialSinkRouter from './routes/engineCredentialSink.js'") + || !indexSource.includes("app.use('/internal/engine-credential-sink', express.json({") + ) throw new Error('engine_credential_sink_predecessor_mount_missing') + + const gateway = await readFile(join(engineRoot, 'nodedc-source/server/routes/engineAgentGateway.js'), 'utf8') + for (const tool of [ + '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 (!gateway.includes(tool)) throw new Error(`engine_publish_grant_tool_missing:${tool}`) + } + + const n8nRoute = await readFile(join(engineRoot, 'nodedc-source/server/routes/n8n.js'), 'utf8') + if (n8nRoute.includes("from '../credentialSink/")) { + throw new Error('engine_publish_grant_depends_on_legacy_sink') + } + if (!n8nRoute.includes('engineDataProductPublishGrantN8nAdapter')) { + throw new Error('engine_publish_grant_native_adapter_missing') + } + if (!n8nRoute.includes('engineCredentialSinkN8nAdapter')) { + throw new Error('engine_credential_sink_native_adapter_missing') + } + + const grantDirectory = join(engineRoot, 'nodedc-source/server/dataProductPublishGrant') + const grantFiles = (await readdir(grantDirectory, { withFileTypes: true })) + .filter((entry) => entry.isFile()) + .map((entry) => entry.name) + .sort() + const expectedGrantFiles = [ + 'acceptance.js', + 'providerCatalog.js', + 'service.js', + 'signedDataPlaneClient.js', + 'store.js', + ] + if (JSON.stringify(grantFiles) !== JSON.stringify(expectedGrantFiles)) { + throw new Error('engine_publish_grant_source_set_mismatch') + } + for (const entry of entries) { + const source = resolve(engineRoot, entry) + const info = await lstat(source) + if (info.isSymbolicLink() || (!info.isFile() && !info.isDirectory())) { + throw new Error(`engine_artifact_source_unsafe:${entry}`) + } + } + for (const source of [ + ...expectedGrantFiles.map((name) => join(grantDirectory, name)), + join(engineRoot, 'nodedc-source/server/routes/engineAgentGateway.js'), + join(engineRoot, 'nodedc-source/server/routes/n8n.js'), + join(engineRoot, 'nodedc-source/server/routes/ndcAgentMcp.js'), + join(engineRoot, 'nodedc-source/server/engineAgents/store.js'), + ]) run('node', ['--check', source]) +} + +async function assertArtifactTargetFresh(target) { + try { + await lstat(target) + } catch (error) { + if (error?.code === 'ENOENT') return + throw error + } + throw new Error('engine_publish_grant_artifact_already_exists') +} + +async function copySafe(source, destination) { + const info = await lstat(source) + if (info.isSymbolicLink()) throw new Error(`source_symlink_rejected:${source}`) + if (info.isFile()) { + await mkdir(dirname(destination), { recursive: true }) + await cp(source, destination, { force: false, verbatimSymlinks: true }) + return + } + if (!info.isDirectory()) throw new Error(`source_type_rejected:${source}`) + await mkdir(destination, { recursive: true }) + for (const entry of await readdir(source, { withFileTypes: true })) { + if (ignoredBasenames.has(entry.name) || entry.name.startsWith('.env')) continue + const childSource = join(source, entry.name) + if (entry.isSymbolicLink()) { + throw new Error(`source_symlink_rejected:${relative(engineRoot, childSource)}`) + } + await copySafe(childSource, join(destination, entry.name)) + } +} + +function canonicalTarScript() { + return [ + 'import gzip, io, pathlib, sys, tarfile', + 'root=pathlib.Path(sys.argv[2])', + "with open(sys.argv[1], 'wb') as out:", + " with gzip.GzipFile(filename='', mode='wb', fileobj=out, compresslevel=9, mtime=0) as gz:", + " with tarfile.open(fileobj=gz, mode='w', format=tarfile.PAX_FORMAT) as tar:", + " for top in ('manifest.env','files.txt','payload'):", + " p=root/top; paths=[p] + (sorted(p.rglob('*')) if p.is_dir() else [])", + ' for x in paths:', + " info=tar.gettarinfo(str(x), arcname=x.relative_to(root).as_posix())", + " info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644", + " with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info, src if info.isfile() else None)", + ].join('\n') +} + +function run(command, args) { + const result = spawnSync(command, args, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + maxBuffer: 128 * 1024 * 1024, + }) + if (result.status !== 0) throw new Error(`${command}_failed:${result.stderr || result.stdout}`) + return result +} + +function digest(bytes) { + return createHash('sha256').update(bytes).digest('hex') +} diff --git a/infra/deploy-runner/build-engine-n8n-private-extension-artifact.mjs b/infra/deploy-runner/build-engine-n8n-private-extension-artifact.mjs index be74dfa..ae2d5f2 100644 --- a/infra/deploy-runner/build-engine-n8n-private-extension-artifact.mjs +++ b/infra/deploy-runner/build-engine-n8n-private-extension-artifact.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node import { createHash } from "node:crypto"; import { spawnSync } from "node:child_process"; -import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { cp, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { createRequire, Module } from "node:module"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; @@ -24,8 +24,10 @@ const n8nVersion = "2.3.2"; const baseImage = "docker.n8n.io/n8nio/n8n:2.3.2"; const architecture = "amd64"; const generatedAt = "2026-07-15T21:51:41.000Z"; -const activationId = "engine-n8n-private-extension-20260716-003"; -const rollbackId = "engine-n8n-private-extension-rollback-20260716-003"; +const previouslyIssuedTransitionIds = new Set(["20260715-002", "20260716-003"]); +const transitionId = readTransitionId(process.argv.slice(2), process.env.NODEDC_N8N_TRANSITION_ID); +const activationId = `engine-n8n-private-extension-${transitionId}`; +const rollbackId = `engine-n8n-private-extension-rollback-${transitionId}`; const transitionRoot = "nodedc-source/services/n8n/private-extensions"; const descriptorRel = `${transitionRoot}/ndc-activation.json`; @@ -62,6 +64,8 @@ const credentialModules = [ ]; await mkdir(artifactRoot, { recursive: true }); +await assertArtifactTargetFresh(join(artifactRoot, `nodedc-${activationId}.tgz`)); +await assertArtifactTargetFresh(join(artifactRoot, `nodedc-${rollbackId}.tgz`)); assertSha(await readFile(stageArtifact), stageArtifactSha256, "staging artifact"); assertEngineBaseline(await readFile(join(engineRoot, "docker-compose.yml"), "utf8")); @@ -125,13 +129,15 @@ try { const rollbackDescriptor = descriptor("rollback-inactive", [], [], releaseId); const override = composeOverride(); - await writeJson(join(engineRoot, nodesCatalogRel), activeNodes); - await writeJson(join(engineRoot, credentialsCatalogRel), activeCredentials); - await writeJson(join(engineRoot, metaRel), activeMeta); - await writeJson(join(engineRoot, descriptorRel), activationDescriptor); - await writeFile(join(engineRoot, overrideRel), override, "utf8"); - await cp(join(packageRoot, "dist/icons/ndc.svg"), join(engineRoot, iconRel), { force: true }); - await cp(join(packageRoot, "dist/icons/ndc.dark.svg"), join(engineRoot, darkIconRel), { force: true }); + const generatedRoot = join(work, "generated-engine-payload"); + await writeJson(join(generatedRoot, nodesCatalogRel), activeNodes); + await writeJson(join(generatedRoot, credentialsCatalogRel), activeCredentials); + await writeJson(join(generatedRoot, metaRel), activeMeta); + await writeJson(join(generatedRoot, descriptorRel), activationDescriptor); + await writeFile(join(generatedRoot, overrideRel), override, "utf8"); + await mkdir(join(generatedRoot, iconRoot), { recursive: true }); + await cp(join(packageRoot, "dist/icons/ndc.svg"), join(generatedRoot, iconRel), { force: false }); + await cp(join(packageRoot, "dist/icons/ndc.dark.svg"), join(generatedRoot, darkIconRel), { force: false }); const activationEntries = [ descriptorRel, @@ -144,7 +150,7 @@ try { ]; const activationArtifact = await buildArtifact(work, activationId, activationEntries, async (payload) => { for (const rel of activationEntries) { - await cp(join(engineRoot, rel), join(payload, rel), { recursive: true, force: false }); + await cp(join(generatedRoot, rel), join(payload, rel), { recursive: true, force: false }); } }); @@ -159,6 +165,7 @@ try { console.log(JSON.stringify({ ok: true, + transitionId, releaseId, packageSha256, nodeTypes: expectedNodeTypes, @@ -192,6 +199,42 @@ function descriptor(action, nodeTypes, credentialTypes, expectedCurrent) { }; } +function readTransitionId(args, environmentValue) { + if (args.length > 1) throw new Error("transition_id_argument_count_invalid"); + const argumentValue = args[0] || ""; + const envValue = String(environmentValue || "").trim(); + if (argumentValue && envValue && argumentValue !== envValue) { + throw new Error("transition_id_sources_conflict"); + } + const value = argumentValue || envValue; + if (!value) throw new Error("transition_id_required"); + const match = /^(\d{4})(\d{2})(\d{2})-([0-9]{3})$/.exec(value); + if (!match || match[4] === "000") throw new Error("transition_id_invalid"); + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + const parsed = new Date(Date.UTC(year, month - 1, day)); + if (parsed.getUTCFullYear() !== year + || parsed.getUTCMonth() !== month - 1 + || parsed.getUTCDate() !== day) { + throw new Error("transition_id_invalid"); + } + if (previouslyIssuedTransitionIds.has(value)) { + throw new Error("transition_id_already_issued"); + } + return value; +} + +async function assertArtifactTargetFresh(path) { + try { + await lstat(path); + } catch (error) { + if (error?.code === "ENOENT") return; + throw error; + } + throw new Error("transition_artifact_already_exists"); +} + function composeOverride() { const health = "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)});"; return [ diff --git a/infra/deploy-runner/build-external-data-plane-artifact.mjs b/infra/deploy-runner/build-external-data-plane-artifact.mjs index 55abd1a..5c2595c 100644 --- a/infra/deploy-runner/build-external-data-plane-artifact.mjs +++ b/infra/deploy-runner/build-external-data-plane-artifact.mjs @@ -1,14 +1,14 @@ #!/usr/bin/env node import { createHash } from "node:crypto"; import { spawnSync } from "node:child_process"; -import { cp, lstat, mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; +import { cp, lstat, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const scriptDir = dirname(fileURLToPath(import.meta.url)); const platformRoot = resolve(scriptDir, "../.."); -const artifactDir = resolve(scriptDir, "../deploy-artifacts"); +const artifactDir = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts")); const [patchId = "external-data-plane-20260714-001", ...extra] = process.argv.slice(2); if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) { @@ -18,13 +18,21 @@ if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) { const files = [ ["infra/synology/docker-compose.external-data-plane.yml", "platform/docker-compose.external-data-plane.yml"], ["services/external-data-plane", "platform/services/external-data-plane"], - ["packages/external-provider-contract", "platform/packages/external-provider-contract"], + ["packages/external-provider-contract/package.json", "platform/packages/external-provider-contract/package.json"], + ["packages/external-provider-contract/src/contract-version.mjs", "platform/packages/external-provider-contract/src/contract-version.mjs"], + ["packages/external-provider-contract/src/data-plane.mjs", "platform/packages/external-provider-contract/src/data-plane.mjs"], + ["packages/external-provider-contract/src/data-product.mjs", "platform/packages/external-provider-contract/src/data-product.mjs"], + ["packages/external-provider-contract/src/intake-batch.mjs", "platform/packages/external-provider-contract/src/intake-batch.mjs"], + ["packages/external-provider-contract/src/sensitive-field-policy.mjs", "platform/packages/external-provider-contract/src/sensitive-field-policy.mjs"], ]; const ignoredBasenames = new Set([".DS_Store", ".git", "node_modules"]); +const ignoredDirectoryNames = new Set(["test"]); const stage = await mkdtemp(join(tmpdir(), "nodedc-external-data-plane-artifact-")); const payload = join(stage, "payload"); const target = join(artifactDir, `nodedc-platform-${patchId}.tgz`); +await assertSourceBoundary(); + try { await mkdir(payload, { recursive: true }); for (const [sourceRelative, destinationRelative] of files) { @@ -34,19 +42,66 @@ try { await writeFile(join(stage, "files.txt"), `${files.map(([, destination]) => destination).join("\n")}\n`, "utf8"); await mkdir(artifactDir, { recursive: true }); - const tar = spawnSync("python3", ["-c", [ - "import sys, tarfile", - "with tarfile.open(sys.argv[1], 'w:gz', format=tarfile.PAX_FORMAT) as archive:", - " [archive.add(name, arcname=name, recursive=True) for name in ('manifest.env', 'files.txt', 'payload')]", - ].join("\n"), target], { cwd: stage, encoding: "utf8" }); + const tar = spawnSync("python3", ["-c", canonicalTarScript(), target, stage], { + encoding: "utf8", + maxBuffer: 128 * 1024 * 1024, + }); if (tar.status !== 0) throw new Error(`tar_failed:${tar.stderr || tar.stdout}`); - const digest = createHash("sha256").update(await (await import("node:fs/promises")).readFile(target)).digest("hex"); - console.log(JSON.stringify({ ok: true, patchId, artifact: target, sha256: digest }, null, 2)); + const digest = createHash("sha256").update(await readFile(target)).digest("hex"); + console.log(JSON.stringify({ + ok: true, + patchId, + artifact: target, + sha256: digest, + entries: files.map(([, destination]) => destination), + excluded: [ + ".env*", + "node_modules", + "services/external-data-plane/test", + "private-key.pem", + "secrets", + ], + }, null, 2)); } finally { await rm(stage, { recursive: true, force: true }); } +async function assertSourceBoundary() { + const compose = await readFile( + resolve(platformRoot, "infra/synology/docker-compose.external-data-plane.yml"), + "utf8", + ); + for (const fragment of [ + "source: /volume1/docker/nodedc-platform/trust/engine-managed-provisioner", + "target: /run/nodedc-trust/engine-managed-provisioner", + "EXTERNAL_DATA_PLANE_MANAGED_PROVISIONER_PUBLIC_KEY_FILE: /run/nodedc-trust/engine-managed-provisioner/public-key.pem", + "create_host_path: false", + ]) { + if (!compose.includes(fragment)) throw new Error(`platform_compose_boundary_missing:${fragment}`); + } + if ( + compose.includes("private-key.pem") + || compose.includes("ENGINE_EDP_MANAGED_PROVISIONER_PRIVATE_KEY_FILE") + ) throw new Error("platform_artifact_private_key_boundary_violation"); +} + +function canonicalTarScript() { + return [ + "import gzip, io, pathlib, sys, tarfile", + "root=pathlib.Path(sys.argv[2])", + "with open(sys.argv[1], 'wb') as out:", + " with gzip.GzipFile(filename='', mode='wb', fileobj=out, compresslevel=9, mtime=0) as gz:", + " with tarfile.open(fileobj=gz, mode='w', format=tarfile.PAX_FORMAT) as tar:", + " for top in ('manifest.env','files.txt','payload'):", + " p=root/top; paths=[p] + (sorted(p.rglob('*')) if p.is_dir() else [])", + " for x in paths:", + " info=tar.gettarinfo(str(x), arcname=x.relative_to(root).as_posix())", + " info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644", + " with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info, src if info.isfile() else None)", + ].join("\n"); +} + async function copySafe(source, destination) { const sourceStat = await lstat(source); if (sourceStat.isSymbolicLink()) throw new Error(`source_symlink_rejected:${source}`); @@ -60,6 +115,7 @@ async function copySafe(source, destination) { await mkdir(destination, { recursive: true }); for (const entry of await readdir(source, { withFileTypes: true })) { if (ignoredBasenames.has(entry.name) || entry.name.startsWith(".env")) continue; + if (entry.isDirectory() && ignoredDirectoryNames.has(entry.name)) continue; const childSource = join(source, entry.name); const childDestination = join(destination, entry.name); if (entry.isSymbolicLink()) throw new Error(`source_symlink_rejected:${relative(platformRoot, childSource)}`); diff --git a/infra/deploy-runner/nodedc-deploy b/infra/deploy-runner/nodedc-deploy index fc8b6d2..fe2e034 100755 --- a/infra/deploy-runner/nodedc-deploy +++ b/infra/deploy-runner/nodedc-deploy @@ -1,5 +1,7 @@ #!/usr/bin/env python3 import argparse +import base64 +import hashlib import json import os import re @@ -25,6 +27,7 @@ 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" @@ -36,6 +39,29 @@ 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_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" 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") @@ -48,10 +74,24 @@ ENGINE_N8N_CREDENTIALS_CATALOG_REL = f"{ENGINE_N8N_SCHEMA_ROOT_REL}/credentials. 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_CONTROL_PLANE_STATE_REL = "nodedc-control-plane" +ENGINE_PUBLISH_GRANT_STATE_REL = f"{ENGINE_CONTROL_PLANE_STATE_REL}/publish-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_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" +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_ACTIVATION_NODES_CATALOG_JSON_SHA256 = "230f712230f7ee8debaa4b44a4358cc19c205bc43305d8ed89d5e3daac466506" @@ -94,6 +134,52 @@ ENGINE_N8N_CREDENTIAL_TYPES = ( "ndcDataProductReaderApi", "ndcFoundryBindingApi", ) +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_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": "e3450a4e1d5318dbac37627b67804d62804e27e2032c9e90478a1e739cea6d3d", + "nodedc-source/server/routes/engineCredentialSink.js": "9cbb69dbc8cbe6181cd5b0170fe9c4d717b0a173866ca98cba3e3766c0bab94e", + "nodedc-source/server/routes/n8n.js": "783d822e2457d82e890f43bc00c7e33822077dc2841f0511a89ecb210fd36d48", + "nodedc-source/server/routes/ndcAgentMcp.js": "fbb3342b1a617b956d5b3a6a40d5111aa107c1176b4ff89c37b104995bada081", + ENGINE_CREDENTIAL_BACKEND_OVERRIDE_TEMPLATE_REL: "944fa64b08255eb8207b93fd327aebb98ecd9400d37d25fcfa8e3a040ee44afe", +} +ENGINE_CREDENTIAL_SINK_CONTRACT_SHA256 = "b4800eead9bf94793ff1280d06e34aad6b37ef055a9d7f8d83d7fb5f9bd66d8b" ENGINE_BASE_COMPOSE_SERVICES = ( "postgresql", "authentik-server", @@ -306,6 +392,10 @@ class DeployError(Exception): pass +class ReconciliationRequired(DeployError): + pass + + def die(message): raise DeployError(message) @@ -353,10 +443,10 @@ def is_relative_to(child, parent): def ensure_layout(): - for path in (INBOX, APPLIED_DIR, FAILED_DIR, BACKUPS_DIR, STATE_DIR, TMP_DIR): + 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): + for path in (STATE_DIR, BACKUPS_DIR, TMP_DIR, RUNTIME_DIR): os.chown(path, 0, 0) path.chmod(0o700) @@ -599,6 +689,354 @@ def ensure_external_data_plane_provisioner_secret(): 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" + + +def ensure_engine_publish_grant_private_state(): + # 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") + + for state_path, label in ( + (engine_root / ENGINE_CONTROL_PLANE_STATE_REL, "Engine control-plane state"), + (engine_root / ENGINE_PUBLISH_GRANT_STATE_REL, "Engine Publish grant state"), + ): + 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 fsync_directory(path): descriptor = os.open(str(path), os.O_RDONLY | os.O_DIRECTORY) try: @@ -642,6 +1080,7 @@ def verify_install(): 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") @@ -1080,6 +1519,10 @@ def allowed_payload_path(component, rel): "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 == "nodedc-source/dist": return True @@ -1836,6 +2279,202 @@ def validate_engine_n8n_transition(payload_dir, entries): return descriptor +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 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}") + validate_no_lifecycle_scripts(payload_dir, "Engine data product publish grant") + + +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(): @@ -1910,6 +2549,15 @@ def load_artifact(artifact, work_dir): 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) + if touches_engine_credential_sink(manifest["component"], entries): + validate_engine_credential_sink_slice(payload_dir, entries) + if ( + 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 ENGINE_AGENT_FULL_GRANT_MIGRATION_STORE_REL in entries: + validate_engine_agent_full_grant_migration_slice(payload_dir, entries) return manifest, entries, payload_dir @@ -1969,6 +2617,290 @@ 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/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_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_PREDECESSOR_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) + ) + if forbidden_changes: + die( + "Engine data product publish grant update crosses its source boundary: " + f"path={forbidden_changes[0]}" + ) + return { + "mode": "installed-source-update", + "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_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_n8n_transition(component, entries): # The actual Engine topology has one n8n process and no separately @@ -1976,6 +2908,19 @@ def component_services(component, entries=None): # 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 = [] @@ -2016,6 +2961,8 @@ def component_services(component, entries=None): return tuple(selected) if component == "platform" and entries is not None: + if is_platform_provider_catalog_only(entries): + return () selected = [] def add(*services): @@ -2031,7 +2978,7 @@ def component_services(component, entries=None): touches_ontology = any(rel == "platform/ontology-core" or rel.startswith("platform/ontology-core/") for rel in entries) touches_gelios = any(rel == "platform/gelios-gateway" or rel.startswith("platform/gelios-gateway/") for rel in entries) touches_map_gateway = any(rel == "platform/services/map-gateway" or rel.startswith("platform/services/map-gateway/") for rel in entries) - touches_external_data_plane = any(rel == "platform/services/external-data-plane" or rel.startswith("platform/services/external-data-plane/") or rel == "platform/packages/external-provider-contract" or rel.startswith("platform/packages/external-provider-contract/") or rel == "platform/docker-compose.external-data-plane.yml" for rel in entries) + touches_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 @@ -2049,7 +2996,10 @@ def component_services(component, entries=None): if touches_map_gateway: add("map-gateway") if touches_external_data_plane: - add("external-data-plane-postgres", "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: @@ -2071,7 +3021,39 @@ def component_compose_no_deps(component, entries=None): return bool(COMPONENTS[component].get("compose_no_deps")) -def component_compose_files(component): +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"] @@ -2087,8 +3069,37 @@ def component_compose_files(component): 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) return tuple(files) - return COMPONENTS[component].get("compose_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): @@ -2120,7 +3131,7 @@ def component_builds(component, entries=None): touches_ontology = any(rel == "platform/ontology-core" or rel.startswith("platform/ontology-core/") for rel in entries) touches_gelios = any(rel == "platform/gelios-gateway" or rel.startswith("platform/gelios-gateway/") for rel in entries) touches_map_gateway = any(rel == "platform/services/map-gateway" or rel.startswith("platform/services/map-gateway/") for rel in entries) - touches_external_data_plane = any(rel == "platform/services/external-data-plane" or rel.startswith("platform/services/external-data-plane/") or rel == "platform/packages/external-provider-contract" or rel.startswith("platform/packages/external-provider-contract/") or rel == "platform/docker-compose.external-data-plane.yml" for rel in entries) + touches_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 = [] @@ -2216,6 +3227,1068 @@ def docker_json(args, label): 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 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), + ), + } + 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_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_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"], @@ -2270,6 +4343,26 @@ def engine_n8n_version(container_id): 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 = [ @@ -2280,14 +4373,7 @@ def engine_n8n_private_loader_catalog(container_id): return {"node_types": [], "credential_types": []} if len(package_mounts) != 1 or package_mounts[0].get("RW") is not False: die("Engine n8n package-loader probe found an unsafe package mount") - script = ( - "const {PackageDirectoryLoader}=require('/usr/local/lib/node_modules/n8n/node_modules/n8n-core');" - "(async()=>{const loader=new PackageDirectoryLoader('" - + ENGINE_N8N_RUNTIME_PACKAGE_PATH - + "');await loader.loadAll();process.stdout.write(JSON.stringify({packageName:loader.packageName," - "nodes:loader.types.nodes.map(x=>x.name),credentials:loader.types.credentials.map(x=>x.name)}))})()" - ".catch(()=>process.exit(1));" - ) + script = engine_n8n_package_loader_probe_script() result = subprocess.run( [str(DOCKER), "exec", container_id, "node", "-e", script], check=False, @@ -2296,7 +4382,9 @@ def engine_n8n_private_loader_catalog(container_id): timeout=120, ) if result.returncode != 0: - die("Engine n8n package-loader probe failed") + 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: @@ -2645,6 +4733,7 @@ def plan_artifact(artifact): ensure_layout() sha = sha256_file(artifact) + publish_grant_preflight = None with tempfile.TemporaryDirectory(prefix="plan-", dir=TMP_DIR) as tmp: manifest, entries, payload_dir = load_artifact(artifact, Path(tmp)) transition_descriptor = None @@ -2657,13 +4746,45 @@ def plan_artifact(artifact): 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) + ) 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_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_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_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}") @@ -2702,14 +4823,7 @@ def plan_artifact(artifact): print(f"n8n_expected_credential_types={','.join(transition_descriptor['expectedCredentialTypes'])}") print("n8n_rollback_baseline=verified_inactive") touches_map_gateway = component == "platform" and any(rel == "platform/services/map-gateway" or rel.startswith("platform/services/map-gateway/") for rel in entries) - touches_external_data_plane = component == "platform" and any( - rel == "platform/services/external-data-plane" - or rel.startswith("platform/services/external-data-plane/") - or rel == "platform/packages/external-provider-contract" - or rel.startswith("platform/packages/external-provider-contract/") - or rel == "platform/docker-compose.external-data-plane.yml" - for rel in entries - ) + 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": @@ -2718,6 +4832,38 @@ def plan_artifact(artifact): if touches_external_data_plane: print(f"runtime_secret=runner-managed:{EXTERNAL_DATA_PLANE_PROVISIONER_SECRET_FILE}") print(f"runtime_grants=runner-managed:{EXTERNAL_DATA_PLANE_READER_GRANTS_DIR}") + if 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 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": @@ -2766,6 +4912,215 @@ def read_backup_path_list(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" @@ -2864,6 +5219,94 @@ def copy_payload_path(payload_dir, root, rel, current_stamp): 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 seal_n8n_private_extension_release(root, rel): release_dir = root / rel if not release_dir.is_dir() or release_dir.is_symlink(): @@ -2927,7 +5370,7 @@ def run_build(component, entries=None): subprocess.run(cmd, cwd=str(build_root), env=env, check=True) -def compose_base_cmd(component): +def compose_base_cmd(component, allow_prepared_engine_backend=False): cmd = [str(DOCKER), "compose"] compose_project = component_compose_project(component) if compose_project: @@ -2935,15 +5378,28 @@ def compose_base_cmd(component): 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): + 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) - cmd = [*compose_base_cmd(component), "up", "-d", "--force-recreate"] - if is_engine_n8n_transition(component, entries): + 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") @@ -2954,15 +5410,46 @@ def run_compose(component, services, entries=None): subprocess.run(cmd, cwd=str(compose_root), check=True) except subprocess.CalledProcessError: subprocess.run( - [*compose_base_cmd(component), "logs", "--no-color", "--tail=180", *services], + [ + *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), "ps"], cwd=str(compose_root), check=True) + subprocess.run( + [ + *compose_base_cmd( + component, + allow_prepared_engine_backend=allow_prepared_engine_backend, + ), + "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_n8n_transition(component, entries): descriptor = current_engine_n8n_transition_descriptor() if descriptor is None: @@ -2971,6 +5458,11 @@ def prepare_component_runtime(component, entries=None): ensure_engine_n8n_sealed_release(descriptor, stamp()) 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_root_owned_grant_directory( @@ -3017,7 +5509,7 @@ def prepare_component_runtime(component, entries=None): if component == "platform" and entries is not None: touches_map_gateway = any(rel == "platform/services/map-gateway" or rel.startswith("platform/services/map-gateway/") for rel in entries) - touches_external_data_plane = any(rel == "platform/services/external-data-plane" or rel.startswith("platform/services/external-data-plane/") or rel == "platform/packages/external-provider-contract" or rel.startswith("platform/packages/external-provider-contract/") or rel == "platform/docker-compose.external-data-plane.yml" for rel in entries) + 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 @@ -3031,6 +5523,7 @@ def prepare_component_runtime(component, entries=None): sync_map_egress_proxy_secret() if touches_external_data_plane: ensure_external_data_plane_provisioner_secret() + ensure_engine_edp_managed_provisioner_keypair() ensure_root_owned_grant_directory( EXTERNAL_DATA_PLANE_READER_GRANTS_DIR, "external data plane reader grants", @@ -3050,6 +5543,8 @@ def prepare_component_runtime(component, entries=None): 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) @@ -3059,9 +5554,11 @@ def run_component_runtime(component, entries, services): 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 @@ -3071,10 +5568,26 @@ def healthcheck_url(check): 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: + if 200 <= exc.code < 400 and not expected_json: return last_error = f"HTTP {exc.code}" except Exception as exc: @@ -3083,20 +5596,61 @@ def healthcheck_url(check): die(f"healthcheck failed for {url}: {last_error}") -def component_healthchecks(component, entries=None): +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" + return { + "url": "http://127.0.0.1:18106/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 component == "platform" and entries is not None and is_platform_provider_catalog_only(entries): + return () + if ( + ( + is_engine_data_product_publish_grant_slice(component, entries) + or is_engine_agent_full_grant_migration_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 = any(rel == "platform/services/external-data-plane" or rel.startswith("platform/services/external-data-plane/") or rel == "platform/packages/external-provider-contract" or rel.startswith("platform/packages/external-provider-contract/") or rel == "platform/docker-compose.external-data-plane.yml" for rel in entries) + touches_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: @@ -3108,7 +5662,7 @@ def component_healthchecks(component, entries=None): if touches_map_gateway: checks.append({"url": "http://127.0.0.1:18103/healthz", "headers": {"x-nodedc-user-id": "deploy-healthcheck"}}) if touches_external_data_plane: - checks.append("http://127.0.0.1:18106/healthz") + checks.append(external_data_plane_healthcheck()) return tuple(checks) @@ -3131,15 +5685,100 @@ def healthcheck_container(container_name): die(f"container healthcheck failed for {container_name}: {last_status}") -def run_healthchecks(component, entries=None): +def healthcheck_compose_service(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: + die(f"Compose service container lookup failed: {service}") + healthcheck_container(container_ids[0]) + + +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 - for url in component_healthchecks(component, entries): + touches_publish_grant = is_engine_data_product_publish_grant_slice( + component, + entries, + ) + touches_agent_grant_migration = is_engine_agent_full_grant_migration_slice( + component, + entries, + ) + if ( + touches_engine_credential_sink(component, entries) + or touches_publish_grant + or touches_agent_grant_migration + ): + # 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_agent_grant_migration + ): + 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() container_name = COMPONENTS[component].get("health_container") if container_name: healthcheck_container(container_name) @@ -3167,8 +5806,12 @@ def apply_artifact(artifact): entries = None component = None root = None + services = None transition_descriptor = None apply_started = False + engine_backend_recreated = False + engine_backend_initial_mode = None + runtime_started = False current_stamp = stamp() with DeployLock(): @@ -3193,6 +5836,16 @@ def apply_artifact(artifact): 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_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 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) @@ -3230,6 +5883,8 @@ def apply_artifact(artifact): 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: @@ -3245,6 +5900,13 @@ def apply_artifact(artifact): 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 for rel in entries: @@ -3257,8 +5919,20 @@ def apply_artifact(artifact): if component_publish_dist(component, entries): publish_engine_dist(root, current_stamp) - run_component_runtime(component, entries, services) - run_healthchecks(component, entries) + 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, { @@ -3277,24 +5951,83 @@ def apply_artifact(artifact): apply_started and backup_dir is not None and root is not None - and transition_descriptor is not None - and is_engine_n8n_transition(component, entries) ): - try: - restored_action = restore_engine_n8n_transition( - root, - backup_dir, - entries, - transition_descriptor, - current_stamp, + 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 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_agent_full_grant_migration_slice(component, entries) ) - 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) + 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(): + if artifact.exists() and rollback_status != "deferred:reconciliation-required": try: failed_path = move_artifact(artifact, FAILED_DIR, suffix=f".{current_stamp}") except Exception: @@ -3310,7 +6043,11 @@ def apply_artifact(artifact): "rollback_status": rollback_status, "sha256": sha, "started_apply": apply_started, - "status": "failed", + "status": ( + "reconciliation-required" + if rollback_status == "deferred:reconciliation-required" + else "failed" + ), }) if backup_id: print(f"backup={backup_id}", file=sys.stderr) @@ -3318,6 +6055,8 @@ def apply_artifact(artifact): 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) diff --git a/infra/deploy-runner/test_engine_agent_full_grant_migration_artifact.py b/infra/deploy-runner/test_engine_agent_full_grant_migration_artifact.py new file mode 100644 index 0000000..8985a66 --- /dev/null +++ b/infra/deploy-runner/test_engine_agent_full_grant_migration_artifact.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +import hashlib +import importlib.machinery +import importlib.util +import json +import os +import subprocess +import tarfile +import tempfile +import unittest +from pathlib import Path +from unittest import mock + + +SCRIPT_DIR = Path(__file__).resolve().parent +BUILDER = SCRIPT_DIR / "build-engine-agent-full-grant-migration-artifact.mjs" +RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy" +PATCH_ID = "engine-agent-full-grant-migration-20990101-001" +STORE_REL = "nodedc-source/server/engineAgents/store.js" +PREDECESSOR_SHA256 = "52daa43499d6d9a97fe7ffa891edb9212b7791e733f91dd3ca686d42739b7e9a" +TARGET_SHA256 = "2e62654c2dc12905efcc83a9dff45a818dd7b47924a10600160835c4416540e9" + + +def load_runner(): + loader = importlib.machinery.SourceFileLoader( + "nodedc_agent_grant_migration_runner_under_test", + str(RUNNER_PATH), + ) + spec = importlib.util.spec_from_loader(loader.name, loader) + module = importlib.util.module_from_spec(spec) + loader.exec_module(module) + return module + + +RUNNER = load_runner() + + +class EngineAgentFullGrantMigrationArtifactTest(unittest.TestCase): + def build(self, artifact_dir, patch_id=PATCH_ID): + environment = os.environ.copy() + environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir) + result = subprocess.run( + ["node", str(BUILDER), patch_id], + check=True, + capture_output=True, + text=True, + env=environment, + ) + return json.loads(result.stdout) + + def test_artifact_is_exact_reproducible_and_runner_accepted(self): + with tempfile.TemporaryDirectory(prefix="nodedc-agent-grant-migration-") as directory: + root = Path(directory) + first_dir = root / "first" + second_dir = root / "second" + first_dir.mkdir() + second_dir.mkdir() + first = self.build(first_dir) + second = self.build(second_dir) + first_artifact = Path(first["artifact"]) + second_artifact = Path(second["artifact"]) + + self.assertEqual(first["entries"], [STORE_REL]) + self.assertEqual(first["predecessorSha256"], PREDECESSOR_SHA256) + self.assertEqual(first["targetSha256"], TARGET_SHA256) + self.assertEqual(first["sha256"], hashlib.sha256(first_artifact.read_bytes()).hexdigest()) + self.assertEqual(first["sha256"], second["sha256"]) + self.assertEqual(first_artifact.read_bytes(), second_artifact.read_bytes()) + + with tarfile.open(first_artifact, "r:gz") as archive: + members = archive.getmembers() + names = {member.name for member in members} + manifest = archive.extractfile("manifest.env").read().decode("utf-8") + files = archive.extractfile("files.txt").read().decode("utf-8") + store = archive.extractfile(f"payload/{STORE_REL}").read() + self.assertEqual( + manifest, + f"id={PATCH_ID}\ncomponent=engine\ntype=app-overlay\n", + ) + self.assertEqual(files, f"{STORE_REL}\n") + self.assertEqual(hashlib.sha256(store).hexdigest(), TARGET_SHA256) + self.assertFalse(any(member.issym() or member.islnk() for member in members)) + self.assertFalse(any( + name.startswith("payload/nodedc-source/server/data/") + or name.startswith("payload/nodedc-source/dist/") + or name.startswith("payload/nodedc-source/server/credentialSink/") + for name in names + )) + + with tempfile.TemporaryDirectory(prefix="nodedc-agent-grant-runner-") as work: + manifest_value, entries, _payload = RUNNER.load_artifact( + first_artifact, + Path(work), + ) + self.assertEqual(manifest_value["id"], PATCH_ID) + self.assertEqual(tuple(entries), RUNNER.ENGINE_AGENT_FULL_GRANT_MIGRATION_ARTIFACT_ENTRIES) + self.assertTrue(RUNNER.is_engine_agent_full_grant_migration_slice("engine", entries)) + self.assertEqual(RUNNER.component_services("engine", entries), ("nodedc-backend",)) + self.assertEqual(RUNNER.component_builds("engine", entries), ()) + self.assertEqual( + RUNNER.component_healthchecks("engine", entries, ("nodedc-backend",)), + ( + "http://127.0.0.1:3001/health", + "http://127.0.0.1:3001/internal/engine-credential-sink/v1/health", + ), + ) + + def test_predecessor_and_runtime_acceptance_are_exact(self): + with tempfile.TemporaryDirectory(prefix="nodedc-agent-grant-predecessor-") as directory: + root = Path(directory) + store = root / STORE_REL + store.parent.mkdir(parents=True) + store.write_text("predecessor", encoding="utf-8") + override = root / RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_OVERRIDE_REL + override.parent.mkdir(parents=True) + override.write_text( + RUNNER.expected_engine_data_product_publish_grant_override(), + encoding="utf-8", + ) + original_root = RUNNER.COMPONENTS["engine"]["payload_root"] + RUNNER.COMPONENTS["engine"]["payload_root"] = root + try: + with mock.patch.object(RUNNER, "sha256_file", return_value=PREDECESSOR_SHA256): + self.assertEqual( + RUNNER.preflight_engine_agent_full_grant_migration_predecessor(), + PREDECESSOR_SHA256, + ) + with mock.patch.object(RUNNER, "sha256_file", return_value="0" * 64): + with self.assertRaisesRegex(RUNNER.DeployError, "predecessor drift"): + RUNNER.preflight_engine_agent_full_grant_migration_predecessor() + finally: + RUNNER.COMPONENTS["engine"]["payload_root"] = original_root + + expected = f"store-v2-full-developer:{TARGET_SHA256}" + with mock.patch.object( + RUNNER, + "engine_backend_container_id", + return_value="container-id", + ): + with mock.patch.object( + RUNNER, + "run_engine_backend_probe", + return_value=expected, + ) as probe: + self.assertEqual(RUNNER.accept_engine_agent_full_grant_migration_runtime(), expected) + arguments, label = probe.call_args.args[:2] + self.assertEqual(label, "agent full grant migration") + self.assertEqual(arguments[0:2], ("node", "--input-type=module")) + self.assertIn(TARGET_SHA256, arguments) + self.assertEqual(probe.call_args.kwargs["container_id"], "container-id") + + def test_builder_rejects_invalid_id_and_overwrite(self): + with tempfile.TemporaryDirectory(prefix="nodedc-agent-grant-negative-") as directory: + root = Path(directory) + environment = {**os.environ, "NODEDC_DEPLOY_ARTIFACT_DIR": str(root)} + invalid = subprocess.run( + ["node", str(BUILDER), "engine-data-product-publish-grant-20990101-001"], + check=False, + capture_output=True, + text=True, + env=environment, + ) + self.assertNotEqual(invalid.returncode, 0) + self.assertIn("fresh-patch-id", invalid.stderr) + + issued = subprocess.run( + ["node", str(BUILDER), "engine-agent-full-grant-migration-20260717-001"], + check=False, + capture_output=True, + text=True, + env=environment, + ) + self.assertNotEqual(issued.returncode, 0) + self.assertIn("patch_id_already_issued", issued.stderr) + + self.build(root) + duplicate = subprocess.run( + ["node", str(BUILDER), PATCH_ID], + check=False, + capture_output=True, + text=True, + env=environment, + ) + self.assertNotEqual(duplicate.returncode, 0) + self.assertIn("artifact_already_exists", duplicate.stderr) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/infra/deploy-runner/test_engine_credential_sink_recovery_artifact.py b/infra/deploy-runner/test_engine_credential_sink_recovery_artifact.py new file mode 100644 index 0000000..0d09340 --- /dev/null +++ b/infra/deploy-runner/test_engine_credential_sink_recovery_artifact.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +import hashlib +import importlib.machinery +import importlib.util +import json +import os +import shutil +import subprocess +import tarfile +import tempfile +import unittest +from pathlib import Path + + +SCRIPT_DIR = Path(__file__).resolve().parent +BUILDER = SCRIPT_DIR / "build-engine-credential-sink-recovery-artifact.mjs" +RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy" +SOURCE = Path("/Volumes/docker/nodedc-deploy/applied/nodedc-engine-credential-sink-20260716-001.tgz") +SOURCE_SHA256 = "0a96add05fe59db8f490927f66e07a84490474a7afb3ef7de51e1d6fd96f86a2" +PATCH_ID = "engine-credential-sink-recovery-20990101-001" + + +def load_runner(): + loader = importlib.machinery.SourceFileLoader( + "nodedc_recovery_runner_under_test", + str(RUNNER_PATH), + ) + spec = importlib.util.spec_from_loader(loader.name, loader) + module = importlib.util.module_from_spec(spec) + loader.exec_module(module) + return module + + +RUNNER = load_runner() + + +class EngineCredentialSinkRecoveryArtifactTest(unittest.TestCase): + def build(self, artifact_dir, patch_id=PATCH_ID, source=SOURCE): + environment = os.environ.copy() + environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir) + environment["NODEDC_ENGINE_CREDENTIAL_SINK_RECOVERY_SOURCE"] = str(source) + result = subprocess.run( + ["node", str(BUILDER), patch_id], + check=True, + capture_output=True, + text=True, + env=environment, + ) + return json.loads(result.stdout) + + def test_recovery_changes_only_manifest_id_and_passes_runner_policy(self): + self.assertTrue(SOURCE.is_file()) + self.assertEqual(hashlib.sha256(SOURCE.read_bytes()).hexdigest(), SOURCE_SHA256) + with tempfile.TemporaryDirectory(prefix="nodedc-engine-sink-recovery-") as directory: + root = Path(directory) + first_dir = root / "first" + second_dir = root / "second" + first_dir.mkdir() + second_dir.mkdir() + first = self.build(first_dir) + second = self.build(second_dir) + first_artifact = Path(first["artifact"]) + second_artifact = Path(second["artifact"]) + + self.assertEqual(first["sourceArtifactSha256"], SOURCE_SHA256) + self.assertEqual(first["changed"], ["manifest.env:id"]) + self.assertEqual(first["sha256"], second["sha256"]) + self.assertEqual(first_artifact.read_bytes(), second_artifact.read_bytes()) + + with tarfile.open(SOURCE, "r:gz") as old, tarfile.open(first_artifact, "r:gz") as new: + old_files = old.extractfile("files.txt").read() + new_files = new.extractfile("files.txt").read() + self.assertEqual(old_files, new_files) + self.assertEqual( + new.extractfile("manifest.env").read().decode("utf-8"), + f"id={PATCH_ID}\ncomponent=engine\ntype=app-overlay\n", + ) + for relative_path, expected_sha256 in first["payloadSha256"].items(): + old_bytes = old.extractfile(f"payload/{relative_path}").read() + new_bytes = new.extractfile(f"payload/{relative_path}").read() + self.assertEqual(old_bytes, new_bytes) + self.assertEqual(hashlib.sha256(new_bytes).hexdigest(), expected_sha256) + + with tempfile.TemporaryDirectory(prefix="nodedc-recovery-runner-") as work: + manifest, entries, _payload = RUNNER.load_artifact( + first_artifact, + Path(work), + ) + self.assertEqual(manifest["id"], PATCH_ID) + self.assertEqual(tuple(entries), RUNNER.ENGINE_CREDENTIAL_SINK_ARTIFACT_ENTRIES) + self.assertEqual(RUNNER.component_services("engine", entries), ("nodedc-backend",)) + + def test_recovery_rejects_tampered_source_invalid_id_and_overwrite(self): + with tempfile.TemporaryDirectory(prefix="nodedc-engine-sink-recovery-negative-") as directory: + root = Path(directory) + tampered = root / "tampered.tgz" + shutil.copyfile(SOURCE, tampered) + with tampered.open("ab") as stream: + stream.write(b"tampered") + + environment = os.environ.copy() + environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(root / "artifacts") + environment["NODEDC_ENGINE_CREDENTIAL_SINK_RECOVERY_SOURCE"] = str(tampered) + result = subprocess.run( + ["node", str(BUILDER), PATCH_ID], + check=False, + capture_output=True, + text=True, + env=environment, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("source_sha256_mismatch", result.stderr) + + invalid = subprocess.run( + ["node", str(BUILDER), "engine-credential-sink-20260716-001"], + check=False, + capture_output=True, + text=True, + env={ + **os.environ, + "NODEDC_DEPLOY_ARTIFACT_DIR": str(root / "invalid"), + "NODEDC_ENGINE_CREDENTIAL_SINK_RECOVERY_SOURCE": str(SOURCE), + }, + ) + self.assertNotEqual(invalid.returncode, 0) + self.assertIn("fresh-recovery-patch-id", invalid.stderr) + + artifact_dir = root / "duplicate" + artifact_dir.mkdir() + self.build(artifact_dir) + duplicate = subprocess.run( + ["node", str(BUILDER), PATCH_ID], + check=False, + capture_output=True, + text=True, + env={ + **os.environ, + "NODEDC_DEPLOY_ARTIFACT_DIR": str(artifact_dir), + "NODEDC_ENGINE_CREDENTIAL_SINK_RECOVERY_SOURCE": str(SOURCE), + }, + ) + self.assertNotEqual(duplicate.returncode, 0) + self.assertIn("artifact_already_exists", duplicate.stderr) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/infra/deploy-runner/test_engine_n8n_private_extension.py b/infra/deploy-runner/test_engine_n8n_private_extension.py index d20697f..922d148 100644 --- a/infra/deploy-runner/test_engine_n8n_private_extension.py +++ b/infra/deploy-runner/test_engine_n8n_private_extension.py @@ -1,6 +1,8 @@ #!/usr/bin/env python3 import importlib.machinery import importlib.util +import hashlib +import inspect import json import os import subprocess @@ -8,6 +10,7 @@ import tarfile import tempfile import unittest from pathlib import Path +from unittest import mock SCRIPT_DIR = Path(__file__).resolve().parent @@ -20,6 +23,18 @@ STAGE_ARTIFACT = ( / "infra/deploy-artifacts" / "nodedc-n8n-private-extension-n8n-nodes-ndc-release-20260716-003.tgz" ) +TRANSITION_ID = "20260717-004" +SEALED_RELEASE_ID = "0.1.2-05e4b38b14b4a019" +SEALED_PACKAGE_SHA256 = "05e4b38b14b4a019ce1f6eee27b9e320094cb3560903bd68074966b3a1267af5" +BUILDER_ENGINE_PATHS = ( + "nodedc-source/server/assets/n8n/schema/v2.3.2/nodes.catalog.json", + "nodedc-source/server/assets/n8n/schema/v2.3.2/credentials.catalog.json", + "nodedc-source/server/assets/n8n/schema/v2.3.2/meta.json", + "nodedc-source/server/assets/n8n/icons/ndc.svg", + "nodedc-source/server/assets/n8n/icons/ndc.dark.svg", + "nodedc-source/services/n8n/private-extensions/ndc-activation.json", + "nodedc-source/services/n8n/private-extensions/docker-compose.ndc-private-extension.yml", +) EXPECTED_NODES = [ "n8n-nodes-ndc.ndcDataProductPublish", "n8n-nodes-ndc.ndcDataProductRead", @@ -30,6 +45,14 @@ EXPECTED_CREDENTIALS = [ "ndcDataProductReaderApi", "ndcFoundryBindingApi", ] +PRIVATE_EXTENSION_CANON_FUNCTION_SHA256 = { + "expected_engine_n8n_compose_override": "8ee93f3e7c407f5557c711119236449a440d73a7fcac1bbfa50a09e6a13c1cff", + "engine_n8n_package_loader_probe_script": "2b3b3c96fad6e5e48ebea74426b21bc7eea6b6fe0e786b537da3366d18aedd9b", + "engine_n8n_private_loader_catalog": "a2ae389b4ef215900cf967b863d01056bd2a8eec55554566d0f6005e4e0bcbac", + "validate_engine_n8n_base_compose_source": "adacc5b20e52684cbc427362ddba5a6d2e13ef2091a89859e2b8f7bbf2e20458", + "preflight_engine_n8n_transition": "da3cec853e3e3e20df4d1e301aab98b77815f93ec3d68b0b61c84c6e6a3fb335", + "accept_engine_n8n_runtime": "1a2614a465161e3833e84aca402817682da1a538bdb2aecc4af66b665497338d", +} def load_runner(): @@ -40,6 +63,32 @@ def load_runner(): return module +def snapshot_engine_builder_paths(): + snapshot = {} + for relative in BUILDER_ENGINE_PATHS: + path = ENGINE_ROOT / relative + try: + path_stat = path.lstat() + except FileNotFoundError: + snapshot[relative] = None + continue + if path.is_symlink(): + content = ("symlink", os.readlink(path)) + elif path.is_file(): + content = ("file", path.read_bytes()) + else: + content = ("other", None) + snapshot[relative] = ( + path_stat.st_mode, + path_stat.st_uid, + path_stat.st_gid, + path_stat.st_ino, + path_stat.st_mtime_ns, + content, + ) + return snapshot + + RUNNER = load_runner() @@ -49,14 +98,21 @@ class EngineN8nPrivateExtensionTest(unittest.TestCase): cls.temporary = tempfile.TemporaryDirectory(prefix="nodedc-engine-n8n-policy-") cls.root = Path(cls.temporary.name) cls.results = [] + cls.engine_snapshot_before = snapshot_engine_builder_paths() for index in range(2): output = cls.root / f"build-{index}" output.mkdir() env = os.environ.copy() + env.pop("NODEDC_N8N_TRANSITION_ID", None) env["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(output) env["NODEDC_N8N_EXTENSION_STAGE_ARTIFACT"] = str(STAGE_ARTIFACT) + command = ["node", str(BUILDER_PATH)] + if index == 0: + command.append(TRANSITION_ID) + else: + env["NODEDC_N8N_TRANSITION_ID"] = TRANSITION_ID result = subprocess.run( - ["node", str(BUILDER_PATH)], + command, cwd=PLATFORM_ROOT, env=env, check=True, @@ -64,6 +120,7 @@ class EngineN8nPrivateExtensionTest(unittest.TestCase): text=True, ) cls.results.append(json.loads(result.stdout)) + cls.engine_snapshot_after = snapshot_engine_builder_paths() @classmethod def tearDownClass(cls): @@ -81,6 +138,69 @@ class EngineN8nPrivateExtensionTest(unittest.TestCase): self.assertEqual(first[4:8], b"\0\0\0\0") self.assertEqual(first[3] & 0x08, 0) + def test_successful_generation_003_runner_functions_are_frozen(self): + actual = { + name: hashlib.sha256(inspect.getsource(getattr(RUNNER, name)).encode("utf-8")).hexdigest() + for name in PRIVATE_EXTENSION_CANON_FUNCTION_SHA256 + } + self.assertEqual(actual, PRIVATE_EXTENSION_CANON_FUNCTION_SHA256) + + def test_explicit_transition_id_does_not_mutate_sealed_release_identity(self): + expected_ids = { + "activation": f"engine-n8n-private-extension-{TRANSITION_ID}", + "rollback": f"engine-n8n-private-extension-rollback-{TRANSITION_ID}", + } + for result in self.results: + self.assertEqual(result["transitionId"], TRANSITION_ID) + self.assertEqual(result["releaseId"], SEALED_RELEASE_ID) + self.assertEqual(result["packageSha256"], SEALED_PACKAGE_SHA256) + for kind, expected_id in expected_ids.items(): + self.assertEqual(result[kind]["id"], expected_id) + self.assertEqual(Path(result[kind]["artifact"]).name, f"nodedc-{expected_id}.tgz") + + def test_builder_does_not_mutate_engine_source(self): + self.assertEqual(self.engine_snapshot_after, self.engine_snapshot_before) + + def test_transition_id_rejects_missing_invalid_and_previously_issued_values(self): + cases = ( + ([], "transition_id_required"), + (["20260716-003"], "transition_id_already_issued"), + (["engine-n8n-private-extension-20260717-004"], "transition_id_invalid"), + (["20260230-004"], "transition_id_invalid"), + (["20260717-000"], "transition_id_invalid"), + ([TRANSITION_ID, "unexpected-second-id"], "transition_id_argument_count_invalid"), + ) + for arguments, expected_error in cases: + with self.subTest(arguments=arguments): + env = os.environ.copy() + env.pop("NODEDC_N8N_TRANSITION_ID", None) + result = subprocess.run( + ["node", str(BUILDER_PATH), *arguments], + cwd=PLATFORM_ROOT, + env=env, + check=False, + capture_output=True, + text=True, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn(expected_error, result.stderr) + + def test_existing_transition_artifact_is_never_overwritten(self): + env = os.environ.copy() + env.pop("NODEDC_N8N_TRANSITION_ID", None) + env["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(self.artifact(0, "activation").parent) + env["NODEDC_N8N_EXTENSION_STAGE_ARTIFACT"] = str(STAGE_ARTIFACT) + result = subprocess.run( + ["node", str(BUILDER_PATH), TRANSITION_ID], + cwd=PLATFORM_ROOT, + env=env, + check=False, + capture_output=True, + text=True, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("transition_artifact_already_exists", result.stderr) + def test_activation_and_rollback_pass_strict_runner_policy(self): expected = {"activation": ("activate", 7), "rollback": ("rollback-inactive", 4)} for kind, (action, entry_count) in expected.items(): @@ -137,12 +257,213 @@ class EngineN8nPrivateExtensionTest(unittest.TestCase): ) override = (payload / RUNNER.ENGINE_N8N_COMPOSE_OVERRIDE_REL).read_text() self.assertEqual(override, RUNNER.expected_engine_n8n_compose_override(descriptor)) + self.assertEqual( + hashlib.sha256(override.encode("utf-8")).hexdigest(), + "a29e2f92b87dda59da2b4e3ce250bc9705ed9fa551c0fbe4e95464fca5caa3e1", + ) self.assertIn("pull_policy: never", override) self.assertIn("N8N_USER_FOLDER: /home/node", override) + self.assertNotIn("NODE_PATH", override) self.assertIn(":/home/node/.n8n/nodes/node_modules/n8n-nodes-ndc:ro", override) self.assertNotIn("N8N_CUSTOM_EXTENSIONS", override) self.assertNotIn("build:", override) + def test_generation_003_override_is_accepted_as_installed_canon(self): + original_root = RUNNER.COMPONENTS["engine"]["payload_root"] + original_compose = RUNNER.COMPONENTS["engine"]["compose_root"] + try: + with tempfile.TemporaryDirectory() as directory: + work = Path(directory) + extracted = work / "artifact" + extracted.mkdir() + _manifest, _entries, payload = RUNNER.load_artifact( + self.artifact(0, "activation"), + extracted, + ) + engine_root = work / "engine" + engine_root.mkdir() + (engine_root / "docker-compose.yml").write_text("services:\n", encoding="utf-8") + for relative in ( + RUNNER.ENGINE_N8N_TRANSITION_DESCRIPTOR_REL, + RUNNER.ENGINE_N8N_COMPOSE_OVERRIDE_REL, + ): + target = engine_root / relative + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes((payload / relative).read_bytes()) + RUNNER.COMPONENTS["engine"]["payload_root"] = engine_root + RUNNER.COMPONENTS["engine"]["compose_root"] = engine_root + compose_files = RUNNER.component_compose_files("engine") + self.assertEqual(compose_files, ( + engine_root / "docker-compose.yml", + engine_root / RUNNER.ENGINE_N8N_COMPOSE_OVERRIDE_REL, + )) + finally: + RUNNER.COMPONENTS["engine"]["payload_root"] = original_root + RUNNER.COMPONENTS["engine"]["compose_root"] = original_compose + + def test_loader_probe_pins_n8n_node_path(self): + container = { + "Mounts": [{ + "Destination": RUNNER.ENGINE_N8N_RUNTIME_PACKAGE_PATH, + "RW": False, + }], + } + completed = subprocess.CompletedProcess( + args=[], + returncode=0, + stdout=json.dumps({ + "packageName": "n8n-nodes-ndc", + "nodes": ["ndcDataProductPublish", "ndcDataProductRead", "ndcFoundryBinding"], + "credentials": EXPECTED_CREDENTIALS, + }), + stderr="", + ) + with ( + mock.patch.object(RUNNER, "inspect_container", return_value=container), + mock.patch.object(RUNNER.subprocess, "run", return_value=completed) as run, + ): + catalog = RUNNER.engine_n8n_private_loader_catalog("n8n-container") + + command = run.call_args.args[0] + self.assertEqual(command[:-1], [ + str(RUNNER.DOCKER), + "exec", + "n8n-container", + "node", + "-e", + ]) + probe_script = command[-1] + self.assertIn( + f"process.env.NODE_PATH='{RUNNER.ENGINE_N8N_NODE_MODULES_PATH}'", + probe_script, + ) + self.assertIn("Module._initPaths()", probe_script) + self.assertIn("PackageDirectoryLoader", probe_script) + self.assertEqual(catalog, { + "node_types": EXPECTED_NODES, + "credential_types": EXPECTED_CREDENTIALS, + }) + + def test_loader_probe_failure_emits_only_allowlisted_diagnostic(self): + container = { + "Mounts": [{ + "Destination": RUNNER.ENGINE_N8N_RUNTIME_PACKAGE_PATH, + "RW": False, + }], + } + cases = ( + ("MODULE_NOT_FOUND", "MODULE_NOT_FOUND"), + ("Bearer secret-that-must-not-be-logged", "PROBE_ERROR"), + ) + for probe_stderr, expected_category in cases: + with ( + self.subTest(stderr=probe_stderr), + mock.patch.object(RUNNER, "inspect_container", return_value=container), + mock.patch.object( + RUNNER.subprocess, + "run", + return_value=subprocess.CompletedProcess( + args=[], + returncode=1, + stdout="", + stderr=probe_stderr, + ), + ), + ): + with self.assertRaises(RUNNER.DeployError) as raised: + RUNNER.engine_n8n_private_loader_catalog("n8n-container") + message = str(raised.exception) + self.assertIn(f"category={expected_category}", message) + self.assertNotIn("secret-that-must-not-be-logged", message) + + def test_active_runtime_acceptance_does_not_require_node_path(self): + with tempfile.TemporaryDirectory() as directory: + _manifest, _entries, payload = RUNNER.load_artifact( + self.artifact(0, "activation"), + Path(directory), + ) + descriptor = RUNNER.read_engine_n8n_transition_descriptor( + payload / RUNNER.ENGINE_N8N_TRANSITION_DESCRIPTOR_REL + ) + container = { + "State": {"Status": "running", "StartedAt": "2026-07-16T00:00:00Z"}, + "Image": "sha256:base-image", + "Config": { + "Env": [ + "N8N_USER_FOLDER=/home/node", + "N8N_COMMUNITY_PACKAGES_ENABLED=true", + "N8N_COMMUNITY_PACKAGES_PREVENT_LOADING=false", + "N8N_REINSTALL_MISSING_PACKAGES=false", + ], + "Labels": { + "nodedc.n8n-private-extension.release": descriptor["releaseId"], + "nodedc.n8n-private-extension.package-sha256": descriptor["packageSha256"], + }, + }, + "Mounts": [{ + "Destination": RUNNER.ENGINE_N8N_RUNTIME_PACKAGE_PATH, + "Source": f"/volume2/nodedc-demo/{descriptor['sealedReleaseRelativePath']}", + "RW": False, + }], + "RestartCount": 0, + } + log_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr="") + with ( + mock.patch.object( + RUNNER, + "inspect_engine_n8n_base_image", + return_value={"id": "sha256:base-image"}, + ), + mock.patch.object(RUNNER, "engine_n8n_container_ids", return_value=("n8n-container",)), + mock.patch.object(RUNNER, "inspect_container", return_value=container), + mock.patch.object(RUNNER, "wait_engine_n8n_readiness"), + mock.patch.object(RUNNER, "engine_n8n_version", return_value=RUNNER.ENGINE_N8N_VERSION), + mock.patch.object(RUNNER.subprocess, "run", return_value=log_result), + mock.patch.object( + RUNNER, + "engine_n8n_private_loader_catalog", + return_value={ + "node_types": EXPECTED_NODES, + "credential_types": EXPECTED_CREDENTIALS, + }, + ), + mock.patch.object(RUNNER, "validate_engine_n8n_catalog_payload"), + mock.patch.object(RUNNER.time, "sleep"), + ): + accepted = RUNNER.accept_engine_n8n_runtime(descriptor) + self.assertEqual(accepted["node_types"], EXPECTED_NODES) + + def test_private_extension_transition_does_not_enter_publish_grant_domain(self): + entries = (RUNNER.ENGINE_N8N_TRANSITION_DESCRIPTOR_REL,) + descriptor = {"action": "rollback-inactive"} + self.assertFalse(RUNNER.touches_engine_data_product_publish_grant(entries)) + self.assertEqual(RUNNER.component_services("engine", entries), ("n8n",)) + with ( + mock.patch.object(RUNNER, "current_engine_n8n_transition_descriptor", return_value=descriptor), + mock.patch.object(RUNNER, "ensure_engine_edp_managed_provisioner_keypair") as keypair, + mock.patch.object(RUNNER, "ensure_engine_publish_grant_private_state") as private_state, + ): + RUNNER.prepare_component_runtime("engine", entries) + keypair.assert_not_called() + private_state.assert_not_called() + + def test_generic_engine_compose_artifact_keeps_legacy_service_selection(self): + self.assertEqual( + RUNNER.component_services("engine", ("docker-compose.yml",)), + RUNNER.COMPONENTS["engine"]["services"], + ) + self.assertEqual( + RUNNER.component_services( + "engine", + ("docker-compose.yml", "nodedc-source/server/dataProductPublishGrant/store.js"), + ), + RUNNER.COMPONENTS["engine"]["services"], + ) + self.assertFalse(RUNNER.is_engine_data_product_publish_grant_slice( + "engine", + ("docker-compose.yml", "nodedc-source/server/dataProductPublishGrant/store.js"), + )) + def test_unknown_descriptor_key_is_rejected(self): with tempfile.TemporaryDirectory() as directory: work = Path(directory) diff --git a/infra/deploy-runner/test_engine_publish_grant_artifact.py b/infra/deploy-runner/test_engine_publish_grant_artifact.py new file mode 100644 index 0000000..8066d10 --- /dev/null +++ b/infra/deploy-runner/test_engine_publish_grant_artifact.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 +import hashlib +import json +import os +import subprocess +import tarfile +import tempfile +import unittest +from pathlib import Path + + +SCRIPT_DIR = Path(__file__).resolve().parent +BUILDER = SCRIPT_DIR / "build-engine-data-product-publish-grant-artifact.mjs" +EXPECTED_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", + "nodedc-source/services/backend/data-product-publish-grant/docker-compose.immutable-runtime.yml", +] +PATCH_ID = "engine-data-product-publish-grant-20990101-001" +SUCCESSFUL_CREDENTIAL_SINK_INDEX_SHA256 = ( + "b2b790b02839570d967a2ca68b00e2724485a99389ac9b3a589a1b22302a36b8" +) + + +class EnginePublishGrantArtifactTest(unittest.TestCase): + def build(self, artifact_dir, patch_id): + environment = os.environ.copy() + environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir) + result = subprocess.run( + ["node", str(BUILDER), patch_id], + check=True, + capture_output=True, + text=True, + env=environment, + ) + return json.loads(result.stdout) + + def test_artifact_is_narrow_secret_free_and_deterministic(self): + with tempfile.TemporaryDirectory(prefix="nodedc-engine-publish-artifact-") as directory: + root = Path(directory) + first_dir = root / "first" + second_dir = root / "second" + first_dir.mkdir() + second_dir.mkdir() + first = self.build(first_dir, PATCH_ID) + artifact = Path(first["artifact"]) + first_bytes = artifact.read_bytes() + second = self.build(second_dir, PATCH_ID) + second_bytes = Path(second["artifact"]).read_bytes() + + self.assertEqual(first["entries"], EXPECTED_ENTRIES) + self.assertEqual(first["sha256"], hashlib.sha256(first_bytes).hexdigest()) + self.assertEqual(first["sha256"], second["sha256"]) + self.assertEqual(first_bytes, second_bytes) + + with tarfile.open(artifact, "r:gz") as archive: + names = {member.name for member in archive.getmembers()} + files = archive.extractfile("files.txt").read().decode("utf-8").splitlines() + manifest = archive.extractfile("manifest.env").read().decode("utf-8") + n8n_source = archive.extractfile( + "payload/nodedc-source/server/routes/n8n.js" + ).read().decode("utf-8") + gateway_source = archive.extractfile( + "payload/nodedc-source/server/routes/engineAgentGateway.js" + ).read().decode("utf-8") + runtime_override = archive.extractfile( + "payload/nodedc-source/services/backend/data-product-publish-grant/" + "docker-compose.immutable-runtime.yml" + ).read().decode("utf-8") + + self.assertEqual(files, EXPECTED_ENTRIES) + self.assertEqual( + manifest, + f"id={PATCH_ID}\ncomponent=engine\ntype=app-overlay\n", + ) + forbidden_roots = ( + "payload/docker-compose.yml", + "payload/nodedc-source/server/index.js", + "payload/nodedc-source/server/credentialPolicies/ndcPrivateNode.js", + "payload/nodedc-source/server/credentialSink/", + "payload/nodedc-source/server/routes/engineCredentialSink.js", + "payload/nodedc-source/server/middleware/demoAccess.js", + "payload/nodedc-source/server/routes/ndcAgentMcp.js", + "payload/nodedc-source/server/tests/", + "payload/nodedc-source/dist/", + "payload/nodedc-source/server/data/", + ) + self.assertFalse(any( + name == root.rstrip("/") or name.startswith(root) + for name in names + for root in forbidden_roots + )) + self.assertNotIn("from '../credentialSink/", n8n_source) + self.assertIn("engineCredentialSinkN8nAdapter", n8n_source) + self.assertIn("engineDataProductPublishGrantN8nAdapter", n8n_source) + 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", + ): + self.assertIn(tool, gateway_source) + self.assertIn(" nodedc-backend:", runtime_override) + self.assertNotIn(" n8n:", runtime_override) + self.assertIn( + "source: /volume2/nodedc-demo/nodedc-control-plane/publish-grants", + runtime_override, + ) + self.assertNotIn( + "source: /volume2/nodedc-demo/nodedc-control-plane\n", + runtime_override, + ) + self.assertIn("read_only: true", runtime_override) + self.assertEqual(runtime_override.count("create_host_path: false"), 2) + + def test_builder_requires_a_fresh_never_issued_patch_id(self): + with tempfile.TemporaryDirectory(prefix="nodedc-engine-publish-id-") as directory: + artifact_dir = Path(directory) + environment = os.environ.copy() + environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir) + cases = ( + ([], "fresh-patch-id"), + (["engine-data-product-publish-grant-20260716-001"], "patch_id_already_issued"), + (["engine-data-product-publish-grant-20260717-001"], "patch_id_already_issued"), + (["engine-data-product-publish-grant-20260717-002"], "patch_id_already_issued"), + (["engine-publish-grant-unit-001"], "fresh-patch-id"), + ) + for arguments, expected in cases: + with self.subTest(arguments=arguments): + result = subprocess.run( + ["node", str(BUILDER), *arguments], + check=False, + capture_output=True, + text=True, + env=environment, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn(expected, result.stderr) + + self.build(artifact_dir, PATCH_ID) + duplicate = subprocess.run( + ["node", str(BUILDER), PATCH_ID], + check=False, + capture_output=True, + text=True, + env=environment, + ) + self.assertNotEqual(duplicate.returncode, 0) + self.assertIn("artifact_already_exists", duplicate.stderr) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/infra/deploy-runner/test_external_data_plane_artifact.py b/infra/deploy-runner/test_external_data_plane_artifact.py new file mode 100644 index 0000000..1082d46 --- /dev/null +++ b/infra/deploy-runner/test_external_data_plane_artifact.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +import hashlib +import json +import os +import subprocess +import tarfile +import tempfile +import unittest +from pathlib import Path + + +SCRIPT_DIR = Path(__file__).resolve().parent +BUILDER = SCRIPT_DIR / "build-external-data-plane-artifact.mjs" +EXPECTED_ENTRIES = [ + "platform/docker-compose.external-data-plane.yml", + "platform/services/external-data-plane", + "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/sensitive-field-policy.mjs", +] + + +class ExternalDataPlaneArtifactTest(unittest.TestCase): + def build(self, artifact_dir, patch_id): + environment = os.environ.copy() + environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir) + result = subprocess.run( + ["node", str(BUILDER), patch_id], + check=True, + capture_output=True, + text=True, + env=environment, + ) + return json.loads(result.stdout) + + def test_artifact_is_runtime_only_public_trust_and_deterministic(self): + with tempfile.TemporaryDirectory(prefix="nodedc-edp-artifact-") as directory: + artifact_dir = Path(directory) + first = self.build(artifact_dir, "edp-managed-publish-unit-001") + artifact = Path(first["artifact"]) + first_bytes = artifact.read_bytes() + second = self.build(artifact_dir, "edp-managed-publish-unit-001") + second_bytes = Path(second["artifact"]).read_bytes() + + self.assertEqual(first["entries"], EXPECTED_ENTRIES) + self.assertEqual(first["sha256"], hashlib.sha256(first_bytes).hexdigest()) + self.assertEqual(first["sha256"], second["sha256"]) + self.assertEqual(first_bytes, second_bytes) + + with tarfile.open(artifact, "r:gz") as archive: + members = archive.getmembers() + names = {member.name for member in members} + files = archive.extractfile("files.txt").read().decode("utf-8").splitlines() + manifest = archive.extractfile("manifest.env").read().decode("utf-8") + compose = archive.extractfile( + "payload/platform/docker-compose.external-data-plane.yml" + ).read().decode("utf-8") + regular_payloads = [ + archive.extractfile(member).read() + for member in members + if member.isfile() + ] + + self.assertEqual(files, EXPECTED_ENTRIES) + self.assertEqual( + manifest, + "id=edp-managed-publish-unit-001\ncomponent=platform\ntype=app-overlay\n", + ) + self.assertFalse(any( + name.startswith("payload/platform/services/external-data-plane/test/") + or "/node_modules/" in name + or Path(name).name.startswith(".env") + or Path(name).name == "private-key.pem" + for name in names + )) + self.assertIn( + "source: /volume1/docker/nodedc-platform/trust/engine-managed-provisioner", + compose, + ) + self.assertNotIn("private-key.pem", compose) + self.assertNotIn(b"-----BEGIN PRIVATE KEY-----", b"\n".join(regular_payloads)) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/infra/deploy-runner/test_platform_registry.py b/infra/deploy-runner/test_platform_registry.py new file mode 100644 index 0000000..0d0531c --- /dev/null +++ b/infra/deploy-runner/test_platform_registry.py @@ -0,0 +1,926 @@ +#!/usr/bin/env python3 +import importlib.machinery +import importlib.util +import hashlib +import inspect +import json +import stat +import tempfile +import unittest +from pathlib import Path +from unittest import mock + + +SCRIPT_DIR = Path(__file__).resolve().parent +RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy" + + +def load_runner(): + loader = importlib.machinery.SourceFileLoader( + "nodedc_platform_deploy_under_test", + str(RUNNER_PATH), + ) + spec = importlib.util.spec_from_loader(loader.name, loader) + module = importlib.util.module_from_spec(spec) + loader.exec_module(module) + return module + + +RUNNER = load_runner() + +LEGACY_CREDENTIAL_RUNTIME_CANON_FUNCTION_SHA256 = { + "build_canonical_engine_backend_rootfs": "7f8fca6380632d717ee38a3d58b5bbc58d34b0e6c81e2764a759ddd63bfd07d0", + "engine_backend_container_id": "08dc25b6fee37e8c0d942df91fc575d6766b99c306ad06c2c14afded8de3b375", + "engine_backend_immutable_runtime_is_current": "9c6b9a73ac5004830b1f06f56bfc1f25336d11001a97a5de7a04388e10a20990", + "engine_backend_node_modules_tree_sha256": "01749704e1624d247bdf41242aa71961f5e5bc092ffa906b69cdb911907b70c4", + "engine_backend_rootfs_member_allowed": "62342e5891a1ea885f1ff02169a100f9ca94464eb7dfb62a7cbe67750ebcbb91", + "engine_backend_rootfs_toolchain_link_is_omitted": "83772bc1a8aa654e5c1d0757d43b650ee978dc73aef7ae8fac567dbacc8c7c49", + "engine_backend_tool_versions": "c21d7c11ee8c40ac5e3e5c6c24c441d1ce52c9dd2c22fc1d8f26e121138fa78a", + "ensure_engine_backend_release_image": "f65a5f8573b911980f252dc3beed88cd6ee14e0f0c519c333ea90b78c8910953", + "expected_engine_credential_backend_override": "b7c9db0059a4d262b8fe61dc0745b8ee1a4b75d56d227c39ba08115c7e414486", + "inspect_engine_backend_derived_image": "2336ce8068bd321b6c8ac6e62b430dbcbf991201cb9b4178589294d1baf7e818", + "preflight_engine_credential_sink_ready": "3f084ade6346644c86aa9540fbcf70b3a44e32c7add0876935f45177569aad6d", + "prepare_engine_credential_backend_runtime": "0def464639767dcf85acd7978459b2f5c1ebf963424dd401d4c29aae7bb1aaa5", + "quarantine_engine_backend_partial_runtime": "57e455f874c681815cf42bf529d0b8620ae8317e82551d784cbd6dae913603d2", + "read_engine_backend_runtime_metadata": "7ee0ec2c72eb699db4b6a7d3d8d912f1b0c0c724c2a054eee223fba3ec0a1cb2", + "run_compose": "8425528d4934f82e79ebf3551c4246505e4a3cdf3f450949d7618a4312fee169", + "run_engine_backend_probe": "f0db7210b64cf3c80c1f0a00165f27dcab6962781a69dc2050d84a5373cedaa9", + "touches_engine_credential_sink": "5d0e0d680d1a8508fb1ac21d8c8b3aa92466fe8b219d2ddc1fe5411f94fca912", + "validate_engine_backend_activation_marker": "66c4d5d9b0b608c030c2c9c97e2e46e1f1634b7ba024dc107c15478091765483", + "validate_engine_backend_dependencies": "ca1383dffd5a6397ecfeedcc8f88ab59e90a33d5089590f0249f789b05ed29e2", + "validate_engine_backend_mounts": "5614267d1d0f734dd68ed7565292a53f34777b6dc3640137034f6db093ef7431", + "validate_engine_backend_runtime_override_file": "eae1d32ab75cc59916dbd1d4cd73a072c156abf9014d8ef234fcd2333748139d", + "validate_engine_credential_sink_slice": "87e6571baf47c30af120da32a327a88164415bf65b198e7190c28df7365bcfd5", +} + + +class CanonicalPlatformRegistryTest(unittest.TestCase): + def test_legacy_credential_runtime_functions_match_last_successful_canon(self): + actual = { + name: hashlib.sha256( + inspect.getsource(getattr(RUNNER, name)).encode("utf-8"), + ).hexdigest() + for name in LEGACY_CREDENTIAL_RUNTIME_CANON_FUNCTION_SHA256 + } + self.assertEqual(actual, LEGACY_CREDENTIAL_RUNTIME_CANON_FUNCTION_SHA256) + + def test_publish_mount_extension_delegates_baseline_inventory_to_legacy_guard(self): + baseline_mounts = [ + { + "Type": "bind", + "Source": "/baseline/app", + "Destination": "/app", + "RW": True, + }, + ] + publish_mounts = [ + { + "Type": "bind", + "Source": str(RUNNER.ENGINE_PUBLISH_GRANT_STATE_PATH), + "Destination": RUNNER.ENGINE_PUBLISH_GRANT_CONTAINER_PATH, + "RW": True, + }, + { + "Type": "bind", + "Source": str(RUNNER.ENGINE_EDP_MANAGED_PROVISIONER_PRIVATE_KEY_FILE), + "Destination": RUNNER.ENGINE_EDP_PRIVATE_KEY_CONTAINER_PATH, + "RW": False, + }, + ] + container = {"Mounts": [*baseline_mounts, *publish_mounts]} + with tempfile.TemporaryDirectory(prefix="nodedc-publish-mounts-") as directory: + root = Path(directory) + override = root / RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_OVERRIDE_REL + override.parent.mkdir(parents=True) + override.write_text( + RUNNER.expected_engine_data_product_publish_grant_override(), + encoding="utf-8", + ) + original_root = RUNNER.COMPONENTS["engine"]["payload_root"] + RUNNER.COMPONENTS["engine"]["payload_root"] = root + try: + with mock.patch.object( + RUNNER, + "validate_engine_backend_mounts", + ) as legacy_guard: + RUNNER.validate_engine_backend_mounts_for_installed_runtime( + container, + True, + ) + legacy_guard.assert_called_once_with( + {"Mounts": baseline_mounts}, + True, + ) + + wrong_source = { + "Mounts": [ + *baseline_mounts, + {**publish_mounts[0], "Source": "/wrong"}, + publish_mounts[1], + ], + } + with self.assertRaisesRegex( + RUNNER.DeployError, + "Publish mount barrier mismatch", + ): + RUNNER.validate_engine_backend_mounts_for_installed_runtime( + wrong_source, + True, + ) + finally: + RUNNER.COMPONENTS["engine"]["payload_root"] = original_root + + def test_publish_mount_extension_accepts_exact_complete_runtime_inventory(self): + baseline = { + "/app": (True, "/engine/source"), + "/app/deploy/docker-compose.yml": (False, "/engine/docker-compose.yml"), + "/seed-api": (False, "/engine/seed-api"), + "/seed-data": (False, "/engine/seed-data"), + "/app/node_modules": (False, str(RUNNER.ENGINE_CREDENTIAL_BACKEND_NODE_MODULES_DIR)), + "/app/server/data": (True, "/engine/data"), + "/app/server/storage": (True, "/engine/storage"), + "/app/server/logs": (True, "/engine/logs"), + "/var/run/docker.sock": (True, "/var/run/docker.sock"), + } + mounts = [ + { + "Type": "bind", + "Source": source, + "Destination": destination, + "RW": writable, + } + for destination, (writable, source) in baseline.items() + ] + mounts.extend(( + { + "Type": "bind", + "Source": str(RUNNER.ENGINE_PUBLISH_GRANT_STATE_PATH), + "Destination": RUNNER.ENGINE_PUBLISH_GRANT_CONTAINER_PATH, + "RW": True, + }, + { + "Type": "bind", + "Source": str(RUNNER.ENGINE_EDP_MANAGED_PROVISIONER_PRIVATE_KEY_FILE), + "Destination": RUNNER.ENGINE_EDP_PRIVATE_KEY_CONTAINER_PATH, + "RW": False, + }, + )) + with tempfile.TemporaryDirectory(prefix="nodedc-publish-full-mounts-") as directory: + root = Path(directory) + override = root / RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_OVERRIDE_REL + override.parent.mkdir(parents=True) + override.write_text( + RUNNER.expected_engine_data_product_publish_grant_override(), + encoding="utf-8", + ) + original_root = RUNNER.COMPONENTS["engine"]["payload_root"] + RUNNER.COMPONENTS["engine"]["payload_root"] = root + try: + RUNNER.validate_engine_backend_mounts_for_installed_runtime( + {"Mounts": mounts}, + True, + ) + finally: + RUNNER.COMPONENTS["engine"]["payload_root"] = original_root + + def test_absent_publish_overlay_uses_legacy_mount_guard_unchanged(self): + container = {"Mounts": [{"Destination": "/app"}]} + with tempfile.TemporaryDirectory(prefix="nodedc-no-publish-mounts-") as directory: + original_root = RUNNER.COMPONENTS["engine"]["payload_root"] + RUNNER.COMPONENTS["engine"]["payload_root"] = Path(directory) + try: + with mock.patch.object( + RUNNER, + "validate_engine_backend_mounts", + ) as legacy_guard: + RUNNER.validate_engine_backend_mounts_for_installed_runtime( + container, + False, + ) + legacy_guard.assert_called_once_with(container, False) + finally: + RUNNER.COMPONENTS["engine"]["payload_root"] = original_root + + def test_runner_does_not_reinterpret_compose_schema(self): + self.assertFalse(hasattr(RUNNER, "render_compose_model")) + self.assertFalse(hasattr(RUNNER, "validate_platform_compose_model")) + self.assertFalse(hasattr(RUNNER, "validate_engine_compose_model")) + self.assertFalse(hasattr(RUNNER, "preflight_platform_compose_candidate")) + self.assertFalse(hasattr(RUNNER, "preflight_engine_compose_candidate")) + + def test_platform_edp_change_recreates_only_application(self): + entries = ( + "platform/docker-compose.external-data-plane.yml", + "platform/services/external-data-plane", + ) + services = RUNNER.component_services("platform", entries) + self.assertEqual(services, ("external-data-plane",)) + self.assertNotIn("external-data-plane-postgres", services) + + builds = RUNNER.component_builds("platform", entries) + self.assertEqual(len(builds), 1) + build_root, build_args = builds[0] + self.assertEqual( + build_root, + Path("/volume1/docker/nodedc-platform/platform"), + ) + self.assertEqual( + build_args, + ( + "build", + "--no-cache", + "-f", + "services/external-data-plane/Dockerfile", + "-t", + RUNNER.EXTERNAL_DATA_PLANE_IMAGE, + ".", + ), + ) + + def test_provider_catalog_is_metadata_only(self): + entries = ( + "platform/packages/external-provider-contract/providers/gelios/v1/provider.json", + ) + self.assertFalse(RUNNER.touches_external_data_plane_files(entries)) + self.assertEqual(RUNNER.component_services("platform", entries), ()) + self.assertEqual(RUNNER.component_builds("platform", entries), ()) + self.assertEqual(RUNNER.component_healthchecks("platform", entries), ()) + + with ( + mock.patch.object(RUNNER, "run_build") as build, + mock.patch.object(RUNNER, "prepare_component_runtime") as prepare, + mock.patch.object(RUNNER, "run_compose") as compose, + mock.patch.object(RUNNER, "healthcheck_url") as healthcheck, + ): + RUNNER.run_component_runtime("platform", entries, ()) + RUNNER.run_healthchecks("platform", entries, ()) + + build.assert_not_called() + prepare.assert_not_called() + compose.assert_not_called() + healthcheck.assert_not_called() + + def test_generic_engine_compose_change_keeps_legacy_service_selection(self): + services = RUNNER.component_services( + "engine", + ( + "docker-compose.yml", + "nodedc-source/server/index.js", + "nodedc-source/server/dataProductPublishGrant/store.js", + ), + ) + self.assertEqual(services, ("nodedc-backend", "app")) + self.assertNotIn("n8n-postgres", services) + self.assertNotIn("postgresql", services) + self.assertNotIn("n8n", services) + + def test_engine_runtime_prepares_managed_private_state(self): + with ( + mock.patch.object( + RUNNER, + "ensure_engine_edp_managed_provisioner_keypair", + ) as keypair, + mock.patch.object( + RUNNER, + "ensure_engine_publish_grant_private_state", + ) as private_state, + ): + RUNNER.prepare_component_runtime( + "engine", + RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_ARTIFACT_ENTRIES, + ) + + keypair.assert_called_once_with() + private_state.assert_called_once_with() + + def test_partial_publish_path_has_no_publish_runtime_side_effects(self): + entries = ("nodedc-source/server/dataProductPublishGrant/store.js",) + self.assertTrue(RUNNER.touches_engine_data_product_publish_grant(entries)) + self.assertFalse(RUNNER.is_engine_data_product_publish_grant_slice( + "engine", + entries, + )) + with ( + mock.patch.object( + RUNNER, + "ensure_engine_edp_managed_provisioner_keypair", + ) as keypair, + mock.patch.object( + RUNNER, + "ensure_engine_publish_grant_private_state", + ) as private_state, + ): + RUNNER.prepare_component_runtime("engine", entries) + keypair.assert_not_called() + private_state.assert_not_called() + + def test_publish_predecessor_guard_checks_every_pinned_file_before_mutation(self): + with tempfile.TemporaryDirectory(prefix="nodedc-publish-predecessor-") as directory: + root = Path(directory) + files = { + "docker-compose.yml": b"services: {}\n", + "nodedc-source/server/routes/n8n.js": b"export const baseline = true\n", + } + expected = {} + for relative_path, value in files.items(): + path = root / relative_path + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(value) + expected[relative_path] = hashlib.sha256(value).hexdigest() + + original_root = RUNNER.COMPONENTS["engine"]["payload_root"] + original_hashes = RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_PREDECESSOR_SHA256 + RUNNER.COMPONENTS["engine"]["payload_root"] = root + RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_PREDECESSOR_SHA256 = expected + try: + self.assertEqual( + RUNNER.preflight_engine_data_product_publish_grant_predecessor(), + { + "mode": "credential-sink-initial-transition", + "compose_sha256": expected["docker-compose.yml"], + "changed_paths": (), + }, + ) + (root / "nodedc-source/server/routes/n8n.js").write_bytes(b"drift\n") + with self.assertRaisesRegex( + RUNNER.DeployError, + "path=nodedc-source/server/routes/n8n.js", + ): + RUNNER.preflight_engine_data_product_publish_grant_predecessor() + finally: + RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_PREDECESSOR_SHA256 = original_hashes + RUNNER.COMPONENTS["engine"]["payload_root"] = original_root + + def test_installed_publish_update_preserves_foundation_and_allows_only_grant_source_changes(self): + with tempfile.TemporaryDirectory(prefix="nodedc-publish-update-") as directory: + base = Path(directory) + root = base / "installed" + payload = base / "payload" + root.mkdir() + payload.mkdir() + + compose_bytes = b"services: {}\n" + compose = root / "docker-compose.yml" + compose.write_bytes(compose_bytes) + expected = { + "docker-compose.yml": hashlib.sha256(compose_bytes).hexdigest(), + } + shared_files = { + "nodedc-source/server/assets/provider-packages/v1/catalog.json": "{}\n", + "nodedc-source/server/engineAgents/store.js": "export const profile = 'full-developer'\n", + "nodedc-source/server/routes/engineAgentGateway.js": "\n".join(( + "engine_plan_data_product_publish_grant", + "engine_apply_data_product_publish_grant", + "engine_accept_data_product_publish_grant", + "engine_rollback_data_product_publish_grant", + )), + "nodedc-source/server/routes/n8n.js": ( + "engineCredentialSinkN8nAdapter\n" + "engineDataProductPublishGrantN8nAdapter\n" + ), + RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_OVERRIDE_REL: ( + RUNNER.expected_engine_data_product_publish_grant_override() + ), + } + for name in RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_SOURCE_FILES: + shared_files[f"nodedc-source/server/dataProductPublishGrant/{name}"] = ( + f"export const source = '{name}'\n" + ) + for relative_path, value in shared_files.items(): + for destination in (root, payload): + path = destination / relative_path + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(value, encoding="utf-8") + acceptance_rel = "nodedc-source/server/dataProductPublishGrant/acceptance.js" + (payload / acceptance_rel).write_text( + "export const source = 'acceptance-fixed'\n", + encoding="utf-8", + ) + + original_root = RUNNER.COMPONENTS["engine"]["payload_root"] + original_hashes = RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_PREDECESSOR_SHA256 + RUNNER.COMPONENTS["engine"]["payload_root"] = root + RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_PREDECESSOR_SHA256 = expected + try: + self.assertEqual( + RUNNER.preflight_engine_data_product_publish_grant_predecessor(payload), + { + "mode": "installed-source-update", + "compose_sha256": expected["docker-compose.yml"], + "changed_paths": (acceptance_rel,), + }, + ) + + gateway_rel = "nodedc-source/server/routes/engineAgentGateway.js" + (payload / gateway_rel).write_text("cross-boundary change\n", encoding="utf-8") + with self.assertRaisesRegex( + RUNNER.DeployError, + "crosses its source boundary", + ): + RUNNER.preflight_engine_data_product_publish_grant_predecessor(payload) + + (payload / gateway_rel).write_text(shared_files[gateway_rel], encoding="utf-8") + compose.write_text("drift\n", encoding="utf-8") + with self.assertRaisesRegex( + RUNNER.DeployError, + "foundation drift", + ): + RUNNER.preflight_engine_data_product_publish_grant_predecessor(payload) + finally: + RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_PREDECESSOR_SHA256 = original_hashes + RUNNER.COMPONENTS["engine"]["payload_root"] = original_root + + def test_edp_healthcheck_requires_database_and_managed_provisioning(self): + checks = RUNNER.component_healthchecks( + "platform", + ("platform/services/external-data-plane/src/server.mjs",), + ("external-data-plane",), + ) + self.assertEqual( + checks, + (RUNNER.external_data_plane_healthcheck(),), + ) + + def test_edp_runtime_never_probes_unselected_platform_services(self): + entries = ("platform/services/external-data-plane/src/server.mjs",) + services = ("external-data-plane",) + with mock.patch.object(RUNNER, "healthcheck_url") as healthcheck: + RUNNER.run_healthchecks("platform", entries, services) + + healthcheck.assert_called_once_with( + RUNNER.external_data_plane_healthcheck(), + ) + + def test_json_healthcheck_accepts_expected_contract(self): + class Response: + status = 200 + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def read(self, _limit): + return json.dumps({ + "ok": True, + "database": "ready", + }).encode("utf-8") + + with mock.patch.object( + RUNNER.NO_REDIRECT_OPENER, + "open", + return_value=Response(), + ): + RUNNER.healthcheck_url({ + "url": "http://127.0.0.1:18106/healthz", + "expected_json": { + "ok": True, + "database": "ready", + }, + }) + + def test_json_healthcheck_accepts_restored_edp_baseline_contract(self): + class Response: + status = 200 + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def read(self, _limit): + return json.dumps({ + "ok": True, + "service": "nodedc-external-data-plane", + "database": "ready", + }).encode("utf-8") + + with mock.patch.object( + RUNNER.NO_REDIRECT_OPENER, + "open", + return_value=Response(), + ): + RUNNER.healthcheck_url( + RUNNER.external_data_plane_healthcheck(require_managed=False), + ) + + def test_json_healthcheck_does_not_accept_redirect(self): + class Response: + status = 200 + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def read(self, _limit): + return json.dumps({ + "ok": True, + "database": "ready", + }).encode("utf-8") + + url = "http://127.0.0.1:18106/healthz" + redirect = RUNNER.urllib.error.HTTPError( + url, + 302, + "Found", + {}, + None, + ) + with ( + mock.patch.object( + RUNNER.NO_REDIRECT_OPENER, + "open", + side_effect=(redirect, Response()), + ) as opener, + mock.patch.object(RUNNER.time, "sleep"), + ): + RUNNER.healthcheck_url({ + "url": url, + "expected_json": { + "ok": True, + "database": "ready", + }, + }) + + self.assertEqual(opener.call_count, 2) + + def test_publish_grant_healthcheck_does_not_enter_n8n_transition(self): + services = ("nodedc-backend",) + with ( + mock.patch.object(RUNNER, "accept_engine_n8n_runtime") as accept, + mock.patch.object(RUNNER, "healthcheck_compose_service") as compose_health, + mock.patch.object(RUNNER, "healthcheck_url") as healthcheck, + mock.patch.object( + RUNNER, + "preflight_engine_credential_backend_runtime", + return_value={"mode": "verified-derived-retry"}, + ) as backend_preflight, + ): + RUNNER.run_healthchecks( + "engine", + RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_ARTIFACT_ENTRIES, + services, + ) + + accept.assert_not_called() + compose_health.assert_called_once_with("engine", "nodedc-backend") + healthcheck.assert_called_once_with( + "http://127.0.0.1:3001/health", + ) + backend_preflight.assert_called_once_with() + + def test_engine_ui_healthcheck_requires_selected_app(self): + grant_entries = RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_ARTIFACT_ENTRIES + self.assertEqual( + RUNNER.component_healthchecks( + "engine", + grant_entries, + ("nodedc-backend",), + ), + ("http://127.0.0.1:3001/health",), + ) + self.assertEqual( + RUNNER.component_healthchecks( + "engine", + ("docker-compose.yml",), + ("nodedc-backend", "app"), + ), + ( + "http://127.0.0.1:8080/", + "http://127.0.0.1:3001/health", + ), + ) + + def test_optional_edp_overlay_is_omitted_until_installed(self): + with tempfile.TemporaryDirectory( + prefix="nodedc-compose-files-", + ) as directory: + temporary = Path(directory) + base = temporary / "docker-compose.platform-http.yml" + edp = temporary / "docker-compose.external-data-plane.yml" + base.write_text( + "name: nodedc-platform\n", + encoding="utf-8", + ) + original = RUNNER.COMPONENTS["platform"]["compose_files"] + RUNNER.COMPONENTS["platform"]["compose_files"] = (base, edp) + try: + self.assertEqual( + RUNNER.component_compose_files("platform"), + (base,), + ) + finally: + RUNNER.COMPONENTS["platform"]["compose_files"] = original + + +class PlatformOverlayRollbackTest(unittest.TestCase): + def test_engine_runtime_failure_restores_source_and_recreates_exact_services(self): + with tempfile.TemporaryDirectory(prefix="nodedc-engine-runtime-rollback-") as directory: + temporary = Path(directory) + root = temporary / "engine" + backup = temporary / "backup" + runner_tmp = temporary / "tmp" + compose = root / "docker-compose.yml" + backend = root / "nodedc-source/server/index.js" + compose.parent.mkdir(parents=True) + backend.parent.mkdir(parents=True) + backup.mkdir() + runner_tmp.mkdir() + compose.write_text("name: baseline\n", encoding="utf-8") + backend.write_text("export const version = 1;\n", encoding="utf-8") + entries = ["docker-compose.yml", "nodedc-source/server/index.js"] + services = ("n8n", "nodedc-backend", "app") + RUNNER.create_backup(root, backup, entries, include_nginx_html=False) + compose.write_text("name: candidate\n", encoding="utf-8") + backend.write_text("export const version = 2;\n", encoding="utf-8") + + def assert_restored(component, rollback_entries, rollback_services): + self.assertEqual(component, "engine") + self.assertEqual(rollback_entries, entries) + self.assertEqual(rollback_services, services) + self.assertEqual(compose.read_text(encoding="utf-8"), "name: baseline\n") + self.assertEqual( + backend.read_text(encoding="utf-8"), + "export const version = 1;\n", + ) + + with ( + mock.patch.object(RUNNER, "TMP_DIR", runner_tmp), + mock.patch.object( + RUNNER, + "run_component_runtime", + side_effect=assert_restored, + ) as runtime, + mock.patch.object(RUNNER, "run_healthchecks") as healthchecks, + ): + status = RUNNER.rollback_engine_apply( + root, + backup, + entries, + "test-stamp", + runtime_started=True, + applied_services=services, + ) + + self.assertEqual(status, "source+runtime-restored:2") + runtime.assert_called_once() + healthchecks.assert_called_once_with("engine", entries, services) + + def test_candidate_only_service_cleanup_never_requests_volume_removal(self): + with ( + mock.patch.object(RUNNER, "component_compose_root", return_value=Path("/live/platform")), + mock.patch.object(RUNNER, "compose_base_cmd", return_value=["docker", "compose"]), + mock.patch.object(RUNNER.subprocess, "run") as run, + ): + RUNNER.stop_and_remove_compose_services("platform", ("external-data-plane",)) + + command = run.call_args.args[0] + self.assertEqual( + command, + ["docker", "compose", "rm", "--stop", "--force", "external-data-plane"], + ) + self.assertNotIn("--volumes", command) + + def test_failed_first_edp_activation_removes_containers_without_deleting_volume(self): + with tempfile.TemporaryDirectory(prefix="nodedc-platform-edp-rollback-") as directory: + temporary = Path(directory) + root = temporary / "root" + backup = temporary / "backup" + runner_tmp = temporary / "tmp" + overlay = root / RUNNER.PLATFORM_EXTERNAL_DATA_PLANE_COMPOSE_REL + overlay.parent.mkdir(parents=True) + backup.mkdir() + runner_tmp.mkdir() + entries = [RUNNER.PLATFORM_EXTERNAL_DATA_PLANE_COMPOSE_REL] + RUNNER.create_backup(root, backup, entries, include_nginx_html=False) + overlay.write_text("name: nodedc-platform\n", encoding="utf-8") + + with ( + mock.patch.object(RUNNER, "TMP_DIR", runner_tmp), + mock.patch.object(RUNNER, "stop_and_remove_compose_services") as remove, + mock.patch.object(RUNNER, "run_component_runtime") as runtime, + mock.patch.object(RUNNER, "run_healthchecks") as healthchecks, + ): + status = RUNNER.rollback_platform_apply( + root, + backup, + entries, + "test-stamp", + runtime_started=True, + applied_services=("external-data-plane",), + ) + + remove.assert_called_once_with( + "platform", + ("external-data-plane",), + ) + runtime.assert_not_called() + healthchecks.assert_not_called() + self.assertFalse(overlay.exists()) + self.assertEqual(status, "source+runtime-restored:1") + + def test_candidate_cleanup_failure_still_restores_source_before_reporting_failure(self): + with tempfile.TemporaryDirectory(prefix="nodedc-platform-edp-rollback-") as directory: + temporary = Path(directory) + root = temporary / "root" + backup = temporary / "backup" + runner_tmp = temporary / "tmp" + overlay = root / RUNNER.PLATFORM_EXTERNAL_DATA_PLANE_COMPOSE_REL + overlay.parent.mkdir(parents=True) + backup.mkdir() + runner_tmp.mkdir() + entries = [RUNNER.PLATFORM_EXTERNAL_DATA_PLANE_COMPOSE_REL] + RUNNER.create_backup(root, backup, entries, include_nginx_html=False) + overlay.write_text("name: rejected\n", encoding="utf-8") + + with ( + mock.patch.object(RUNNER, "TMP_DIR", runner_tmp), + mock.patch.object( + RUNNER, + "stop_and_remove_compose_services", + side_effect=RuntimeError("simulated cleanup failure"), + ), + ): + with self.assertRaisesRegex(RUNNER.DeployError, "cleanup failed after source restore"): + RUNNER.rollback_platform_apply( + root, + backup, + entries, + "test-stamp", + runtime_started=True, + applied_services=("external-data-plane",), + ) + self.assertFalse(overlay.exists()) + + def test_runtime_failure_rebuilds_recreates_and_accepts_restored_overlay(self): + with tempfile.TemporaryDirectory(prefix="nodedc-platform-runtime-rollback-") as directory: + temporary = Path(directory) + root = temporary / "root" + backup = temporary / "backup" + runner_tmp = temporary / "tmp" + gateway = root / "platform/gelios-gateway" + gateway.mkdir(parents=True) + backup.mkdir() + runner_tmp.mkdir() + source = gateway / "server.mjs" + source.write_text("export const version = 1;\n", encoding="utf-8") + entries = ["platform/gelios-gateway"] + RUNNER.create_backup(root, backup, entries, include_nginx_html=False) + source.write_text("export const version = 2;\n", encoding="utf-8") + + def assert_restored_runtime(component, rollback_entries, services): + self.assertEqual(component, "platform") + self.assertEqual(rollback_entries, entries) + self.assertEqual(services, ("gelios-postgres", "gelios-gateway")) + self.assertEqual(source.read_text(encoding="utf-8"), "export const version = 1;\n") + + with ( + mock.patch.object(RUNNER, "TMP_DIR", runner_tmp), + mock.patch.object( + RUNNER, + "run_component_runtime", + side_effect=assert_restored_runtime, + ) as runtime, + mock.patch.object(RUNNER, "run_healthchecks") as healthchecks, + ): + status = RUNNER.rollback_platform_apply( + root, + backup, + entries, + "test-stamp", + runtime_started=True, + applied_services=("gelios-postgres", "gelios-gateway"), + ) + + self.assertEqual(status, "source+runtime-restored:1") + runtime.assert_called_once() + healthchecks.assert_called_once_with( + "platform", + entries, + ("gelios-postgres", "gelios-gateway"), + ) + + def test_existing_edp_rollback_accepts_only_restored_edp(self): + with tempfile.TemporaryDirectory(prefix="nodedc-platform-edp-runtime-rollback-") as directory: + temporary = Path(directory) + root = temporary / "root" + backup = temporary / "backup" + runner_tmp = temporary / "tmp" + service = root / "platform/services/external-data-plane" + service.mkdir(parents=True) + backup.mkdir() + runner_tmp.mkdir() + source = service / "server.mjs" + source.write_text("export const version = 1;\n", encoding="utf-8") + entries = ["platform/services/external-data-plane"] + RUNNER.create_backup(root, backup, entries, include_nginx_html=False) + source.write_text("export const version = 2;\n", encoding="utf-8") + + with ( + mock.patch.object(RUNNER, "TMP_DIR", runner_tmp), + mock.patch.object(RUNNER, "run_component_runtime") as runtime, + mock.patch.object(RUNNER, "healthcheck_url") as healthcheck, + ): + status = RUNNER.rollback_platform_apply( + root, + backup, + entries, + "test-stamp", + runtime_started=True, + applied_services=("external-data-plane",), + ) + + self.assertEqual(status, "source+runtime-restored:1") + self.assertEqual( + source.read_text(encoding="utf-8"), + "export const version = 1;\n", + ) + runtime.assert_called_once_with( + "platform", + entries, + ("external-data-plane",), + ) + healthcheck.assert_called_once_with( + RUNNER.external_data_plane_healthcheck(require_managed=False), + ) + + def test_restores_existing_overlay_and_removes_new_paths(self): + with tempfile.TemporaryDirectory(prefix="nodedc-platform-rollback-test-") as directory: + temporary = Path(directory) + root = temporary / "root" + backup = temporary / "backup" + runner_tmp = temporary / "tmp" + root.mkdir() + backup.mkdir() + runner_tmp.mkdir() + + compose = root / "platform/docker-compose.platform-http.yml" + gateway = root / "platform/gelios-gateway" + new_service = root / "platform/services/new-service" + unrelated = root / "platform/unrelated.txt" + compose.parent.mkdir(parents=True) + gateway.mkdir() + compose.write_text("name: nodedc-platform\n", encoding="utf-8") + executable = gateway / "run.sh" + executable.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + executable.chmod(0o755) + (gateway / "server.mjs").write_text("export const version = 1;\n", encoding="utf-8") + unrelated.write_text("untouched\n", encoding="utf-8") + + entries = [ + "platform/docker-compose.platform-http.yml", + "platform/gelios-gateway", + "platform/services/new-service", + ] + RUNNER.create_backup(root, backup, entries, include_nginx_html=False) + + compose.write_text("name: broken\n", encoding="utf-8") + (gateway / "server.mjs").write_text("export const version = 2;\n", encoding="utf-8") + executable.chmod(0o644) + new_service.mkdir(parents=True) + (new_service / "bad.mjs").write_text("broken\n", encoding="utf-8") + + with mock.patch.object(RUNNER, "TMP_DIR", runner_tmp): + restored = RUNNER.restore_platform_overlay(root, backup, entries, "test-stamp") + + self.assertEqual(restored, len(entries)) + self.assertEqual(compose.read_text(encoding="utf-8"), "name: nodedc-platform\n") + self.assertEqual( + (gateway / "server.mjs").read_text(encoding="utf-8"), + "export const version = 1;\n", + ) + self.assertTrue(stat.S_IMODE((gateway / "run.sh").stat().st_mode) & stat.S_IXUSR) + self.assertFalse(new_service.exists()) + self.assertEqual(unrelated.read_text(encoding="utf-8"), "untouched\n") + + def test_rejects_inconsistent_backup_partition_before_restore(self): + with tempfile.TemporaryDirectory(prefix="nodedc-platform-rollback-test-") as directory: + temporary = Path(directory) + root = temporary / "root" + backup = temporary / "backup" + runner_tmp = temporary / "tmp" + target = root / "platform/docker-compose.platform-http.yml" + target.parent.mkdir(parents=True) + target.write_text("original\n", encoding="utf-8") + backup.mkdir() + runner_tmp.mkdir() + entries = ["platform/docker-compose.platform-http.yml"] + RUNNER.create_backup(root, backup, entries, include_nginx_html=False) + (backup / "missing-files.txt").write_text( + "platform/docker-compose.platform-http.yml\n", + encoding="utf-8", + ) + target.write_text("candidate\n", encoding="utf-8") + + with mock.patch.object(RUNNER, "TMP_DIR", runner_tmp): + with self.assertRaisesRegex(RUNNER.DeployError, "backup entry set mismatch"): + RUNNER.restore_platform_overlay(root, backup, entries, "test-stamp") + self.assertEqual(target.read_text(encoding="utf-8"), "candidate\n") + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/infra/docker-compose.dev.yml b/infra/docker-compose.dev.yml index fda87a0..e8d27cf 100644 --- a/infra/docker-compose.dev.yml +++ b/infra/docker-compose.dev.yml @@ -192,9 +192,8 @@ services: retries: 3 start_period: 10s - # External-provider telemetry is isolated from Platform identity state. The - # database is not published; only the Gateway is exposed on the local host - # for operator checks and Engine development. + # Frozen legacy Gelios compatibility contour. Keep it reproducible and + # isolated; new provider integrations use the provider-neutral data plane. gelios-postgres: image: ${GELIOS_TIMESCALE_IMAGE:-timescale/timescaledb-ha:pg16.14-ts2.28.2-all} restart: unless-stopped diff --git a/infra/synology/.env.synology.example b/infra/synology/.env.synology.example index 2306c6f..d08763a 100644 --- a/infra/synology/.env.synology.example +++ b/infra/synology/.env.synology.example @@ -97,9 +97,20 @@ EXTERNAL_DATA_PLANE_WRITER_BINDING_MAX_TTL_DAYS=90 EXTERNAL_DATA_PLANE_MAX_FUTURE_SKEW_SECONDS=300 EXTERNAL_DATA_PLANE_RETENTION_SWEEP_MS=3600000 EXTERNAL_DATA_PLANE_LEGACY_INTAKE_ENABLED=false -# The writer-provisioner secret is a root-owned file under -# /volume1/docker/nodedc-platform/secrets/external-data-plane-provisioner/, -# never a shared .env value. +# Internal control-plane writer/reader binding issuance; keep false until the +# atomic NDC L2 ensure-grant operation is deployed. Legacy path returns a +# plaintext capability and must never be exposed to users. +EXTERNAL_DATA_PLANE_PROVISIONING_ENABLED=false +# Digest-only managed writer-binding ensure. It accepts only signed Engine +# service requests; the legacy provisioner bearer is deliberately invalid here. +# Keep false until the matching Engine private key is provisioned. The trust +# directory is mounted read-only into EDP and contains only `public-key.pem`. +EXTERNAL_DATA_PLANE_MANAGED_PROVISIONING_ENABLED=false +EXTERNAL_DATA_PLANE_MANAGED_PROVISIONER_SERVICE_ID=nodedc-engine +EXTERNAL_DATA_PLANE_MANAGED_PROVISIONER_KEY_ID=engine-edp-managed-provisioner-v1 +EXTERNAL_DATA_PLANE_MANAGED_PROVISIONER_AUDIENCE=nodedc-external-data-plane.managed-provisioning.v1 +EXTERNAL_DATA_PLANE_MANAGED_PROVISIONER_MAX_SKEW_SECONDS=60 +EXTERNAL_DATA_PLANE_MANAGED_PROVISIONER_REPLAY_CACHE_MAX_ENTRIES=10000 NOTIFICATION_PG_DB=nodedc_notifications NOTIFICATION_PG_USER=nodedc_notifications @@ -132,8 +143,9 @@ AI_WORKSPACE_ONTOLOGY_MCP_ENABLED=true AI_WORKSPACE_ONTOLOGY_MCP_PUBLIC_URL= ONTOLOGY_CORE_HOST_BIND=127.0.0.1:18104 -# Gelios Gateway — storage/read service. Provider credentials stay in the -# protected Engine Collector. Intake remains disabled until scope is approved. +# Gelios Gateway — frozen legacy storage/read compatibility service. Provider +# credentials stay in the protected Engine Collector. Do not extend this +# provider-specific contour for new providers. GELIOS_TIMESCALE_IMAGE=timescale/timescaledb-ha:pg16.14-ts2.28.2-all GELIOS_PG_DB=nodedc_gelios GELIOS_PG_USER=nodedc_gelios @@ -141,8 +153,6 @@ GELIOS_PG_PASS=replace-with-random-synology-secret # URL-encode reserved characters in GELIOS_PG_PASS when forming this URL. GELIOS_DATABASE_URL=postgresql://nodedc_gelios:replace-with-url-encoded-synology-secret@gelios-postgres:5432/nodedc_gelios GELIOS_GATEWAY_HOST_BIND=127.0.0.1:18105 -# Explicit tenant and connection identifiers are deployment configuration; -# do not encode a customer or pilot name in source defaults. GELIOS_TENANT_ID=replace-with-tenant-id GELIOS_CONNECTION_ID=gelios-connection-id # `all` accepts every unit returned by this approved tenant + connection. diff --git a/infra/synology/apply-current-runtime.sh b/infra/synology/apply-current-runtime.sh index ba1cb84..9241d33 100755 --- a/infra/synology/apply-current-runtime.sh +++ b/infra/synology/apply-current-runtime.sh @@ -38,7 +38,7 @@ echo "== ontology core image build ==" cd "${PLATFORM_DIR}/ontology-core" "${DOCKER_BIN}" build --no-cache -t nodedc/ontology-core:local . -echo "== gelios gateway image build ==" +echo "== frozen legacy gelios gateway image build ==" cd "${PLATFORM_DIR}/gelios-gateway" "${DOCKER_BIN}" build --no-cache -t nodedc/gelios-gateway:local . @@ -53,7 +53,7 @@ echo "== ontology core health check ==" "${DOCKER_BIN}" exec nodedc-platform-ontology-core-1 sh -lc \ 'node -e '"'"'fetch("http://127.0.0.1:18104/healthz").then(async (response) => { console.log(await response.text()); process.exit(response.ok ? 0 : 1); }).catch((error) => { console.error(error); process.exit(1); })'"'"'' -echo "== gelios gateway health check ==" +echo "== frozen legacy gelios gateway health check ==" "${DOCKER_BIN}" exec nodedc-platform-gelios-gateway-1 sh -lc \ 'node -e '"'"'fetch("http://127.0.0.1:18105/healthz").then(async (response) => { console.log(await response.text()); process.exit(response.ok ? 0 : 1); }).catch((error) => { console.error(error); process.exit(1); })'"'"'' diff --git a/infra/synology/docker-compose.external-data-plane.yml b/infra/synology/docker-compose.external-data-plane.yml index 76eff31..366d5aa 100644 --- a/infra/synology/docker-compose.external-data-plane.yml +++ b/infra/synology/docker-compose.external-data-plane.yml @@ -51,12 +51,23 @@ services: # Migration-only shared-token routes stay closed in a canonical install. EXTERNAL_DATA_PLANE_LEGACY_INTAKE_ENABLED: ${EXTERNAL_DATA_PLANE_LEGACY_INTAKE_ENABLED:-false} NODEDC_INTERNAL_ACCESS_TOKEN: ${NODEDC_INTERNAL_ACCESS_TOKEN:-} - # Runner-owned secret file. This credential must never live in the - # shared .env.synology that is inherited by unrelated services. + # Legacy manual one-time issuance uses this EDP-only bearer. Managed + # Engine ensure/revoke never accepts it and uses the Ed25519 boundary + # below. Neither credential is exposed to a graph, MCP client or provider. EXTERNAL_DATA_PLANE_PROVISIONER_TOKEN_FILE: /run/nodedc-secrets/external-data-plane-provisioner-token - # This stays false until the dedicated Engine provisioner is deployed and - # receives the same read-only secret mount. - EXTERNAL_DATA_PLANE_PROVISIONING_ENABLED: "false" + # Disabled until the atomic native NDC L2 ensure-grant control-plane + # operation is present. This legacy API returns plaintext capabilities; + # a root/UI transfer is emergency diagnostics only. + EXTERNAL_DATA_PLANE_PROVISIONING_ENABLED: ${EXTERNAL_DATA_PLANE_PROVISIONING_ENABLED:-false} + # Digest-only, idempotent writer-binding ensure path. Enable only after + # Engine owns the matching private key and the public trust file exists. + EXTERNAL_DATA_PLANE_MANAGED_PROVISIONING_ENABLED: ${EXTERNAL_DATA_PLANE_MANAGED_PROVISIONING_ENABLED:-false} + EXTERNAL_DATA_PLANE_MANAGED_PROVISIONER_PUBLIC_KEY_FILE: /run/nodedc-trust/engine-managed-provisioner/public-key.pem + EXTERNAL_DATA_PLANE_MANAGED_PROVISIONER_SERVICE_ID: ${EXTERNAL_DATA_PLANE_MANAGED_PROVISIONER_SERVICE_ID:-nodedc-engine} + EXTERNAL_DATA_PLANE_MANAGED_PROVISIONER_KEY_ID: ${EXTERNAL_DATA_PLANE_MANAGED_PROVISIONER_KEY_ID:-engine-edp-managed-provisioner-v1} + EXTERNAL_DATA_PLANE_MANAGED_PROVISIONER_AUDIENCE: ${EXTERNAL_DATA_PLANE_MANAGED_PROVISIONER_AUDIENCE:-nodedc-external-data-plane.managed-provisioning.v1} + EXTERNAL_DATA_PLANE_MANAGED_PROVISIONER_MAX_SKEW_SECONDS: ${EXTERNAL_DATA_PLANE_MANAGED_PROVISIONER_MAX_SKEW_SECONDS:-60} + EXTERNAL_DATA_PLANE_MANAGED_PROVISIONER_REPLAY_CACHE_MAX_ENTRIES: ${EXTERNAL_DATA_PLANE_MANAGED_PROVISIONER_REPLAY_CACHE_MAX_ENTRIES:-10000} volumes: - type: bind source: /volume1/docker/nodedc-platform/secrets/external-data-plane-provisioner/token @@ -64,6 +75,12 @@ services: read_only: true bind: create_host_path: false + - type: bind + source: /volume1/docker/nodedc-platform/trust/engine-managed-provisioner + target: /run/nodedc-trust/engine-managed-provisioner + read_only: true + bind: + create_host_path: false expose: - "18106" ports: diff --git a/infra/synology/docker-compose.platform-http.yml b/infra/synology/docker-compose.platform-http.yml index ecde896..46be4e3 100644 --- a/infra/synology/docker-compose.platform-http.yml +++ b/infra/synology/docker-compose.platform-http.yml @@ -184,8 +184,8 @@ services: networks: - engine - # The provider database is private. Gateway is the only component that can - # reach it; the host bind is loopback-only for audited operator diagnostics. + # Frozen legacy Gelios compatibility contour. Keep it reproducible and + # isolated; new provider integrations use the provider-neutral data plane. gelios-postgres: image: ${GELIOS_TIMESCALE_IMAGE:-timescale/timescaledb-ha:pg16.14-ts2.28.2-all} restart: unless-stopped diff --git a/infra/synology/verify-current-runtime.sh b/infra/synology/verify-current-runtime.sh index 0e56e90..2e72a31 100755 --- a/infra/synology/verify-current-runtime.sh +++ b/infra/synology/verify-current-runtime.sh @@ -67,7 +67,7 @@ echo "== ontology core health check ==" "${DOCKER_BIN}" exec nodedc-platform-ontology-core-1 sh -lc \ 'node -e '"'"'fetch("http://127.0.0.1:18104/healthz").then(async (response) => { console.log(await response.text()); process.exit(response.ok ? 0 : 1); }).catch((error) => { console.error(error); process.exit(1); })'"'"'' -echo "== gelios gateway health check ==" +echo "== frozen legacy gelios gateway health check ==" "${DOCKER_BIN}" exec nodedc-platform-gelios-gateway-1 sh -lc \ 'node -e '"'"'fetch("http://127.0.0.1:18105/healthz").then(async (response) => { console.log(await response.text()); process.exit(response.ok ? 0 : 1); }).catch((error) => { console.error(error); process.exit(1); })'"'"''