feat(deploy): extend canon for managed EDP and Engine grants

This commit is contained in:
Codex 2026-07-17 18:09:39 +03:00
parent a0a4d36fa2
commit 2dd6e33a54
19 changed files with 5336 additions and 106 deletions

View File

@ -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

View File

@ -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 <fresh-patch-id>')
}
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')
}

View File

@ -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 <fresh-recovery-patch-id>')
}
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')
}

View File

@ -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 <fresh-patch-id>')
}
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')
}

View File

@ -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 [

View File

@ -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)}`);

File diff suppressed because it is too large Load Diff

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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.

View File

@ -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); })'"'"''

View File

@ -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:

View File

@ -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

View File

@ -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); })'"'"''