feat(deploy): extend canon for managed EDP and Engine grants
This commit is contained in:
parent
a0a4d36fa2
commit
2dd6e33a54
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
|
|
@ -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')
|
||||
}
|
||||
|
|
@ -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')
|
||||
}
|
||||
|
|
@ -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 [
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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); })'"'"''
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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); })'"'"''
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue