#!/usr/bin/env node import { createHash } from 'node:crypto' import { spawnSync } from 'node:child_process' import { cp, lstat, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join, relative, resolve } from 'node:path' import { fileURLToPath } from 'node:url' const here = dirname(fileURLToPath(import.meta.url)) const platformRoot = resolve(here, '../..') const workspaceRoot = resolve(platformRoot, '..') const engineRoot = resolve(workspaceRoot, 'NODEDC_ENGINE_INFRA') const artifactRoot = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || join(workspaceRoot, 'deploy-artifacts')) const [patchId = '', ...extra] = process.argv.slice(2) const previouslyIssuedPatchIds = new Set([ 'engine-data-product-publish-grant-20260716-001', 'engine-data-product-publish-grant-20260717-001', 'engine-data-product-publish-grant-20260717-002', ]) if (extra.length || !/^engine-data-product-publish-grant-\d{8}-\d{3}$/.test(patchId)) { throw new Error('usage: build-engine-data-product-publish-grant-artifact.mjs ') } if (previouslyIssuedPatchIds.has(patchId)) throw new Error('engine_publish_grant_patch_id_already_issued') // Deliberately exclude frontend/dist, runtime data, tests, every credential // value, and the already-installed credential-sink implementation. The sink // remains mounted and byte-frozen as the predecessor domain; this artifact // adds the exact Data Product Publish grant flow beside it. const entries = Object.freeze([ 'nodedc-source/server/assets/provider-packages/v1/catalog.json', 'nodedc-source/server/dataProductPublishGrant', 'nodedc-source/server/engineAgents/store.js', 'nodedc-source/server/routes/engineAgentGateway.js', 'nodedc-source/server/routes/n8n.js', 'nodedc-source/services/backend/data-product-publish-grant/docker-compose.immutable-runtime.yml', ]) const ignoredBasenames = new Set(['.DS_Store', '.git', 'node_modules']) const credentialSinkPredecessorSha256 = Object.freeze({ 'docker-compose.yml': '258cebb64ff1943c939655cc55bdce00fc5c4dced67ec291d84d6df066ace50e', 'nodedc-source/server/credentialPolicies/ndcPrivateNode.js': '723874a02dc7b8a68b22ff2304431cfe64f28933cedf5f1e6cda79b2e1cf704a', 'nodedc-source/server/credentialSink/core.js': '9f0facc41fd398fcd955cffdd486abb126cdcd756c87ffde667fcbe00e2c41d3', 'nodedc-source/server/credentialSink/requestAuth.js': 'f8c9237c3e6f4219dee0d4f956d6e97f76f5bd7ba8a6fd0b4fb21aceffa38138', 'nodedc-source/server/credentialSink/store.js': 'c78dc285a973b6acd8a2330f0310935ad08720b2905454492f292d235abf12c0', 'nodedc-source/server/credentialSink/vendor/engine-credential-sink.mjs': 'b4800eead9bf94793ff1280d06e34aad6b37ef055a9d7f8d83d7fb5f9bd66d8b', 'nodedc-source/server/index.js': 'b2b790b02839570d967a2ca68b00e2724485a99389ac9b3a589a1b22302a36b8', 'nodedc-source/server/routes/engineCredentialSink.js': '9cbb69dbc8cbe6181cd5b0170fe9c4d717b0a173866ca98cba3e3766c0bab94e', 'nodedc-source/server/routes/ndcAgentMcp.js': 'fbb3342b1a617b956d5b3a6a40d5111aa107c1176b4ff89c37b104995bada081', 'nodedc-source/services/backend/credential-sink/docker-compose.immutable-runtime.yml': '944fa64b08255eb8207b93fd327aebb98ecd9400d37d25fcfa8e3a040ee44afe', }) const publishGrantRuntimeOverride = [ 'services:', ' nodedc-backend:', ' user: "0:0"', ' environment:', ' ENGINE_DATA_PLANE_BASE_URL: http://external-data-plane:18106', ' ENGINE_EDP_MANAGED_PROVISIONER_PRIVATE_KEY_FILE: /run/nodedc-secrets/engine-edp-managed-provisioner-private.pem', ' ENGINE_CONTROL_PLANE_PUBLISH_GRANT_ROOT: /var/lib/nodedc-control-plane/publish-grants', ' volumes:', ' - type: bind', ' source: /volume2/nodedc-demo/nodedc-control-plane/publish-grants', ' target: /var/lib/nodedc-control-plane/publish-grants', ' bind:', ' create_host_path: false', ' - type: bind', ' source: /volume1/docker/nodedc-platform/secrets/engine-edp-managed-provisioner/private-key.pem', ' target: /run/nodedc-secrets/engine-edp-managed-provisioner-private.pem', ' read_only: true', ' bind:', ' create_host_path: false', '', ].join('\n') await assertSourceBoundary() await mkdir(artifactRoot, { recursive: true }) const artifact = join(artifactRoot, `nodedc-${patchId}.tgz`) await assertArtifactTargetFresh(artifact) const stage = await mkdtemp(join(tmpdir(), 'nodedc-engine-publish-grant-artifact-')) const payload = join(stage, 'payload') try { await mkdir(payload, { recursive: true }) for (const entry of entries) { await copySafe(resolve(engineRoot, entry), join(payload, entry)) } await writeFile( join(stage, 'manifest.env'), `id=${patchId}\ncomponent=engine\ntype=app-overlay\n`, 'utf8', ) await writeFile(join(stage, 'files.txt'), `${entries.join('\n')}\n`, 'utf8') run('python3', ['-c', canonicalTarScript(), artifact, stage]) const sha256 = digest(await readFile(artifact)) console.log(JSON.stringify({ ok: true, patchId, artifact, sha256, entries, excluded: [ 'docker-compose.yml', 'nodedc-source/server/index.js', 'nodedc-source/server/credentialPolicies/ndcPrivateNode.js', 'nodedc-source/server/credentialSink', 'nodedc-source/server/routes/engineCredentialSink.js', 'nodedc-source/server/middleware/demoAccess.js', 'nodedc-source/server/routes/ndcAgentMcp.js', 'nodedc-source/server/data', 'nodedc-source/dist', 'nodedc-source/server/tests', ], preservedPredecessor: Object.keys(credentialSinkPredecessorSha256), }, null, 2)) } finally { await rm(stage, { recursive: true, force: true }) } async function assertSourceBoundary() { for (const [relativePath, expectedSha256] of Object.entries(credentialSinkPredecessorSha256)) { const bytes = await readFile(join(engineRoot, relativePath)) if (digest(bytes) !== expectedSha256) { throw new Error(`engine_credential_sink_predecessor_drift:${relativePath}`) } } const runtimeOverridePath = join( engineRoot, 'nodedc-source/services/backend/data-product-publish-grant/docker-compose.immutable-runtime.yml', ) if (await readFile(runtimeOverridePath, 'utf8') !== publishGrantRuntimeOverride) { throw new Error('engine_publish_grant_runtime_override_mismatch') } const indexSource = await readFile(join(engineRoot, 'nodedc-source/server/index.js'), 'utf8') if (!indexSource.includes("app.use('/api/engine-agent-mcp', engineAgentMcpRouter)")) { throw new Error('engine_agent_mcp_mount_missing') } if ( !indexSource.includes("import engineCredentialSinkRouter from './routes/engineCredentialSink.js'") || !indexSource.includes("app.use('/internal/engine-credential-sink', express.json({") ) throw new Error('engine_credential_sink_predecessor_mount_missing') const gateway = await readFile(join(engineRoot, 'nodedc-source/server/routes/engineAgentGateway.js'), 'utf8') for (const tool of [ 'engine_plan_data_product_publish_grant', 'engine_apply_data_product_publish_grant', 'engine_accept_data_product_publish_grant', 'engine_rollback_data_product_publish_grant', ]) { if (!gateway.includes(tool)) throw new Error(`engine_publish_grant_tool_missing:${tool}`) } const n8nRoute = await readFile(join(engineRoot, 'nodedc-source/server/routes/n8n.js'), 'utf8') if (n8nRoute.includes("from '../credentialSink/")) { throw new Error('engine_publish_grant_depends_on_legacy_sink') } if (!n8nRoute.includes('engineDataProductPublishGrantN8nAdapter')) { throw new Error('engine_publish_grant_native_adapter_missing') } if (!n8nRoute.includes('engineCredentialSinkN8nAdapter')) { throw new Error('engine_credential_sink_native_adapter_missing') } const grantDirectory = join(engineRoot, 'nodedc-source/server/dataProductPublishGrant') const grantFiles = (await readdir(grantDirectory, { withFileTypes: true })) .filter((entry) => entry.isFile()) .map((entry) => entry.name) .sort() const expectedGrantFiles = [ 'acceptance.js', 'providerCatalog.js', 'service.js', 'signedDataPlaneClient.js', 'store.js', ] if (JSON.stringify(grantFiles) !== JSON.stringify(expectedGrantFiles)) { throw new Error('engine_publish_grant_source_set_mismatch') } for (const entry of entries) { const source = resolve(engineRoot, entry) const info = await lstat(source) if (info.isSymbolicLink() || (!info.isFile() && !info.isDirectory())) { throw new Error(`engine_artifact_source_unsafe:${entry}`) } } for (const source of [ ...expectedGrantFiles.map((name) => join(grantDirectory, name)), join(engineRoot, 'nodedc-source/server/routes/engineAgentGateway.js'), join(engineRoot, 'nodedc-source/server/routes/n8n.js'), join(engineRoot, 'nodedc-source/server/routes/ndcAgentMcp.js'), join(engineRoot, 'nodedc-source/server/engineAgents/store.js'), ]) run('node', ['--check', source]) } async function assertArtifactTargetFresh(target) { try { await lstat(target) } catch (error) { if (error?.code === 'ENOENT') return throw error } throw new Error('engine_publish_grant_artifact_already_exists') } async function copySafe(source, destination) { const info = await lstat(source) if (info.isSymbolicLink()) throw new Error(`source_symlink_rejected:${source}`) if (info.isFile()) { await mkdir(dirname(destination), { recursive: true }) await cp(source, destination, { force: false, verbatimSymlinks: true }) return } if (!info.isDirectory()) throw new Error(`source_type_rejected:${source}`) await mkdir(destination, { recursive: true }) for (const entry of await readdir(source, { withFileTypes: true })) { if (ignoredBasenames.has(entry.name) || entry.name.startsWith('.env')) continue const childSource = join(source, entry.name) if (entry.isSymbolicLink()) { throw new Error(`source_symlink_rejected:${relative(engineRoot, childSource)}`) } await copySafe(childSource, join(destination, entry.name)) } } function canonicalTarScript() { return [ 'import gzip, io, pathlib, sys, tarfile', 'root=pathlib.Path(sys.argv[2])', "with open(sys.argv[1], 'wb') as out:", " with gzip.GzipFile(filename='', mode='wb', fileobj=out, compresslevel=9, mtime=0) as gz:", " with tarfile.open(fileobj=gz, mode='w', format=tarfile.PAX_FORMAT) as tar:", " for top in ('manifest.env','files.txt','payload'):", " p=root/top; paths=[p] + (sorted(p.rglob('*')) if p.is_dir() else [])", ' for x in paths:', " info=tar.gettarinfo(str(x), arcname=x.relative_to(root).as_posix())", " info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644", " with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info, src if info.isfile() else None)", ].join('\n') } function run(command, args) { const result = spawnSync(command, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 128 * 1024 * 1024, }) if (result.status !== 0) throw new Error(`${command}_failed:${result.stderr || result.stdout}`) return result } function digest(bytes) { return createHash('sha256').update(bytes).digest('hex') }