feat(deploy): add Engine ontology SDK transition
This commit is contained in:
parent
f67c49bc4d
commit
d85912b9d7
|
|
@ -16,6 +16,12 @@ const storeRelativePath = 'nodedc-source/server/engineAgents/store.js'
|
|||
const predecessorSha256 = '52daa43499d6d9a97fe7ffa891edb9212b7791e733f91dd3ca686d42739b7e9a'
|
||||
const targetSha256 = '2e62654c2dc12905efcc83a9dff45a818dd7b47924a10600160835c4416540e9'
|
||||
const readerExtendedSourceSha256 = 'debd351fe0b8c72b33b8c79909b8f339cbaf061b970576f3ae72df52ebaa211f'
|
||||
const ontologyExtendedSourceSha256 = '4cd4bdd5958cfafee184e98a04fe12aa0c1cbe884326beaec63c99f9fff61285'
|
||||
const frozenTargetArtifact = join(
|
||||
workspaceRoot,
|
||||
'deploy-artifacts/nodedc-engine-agent-full-grant-migration-20260717-002.tgz',
|
||||
)
|
||||
const frozenTargetArtifactSha256 = 'd104ee6e63d1fccb9069e2b3db5e0446907ee9bf9cb04f2387636c272f803d68'
|
||||
const previouslyIssuedPatchIds = new Set([
|
||||
'engine-agent-full-grant-migration-20260717-001',
|
||||
])
|
||||
|
|
@ -30,7 +36,7 @@ if (previouslyIssuedPatchIds.has(patchId)) {
|
|||
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 = materializeFrozenTarget(await readFile(source))
|
||||
const sourceBytes = await materializeFrozenTarget(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 [
|
||||
|
|
@ -121,9 +127,28 @@ function digest(bytes) {
|
|||
return createHash('sha256').update(bytes).digest('hex')
|
||||
}
|
||||
|
||||
function materializeFrozenTarget(bytes) {
|
||||
async function materializeFrozenTarget(bytes) {
|
||||
const sourceSha256 = digest(bytes)
|
||||
if (sourceSha256 === targetSha256) return bytes
|
||||
if (sourceSha256 === ontologyExtendedSourceSha256) {
|
||||
const artifactBytes = await readFile(frozenTargetArtifact)
|
||||
if (digest(artifactBytes) !== frozenTargetArtifactSha256) {
|
||||
throw new Error('engine_agent_store_frozen_target_artifact_sha256_mismatch')
|
||||
}
|
||||
const script = [
|
||||
'import pathlib,sys,tarfile',
|
||||
'p=pathlib.Path(sys.argv[1])',
|
||||
"with tarfile.open(p,'r:gz') as t:",
|
||||
" f=t.extractfile('payload/nodedc-source/server/engineAgents/store.js')",
|
||||
" if f is None: raise SystemExit('member-unreadable')",
|
||||
' sys.stdout.buffer.write(f.read())',
|
||||
].join('\n')
|
||||
const frozen = Buffer.from(run('python3', ['-c', script, frozenTargetArtifact]).stdout, 'utf8')
|
||||
if (digest(frozen) !== targetSha256) {
|
||||
throw new Error('engine_agent_store_frozen_target_sha256_mismatch')
|
||||
}
|
||||
return frozen
|
||||
}
|
||||
if (sourceSha256 !== readerExtendedSourceSha256) {
|
||||
throw new Error('engine_agent_store_target_sha256_mismatch')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,161 @@
|
|||
#!/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 engineRoot = resolve(
|
||||
process.env.NODEDC_ENGINE_SOURCE_ROOT || resolve(platformRoot, '../NODEDC_ENGINE_INFRA'),
|
||||
)
|
||||
const canonicalArtifactRoot = resolve(here, '../deploy-artifacts')
|
||||
const artifactRoot = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || canonicalArtifactRoot)
|
||||
const [transitionId = '20260718-005', ...extra] = process.argv.slice(2)
|
||||
if (extra.length || !/^\d{8}-[0-9]{3}$/.test(transitionId)) {
|
||||
throw new Error('usage: build-engine-mcp-ontology-sdk-artifact.mjs [YYYYMMDD-NNN]')
|
||||
}
|
||||
|
||||
const id = `engine-mcp-control-plane-${transitionId}`
|
||||
const target = join(artifactRoot, `nodedc-${id}.tgz`)
|
||||
const predecessorArtifact = join(
|
||||
canonicalArtifactRoot,
|
||||
'nodedc-engine-mcp-control-plane-20260718-003.tgz',
|
||||
)
|
||||
const predecessorArtifactSha256 = '249aef9527666c562e9648b15737e66cc5c1dc7c0788b58ea714da270b5eb4ba'
|
||||
const descriptorRel = 'nodedc-source/services/node-intelligence/activation.json'
|
||||
const gatewayRel = 'nodedc-source/server/routes/engineAgentGateway.js'
|
||||
const files = [
|
||||
'nodedc-source/server/assets/engine-agent-npm/bin/nodedc-engine-codex-agent.mjs',
|
||||
'nodedc-source/server/assets/engine-agent-npm/package.json',
|
||||
'nodedc-source/server/assets/nodedc-engine-codex-agent-0.1.5.tgz',
|
||||
'nodedc-source/server/assets/provider-packages/v1/catalog.json',
|
||||
'nodedc-source/server/dataProductPublishGrant/providerCatalog.js',
|
||||
'nodedc-source/server/engineAgents/store.js',
|
||||
gatewayRel,
|
||||
descriptorRel,
|
||||
]
|
||||
const expectedSha256 = new Map([
|
||||
[files[0], 'adff3e474c914680f7d36b204d1dc03e69dc8065fff1f80b6713526cfc970137'],
|
||||
[files[1], '0741647e4f7f58f69f3021d367484609a8e1eb7b8727d6ad9639bd9906b7f455'],
|
||||
[files[2], '72a1b2d41a12a298eaae63ba3334c7c66b6ca53de6a3f03a48607d4ff6da42fb'],
|
||||
[files[3], '4800e1a1c2af5e4893b1a403c04e91f55689383457caff833edc9e35e8e7e37a'],
|
||||
[files[4], 'd5511a8bd3b4238af88a89537661c9ca4c0126bca7b5ad225985e27ccdb2c64c'],
|
||||
[files[5], '4cd4bdd5958cfafee184e98a04fe12aa0c1cbe884326beaec63c99f9fff61285'],
|
||||
[files[6], '25fa013ddbfd7d0c7ece8c792daec5f345062757c85eb56cfbff45bf1e812152'],
|
||||
])
|
||||
|
||||
await assertFresh(target)
|
||||
assertSha(await readFile(predecessorArtifact), predecessorArtifactSha256, 'predecessor MCP artifact')
|
||||
const stage = await mkdtemp(join(tmpdir(), 'nodedc-engine-mcp-ontology-sdk-'))
|
||||
const payload = join(stage, 'payload')
|
||||
try {
|
||||
await mkdir(payload, { recursive: true })
|
||||
for (const rel of files.slice(0, -1)) {
|
||||
const source = join(engineRoot, rel)
|
||||
const stat = await lstat(source)
|
||||
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`source_boundary_invalid:${rel}`)
|
||||
assertSha(await readFile(source), expectedSha256.get(rel), rel)
|
||||
await mkdir(dirname(join(payload, rel)), { recursive: true })
|
||||
await cp(source, join(payload, rel), { force: false })
|
||||
}
|
||||
|
||||
const descriptor = JSON.parse(extractMember(
|
||||
predecessorArtifact,
|
||||
`payload/${descriptorRel}`,
|
||||
))
|
||||
if (
|
||||
descriptor?.action !== 'activate'
|
||||
|| descriptor?.releaseId !== '2.33.2-974a9fb3492f'
|
||||
|| descriptor?.source?.gatewaySha256 !== '96c726dab5cf1341f74e6e1095d518058ca320e0dd5738e25bdbe75db1f4fc15'
|
||||
|| descriptor?.source?.upstreamProjectionSha256 !== '761a874b102a938bc6018159ddacdaac71ad6ae08e9f0f8d7f3b58a0165a5131'
|
||||
) throw new Error('mcp_control_plane_predecessor_descriptor_mismatch')
|
||||
descriptor.source.gatewaySha256 = expectedSha256.get(gatewayRel)
|
||||
await mkdir(dirname(join(payload, descriptorRel)), { recursive: true })
|
||||
await writeFile(join(payload, descriptorRel), `${JSON.stringify(descriptor, null, 2)}\n`, 'utf8')
|
||||
|
||||
await writeFile(join(stage, 'manifest.env'), `id=${id}\ncomponent=engine\ntype=app-overlay\n`, 'utf8')
|
||||
await writeFile(join(stage, 'files.txt'), `${files.join('\n')}\n`, 'utf8')
|
||||
await mkdir(artifactRoot, { recursive: true })
|
||||
run('python3', ['-c', canonicalTarScript(), target, stage])
|
||||
const artifactSha256 = sha(await readFile(target))
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
id,
|
||||
artifact: target,
|
||||
artifactSha256,
|
||||
services: ['nodedc-backend'],
|
||||
predecessorGatewaySha256: '96c726dab5cf1341f74e6e1095d518058ca320e0dd5738e25bdbe75db1f4fc15',
|
||||
targetGatewaySha256: expectedSha256.get(gatewayRel),
|
||||
mcpVersion: '0.6.0',
|
||||
installerVersion: '0.1.5',
|
||||
ontologyMcp: 'separate-read-only-proxy',
|
||||
providerPackage: 'gelios.provider.v2',
|
||||
providerCredential: 'httpQueryAuth',
|
||||
preserved: ['n8n', 'L1', 'node-intelligence image', 'databases', 'provider credential values'],
|
||||
files,
|
||||
}, null, 2))
|
||||
} finally {
|
||||
await rm(stage, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
async function assertFresh(path) {
|
||||
try {
|
||||
await lstat(path)
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') return
|
||||
throw error
|
||||
}
|
||||
throw new Error('artifact_already_exists')
|
||||
}
|
||||
|
||||
function extractMember(archive, member) {
|
||||
const script = [
|
||||
'import pathlib,sys,tarfile',
|
||||
'p=pathlib.Path(sys.argv[1]); name=sys.argv[2]',
|
||||
"with tarfile.open(p,'r:gz') as t:",
|
||||
' m=t.getmember(name)',
|
||||
" if not m.isfile(): raise SystemExit('member-not-file')",
|
||||
' f=t.extractfile(m)',
|
||||
" if f is None: raise SystemExit('member-unreadable')",
|
||||
' sys.stdout.buffer.write(f.read())',
|
||||
].join('\n')
|
||||
return run('python3', ['-c', script, archive, member]).stdout
|
||||
}
|
||||
|
||||
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 assertSha(bytes, expected, label) {
|
||||
const actual = sha(bytes)
|
||||
if (!expected || actual !== expected) throw new Error(`${label.replaceAll(' ', '_')}_sha256_mismatch:${actual}`)
|
||||
}
|
||||
|
||||
function sha(bytes) {
|
||||
return createHash('sha256').update(bytes).digest('hex')
|
||||
}
|
||||
|
||||
function run(command, args) {
|
||||
const result = spawnSync(command, args, {
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 128 * 1024 * 1024,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
})
|
||||
if (result.status !== 0) throw new Error(`${command}_failed:${result.stderr || result.stdout}`)
|
||||
return result
|
||||
}
|
||||
|
|
@ -174,6 +174,36 @@ ENGINE_MCP_CONTROL_PLANE_NEW_PATHS = (
|
|||
"nodedc-source/server/dataProductReadGrant/store.js",
|
||||
ENGINE_DATA_PRODUCT_READ_GRANT_OVERRIDE_REL,
|
||||
)
|
||||
ENGINE_MCP_ONTOLOGY_SDK_ARTIFACT_ENTRIES = (
|
||||
"nodedc-source/server/assets/engine-agent-npm/bin/nodedc-engine-codex-agent.mjs",
|
||||
"nodedc-source/server/assets/engine-agent-npm/package.json",
|
||||
"nodedc-source/server/assets/nodedc-engine-codex-agent-0.1.5.tgz",
|
||||
"nodedc-source/server/assets/provider-packages/v1/catalog.json",
|
||||
"nodedc-source/server/dataProductPublishGrant/providerCatalog.js",
|
||||
"nodedc-source/server/engineAgents/store.js",
|
||||
ENGINE_NODE_INTELLIGENCE_GATEWAY_REL,
|
||||
ENGINE_MCP_CONTROL_PLANE_DESCRIPTOR_REL,
|
||||
)
|
||||
ENGINE_MCP_ONTOLOGY_SDK_PREDECESSOR_SHA256 = {
|
||||
"nodedc-source/server/assets/engine-agent-npm/bin/nodedc-engine-codex-agent.mjs": "521098de69fe288a56bd4158c849c1055ba24be08e19f7a4111618d0e8138445",
|
||||
"nodedc-source/server/assets/engine-agent-npm/package.json": "cbe113e9b10bb84b9ccbffa3e261908e58a8430c11abe2cf4fd5304741b04597",
|
||||
"nodedc-source/server/assets/provider-packages/v1/catalog.json": "30f89052b6557a2444550bd6e7eed30747b2d9b7ff3a22d079d9905fe0a04702",
|
||||
"nodedc-source/server/dataProductPublishGrant/providerCatalog.js": "901b8fad80018ce177b34ced804b39cb140a47e831414057f484296b373c651d",
|
||||
"nodedc-source/server/engineAgents/store.js": "debd351fe0b8c72b33b8c79909b8f339cbaf061b970576f3ae72df52ebaa211f",
|
||||
ENGINE_NODE_INTELLIGENCE_GATEWAY_REL: "96c726dab5cf1341f74e6e1095d518058ca320e0dd5738e25bdbe75db1f4fc15",
|
||||
}
|
||||
ENGINE_MCP_ONTOLOGY_SDK_TARGET_SHA256 = {
|
||||
"nodedc-source/server/assets/engine-agent-npm/bin/nodedc-engine-codex-agent.mjs": "adff3e474c914680f7d36b204d1dc03e69dc8065fff1f80b6713526cfc970137",
|
||||
"nodedc-source/server/assets/engine-agent-npm/package.json": "0741647e4f7f58f69f3021d367484609a8e1eb7b8727d6ad9639bd9906b7f455",
|
||||
"nodedc-source/server/assets/nodedc-engine-codex-agent-0.1.5.tgz": "72a1b2d41a12a298eaae63ba3334c7c66b6ca53de6a3f03a48607d4ff6da42fb",
|
||||
"nodedc-source/server/assets/provider-packages/v1/catalog.json": "4800e1a1c2af5e4893b1a403c04e91f55689383457caff833edc9e35e8e7e37a",
|
||||
"nodedc-source/server/dataProductPublishGrant/providerCatalog.js": "d5511a8bd3b4238af88a89537661c9ca4c0126bca7b5ad225985e27ccdb2c64c",
|
||||
"nodedc-source/server/engineAgents/store.js": "4cd4bdd5958cfafee184e98a04fe12aa0c1cbe884326beaec63c99f9fff61285",
|
||||
ENGINE_NODE_INTELLIGENCE_GATEWAY_REL: "25fa013ddbfd7d0c7ece8c792daec5f345062757c85eb56cfbff45bf1e812152",
|
||||
}
|
||||
ENGINE_MCP_ONTOLOGY_SDK_NEW_PATHS = (
|
||||
"nodedc-source/server/assets/nodedc-engine-codex-agent-0.1.5.tgz",
|
||||
)
|
||||
ENGINE_CONTROL_PLANE_STATE_REL = "nodedc-control-plane"
|
||||
ENGINE_PUBLISH_GRANT_STATE_REL = f"{ENGINE_CONTROL_PLANE_STATE_REL}/publish-grants"
|
||||
ENGINE_READ_GRANT_STATE_REL = f"{ENGINE_CONTROL_PLANE_STATE_REL}/read-grants"
|
||||
|
|
@ -2813,6 +2843,7 @@ def validate_engine_node_intelligence_transition(payload_dir, entries):
|
|||
"const ENGINE_AGENT_MCP_VERSION = '0.3.0'",
|
||||
"const ENGINE_AGENT_MCP_VERSION = '0.4.0'",
|
||||
"const ENGINE_AGENT_MCP_VERSION = '0.5.0'",
|
||||
"const ENGINE_AGENT_MCP_VERSION = '0.6.0'",
|
||||
)
|
||||
):
|
||||
die("Engine node-intelligence gateway MCP version is not registered")
|
||||
|
|
@ -3073,6 +3104,211 @@ process.stdout.write('engine-mcp-reader-grant:0.5.0:'+required.length+':'+scopes
|
|||
}
|
||||
|
||||
|
||||
def is_engine_mcp_ontology_sdk_slice(component, entries):
|
||||
return (
|
||||
component == "engine"
|
||||
and entries is not None
|
||||
and tuple(entries) == ENGINE_MCP_ONTOLOGY_SDK_ARTIFACT_ENTRIES
|
||||
)
|
||||
|
||||
|
||||
def validate_engine_mcp_ontology_sdk_payload(payload_dir, entries):
|
||||
if tuple(entries) != ENGINE_MCP_ONTOLOGY_SDK_ARTIFACT_ENTRIES:
|
||||
die("Engine MCP Ontology/SDK files.txt exact set/order mismatch")
|
||||
for rel, expected_sha256 in ENGINE_MCP_ONTOLOGY_SDK_TARGET_SHA256.items():
|
||||
path = payload_dir / rel
|
||||
if path.is_symlink() or not path.is_file() or sha256_file(path) != expected_sha256:
|
||||
die(f"Engine MCP Ontology/SDK target sha256 mismatch: {rel}")
|
||||
|
||||
gateway = (payload_dir / ENGINE_NODE_INTELLIGENCE_GATEWAY_REL).read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
installer_path = (
|
||||
payload_dir
|
||||
/ "nodedc-source/server/assets/engine-agent-npm/bin/nodedc-engine-codex-agent.mjs"
|
||||
)
|
||||
installer = installer_path.read_text(encoding="utf-8")
|
||||
package_path = payload_dir / "nodedc-source/server/assets/engine-agent-npm/package.json"
|
||||
package = read_strict_json(package_path, "Engine MCP Codex installer package")
|
||||
if package.get("name") != "@nodedc/engine-codex-agent" or package.get("version") != "0.1.5":
|
||||
die("Engine MCP Ontology/SDK installer package identity mismatch")
|
||||
if any(
|
||||
marker not in gateway
|
||||
for marker in (
|
||||
"const ENGINE_AGENT_MCP_VERSION = '0.6.0'",
|
||||
"authenticateEngineAgentOntologyToken",
|
||||
"apiRouter.post('/ontology-mcp'",
|
||||
"process.env.NODEDC_INTERNAL_ACCESS_TOKEN",
|
||||
"serverName: 'nodedc-engine-agent'",
|
||||
"serverName: 'nodedc_ontology'",
|
||||
)
|
||||
):
|
||||
die("Engine MCP separate Ontology gateway contract is incomplete")
|
||||
if any(
|
||||
marker not in installer
|
||||
for marker in (
|
||||
"const ENGINE_SERVER_NAME = 'nodedc-engine-agent'",
|
||||
"const ONTOLOGY_SERVER_NAME = 'nodedc_ontology'",
|
||||
"Ontology tools/catalog are never embedded or multiplexed into Engine MCP",
|
||||
"version: '0.1.5'",
|
||||
)
|
||||
):
|
||||
die("Engine MCP separate Ontology installer contract is incomplete")
|
||||
|
||||
archive_path = payload_dir / "nodedc-source/server/assets/nodedc-engine-codex-agent-0.1.5.tgz"
|
||||
expected_members = {
|
||||
"package/bin/nodedc-engine-codex-agent.mjs": installer_path.read_bytes(),
|
||||
"package/package.json": package_path.read_bytes(),
|
||||
}
|
||||
observed_members = {}
|
||||
try:
|
||||
with tarfile.open(archive_path, "r:gz") as archive:
|
||||
for member in archive:
|
||||
if not (member.isfile() or member.isdir()) or member.name.startswith("/"):
|
||||
die("Engine MCP Ontology/SDK installer archive member is unsafe")
|
||||
if member.isfile():
|
||||
source = archive.extractfile(member)
|
||||
if source is None:
|
||||
die("Engine MCP Ontology/SDK installer archive member is unreadable")
|
||||
observed_members[member.name] = source.read(MAX_FILE_BYTES + 1)
|
||||
except (OSError, tarfile.TarError):
|
||||
die("Engine MCP Ontology/SDK installer archive is invalid")
|
||||
if observed_members != expected_members:
|
||||
die("Engine MCP Ontology/SDK installer archive/source equality mismatch")
|
||||
|
||||
catalog = read_strict_json(
|
||||
payload_dir / "nodedc-source/server/assets/provider-packages/v1/catalog.json",
|
||||
"Engine provider security catalog",
|
||||
)
|
||||
packages = catalog.get("packages") if isinstance(catalog, dict) else None
|
||||
if not isinstance(packages, list) or len(packages) != 1:
|
||||
die("Engine provider security catalog must contain one active package")
|
||||
provider = packages[0]
|
||||
capabilities = provider.get("capabilities") if isinstance(provider, dict) else None
|
||||
capability = capabilities[0] if isinstance(capabilities, list) and len(capabilities) == 1 else None
|
||||
request = capability.get("request") if isinstance(capability, dict) else None
|
||||
if (
|
||||
provider.get("id") != "gelios.provider.v2"
|
||||
or provider.get("version") != "2.0.0"
|
||||
or (provider.get("providerCredential") or {}).get("credentialType") != "httpQueryAuth"
|
||||
or not isinstance(request, dict)
|
||||
or request.get("method") != "GET"
|
||||
or request.get("url") != "https://admin.geliospro.com/sdk/?svc=get_units¶ms=%7B%7D"
|
||||
):
|
||||
die("Engine Gelios SDK/query-auth security projection mismatch")
|
||||
resolver = (
|
||||
payload_dir / "nodedc-source/server/dataProductPublishGrant/providerCatalog.js"
|
||||
).read_text(encoding="utf-8")
|
||||
if "new Set(['httpBearerAuth', 'httpQueryAuth'])" not in resolver:
|
||||
die("Engine provider credential type allowlist mismatch")
|
||||
store = (payload_dir / "nodedc-source/server/engineAgents/store.js").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
if any(
|
||||
marker not in store
|
||||
for marker in (
|
||||
"const STORE_VERSION = 3",
|
||||
"ontologyTokenHash",
|
||||
"`ndc_eao_${",
|
||||
"authenticateEngineAgentOntologyToken",
|
||||
)
|
||||
):
|
||||
die("Engine separate Ontology token store migration is incomplete")
|
||||
|
||||
descriptor = read_engine_node_intelligence_descriptor(
|
||||
payload_dir / ENGINE_MCP_CONTROL_PLANE_DESCRIPTOR_REL,
|
||||
"Engine MCP Ontology/SDK node-intelligence descriptor",
|
||||
)
|
||||
if (
|
||||
descriptor.get("action") != "activate"
|
||||
or descriptor["source"].get("gatewaySha256")
|
||||
!= ENGINE_MCP_ONTOLOGY_SDK_TARGET_SHA256[ENGINE_NODE_INTELLIGENCE_GATEWAY_REL]
|
||||
or descriptor["source"].get("upstreamProjectionSha256")
|
||||
!= ENGINE_MCP_CONTROL_PLANE_TARGET_SHA256[
|
||||
"nodedc-source/server/nodeIntelligence/upstreamProjection.js"
|
||||
]
|
||||
):
|
||||
die("Engine MCP Ontology/SDK descriptor target mismatch")
|
||||
return descriptor
|
||||
|
||||
|
||||
def preflight_engine_mcp_ontology_sdk_predecessor(payload_dir):
|
||||
candidate = validate_engine_mcp_ontology_sdk_payload(
|
||||
payload_dir,
|
||||
ENGINE_MCP_ONTOLOGY_SDK_ARTIFACT_ENTRIES,
|
||||
)
|
||||
installed = current_engine_node_intelligence_descriptor()
|
||||
validate_installed_engine_node_intelligence_source(installed)
|
||||
if installed is None or installed.get("action") != "activate":
|
||||
die("Engine MCP Ontology/SDK requires active node intelligence")
|
||||
expected_candidate = json.loads(json.dumps(installed))
|
||||
expected_candidate["source"]["gatewaySha256"] = (
|
||||
ENGINE_MCP_ONTOLOGY_SDK_TARGET_SHA256[ENGINE_NODE_INTELLIGENCE_GATEWAY_REL]
|
||||
)
|
||||
if candidate != expected_candidate:
|
||||
die("Engine MCP Ontology/SDK descriptor crosses node-intelligence source boundary")
|
||||
root = component_root("engine")
|
||||
for rel, expected_sha256 in ENGINE_MCP_ONTOLOGY_SDK_PREDECESSOR_SHA256.items():
|
||||
path = root / rel
|
||||
if path.is_symlink() or not path.is_file() or sha256_file(path) != expected_sha256:
|
||||
die(f"Engine MCP Ontology/SDK predecessor drift detected: {rel}")
|
||||
for rel in ENGINE_MCP_ONTOLOGY_SDK_NEW_PATHS:
|
||||
path = root / rel
|
||||
if path.exists() or path.is_symlink():
|
||||
die(f"Engine MCP Ontology/SDK new path already exists: {rel}")
|
||||
backend = preflight_engine_credential_backend_runtime()
|
||||
if backend["mode"] != "verified-derived-retry":
|
||||
die("Engine MCP Ontology/SDK requires the active immutable backend runtime")
|
||||
return {
|
||||
"descriptor": candidate,
|
||||
"predecessor_gateway_sha256": installed["source"]["gatewaySha256"],
|
||||
"target_gateway_sha256": candidate["source"]["gatewaySha256"],
|
||||
"backend_mode": backend["mode"],
|
||||
}
|
||||
|
||||
|
||||
def accept_engine_mcp_ontology_sdk_runtime():
|
||||
root = component_root("engine")
|
||||
descriptor = validate_engine_mcp_ontology_sdk_payload(
|
||||
root,
|
||||
ENGINE_MCP_ONTOLOGY_SDK_ARTIFACT_ENTRIES,
|
||||
)
|
||||
installed = current_engine_node_intelligence_descriptor()
|
||||
if installed != descriptor:
|
||||
die("Engine MCP Ontology/SDK installed descriptor equality failed")
|
||||
validate_installed_engine_node_intelligence_source(installed)
|
||||
backend = preflight_engine_credential_backend_runtime()
|
||||
if backend["mode"] != "verified-derived-retry":
|
||||
die("Engine MCP Ontology/SDK backend runtime acceptance failed")
|
||||
live = run_engine_backend_probe(
|
||||
(
|
||||
"node",
|
||||
"--input-type=module",
|
||||
"-e",
|
||||
"""
|
||||
const gateway=await import('file:///app/server/routes/engineAgentGateway.js');
|
||||
const store=await import('file:///app/server/engineAgents/store.js');
|
||||
const catalog=JSON.parse(await (await import('node:fs/promises')).readFile('/app/server/assets/provider-packages/v1/catalog.json','utf8'));
|
||||
const provider=catalog.packages?.[0];
|
||||
if(gateway.ENGINE_AGENT_MCP_VERSION!=='0.6.0')process.exit(2);
|
||||
if(typeof store.authenticateEngineAgentOntologyToken!=='function')process.exit(3);
|
||||
if(provider?.id!=='gelios.provider.v2'||provider?.providerCredential?.credentialType!=='httpQueryAuth')process.exit(4);
|
||||
process.stdout.write('engine-mcp-ontology-sdk:0.6.0:0.1.5:gelios.provider.v2:httpQueryAuth');
|
||||
""".strip(),
|
||||
),
|
||||
"Engine MCP Ontology/SDK capability",
|
||||
container_id=engine_backend_container_id(),
|
||||
)
|
||||
expected = "engine-mcp-ontology-sdk:0.6.0:0.1.5:gelios.provider.v2:httpQueryAuth"
|
||||
if live != expected:
|
||||
die("Engine MCP Ontology/SDK live capability acceptance mismatch")
|
||||
return {
|
||||
"gateway_sha256": descriptor["source"]["gatewaySha256"],
|
||||
"backend_mode": backend["mode"],
|
||||
"capability": live,
|
||||
}
|
||||
|
||||
|
||||
def validate_no_lifecycle_scripts(payload_dir, label):
|
||||
forbidden = ("preinstall", "install", "postinstall", "prepare", "prepack", "postpack")
|
||||
for package_path in payload_dir.rglob("package.json"):
|
||||
|
|
@ -3491,16 +3727,20 @@ def load_artifact(artifact, work_dir):
|
|||
touches_node_intelligence
|
||||
and not is_engine_node_intelligence_transition(manifest["component"], entries)
|
||||
and not is_engine_mcp_control_plane_slice(manifest["component"], entries)
|
||||
and not is_engine_mcp_ontology_sdk_slice(manifest["component"], entries)
|
||||
):
|
||||
die("Engine node-intelligence payload requires the canonical transition")
|
||||
if is_engine_node_intelligence_transition(manifest["component"], entries):
|
||||
validate_engine_node_intelligence_transition(payload_dir, entries)
|
||||
if is_engine_mcp_control_plane_slice(manifest["component"], entries):
|
||||
validate_engine_mcp_control_plane_payload(payload_dir, entries)
|
||||
if is_engine_mcp_ontology_sdk_slice(manifest["component"], entries):
|
||||
validate_engine_mcp_ontology_sdk_payload(payload_dir, entries)
|
||||
if touches_engine_credential_sink(manifest["component"], entries):
|
||||
validate_engine_credential_sink_slice(payload_dir, entries)
|
||||
if (
|
||||
not is_engine_mcp_control_plane_slice(manifest["component"], entries)
|
||||
and not is_engine_mcp_ontology_sdk_slice(manifest["component"], entries)
|
||||
and (
|
||||
touches_engine_data_product_publish_grant(entries)
|
||||
or ENGINE_DATA_PRODUCT_PUBLISH_GRANT_OVERRIDE_REL in entries
|
||||
|
|
@ -3509,6 +3749,7 @@ def load_artifact(artifact, work_dir):
|
|||
validate_engine_data_product_publish_grant_slice(payload_dir, entries)
|
||||
elif (
|
||||
not is_engine_mcp_control_plane_slice(manifest["component"], entries)
|
||||
and not is_engine_mcp_ontology_sdk_slice(manifest["component"], entries)
|
||||
and ENGINE_AGENT_FULL_GRANT_MIGRATION_STORE_REL in entries
|
||||
):
|
||||
validate_engine_agent_full_grant_migration_slice(payload_dir, entries)
|
||||
|
|
@ -3857,7 +4098,10 @@ def is_platform_provider_catalog_only(entries):
|
|||
|
||||
|
||||
def component_services(component, entries=None):
|
||||
if is_engine_mcp_control_plane_slice(component, entries):
|
||||
if (
|
||||
is_engine_mcp_control_plane_slice(component, entries)
|
||||
or is_engine_mcp_ontology_sdk_slice(component, entries)
|
||||
):
|
||||
# This slice updates only the existing Engine backend control plane.
|
||||
# Node intelligence keeps the same immutable sidecar image and n8n/L1
|
||||
# retain their current generations.
|
||||
|
|
@ -5992,6 +6236,7 @@ def plan_artifact(artifact):
|
|||
publish_grant_preflight = None
|
||||
node_intelligence_preflight = None
|
||||
mcp_control_plane_preflight = None
|
||||
mcp_ontology_sdk_preflight = None
|
||||
with tempfile.TemporaryDirectory(prefix="plan-", dir=TMP_DIR) as tmp:
|
||||
manifest, entries, payload_dir = load_artifact(artifact, Path(tmp))
|
||||
transition_descriptor = None
|
||||
|
|
@ -6016,6 +6261,10 @@ def plan_artifact(artifact):
|
|||
mcp_control_plane_preflight = preflight_engine_mcp_control_plane_predecessor(
|
||||
payload_dir
|
||||
)
|
||||
if is_engine_mcp_ontology_sdk_slice(manifest["component"], entries):
|
||||
mcp_ontology_sdk_preflight = preflight_engine_mcp_ontology_sdk_predecessor(
|
||||
payload_dir
|
||||
)
|
||||
|
||||
component = manifest["component"]
|
||||
root = component_root(component)
|
||||
|
|
@ -6098,6 +6347,24 @@ def plan_artifact(artifact):
|
|||
print(f"backend_current_barrier={mcp_control_plane_preflight['backend_mode']}")
|
||||
print("node_intelligence_image=preserved")
|
||||
print("n8n_l1=untouched")
|
||||
if mcp_ontology_sdk_preflight:
|
||||
print("engine_mcp_transition=separate-ontology+gelios-sdk-query-auth")
|
||||
print("engine_mcp_version=0.6.0")
|
||||
print("engine_mcp_installer=0.1.5")
|
||||
print("engine_mcp_ontology=separate-read-only-proxy")
|
||||
print("engine_provider_package=gelios.provider.v2")
|
||||
print("engine_provider_credential=httpQueryAuth")
|
||||
print(
|
||||
"predecessor_gateway_sha256="
|
||||
f"{mcp_ontology_sdk_preflight['predecessor_gateway_sha256']}"
|
||||
)
|
||||
print(
|
||||
"target_gateway_sha256="
|
||||
f"{mcp_ontology_sdk_preflight['target_gateway_sha256']}"
|
||||
)
|
||||
print(f"backend_current_barrier={mcp_ontology_sdk_preflight['backend_mode']}")
|
||||
print("node_intelligence_image=preserved")
|
||||
print("n8n_l1=untouched")
|
||||
if transition_descriptor:
|
||||
print(f"n8n_transition={transition_descriptor['action']}")
|
||||
print(f"n8n_version={transition_descriptor['n8nVersion']}")
|
||||
|
|
@ -7049,6 +7316,7 @@ def component_healthchecks(component, entries=None, services=None):
|
|||
is_engine_data_product_publish_grant_slice(component, entries)
|
||||
or is_engine_agent_full_grant_migration_slice(component, entries)
|
||||
or is_engine_mcp_control_plane_slice(component, entries)
|
||||
or is_engine_mcp_ontology_sdk_slice(component, entries)
|
||||
)
|
||||
and services is not None
|
||||
):
|
||||
|
|
@ -7362,11 +7630,13 @@ def run_healthchecks(component, entries=None, services=None):
|
|||
entries,
|
||||
)
|
||||
touches_mcp_control_plane = is_engine_mcp_control_plane_slice(component, entries)
|
||||
touches_mcp_ontology_sdk = is_engine_mcp_ontology_sdk_slice(component, entries)
|
||||
if (
|
||||
touches_engine_credential_sink(component, entries)
|
||||
or touches_publish_grant
|
||||
or touches_agent_grant_migration
|
||||
or touches_mcp_control_plane
|
||||
or touches_mcp_ontology_sdk
|
||||
):
|
||||
# The HTTP endpoint can become ready before Docker publishes the first
|
||||
# successful health probe. Wait for the Compose health barrier before
|
||||
|
|
@ -7379,6 +7649,7 @@ def run_healthchecks(component, entries=None, services=None):
|
|||
or touches_publish_grant
|
||||
or touches_agent_grant_migration
|
||||
or touches_mcp_control_plane
|
||||
or touches_mcp_ontology_sdk
|
||||
):
|
||||
runtime = preflight_engine_credential_backend_runtime()
|
||||
if runtime["mode"] != "verified-derived-retry":
|
||||
|
|
@ -7387,6 +7658,8 @@ def run_healthchecks(component, entries=None, services=None):
|
|||
accept_engine_agent_full_grant_migration_runtime()
|
||||
if touches_mcp_control_plane:
|
||||
accept_engine_mcp_control_plane_runtime()
|
||||
if touches_mcp_ontology_sdk:
|
||||
accept_engine_mcp_ontology_sdk_runtime()
|
||||
container_name = COMPONENTS[component].get("health_container")
|
||||
if container_name:
|
||||
healthcheck_container(container_name)
|
||||
|
|
@ -7463,6 +7736,8 @@ def apply_artifact(artifact):
|
|||
node_intelligence_descriptor = node_intelligence_preflight["descriptor"]
|
||||
if is_engine_mcp_control_plane_slice(component, entries):
|
||||
preflight_engine_mcp_control_plane_predecessor(payload_dir)
|
||||
if is_engine_mcp_ontology_sdk_slice(component, entries):
|
||||
preflight_engine_mcp_ontology_sdk_predecessor(payload_dir)
|
||||
if not artifact_only and not compose_root.is_dir():
|
||||
if bootstrap_root and is_relative_to(compose_root.resolve(strict=False), root.resolve(strict=False)):
|
||||
compose_root.mkdir(parents=True, exist_ok=True)
|
||||
|
|
@ -7640,6 +7915,7 @@ def apply_artifact(artifact):
|
|||
is_engine_data_product_publish_grant_slice(component, entries)
|
||||
or is_engine_agent_full_grant_migration_slice(component, entries)
|
||||
or is_engine_mcp_control_plane_slice(component, entries)
|
||||
or is_engine_mcp_ontology_sdk_slice(component, entries)
|
||||
)
|
||||
and services is not None
|
||||
):
|
||||
|
|
|
|||
|
|
@ -0,0 +1,133 @@
|
|||
#!/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
|
||||
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
PLATFORM_ROOT = SCRIPT_DIR.parent.parent
|
||||
RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy"
|
||||
ENGINE_BUILDER = SCRIPT_DIR / "build-engine-mcp-ontology-sdk-artifact.mjs"
|
||||
PLATFORM_BUILDER = SCRIPT_DIR / "build-platform-gelios-provider-v2-artifact.mjs"
|
||||
ENGINE_ARTIFACT = (
|
||||
PLATFORM_ROOT
|
||||
/ "infra/deploy-artifacts/nodedc-engine-mcp-control-plane-20260718-005.tgz"
|
||||
)
|
||||
PLATFORM_ARTIFACT = (
|
||||
PLATFORM_ROOT
|
||||
/ "infra/deploy-artifacts/nodedc-platform-gelios-provider-v2-20260718-005.tgz"
|
||||
)
|
||||
ENGINE_SHA256 = "8d6e1581ebbba420984e2393b8c6f0f21e0b644f630c4ca6483a7cc4a0ae5c71"
|
||||
PLATFORM_SHA256 = "e1b838ec265369af735766fc7d1bf23c5e05bd2d15ef65e2446b4f7efa7a1baa"
|
||||
|
||||
|
||||
def load_runner():
|
||||
loader = importlib.machinery.SourceFileLoader("nodedc_engine_mcp_ontology_sdk", 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 EngineMcpOntologySdkTest(unittest.TestCase):
|
||||
def test_canonical_artifacts_have_pinned_digests_and_safe_members(self):
|
||||
for artifact, expected in (
|
||||
(ENGINE_ARTIFACT, ENGINE_SHA256),
|
||||
(PLATFORM_ARTIFACT, PLATFORM_SHA256),
|
||||
):
|
||||
self.assertEqual(hashlib.sha256(artifact.read_bytes()).hexdigest(), expected)
|
||||
self.assertEqual(artifact.read_bytes()[4:8], b"\0\0\0\0")
|
||||
with tarfile.open(artifact, "r:gz") as archive:
|
||||
for member in archive:
|
||||
self.assertTrue(member.isfile() or member.isdir())
|
||||
self.assertFalse(Path(member.name).name.startswith("._"))
|
||||
|
||||
def test_engine_builder_is_byte_reproducible(self):
|
||||
outputs = []
|
||||
with tempfile.TemporaryDirectory(prefix="nodedc-engine-mcp-ontology-sdk-") as directory:
|
||||
for index in range(2):
|
||||
target = Path(directory) / str(index)
|
||||
target.mkdir()
|
||||
env = os.environ.copy()
|
||||
env["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(target)
|
||||
result = subprocess.run(
|
||||
["node", str(ENGINE_BUILDER), "20260718-005"],
|
||||
cwd=PLATFORM_ROOT,
|
||||
env=env,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
outputs.append(Path(json.loads(result.stdout)["artifact"]).read_bytes())
|
||||
self.assertEqual(outputs[0], outputs[1])
|
||||
self.assertEqual(outputs[0], ENGINE_ARTIFACT.read_bytes())
|
||||
|
||||
def test_platform_builder_is_byte_reproducible(self):
|
||||
outputs = []
|
||||
with tempfile.TemporaryDirectory(prefix="nodedc-platform-gelios-v2-") as directory:
|
||||
for index in range(2):
|
||||
target = Path(directory) / str(index)
|
||||
target.mkdir()
|
||||
env = os.environ.copy()
|
||||
env["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(target)
|
||||
result = subprocess.run(
|
||||
["node", str(PLATFORM_BUILDER), "20260718-005"],
|
||||
cwd=PLATFORM_ROOT,
|
||||
env=env,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
outputs.append(Path(json.loads(result.stdout)["artifact"]).read_bytes())
|
||||
self.assertEqual(outputs[0], outputs[1])
|
||||
self.assertEqual(outputs[0], PLATFORM_ARTIFACT.read_bytes())
|
||||
|
||||
def test_engine_slice_is_exact_and_restarts_only_backend(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
manifest, entries, payload = RUNNER.load_artifact(ENGINE_ARTIFACT, root)
|
||||
descriptor = RUNNER.validate_engine_mcp_ontology_sdk_payload(payload, entries)
|
||||
self.assertEqual(manifest["component"], "engine")
|
||||
self.assertEqual(tuple(entries), RUNNER.ENGINE_MCP_ONTOLOGY_SDK_ARTIFACT_ENTRIES)
|
||||
self.assertEqual(RUNNER.component_services("engine", entries), ("nodedc-backend",))
|
||||
self.assertEqual(RUNNER.component_builds("engine", entries), ())
|
||||
self.assertEqual(
|
||||
descriptor["source"]["gatewaySha256"],
|
||||
RUNNER.ENGINE_MCP_ONTOLOGY_SDK_TARGET_SHA256[
|
||||
RUNNER.ENGINE_NODE_INTELLIGENCE_GATEWAY_REL
|
||||
],
|
||||
)
|
||||
|
||||
def test_platform_provider_package_is_metadata_only(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
manifest, entries, _payload = RUNNER.load_artifact(PLATFORM_ARTIFACT, root)
|
||||
self.assertEqual(manifest["component"], "platform")
|
||||
self.assertTrue(RUNNER.is_platform_provider_catalog_only(entries))
|
||||
self.assertEqual(RUNNER.component_services("platform", entries), ())
|
||||
self.assertEqual(RUNNER.component_builds("platform", entries), ())
|
||||
self.assertEqual(RUNNER.component_healthchecks("platform", entries, ()), ())
|
||||
|
||||
def test_engine_payload_tampering_is_rejected(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
RUNNER.safe_extract(ENGINE_ARTIFACT, root)
|
||||
entries = RUNNER.parse_files_list(root / "files.txt")
|
||||
catalog = root / "payload/nodedc-source/server/assets/provider-packages/v1/catalog.json"
|
||||
catalog.write_text("{}\n", encoding="utf-8")
|
||||
with self.assertRaisesRegex(RUNNER.DeployError, "target sha256 mismatch"):
|
||||
RUNNER.validate_engine_mcp_ontology_sdk_payload(root / "payload", entries)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
Loading…
Reference in New Issue