feat(deploy): register managed platform runtime components

This commit is contained in:
Codex
2026-07-16 02:25:31 +03:00
parent fedaf24098
commit 567f1550ab
29 changed files with 4413 additions and 40 deletions
+158
View File
@@ -28,6 +28,164 @@ Supported components in this source:
- `tasker`
- `ops-agents`
- `bim-viewer`
- `n8n-private-extension`
- `module-foundry`
- `proxy-contur`
- `dc-amd-proxy`
`n8n-private-extension` is a staging-only trust boundary for reviewed offline
n8n private-node releases. Its artifact may contain exactly one digest-bound
`n8n-nodes-ndc` release with `package.tgz`, `release.json` and
`rollback.json`. The runner validates the inner npm tarball, rejects lifecycle
scripts and runtime dependencies, refuses to overwrite an existing release,
and seals the installed release root-owned/read-only under:
```text
/volume1/docker/nodedc-platform/n8n-private-extensions/releases/n8n-nodes-ndc/<version>-<sha256-prefix>
```
This component has no Compose file, service, container mutation or activation
side effect. In particular, staging does **not** make the node visible to n8n.
Activation remains an Engine-owned change: mount the reviewed immutable release
at `/home/node/.n8n/nodes/node_modules/n8n-nodes-ndc`, atomically switch between
verified releases, restart every n8n process, and accept only after MCP exposes
the package-qualified `n8n-nodes-ndc.*` schemas. The Platform runner cannot
cross that boundary and never runs `npm install` in a live container.
Build a verified offline release artifact:
```bash
node infra/deploy-runner/build-n8n-private-extension-artifact.mjs \
n8n-nodes-ndc-release-YYYYMMDD-NNN
```
The builder is byte-reproducible and accepts exactly the three reviewed NDC
runtime types:
- `n8n-nodes-ndc.ndcDataProductPublish`
- `n8n-nodes-ndc.ndcDataProductRead`
- `n8n-nodes-ndc.ndcFoundryBinding`
Their three opaque capability credential schemas are
`ndcDataProductWriterApi`, `ndcDataProductReaderApi` and
`ndcFoundryBindingApi`. A node description containing `usableAsTool` is
rejected because n8n 2.3.2 would synthesize an additional `*Tool` runtime type
and violate the exact-three activation contract. Run the positive and negative
release-policy suite before publishing an artifact:
```bash
PYTHONDONTWRITEBYTECODE=1 \
python3 infra/deploy-runner/test_n8n_private_extension.py
```
Release/rollback manifests use schema v2. Before a first activation, the
Engine-owned activator must verify and record the current inactive state. That
`verified_inactive` state is an allowed rollback baseline when no previous
verified immutable release exists; later upgrades prefer the previous verified
release. Rollback never deletes or mutates a staged release.
The historical `0.1.0` release remains immutable and must not be overwritten.
Release `0.1.1-994756958861518e` is retained as rejected/inactive: its three
node descriptions used `usableAsTool`, so n8n 2.3.2 exposed six NDC runtime
types instead of the required three. It must not be activated, overwritten or
deleted.
The corrected candidate is package version `0.1.2`, built with patch id
`n8n-nodes-ndc-release-20260716-003`. It receives a new digest-bound release
directory and remains inert after staging; only a separately reviewed
Engine-owned activator may select it after exact MCP schema acceptance.
The paired Engine activation is built by
`build-engine-n8n-private-extension-artifact.mjs`. It deliberately does not
copy the dirty Engine `docker-compose.yml` and does not build an image. Instead
it installs a narrowly scoped Compose override plus a strict transition
descriptor. On apply, the runner validates the staged release again, verifies
that the running n8n container and the NAS-local `2.3.2` tag resolve to the
same immutable image ID, and extracts the package into the root-owned,
read-only Engine release tree:
```text
/volume2/nodedc-demo/n8n-private-extensions/releases/n8n-nodes-ndc/0.1.2-05e4b38b14b4a019/package
```
The override sets `N8N_USER_FOLDER=/home/node`, which is required because the
actual Engine service runs as root while the canonical community package path
is below `/home/node/.n8n`. It enables loading but disables reinstall, mounts
only the exact release read-only, and uses both Compose `pull_policy: never`
and `docker compose up --pull never`. No registry access, lifecycle script,
database `installed_packages` row or custom-extension loader is involved.
The runner pins the exact Engine service topology observed in source and
rejects an added worker/webhook generation. Only the single actual `n8n`
service is force-recreated with `--no-deps`; the
Postgres service, `.n8n` data, encryption key and credentials remain intact.
The apply gate verifies readiness, the running image/version, sealed mount,
loader environment, package-loader node/credential sets, scoped loader logs,
restart stability and content-exact pinned Engine MCP catalogs. The runner pins
both the complete 434/385 inactive baseline and the reviewed 437/388
activation catalogs, so a same-count substitution of any built-in schema is
rejected. Any gate failure after
mutation automatically restores the pre-apply catalogs/descriptor and
force-recreates the previous verified runtime. Staged, sealed and failed
releases are retained. The separate rollback artifact returns the first
activation to the verified inactive 434-node/385-credential catalog baseline.
Run both policy suites before publishing the Engine pair:
```bash
PYTHONDONTWRITEBYTECODE=1 \
python3 infra/deploy-runner/test_n8n_private_extension.py
PYTHONDONTWRITEBYTECODE=1 \
python3 infra/deploy-runner/test_engine_n8n_private_extension.py
```
For `platform` artifacts, the allowlist includes the versioned Ontology Core,
the legacy Gelios experiment and the provider-neutral External Data Plane
sources. An External Data Plane artifact builds only its image and starts
`external-data-plane-postgres` plus `external-data-plane`; it never contains a
provider credential, provider endpoint, collection schedule or command
transport. Database credentials remain root-owned live `.env.synology`
configuration and must not reuse `NODEDC_INTERNAL_ACCESS_TOKEN`.
The External Data Plane writer-provisioner credential is different: on the
first relevant Platform apply, the root-owned runner creates
`/volume1/docker/nodedc-platform/secrets/external-data-plane-provisioner/token`
atomically in a dedicated UID/GID `11006` directory (directory `0500`, token
`0400`). It is never an `.env` value or an artifact member; Compose mounts it
read-only only into External Data Plane and, when implemented, its dedicated
Engine provisioner running under the same restricted identity.
`module-foundry` is an independent, authenticated application component. Its
artifact contains source and compose infrastructure only; its live
`/volume1/docker/nodedc-platform/module-foundry/source/.env` is root-owned and
never enters an artifact. The component reuses the existing internal platform
credential for Launcher handoff validation and requires that runtime
configuration before its first `apply`.
The Foundry ↔ Map Gateway signing key is not an application or `.env` setting.
On the first relevant `platform` or `module-foundry` apply, the root-owned
runner creates `/volume1/docker/nodedc-platform/secrets/map-gateway-admin-secret`
atomically (root:gid 1000, mode `0640`). Both containers receive that file only
as a read-only mount. The value is never printed, backed up with source,
included in an artifact, or administered through Foundry.
`proxy-contur` is the canonical VPN egress for selected Map Gateway provider
hosts. Its existing root-owned `PROXY_TOKEN` is copied by the runner into
`/volume1/docker/nodedc-platform/secrets/map-egress-proxy-token` with
`root:gid 1000`, mode `0640`, then mounted read-only only into Map Gateway.
The value is neither printed nor contained in an artifact, Foundry setting, or
browser response. Apply the `proxy-contur` artifact before the Platform Map
Gateway artifact: it creates the private `nodedc-map-egress` Docker network.
`dc-amd-proxy` is the separate, staged connector for the neighbouring AMD VPN
machine. Its active artifact attaches only to the private `nodedc-map-egress`
network, exposes a narrow NAS-LAN pairing port, and has no direct provider
egress. The runner preserves a `0700`, service-user-owned runtime directory
for the one-time paired connector credential and synchronizes the existing
private Map Gateway egress credential as a read-only file. Neither value is
ever placed in an artifact, `.env`, browser response, or runner output. The
separate Platform switch is applied only after the connector and pairing are
verified; it does not alter NAS routes, VPN, DNS, or Tailscale.
Install or update the root-owned live runner on Synology:
@@ -0,0 +1,44 @@
#!/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 { dirname, join, resolve } from "node:path";
import { tmpdir } from "node:os";
import { fileURLToPath } from "node:url";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const platformRoot = resolve(scriptDir, "../..");
const artifactDir = resolve(scriptDir, "../deploy-artifacts");
const [patchId = "dc-amd-proxy-bootstrap-20260715-001", ...extra] = process.argv.slice(2);
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) throw new Error("usage: build-dc-amd-proxy-artifact.mjs [patch-id]");
const files = [
["services/dc-amd-proxy/Dockerfile", "Dockerfile"],
["services/dc-amd-proxy/README.md", "README.md"],
["services/dc-amd-proxy/docker-compose.yml", "docker-compose.yml"],
["services/dc-amd-proxy/package.json", "package.json"],
["services/dc-amd-proxy/server.mjs", "server.mjs"],
];
const stage = await mkdtemp(join(tmpdir(), "nodedc-dc-amd-proxy-artifact-"));
const payload = join(stage, "payload");
const target = join(artifactDir, `nodedc-${patchId}.tgz`);
try {
await mkdir(payload, { recursive: true });
for (const [source, destination] of files) await copySafe(resolve(platformRoot, source), join(payload, destination));
await writeFile(join(stage, "manifest.env"), `id=${patchId}\ncomponent=dc-amd-proxy\ntype=app-overlay\n`, "utf8");
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\nwith tarfile.open(sys.argv[1],'w:gz',format=tarfile.PAX_FORMAT) as a:\n [a.add(n,arcname=n,recursive=True) for n in ('manifest.env','files.txt','payload')]", target], { cwd: stage, encoding: "utf8" });
if (tar.status !== 0) throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
console.log(JSON.stringify({ ok: true, patchId, artifact: target, sha256: createHash("sha256").update(await readFile(target)).digest("hex") }, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
async function copySafe(source, destination) {
const info = await lstat(source);
if (info.isSymbolicLink() || !info.isFile()) throw new Error(`source_file_rejected:${source}`);
await mkdir(dirname(destination), { recursive: true });
await cp(source, destination, { force: true });
}
@@ -0,0 +1,344 @@
#!/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 { createRequire, Module } from "node:module";
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(platformRoot, "../NODEDC_ENGINE_INFRA");
const artifactRoot = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || join(here, "../deploy-artifacts"));
const stageArtifact = resolve(
process.env.NODEDC_N8N_EXTENSION_STAGE_ARTIFACT
|| join(artifactRoot, "nodedc-n8n-private-extension-n8n-nodes-ndc-release-20260716-003.tgz"),
);
const stageArtifactSha256 = "4601c16c57e5182996adf18d0163837511b27ab3f7cfdf97eac679418ee2d078";
const releaseId = "0.1.2-05e4b38b14b4a019";
const packageVersion = "0.1.2";
const packageSha256 = "05e4b38b14b4a019ce1f6eee27b9e320094cb3560903bd68074966b3a1267af5";
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 transitionRoot = "nodedc-source/services/n8n/private-extensions";
const descriptorRel = `${transitionRoot}/ndc-activation.json`;
const overrideRel = `${transitionRoot}/docker-compose.ndc-private-extension.yml`;
const schemaRoot = "nodedc-source/server/assets/n8n/schema/v2.3.2";
const nodesCatalogRel = `${schemaRoot}/nodes.catalog.json`;
const credentialsCatalogRel = `${schemaRoot}/credentials.catalog.json`;
const metaRel = `${schemaRoot}/meta.json`;
const iconRoot = "nodedc-source/server/assets/n8n/icons";
const iconRel = `${iconRoot}/ndc.svg`;
const darkIconRel = `${iconRoot}/ndc.dark.svg`;
const sealedReleaseRelativePath = `n8n-private-extensions/releases/n8n-nodes-ndc/${releaseId}/package`;
const runtimePackagePath = "/home/node/.n8n/nodes/node_modules/n8n-nodes-ndc";
const expectedNodeTypes = [
"n8n-nodes-ndc.ndcDataProductPublish",
"n8n-nodes-ndc.ndcDataProductRead",
"n8n-nodes-ndc.ndcFoundryBinding",
];
const expectedCredentialTypes = [
"ndcDataProductWriterApi",
"ndcDataProductReaderApi",
"ndcFoundryBindingApi",
];
const nodeModules = [
["dist/nodes/NdcDataProductPublish/NdcDataProductPublish.node.js", "NdcDataProductPublish"],
["dist/nodes/NdcDataProductRead/NdcDataProductRead.node.js", "NdcDataProductRead"],
["dist/nodes/NdcFoundryBinding/NdcFoundryBinding.node.js", "NdcFoundryBinding"],
];
const credentialModules = [
["dist/credentials/NdcDataProductWriterApi.credentials.js", "NdcDataProductWriterApi"],
["dist/credentials/NdcDataProductReaderApi.credentials.js", "NdcDataProductReaderApi"],
["dist/credentials/NdcFoundryBindingApi.credentials.js", "NdcFoundryBindingApi"],
];
await mkdir(artifactRoot, { recursive: true });
assertSha(await readFile(stageArtifact), stageArtifactSha256, "staging artifact");
assertEngineBaseline(await readFile(join(engineRoot, "docker-compose.yml"), "utf8"));
const work = await mkdtemp(join(tmpdir(), "nodedc-engine-n8n-sealed-"));
try {
extractArchive(stageArtifact, work);
const stagedRelease = join(work, "payload", "releases", "n8n-nodes-ndc", releaseId);
const release = JSON.parse(await readFile(join(stagedRelease, "release.json"), "utf8"));
assertRelease(release);
assertSha(await readFile(join(stagedRelease, "package.tgz")), packageSha256, "private package");
const unpacked = join(work, "unpacked");
await mkdir(unpacked);
extractArchive(join(stagedRelease, "package.tgz"), unpacked);
const packageRoot = join(unpacked, "package");
const packageJson = JSON.parse(await readFile(join(packageRoot, "package.json"), "utf8"));
assertPackage(packageJson);
const devNodeModules = join(platformRoot, "packages", "n8n-nodes-ndc", "node_modules");
const nodePath = String(process.env.NODE_PATH || "").split(":").filter(Boolean);
if (!nodePath.includes(devNodeModules)) nodePath.unshift(devNodeModules);
process.env.NODE_PATH = nodePath.join(":");
Module._initPaths();
const packageRequire = createRequire(join(packageRoot, "package.json"));
const privateNodes = nodeModules.map(([path, className], index) => {
const NodeClass = packageRequire(join(packageRoot, path))[className];
if (typeof NodeClass !== "function") throw new Error(`node_class_missing:${className}`);
const description = structuredClone(new NodeClass().description);
description.name = expectedNodeTypes[index];
description.icon = { light: "file:ndc.svg", dark: "file:ndc.dark.svg" };
if (Object.prototype.hasOwnProperty.call(description, "usableAsTool")) {
throw new Error(`tool_variant_forbidden:${description.name}`);
}
return description;
});
const privateCredentials = credentialModules.map(([path, className]) => {
const CredentialClass = packageRequire(join(packageRoot, path))[className];
if (typeof CredentialClass !== "function") throw new Error(`credential_class_missing:${className}`);
const description = structuredClone(new CredentialClass());
description.icon = { light: "file:ndc.svg", dark: "file:ndc.dark.svg" };
return description;
});
assertExact(privateNodes.map((item) => item.name), expectedNodeTypes, "node types");
assertExact(privateCredentials.map((item) => item.name), expectedCredentialTypes, "credential types");
const baselineNodes = JSON.parse(gitFile(nodesCatalogRel));
const baselineCredentials = JSON.parse(gitFile(credentialsCatalogRel));
const baselineMeta = JSON.parse(gitFile(metaRel));
assertBaselineCatalogs(baselineNodes, baselineCredentials, baselineMeta);
const activeNodes = [...baselineNodes, ...privateNodes];
const activeCredentials = [...baselineCredentials, ...privateCredentials];
const activeMeta = {
n8nVersion,
generatedAt,
source: `n8n-core+n8n-nodes-ndc@${packageVersion}`,
nodeCount: activeNodes.length,
credentialCount: activeCredentials.length,
};
const activationDescriptor = descriptor("activate", expectedNodeTypes, expectedCredentialTypes, "verified_inactive");
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 activationEntries = [
descriptorRel,
overrideRel,
nodesCatalogRel,
credentialsCatalogRel,
metaRel,
iconRel,
darkIconRel,
];
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 });
}
});
const rollbackEntries = [descriptorRel, nodesCatalogRel, credentialsCatalogRel, metaRel];
const rollbackArtifact = await buildArtifact(work, rollbackId, rollbackEntries, async (payload) => {
await writeJson(join(payload, descriptorRel), rollbackDescriptor);
await mkdir(join(payload, schemaRoot), { recursive: true });
await writeFile(join(payload, nodesCatalogRel), `${JSON.stringify(baselineNodes, null, 2)}\n`, "utf8");
await writeFile(join(payload, credentialsCatalogRel), `${JSON.stringify(baselineCredentials, null, 2)}\n`, "utf8");
await writeFile(join(payload, metaRel), `${JSON.stringify(baselineMeta, null, 2)}\n`, "utf8");
});
console.log(JSON.stringify({
ok: true,
releaseId,
packageSha256,
nodeTypes: expectedNodeTypes,
credentialTypes: expectedCredentialTypes,
activation: activationArtifact,
rollback: rollbackArtifact,
}, null, 2));
} finally {
await rm(work, { recursive: true, force: true });
}
function descriptor(action, nodeTypes, credentialTypes, expectedCurrent) {
return {
schemaVersion: "nodedc.engine-n8n-private-extension-transition/v1",
action,
releaseId,
packageVersion,
packageSha256,
n8nVersion,
baseImage,
baseImageArchitecture: architecture,
baseImageIdentityPolicy: "running-container-and-local-tag-must-match",
sealedReleaseRelativePath,
composeOverride: overrideRel,
runtimePackagePath,
topologyServices: ["n8n"],
expectedCurrent,
expectedNodeTypes: nodeTypes,
expectedCredentialTypes: credentialTypes,
rollbackBaseline: "verified_inactive",
};
}
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 [
"services:",
" n8n:",
` image: ${baseImage}`,
" platform: linux/amd64",
" pull_policy: never",
" environment:",
" N8N_USER_FOLDER: /home/node",
" N8N_COMMUNITY_PACKAGES_ENABLED: \"true\"",
" N8N_COMMUNITY_PACKAGES_PREVENT_LOADING: \"false\"",
" N8N_REINSTALL_MISSING_PACKAGES: \"false\"",
" volumes:",
` - /volume2/nodedc-demo/${sealedReleaseRelativePath}:${runtimePackagePath}:ro`,
" healthcheck:",
` test: ${JSON.stringify(["CMD", "node", "-e", health])}`,
" interval: 10s",
" timeout: 5s",
" retries: 30",
" start_period: 30s",
" labels:",
` nodedc.n8n-private-extension.release: ${releaseId}`,
` nodedc.n8n-private-extension.package-sha256: ${packageSha256}`,
"",
].join("\n");
}
async function buildArtifact(workRoot, id, entries, populate) {
const stage = join(workRoot, id);
const payload = join(stage, "payload");
await mkdir(payload, { recursive: true });
await populate(payload);
await writeFile(join(stage, "manifest.env"), `id=${id}\ncomponent=engine\ntype=app-overlay\n`, "utf8");
await writeFile(join(stage, "files.txt"), `${entries.join("\n")}\n`, "utf8");
const artifact = join(artifactRoot, `nodedc-${id}.tgz`);
run("python3", ["-c", canonicalTarScript(), artifact, stage]);
return { id, artifact, sha256: sha(await readFile(artifact)), entries };
}
function assertRelease(value) {
if (value?.releaseId !== releaseId
|| value?.package?.name !== "n8n-nodes-ndc"
|| value?.package?.version !== packageVersion
|| value?.package?.sha256 !== packageSha256
|| value?.storage?.relativePath !== `releases/n8n-nodes-ndc/${releaseId}`) {
throw new Error("staged_release_identity_mismatch");
}
}
function assertPackage(value) {
if (value.name !== "n8n-nodes-ndc" || value.version !== packageVersion || value.private !== true) {
throw new Error("package_identity_mismatch");
}
if (value.dependencies !== undefined) throw new Error("runtime_dependencies_forbidden");
for (const name of ["preinstall", "install", "postinstall", "prepare", "prepack", "postpack"]) {
if (value.scripts?.[name] !== undefined) throw new Error(`lifecycle_forbidden:${name}`);
}
assertExact(value.n8n?.nodes, nodeModules.map(([path]) => path), "package nodes");
assertExact(value.n8n?.credentials, credentialModules.map(([path]) => path), "package credentials");
}
function assertBaselineCatalogs(nodes, credentials, meta) {
if (!Array.isArray(nodes) || nodes.length !== 434 || nodes.some((item) => String(item?.name || "").startsWith("n8n-nodes-ndc."))) {
throw new Error("baseline_node_catalog_mismatch");
}
if (!Array.isArray(credentials) || credentials.length !== 385
|| credentials.some((item) => expectedCredentialTypes.includes(String(item?.name || "")))) {
throw new Error("baseline_credential_catalog_mismatch");
}
if (meta?.n8nVersion !== n8nVersion || meta?.nodeCount !== 434 || meta?.credentialCount !== 385) {
throw new Error("baseline_meta_mismatch");
}
}
function assertEngineBaseline(compose) {
const exactImage = `image: docker.n8n.io/n8nio/n8n:\${N8N_IMAGE_TAG:-${n8nVersion}}`;
if (!compose.includes(exactImage)) throw new Error("engine_n8n_version_mismatch");
if ((compose.match(/^ n8n:\s*$/gm) || []).length !== 1) throw new Error("engine_n8n_topology_mismatch");
if (/^ n8n-(?:worker|webhook)|^ (?:worker|webhook):/gm.test(compose)) throw new Error("unexpected_n8n_process_service");
if (compose.includes("N8N_CUSTOM_EXTENSIONS") || compose.includes("CUSTOM.")) throw new Error("custom_extension_loader_forbidden");
}
function assertExact(actual, expected, label) {
if (!Array.isArray(actual) || JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error(`${label.replaceAll(" ", "_")}_mismatch`);
}
}
function assertSha(bytes, expected, label) {
const actual = sha(bytes);
if (actual !== expected) throw new Error(`${label.replaceAll(" ", "_")}_sha256_mismatch:${actual}`);
}
function gitFile(rel) {
return run("git", ["show", `HEAD:${rel}`], engineRoot).stdout;
}
async function writeJson(path, value) {
await mkdir(dirname(path), { recursive: true });
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, "utf8");
}
function extractArchive(archive, destination) {
const script = [
"import pathlib, sys, tarfile",
"src=pathlib.Path(sys.argv[1]); dst=pathlib.Path(sys.argv[2]).resolve()",
"with tarfile.open(src, 'r:gz') as tf:",
" for m in tf:",
" p=pathlib.PurePosixPath(m.name)",
" if p.is_absolute() or '..' in p.parts or any(x.startswith('._') for x in p.parts) or not (m.isfile() or m.isdir()): raise SystemExit('unsafe archive member')",
" target=dst.joinpath(*p.parts)",
" target.mkdir(parents=True, exist_ok=True) if m.isdir() else target.parent.mkdir(parents=True, exist_ok=True)",
" if m.isfile():",
" source=tf.extractfile(m)",
" with open(target, 'xb') as out: out.write(source.read())",
].join("\n");
run("python3", ["-c", script, archive, destination]);
}
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 sha(bytes) {
return createHash("sha256").update(bytes).digest("hex");
}
function run(command, args, cwd) {
const result = spawnSync(command, args, {
cwd,
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;
}
@@ -0,0 +1,68 @@
#!/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 { 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 [patchId = "external-data-plane-20260714-001", ...extra] = process.argv.slice(2);
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
throw new Error("usage: build-external-data-plane-artifact.mjs [patch-id]");
}
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"],
];
const ignoredBasenames = new Set([".DS_Store", ".git", "node_modules"]);
const stage = await mkdtemp(join(tmpdir(), "nodedc-external-data-plane-artifact-"));
const payload = join(stage, "payload");
const target = join(artifactDir, `nodedc-platform-${patchId}.tgz`);
try {
await mkdir(payload, { recursive: true });
for (const [sourceRelative, destinationRelative] of files) {
await copySafe(resolve(platformRoot, sourceRelative), join(payload, destinationRelative));
}
await writeFile(join(stage, "manifest.env"), `id=${patchId}\ncomponent=platform\ntype=app-overlay\n`, "utf8");
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" });
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));
} finally {
await rm(stage, { recursive: true, force: true });
}
async function copySafe(source, destination) {
const sourceStat = await lstat(source);
if (sourceStat.isSymbolicLink()) throw new Error(`source_symlink_rejected:${source}`);
if (sourceStat.isFile()) {
await mkdir(dirname(destination), { recursive: true });
await cp(source, destination, { force: true, verbatimSymlinks: true });
return;
}
if (!sourceStat.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);
const childDestination = join(destination, entry.name);
if (entry.isSymbolicLink()) throw new Error(`source_symlink_rejected:${relative(platformRoot, childSource)}`);
await copySafe(childSource, childDestination);
}
}
@@ -0,0 +1,109 @@
#!/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 { 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 args = process.argv.slice(2);
const gatewayOnly = args.includes("--gateway-only");
const positionalArgs = args.filter((argument) => argument !== "--gateway-only");
if (positionalArgs.length > 1) {
throw new Error("usage: build-gelios-data-plane-artifact.mjs [patch-id] [--gateway-only]");
}
const patchId = positionalArgs[0] || "gelios-data-plane-20260713-001";
if (!/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
throw new Error("patch_id_must_contain_only_letters_digits_dot_underscore_hyphen");
}
const fullDataPlaneFiles = [
["infra/synology/docker-compose.platform-http.yml", "platform/docker-compose.platform-http.yml"],
["services/ontology-core", "platform/ontology-core"],
["services/ai-workspace-hub", "platform/ai-workspace-hub"],
["services/ai-workspace-assistant", "platform/ai-workspace-assistant"],
["services/gelios-gateway", "platform/gelios-gateway"],
];
// A policy/code update to an already deployed Gelios data plane must not
// carry the common Platform compose file. The deploy runner treats that file
// as a whole-Platform change. The existing Gelios service already uses the
// live Platform env_file, so this overlay can safely recreate only Gelios.
const files = gatewayOnly
? [["services/gelios-gateway", "platform/gelios-gateway"]]
: fullDataPlaneFiles;
const ignoredBasenames = new Set([".DS_Store", ".git", "node_modules"]);
const stage = await mkdtemp(join(tmpdir(), "nodedc-gelios-artifact-"));
const payload = join(stage, "payload");
const target = join(artifactDir, `nodedc-platform-${patchId}.tgz`);
try {
await mkdir(payload, { recursive: true });
for (const [sourceRelative, destinationRelative] of files) {
const source = resolve(platformRoot, sourceRelative);
const destination = join(payload, destinationRelative);
await copySafe(source, destination);
}
if (!gatewayOnly) {
// The Assistant imports the deterministic catalog as a local sibling at
// runtime. Its production Dockerfile therefore expects this directory
// inside the Assistant build context. Keep the deploy artifact equivalent
// to the canonical Synology staging layout without making a second source
// copy in the repository.
await copySafe(
resolve(platformRoot, "services/ontology-core"),
join(payload, "platform/ai-workspace-assistant/ontology-core"),
);
// This nested copy is source-only build input for the Assistant. The nested
// service Dockerfile is neither used nor allowed by the production runner.
await rm(join(payload, "platform/ai-workspace-assistant/ontology-core/Dockerfile"), { force: true });
}
await writeFile(join(stage, "manifest.env"), `id=${patchId}\ncomponent=platform\ntype=app-overlay\n`, "utf8");
await writeFile(join(stage, "files.txt"), `${files.map(([, destination]) => destination).join("\n")}\n`, "utf8");
await mkdir(artifactDir, { recursive: true });
// macOS bsdtar includes AppleDouble sidecar files for extended attributes.
// The root runner rejects those as unexpected members, so use stdlib tarfile
// to generate a portable, data-only archive instead.
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",
});
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, gatewayOnly, artifact: target, sha256: digest }, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
async function copySafe(source, destination) {
const sourceStat = await lstat(source);
if (sourceStat.isSymbolicLink()) throw new Error(`source_symlink_rejected:${source}`);
if (sourceStat.isFile()) {
await mkdir(dirname(destination), { recursive: true });
await cp(source, destination, { force: true, verbatimSymlinks: true });
return;
}
if (!sourceStat.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);
const childDestination = join(destination, entry.name);
if (entry.isSymbolicLink()) throw new Error(`source_symlink_rejected:${relative(platformRoot, childSource)}`);
await copySafe(childSource, childDestination);
}
}
@@ -0,0 +1,85 @@
#!/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 scriptDir = dirname(fileURLToPath(import.meta.url));
const platformRoot = resolve(scriptDir, "../..");
const launcherRoot = resolve(platformRoot, "../../data/nodedc_launcher");
const artifactDir = resolve(scriptDir, "../deploy-artifacts");
const patchId = process.argv[2] || "module-foundry-hub-registration-20260714-001";
const profile = process.argv[3] || "registration";
if (!/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
throw new Error("patch_id_must_contain_only_letters_digits_dot_underscore_hyphen");
}
// DCPLATFORM-21: the production runner overlays this payload onto the existing
// Launcher source and recreates only the `launcher` service. Keep corrective
// patches to their exact reviewed file set instead of re-sending unrelated
// registration sources.
const registrationFiles = [
"server/authentik-sync.mjs",
"server/control-plane-store.mjs",
"server/dev-server.mjs",
"src/entities/service/types.ts",
];
const filesByProfile = {
registration: registrationFiles,
"handoff-fix": ["server/dev-server.mjs"],
};
const files = filesByProfile[profile];
if (!files) {
throw new Error(`unknown_profile:${profile}`);
}
const stage = await mkdtemp(join(tmpdir(), "nodedc-launcher-foundry-artifact-"));
const payloadRoot = join(stage, "payload");
const artifact = join(artifactDir, `launcher-${patchId}.tgz`);
const checksum = `${artifact}.sha256`;
try {
await mkdir(payloadRoot, { recursive: true });
for (const relativePath of files) {
const source = resolve(launcherRoot, relativePath);
const destination = join(payloadRoot, relativePath);
const stat = await lstat(source);
if (!stat.isFile() || stat.isSymbolicLink()) {
throw new Error(`source_file_rejected:${relativePath}`);
}
await mkdir(dirname(destination), { recursive: true });
await cp(source, destination, { force: true, verbatimSymlinks: true });
}
await writeFile(
join(stage, "manifest.env"),
`id=${patchId}\ncomponent=launcher\ntype=app-overlay\n`,
"utf8",
);
await writeFile(join(stage, "files.txt"), `${files.join("\n")}\n`, "utf8");
await mkdir(artifactDir, { recursive: true });
// macOS bsdtar may emit AppleDouble `._*` sidecars. DCPLATFORM-21 rejects
// them, therefore create a data-only POSIX archive through stdlib tarfile.
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"), artifact], {
cwd: stage,
encoding: "utf8",
});
if (tar.status !== 0) throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
const digest = createHash("sha256").update(await readFile(artifact)).digest("hex");
await writeFile(checksum, `${digest} ${artifact.split("/").at(-1)}\n`, "utf8");
console.log(JSON.stringify({ ok: true, patchId, profile, artifact, checksum, sha256: digest, files }, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
@@ -0,0 +1,49 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { cp, lstat, mkdir, mkdtemp, readdir, readFile, 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 [patchId = "platform-map-gateway-20260714-001", ...extra] = process.argv.slice(2);
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) throw new Error("usage: build-map-gateway-artifact.mjs [patch-id]");
const files = [
["infra/synology/docker-compose.platform-http.yml", "platform/docker-compose.platform-http.yml"],
["services/map-gateway", "platform/services/map-gateway"],
];
const ignored = new Set([".DS_Store", ".git", "node_modules"]);
const stage = await mkdtemp(join(tmpdir(), "nodedc-map-gateway-artifact-"));
const payload = join(stage, "payload");
const target = join(artifactDir, `nodedc-platform-${patchId}.tgz`);
try {
await mkdir(payload, { recursive: true });
for (const [source, destination] of files) await copySafe(resolve(platformRoot, source), join(payload, destination));
await writeFile(join(stage, "manifest.env"), `id=${patchId}\ncomponent=platform\ntype=app-overlay\n`, "utf8");
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\nwith tarfile.open(sys.argv[1],'w:gz',format=tarfile.PAX_FORMAT) as a:\n [a.add(n,arcname=n,recursive=True) for n in ('manifest.env','files.txt','payload')]", target], { cwd: stage, encoding: "utf8" });
if (tar.status !== 0) throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
console.log(JSON.stringify({ ok: true, patchId, artifact: target, sha256: createHash("sha256").update(await readFile(target)).digest("hex") }, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
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: 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 (ignored.has(entry.name) || entry.name.startsWith(".env")) continue;
const child = join(source, entry.name);
if (entry.isSymbolicLink()) throw new Error(`source_symlink_rejected:${relative(platformRoot, child)}`);
await copySafe(child, join(destination, entry.name));
}
}
@@ -0,0 +1,85 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { cp, lstat, mkdir, mkdtemp, readdir, readFile, 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 workspaceRoot = resolve(platformRoot, "..");
const foundryRoot = resolve(workspaceRoot, "NODEDC_DESIGN_GUIDELINE");
const artifactDir = resolve(scriptDir, "../deploy-artifacts");
const patchId = process.argv[2] || "module-foundry-bootstrap-20260714-001";
if (!/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
throw new Error("patch_id_must_contain_only_letters_digits_dot_underscore_hyphen");
}
const files = [
".dockerignore",
".env.example",
".gitignore",
"Dockerfile",
"package.json",
"package-lock.json",
"tsconfig.base.json",
"infra/docker-compose.module-foundry.yml",
"apps",
"packages",
"registry",
"runtime-seed",
"scripts",
"server",
];
const ignoredBasenames = new Set([".DS_Store", ".git", "node_modules", "runtime-data", "dist"]);
const stage = await mkdtemp(join(tmpdir(), "nodedc-module-foundry-artifact-"));
const payload = join(stage, "payload");
const target = join(artifactDir, `nodedc-module-foundry-${patchId}.tgz`);
try {
await mkdir(payload, { recursive: true });
for (const sourceRelative of files) {
await copySafe(resolve(foundryRoot, sourceRelative), join(payload, sourceRelative));
}
await writeFile(join(stage, "manifest.env"), `id=${patchId}\ncomponent=module-foundry\ntype=app-overlay\n`, "utf8");
await writeFile(join(stage, "files.txt"), `${files.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",
});
if (tar.status !== 0) throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
const digest = createHash("sha256").update(await readFile(target)).digest("hex");
console.log(JSON.stringify({ ok: true, patchId, artifact: target, sha256: digest }, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
async function copySafe(source, destination) {
const sourceStat = await lstat(source);
if (sourceStat.isSymbolicLink()) throw new Error(`source_symlink_rejected:${relative(foundryRoot, source)}`);
if (sourceStat.isFile()) {
await mkdir(dirname(destination), { recursive: true });
await cp(source, destination, { force: true, verbatimSymlinks: true });
return;
}
if (!sourceStat.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") || entry.name.endsWith(".tsbuildinfo")) continue;
const childSource = join(source, entry.name);
const childDestination = join(destination, entry.name);
if (entry.isSymbolicLink()) throw new Error(`source_symlink_rejected:${relative(foundryRoot, childSource)}`);
await copySafe(childSource, childDestination);
}
}
@@ -0,0 +1,286 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { createRequire } from "node:module";
import { spawnSync } from "node:child_process";
import { cp, 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 scriptDir = dirname(fileURLToPath(import.meta.url));
const platformRoot = resolve(scriptDir, "../..");
const packageRoot = resolve(platformRoot, "packages/n8n-nodes-ndc");
const artifactDir = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts"));
const requireModule = createRequire(import.meta.url);
const expectedPackageVersion = "0.1.2";
const [patchId = "n8n-nodes-ndc-release-20260716-003", ...extra] = process.argv.slice(2);
const expectedRuntimeNodes = [
{
file: "dist/nodes/NdcDataProductPublish/NdcDataProductPublish.node.js",
exportName: "NdcDataProductPublish",
name: "ndcDataProductPublish",
},
{
file: "dist/nodes/NdcDataProductRead/NdcDataProductRead.node.js",
exportName: "NdcDataProductRead",
name: "ndcDataProductRead",
},
{
file: "dist/nodes/NdcFoundryBinding/NdcFoundryBinding.node.js",
exportName: "NdcFoundryBinding",
name: "ndcFoundryBinding",
},
];
const expectedN8nNodes = expectedRuntimeNodes.map((node) => node.file);
const expectedRuntimeNodeTypes = expectedRuntimeNodes.map((node) => `n8n-nodes-ndc.${node.name}`);
const expectedN8nCredentials = [
"dist/credentials/NdcDataProductWriterApi.credentials.js",
"dist/credentials/NdcDataProductReaderApi.credentials.js",
"dist/credentials/NdcFoundryBindingApi.credentials.js",
];
const expectedCredentialTypes = [
"ndcDataProductWriterApi",
"ndcDataProductReaderApi",
"ndcFoundryBindingApi",
];
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
throw new Error("usage: build-n8n-private-extension-artifact.mjs [patch-id]");
}
const packageJson = JSON.parse(await readFile(join(packageRoot, "package.json"), "utf8"));
assertPackageSourcePolicy(packageJson);
run("npm", ["test"], packageRoot);
run("npm", ["run", "lint"], packageRoot);
assertRuntimeNodePolicy(packageJson);
const stage = await mkdtemp(join(tmpdir(), "nodedc-n8n-private-extension-"));
const packDir = join(stage, "pack");
const payload = join(stage, "payload");
try {
await mkdir(packDir, { recursive: true });
const pack = run("npm", ["pack", "--ignore-scripts", "--json", "--pack-destination", packDir], packageRoot);
const packResult = JSON.parse(pack.stdout);
if (!Array.isArray(packResult) || packResult.length !== 1) throw new Error("npm_pack_result_invalid");
const metadata = packResult[0];
assertPackedFilePolicy(metadata, packageJson);
const packedPath = join(packDir, metadata.filename);
const canonicalPackedPath = join(packDir, "n8n-nodes-ndc.canonical.tgz");
const canonicalPackageScript = [
"import gzip, io, sys, tarfile",
"members = []",
"with tarfile.open(sys.argv[1], 'r:gz') as source:",
" for original in source:",
" if not (original.isfile() or original.isdir()):",
" raise SystemExit('unsupported npm package member')",
" content = b''",
" if original.isfile():",
" extracted = source.extractfile(original)",
" if extracted is None:",
" raise SystemExit('unreadable npm package member')",
" content = extracted.read()",
" members.append((original.name, original.isdir(), content))",
"with open(sys.argv[2], 'wb') as output:",
" with gzip.GzipFile(filename='', mode='wb', fileobj=output, compresslevel=9, mtime=0) as compressed:",
" with tarfile.open(fileobj=compressed, mode='w', format=tarfile.PAX_FORMAT) as target:",
" for name, is_dir, content in sorted(members, key=lambda item: item[0]):",
" member = tarfile.TarInfo(name)",
" member.uid = member.gid = 0",
" member.uname = member.gname = 'root'",
" member.mtime = 0",
" member.mode = 0o755 if is_dir else 0o644",
" member.type = tarfile.DIRTYPE if is_dir else tarfile.REGTYPE",
" member.size = 0 if is_dir else len(content)",
" target.addfile(member, None if is_dir else io.BytesIO(content))",
].join("\n");
run("python3", ["-c", canonicalPackageScript, packedPath, canonicalPackedPath], stage);
const packageBytes = await readFile(canonicalPackedPath);
const packageSha256 = createHash("sha256").update(packageBytes).digest("hex");
const releaseId = `${packageJson.version}-${packageSha256.slice(0, 16)}`;
const relativeReleasePath = `releases/n8n-nodes-ndc/${releaseId}`;
const releaseDir = join(payload, relativeReleasePath);
await mkdir(releaseDir, { recursive: true });
await cp(canonicalPackedPath, join(releaseDir, "package.tgz"), { force: false });
const rollbackBaselinePolicy = {
allowed: [
"previous_verified_immutable_release",
"verified_inactive",
],
firstActivation: "verified_inactive",
requiresPreActivationVerification: true,
};
const release = {
schemaVersion: "nodedc.n8n-private-extension-release/v2",
releaseId,
package: {
name: "n8n-nodes-ndc",
version: packageJson.version,
sha256: packageSha256,
bytes: packageBytes.byteLength,
runtimeTypePrefix: "n8n-nodes-ndc.",
},
storage: {
relativePath: relativeReleasePath,
immutable: true,
},
activation: {
owner: "engine",
status: "blocked_pending_engine_owned_mount",
requiredCommunityPackagePath: "/home/node/.n8n/nodes/node_modules/n8n-nodes-ndc",
requiresAtomicReleaseSwitch: true,
requiresAllN8nProcessesRestart: true,
requiresMcpSchemaAcceptance: true,
rollbackBaselinePolicy,
},
};
const rollback = {
schemaVersion: "nodedc.n8n-private-extension-rollback/v2",
releaseId,
packageSha256,
mode: "engine-owned-atomic-release-switch",
baselinePolicy: rollbackBaselinePolicy,
steps: [
"select_verified_previous_release_or_preverified_inactive_baseline",
"switch_engine_owned_mount_atomically",
"restart_all_n8n_processes",
"verify_mcp_schema_state_matches_selected_baseline",
],
forbidden: [
"delete_active_release",
"mutate_engine_core",
"live_npm_install",
],
};
await writeFile(join(releaseDir, "release.json"), `${JSON.stringify(release, null, 2)}\n`, "utf8");
await writeFile(join(releaseDir, "rollback.json"), `${JSON.stringify(rollback, null, 2)}\n`, "utf8");
await writeFile(join(stage, "manifest.env"), `id=${patchId}\ncomponent=n8n-private-extension\ntype=app-overlay\n`, "utf8");
await writeFile(join(stage, "files.txt"), `${relativeReleasePath}\n`, "utf8");
await mkdir(artifactDir, { recursive: true });
const target = join(artifactDir, `nodedc-n8n-private-extension-${patchId}.tgz`);
const tarScript = [
"import gzip, os, pathlib, sys, tarfile",
"root = pathlib.Path(sys.argv[2])",
"def clean(info):",
" info.uid = info.gid = 0",
" info.uname = info.gname = 'root'",
" info.mtime = 0",
" info.mode = 0o755 if info.isdir() else 0o644",
" return info",
"with open(sys.argv[1], 'wb') as output:",
" with gzip.GzipFile(filename='', mode='wb', fileobj=output, compresslevel=9, mtime=0) as compressed:",
" with tarfile.open(fileobj=compressed, mode='w', format=tarfile.PAX_FORMAT) as archive:",
" for top in ('manifest.env', 'files.txt', 'payload'):",
" path = root / top",
" archive.add(path, arcname=top, recursive=False, filter=clean)",
" if path.is_dir():",
" for child in sorted(path.rglob('*'), key=lambda item: item.as_posix()):",
" archive.add(child, arcname=child.relative_to(root).as_posix(), recursive=False, filter=clean)",
].join("\n");
run("python3", ["-c", tarScript, target, stage], stage);
const artifactSha256 = createHash("sha256").update(await readFile(target)).digest("hex");
console.log(JSON.stringify({
ok: true,
patchId,
artifact: target,
artifactSha256,
releaseId,
packageSha256,
nodeTypes: expectedRuntimeNodeTypes,
credentialTypes: expectedCredentialTypes,
activation: "blocked_pending_engine_owned_mount",
}, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
function assertPackageSourcePolicy(value) {
if (value.name !== "n8n-nodes-ndc") throw new Error("package_name_invalid");
if (value.version !== expectedPackageVersion) throw new Error("package_version_invalid");
if (value.private !== true) throw new Error("package_must_remain_private");
if (value.dependencies !== undefined) throw new Error("runtime_dependencies_forbidden");
for (const lifecycle of ["preinstall", "install", "postinstall", "prepack", "prepare", "postpack"]) {
if (value.scripts?.[lifecycle] !== undefined) throw new Error(`lifecycle_script_forbidden:${lifecycle}`);
}
assertExactRegistration(value.n8n?.nodes, expectedN8nNodes, "n8n_nodes");
assertExactRegistration(value.n8n?.credentials, expectedN8nCredentials, "n8n_credentials");
}
function assertRuntimeNodePolicy(packageJson) {
const observedTypes = [];
for (const expected of expectedRuntimeNodes) {
const loaded = requireModule(join(packageRoot, expected.file));
const NodeClass = loaded?.[expected.exportName];
if (typeof NodeClass !== "function") throw new Error(`runtime_node_export_missing:${expected.exportName}`);
const description = new NodeClass()?.description;
if (!description || description.name !== expected.name) {
throw new Error(`runtime_node_name_mismatch:${expected.exportName}`);
}
if ("usableAsTool" in description) {
throw new Error(`runtime_tool_variant_forbidden:${expected.name}`);
}
observedTypes.push(`${packageJson.name}.${description.name}`);
}
if (JSON.stringify(observedTypes) !== JSON.stringify(expectedRuntimeNodeTypes)) {
throw new Error("runtime_node_types_mismatch");
}
}
function assertExactRegistration(actual, expected, label) {
if (!Array.isArray(actual)) throw new Error(`${label}_missing`);
if (actual.length !== new Set(actual).size) throw new Error(`${label}_duplicate`);
if (actual.length !== expected.length || expected.some((value) => !actual.includes(value))) {
throw new Error(`${label}_mismatch`);
}
}
function assertPackedFilePolicy(metadata, sourcePackage) {
if (metadata.name !== sourcePackage.name || metadata.version !== sourcePackage.version) {
throw new Error("npm_pack_identity_mismatch");
}
if (!Number.isSafeInteger(metadata.size) || metadata.size < 1024 || metadata.size > 32 * 1024 * 1024) {
throw new Error("npm_pack_size_invalid");
}
if (!Array.isArray(metadata.files) || metadata.files.length > 512) throw new Error("npm_pack_file_list_invalid");
const paths = new Set();
for (const file of metadata.files) {
if (!file || typeof file.path !== "string" || paths.has(file.path)) throw new Error("npm_pack_file_invalid");
paths.add(file.path);
if (!(file.path === "README.md" || file.path === "package.json" || file.path.startsWith("dist/"))) {
throw new Error(`npm_pack_path_forbidden:${file.path}`);
}
const parts = file.path.split("/");
if (
file.path.includes("\\")
|| parts.some((part) => !part || part === "." || part === ".." || part.startsWith("."))
|| file.mode !== 0o644
) {
throw new Error(`npm_pack_path_unsafe:${file.path}`);
}
}
assertExactRegistration(
[...paths].filter((value) => /^dist\/nodes\/.+\.node\.js$/.test(value)),
expectedN8nNodes,
"npm_pack_nodes",
);
assertExactRegistration(
[...paths].filter((value) => /^dist\/credentials\/.+\.credentials\.js$/.test(value)),
expectedN8nCredentials,
"npm_pack_credentials",
);
for (const registered of [...sourcePackage.n8n.nodes, ...sourcePackage.n8n.credentials]) {
if (!paths.has(registered)) throw new Error(`npm_pack_registration_missing:${registered}`);
}
}
function run(command, args, cwd) {
const result = spawnSync(command, args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
if (result.status !== 0) {
throw new Error(`${command}_failed:${result.stderr || result.stdout}`);
}
return result;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,285 @@
#!/usr/bin/env python3
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
ENGINE_ROOT = PLATFORM_ROOT.parent / "NODEDC_ENGINE_INFRA"
RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy"
BUILDER_PATH = SCRIPT_DIR / "build-engine-n8n-private-extension-artifact.mjs"
STAGE_ARTIFACT = (
PLATFORM_ROOT
/ "infra/deploy-artifacts"
/ "nodedc-n8n-private-extension-n8n-nodes-ndc-release-20260716-003.tgz"
)
EXPECTED_NODES = [
"n8n-nodes-ndc.ndcDataProductPublish",
"n8n-nodes-ndc.ndcDataProductRead",
"n8n-nodes-ndc.ndcFoundryBinding",
]
EXPECTED_CREDENTIALS = [
"ndcDataProductWriterApi",
"ndcDataProductReaderApi",
"ndcFoundryBindingApi",
]
def load_runner():
loader = importlib.machinery.SourceFileLoader("nodedc_engine_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()
class EngineN8nPrivateExtensionTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.temporary = tempfile.TemporaryDirectory(prefix="nodedc-engine-n8n-policy-")
cls.root = Path(cls.temporary.name)
cls.results = []
for index in range(2):
output = cls.root / f"build-{index}"
output.mkdir()
env = os.environ.copy()
env["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(output)
env["NODEDC_N8N_EXTENSION_STAGE_ARTIFACT"] = str(STAGE_ARTIFACT)
result = subprocess.run(
["node", str(BUILDER_PATH)],
cwd=PLATFORM_ROOT,
env=env,
check=True,
capture_output=True,
text=True,
)
cls.results.append(json.loads(result.stdout))
@classmethod
def tearDownClass(cls):
cls.temporary.cleanup()
def artifact(self, build_index, kind):
return Path(self.results[build_index][kind]["artifact"])
def test_engine_artifacts_are_byte_reproducible(self):
for kind in ("activation", "rollback"):
first = self.artifact(0, kind).read_bytes()
second = self.artifact(1, kind).read_bytes()
self.assertEqual(first, second)
self.assertEqual(self.results[0][kind]["sha256"], self.results[1][kind]["sha256"])
self.assertEqual(first[4:8], b"\0\0\0\0")
self.assertEqual(first[3] & 0x08, 0)
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():
with self.subTest(kind=kind), tempfile.TemporaryDirectory() as directory:
manifest, entries, payload = RUNNER.load_artifact(
self.artifact(0, kind),
Path(directory),
)
descriptor = RUNNER.read_engine_n8n_transition_descriptor(
payload / RUNNER.ENGINE_N8N_TRANSITION_DESCRIPTOR_REL
)
self.assertEqual(manifest["component"], "engine")
self.assertEqual(descriptor["action"], action)
self.assertEqual(len(entries), entry_count)
self.assertEqual(RUNNER.component_services("engine", entries), ("n8n",))
self.assertEqual(RUNNER.component_builds("engine", entries), ())
self.assertFalse(RUNNER.component_publish_dist("engine", entries))
def test_activation_catalog_is_exact_three_without_tool_variants(self):
with tempfile.TemporaryDirectory() as directory:
_manifest, _entries, payload = RUNNER.load_artifact(
self.artifact(0, "activation"),
Path(directory),
)
nodes = json.loads((payload / RUNNER.ENGINE_N8N_NODES_CATALOG_REL).read_text())
credentials = json.loads((payload / RUNNER.ENGINE_N8N_CREDENTIALS_CATALOG_REL).read_text())
private_nodes = [item for item in nodes if item.get("name", "").startswith("n8n-nodes-ndc.")]
private_credentials = [item for item in credentials if item.get("name") in EXPECTED_CREDENTIALS]
self.assertEqual([item["name"] for item in private_nodes], EXPECTED_NODES)
self.assertEqual([item["name"] for item in private_credentials], EXPECTED_CREDENTIALS)
self.assertTrue(all("usableAsTool" not in item for item in private_nodes))
self.assertEqual((len(nodes), len(credentials)), (437, 388))
def test_rollback_catalog_restores_verified_inactive_baseline(self):
with tempfile.TemporaryDirectory() as directory:
_manifest, _entries, payload = RUNNER.load_artifact(
self.artifact(0, "rollback"),
Path(directory),
)
nodes = json.loads((payload / RUNNER.ENGINE_N8N_NODES_CATALOG_REL).read_text())
credentials = json.loads((payload / RUNNER.ENGINE_N8N_CREDENTIALS_CATALOG_REL).read_text())
self.assertEqual([item for item in nodes if item.get("name", "").startswith("n8n-nodes-ndc.")], [])
self.assertEqual([item for item in credentials if item.get("name") in EXPECTED_CREDENTIALS], [])
self.assertEqual((len(nodes), len(credentials)), (434, 385))
def test_compose_override_is_runner_derived_and_offline(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
)
override = (payload / RUNNER.ENGINE_N8N_COMPOSE_OVERRIDE_REL).read_text()
self.assertEqual(override, RUNNER.expected_engine_n8n_compose_override(descriptor))
self.assertIn("pull_policy: never", override)
self.assertIn("N8N_USER_FOLDER: /home/node", 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_unknown_descriptor_key_is_rejected(self):
with tempfile.TemporaryDirectory() as directory:
work = Path(directory)
RUNNER.safe_extract(self.artifact(0, "activation"), work)
descriptor_path = work / "payload" / RUNNER.ENGINE_N8N_TRANSITION_DESCRIPTOR_REL
descriptor = json.loads(descriptor_path.read_text())
descriptor["unexpected"] = True
descriptor_path.write_text(json.dumps(descriptor))
with self.assertRaisesRegex(RUNNER.DeployError, "keys mismatch"):
RUNNER.validate_engine_n8n_transition(
work / "payload",
RUNNER.parse_files_list(work / "files.txt"),
)
def test_core_catalog_substitution_is_rejected_even_when_counts_match(self):
cases = (
("activation", RUNNER.ENGINE_N8N_NODES_CATALOG_REL, "node"),
("activation", RUNNER.ENGINE_N8N_CREDENTIALS_CATALOG_REL, "credential"),
("rollback", RUNNER.ENGINE_N8N_NODES_CATALOG_REL, "node"),
("rollback", RUNNER.ENGINE_N8N_CREDENTIALS_CATALOG_REL, "credential"),
)
for kind, relative_path, label in cases:
with self.subTest(kind=kind, catalog=label), tempfile.TemporaryDirectory() as directory:
work = Path(directory)
RUNNER.safe_extract(self.artifact(0, kind), work)
catalog_path = work / "payload" / relative_path
catalog = json.loads(catalog_path.read_text())
if label == "node":
candidate = next(
item for item in catalog
if not item.get("name", "").startswith("n8n-nodes-ndc.")
)
candidate["name"] = "n8n-nodes-unreviewed.hiddenNode"
else:
candidate = next(
item for item in catalog
if item.get("name") not in EXPECTED_CREDENTIALS
)
candidate["name"] = "unreviewedCredential"
catalog_path.write_text(json.dumps(catalog, indent=2) + "\n")
with self.assertRaisesRegex(RUNNER.DeployError, "catalog sha256 mismatch"):
RUNNER.validate_engine_n8n_transition(
work / "payload",
RUNNER.parse_files_list(work / "files.txt"),
)
def test_old_engine_artifact_is_rejected_by_descriptor_gate(self):
old = PLATFORM_ROOT / "infra/deploy-artifacts/nodedc-engine-n8n-private-extension-20260715-002.tgz"
if not old.is_file():
self.skipTest("rejected historical artifact not present")
with tempfile.TemporaryDirectory() as directory:
with self.assertRaisesRegex(RUNNER.DeployError, "canonical transition descriptor"):
RUNNER.load_artifact(old, Path(directory))
def test_engine_source_keeps_base_compose_and_separate_override(self):
original_root = RUNNER.COMPONENTS["engine"]["payload_root"]
original_compose = RUNNER.COMPONENTS["engine"]["compose_root"]
try:
RUNNER.COMPONENTS["engine"]["payload_root"] = ENGINE_ROOT
RUNNER.COMPONENTS["engine"]["compose_root"] = ENGINE_ROOT
RUNNER.validate_engine_n8n_base_compose_source()
finally:
RUNNER.COMPONENTS["engine"]["payload_root"] = original_root
RUNNER.COMPONENTS["engine"]["compose_root"] = original_compose
def test_additional_n8n_runtime_service_is_rejected(self):
original_root = RUNNER.COMPONENTS["engine"]["payload_root"]
original_compose = RUNNER.COMPONENTS["engine"]["compose_root"]
try:
with tempfile.TemporaryDirectory() as directory:
engine_root = Path(directory)
compose = (ENGINE_ROOT / "docker-compose.yml").read_text()
compose += (
"\n n8n-worker-2:\n"
" image: docker.n8n.io/n8nio/n8n:${N8N_IMAGE_TAG:-2.3.2}\n"
)
(engine_root / "docker-compose.yml").write_text(compose)
RUNNER.COMPONENTS["engine"]["payload_root"] = engine_root
RUNNER.COMPONENTS["engine"]["compose_root"] = engine_root
with self.assertRaisesRegex(RUNNER.DeployError, "exact service topology mismatch"):
RUNNER.validate_engine_n8n_base_compose_source()
finally:
RUNNER.COMPONENTS["engine"]["payload_root"] = original_root
RUNNER.COMPONENTS["engine"]["compose_root"] = original_compose
def test_sealed_release_exact_set_includes_implicit_directories(self):
release_relative = Path(
"payload/releases/n8n-nodes-ndc/0.1.2-05e4b38b14b4a019"
)
with tempfile.TemporaryDirectory() as directory:
work = Path(directory)
outer = work / "outer"
RUNNER.safe_extract(STAGE_ARTIFACT, outer)
staged_release = outer / release_relative
sealed_release = work / "sealed"
sealed_release.mkdir()
for filename in ("package.tgz", "release.json", "rollback.json"):
(sealed_release / filename).write_bytes(
(staged_release / filename).read_bytes()
)
expected_paths = {
"package",
"package.tgz",
"release.json",
"rollback.json",
}
with tarfile.open(staged_release / "package.tgz", "r:gz") as archive:
for member in archive:
RUNNER.add_engine_n8n_sealed_member_paths(
expected_paths,
member.name,
)
target = sealed_release.joinpath(*Path(member.name).parts)
if member.isdir():
target.mkdir(parents=True, exist_ok=True)
continue
target.parent.mkdir(parents=True, exist_ok=True)
source = archive.extractfile(member)
self.assertIsNotNone(source)
target.write_bytes(source.read())
actual_paths = {
path.relative_to(sealed_release).as_posix()
for path in sealed_release.rglob("*")
}
self.assertEqual(actual_paths, expected_paths)
self.assertIn("package/dist/nodes", expected_paths)
self.assertIn("package/dist/credentials", expected_paths)
def test_tar_members_have_no_appledouble_or_special_types(self):
for kind in ("activation", "rollback"):
with tarfile.open(self.artifact(0, kind), "r:gz") as archive:
for member in archive:
self.assertFalse(Path(member.name).name.startswith("._"))
self.assertTrue(member.isfile() or member.isdir())
if __name__ == "__main__":
unittest.main(verbosity=2)
@@ -0,0 +1,260 @@
#!/usr/bin/env python3
import hashlib
import importlib.machinery
import importlib.util
import io
import json
import os
import subprocess
import sys
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"
BUILDER_PATH = SCRIPT_DIR / "build-n8n-private-extension-artifact.mjs"
PATCH_ID = "n8n-nodes-ndc-release-20260716-003"
EXPECTED_NODES = [
"dist/nodes/NdcDataProductPublish/NdcDataProductPublish.node.js",
"dist/nodes/NdcDataProductRead/NdcDataProductRead.node.js",
"dist/nodes/NdcFoundryBinding/NdcFoundryBinding.node.js",
]
EXPECTED_NODE_TYPES = [
"n8n-nodes-ndc.ndcDataProductPublish",
"n8n-nodes-ndc.ndcDataProductRead",
"n8n-nodes-ndc.ndcFoundryBinding",
]
EXPECTED_CREDENTIALS = [
"dist/credentials/NdcDataProductWriterApi.credentials.js",
"dist/credentials/NdcDataProductReaderApi.credentials.js",
"dist/credentials/NdcFoundryBindingApi.credentials.js",
]
EXPECTED_CREDENTIAL_TYPES = [
"ndcDataProductWriterApi",
"ndcDataProductReaderApi",
"ndcFoundryBindingApi",
]
def load_runner():
loader = importlib.machinery.SourceFileLoader("nodedc_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()
class N8nPrivateExtensionPolicyTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.temporary = tempfile.TemporaryDirectory(prefix="nodedc-extension-policy-")
cls.root = Path(cls.temporary.name)
cls.artifacts = []
cls.results = []
for index in range(2):
output_dir = cls.root / f"build-{index}"
output_dir.mkdir()
environment = os.environ.copy()
environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(output_dir)
command = ["node", str(BUILDER_PATH)]
if index == 0:
command.append(PATCH_ID)
result = subprocess.run(
command,
cwd=PLATFORM_ROOT,
env=environment,
check=True,
capture_output=True,
text=True,
)
metadata = json.loads(result.stdout)
artifact = Path(metadata["artifact"])
cls.results.append(metadata)
cls.artifacts.append(artifact)
@classmethod
def tearDownClass(cls):
cls.temporary.cleanup()
def test_runner_source_compiles(self):
compile(RUNNER_PATH.read_text(encoding="utf-8"), str(RUNNER_PATH), "exec")
def test_builder_is_byte_reproducible(self):
first = self.artifacts[0].read_bytes()
second = self.artifacts[1].read_bytes()
self.assertEqual(first, second)
self.assertEqual(self.results[0]["artifactSha256"], self.results[1]["artifactSha256"])
self.assertEqual(self.results[0]["patchId"], PATCH_ID)
self.assertEqual(self.results[0]["nodeTypes"], EXPECTED_NODE_TYPES)
self.assertEqual(self.results[0]["credentialTypes"], EXPECTED_CREDENTIAL_TYPES)
self.assertEqual(first[4:8], b"\0\0\0\0", "gzip mtime must be zero")
self.assertEqual(first[3] & 0x08, 0, "gzip header must not carry a host filename")
inner = self._inner_package_bytes()
self.assertEqual(inner[4:8], b"\0\0\0\0", "inner package gzip mtime must be zero")
self.assertEqual(inner[3] & 0x08, 0, "inner package gzip header must not carry a host filename")
def test_positive_release_passes_runner_policy(self):
with tempfile.TemporaryDirectory(prefix="nodedc-extension-load-") as directory:
manifest, entries, _payload = RUNNER.load_artifact(self.artifacts[0], Path(directory))
self.assertEqual(manifest["component"], "n8n-private-extension")
self.assertEqual(len(entries), 1)
self.assertRegex(entries[0], r"^releases/n8n-nodes-ndc/0\.1\.2-[a-f0-9]{16}$")
with tarfile.open(self.artifacts[0], "r:gz") as archive:
names = archive.getnames()
self.assertFalse(any(Path(name).name.startswith("._") for name in names))
self.assertFalse(any(".." in Path(name).parts for name in names))
def test_release_has_exactly_three_non_tool_runtime_types(self):
package = self._inner_package_bytes()
with tarfile.open(fileobj=io.BytesIO(package), mode="r:gz") as archive:
package_json_member = archive.getmember("package/package.json")
package_json = json.loads(archive.extractfile(package_json_member).read())
self.assertEqual(package_json["version"], "0.1.2")
self.assertEqual(package_json["n8n"]["nodes"], EXPECTED_NODES)
self.assertEqual(package_json["n8n"]["credentials"], EXPECTED_CREDENTIALS)
for node_path in EXPECTED_NODES:
source = archive.extractfile(archive.getmember(f"package/{node_path}")).read().decode("utf-8")
self.assertNotRegex(source, r"\busableAsTool\b")
def test_inner_package_stream_and_members_are_canonical(self):
package = self._inner_package_bytes()
self.assertEqual(package[4:8], b"\0\0\0\0")
self.assertEqual(package[3] & 0x08, 0)
with tarfile.open(fileobj=io.BytesIO(package), mode="r:gz") as archive:
members = archive.getmembers()
self.assertEqual([member.name for member in members], sorted(member.name for member in members))
for member in members:
self.assertEqual((member.uid, member.gid, member.uname, member.gname), (0, 0, "root", "root"))
self.assertEqual(member.mtime, 0)
self.assertEqual(member.mode, 0o755 if member.isdir() else 0o644)
def test_v2_first_activation_uses_verified_inactive_baseline(self):
with tempfile.TemporaryDirectory(prefix="nodedc-extension-v2-") as directory:
work = Path(directory)
RUNNER.safe_extract(self.artifacts[0], work)
entries = RUNNER.parse_files_list(work / "files.txt")
release_dir = work / "payload" / entries[0]
release_path = release_dir / "release.json"
rollback_path = release_dir / "rollback.json"
release = json.loads(release_path.read_text(encoding="utf-8"))
rollback = json.loads(rollback_path.read_text(encoding="utf-8"))
expected_policy = {
"allowed": ["previous_verified_immutable_release", "verified_inactive"],
"firstActivation": "verified_inactive",
"requiresPreActivationVerification": True,
}
self.assertEqual(release["schemaVersion"], "nodedc.n8n-private-extension-release/v2")
self.assertEqual(release["package"]["version"], "0.1.2")
self.assertEqual(release["activation"]["rollbackBaselinePolicy"], expected_policy)
self.assertEqual(rollback["schemaVersion"], "nodedc.n8n-private-extension-rollback/v2")
self.assertEqual(rollback["baselinePolicy"], expected_policy)
rollback["baselinePolicy"]["requiresPreActivationVerification"] = False
rollback_path.write_text(json.dumps(rollback), encoding="utf-8")
with self.assertRaisesRegex(RUNNER.DeployError, "rollback manifest mismatch"):
RUNNER.validate_n8n_private_extension_release(work / "payload", entries)
def test_outer_appledouble_and_traversal_are_rejected(self):
with self.assertRaisesRegex(RUNNER.DeployError, "unexpected tar member"):
RUNNER.validate_tar_member(tarfile.TarInfo("._manifest.env"))
with self.assertRaisesRegex(RUNNER.DeployError, "path escape rejected"):
RUNNER.validate_tar_member(tarfile.TarInfo("payload/../escape"))
def test_extra_custom_node_is_rejected(self):
def mutate(package_json):
package_json["n8n"]["nodes"].append("dist/nodes/Extra/Extra.node.js")
package_path, release = self._tampered_package(
mutate,
{"package/dist/nodes/Extra/Extra.node.js": b"module.exports = {};\n"},
)
with self.assertRaisesRegex(RUNNER.DeployError, "n8n.nodes registration mismatch"):
RUNNER.validate_n8n_package_tarball(package_path, release)
def test_lifecycle_script_is_rejected(self):
def mutate(package_json):
package_json.setdefault("scripts", {})["install"] = "echo forbidden"
package_path, release = self._tampered_package(mutate)
with self.assertRaisesRegex(RUNNER.DeployError, "lifecycle script is forbidden"):
RUNNER.validate_n8n_package_tarball(package_path, release)
def test_inner_appledouble_is_rejected(self):
package_path, release = self._tampered_package(
lambda _package_json: None,
{"package/dist/._NdcDataProductPublish.node.js": b"forbidden\n"},
)
with self.assertRaisesRegex(RUNNER.DeployError, "hidden member rejected"):
RUNNER.validate_n8n_package_tarball(package_path, release)
def test_tool_variant_marker_is_rejected_by_runner(self):
target = EXPECTED_NODES[0]
package_path, release = self._tampered_package(
lambda _package_json: None,
file_mutations={target: lambda source: source + b"\n// usableAsTool: true\n"},
)
with self.assertRaisesRegex(RUNNER.DeployError, "would generate a tool variant"):
RUNNER.validate_n8n_package_tarball(package_path, release)
def _tampered_package(self, mutate, extra_files=None, file_mutations=None):
package_bytes = self._inner_package_bytes()
members = []
with tarfile.open(fileobj=io.BytesIO(package_bytes), mode="r:gz") as archive:
for member in archive:
if not member.isfile():
continue
source = archive.extractfile(member)
members.append((member.name, source.read()))
rewritten = []
for name, content in members:
if name == "package/package.json":
package_json = json.loads(content)
mutate(package_json)
content = (json.dumps(package_json, sort_keys=True) + "\n").encode("utf-8")
package_rel = name.removeprefix("package/")
if package_rel in (file_mutations or {}):
content = file_mutations[package_rel](content)
rewritten.append((name, content))
rewritten.extend((extra_files or {}).items())
target = self.root / f"tampered-{len(list(self.root.glob('tampered-*.tgz')))}.tgz"
with tarfile.open(target, "w:gz", format=tarfile.PAX_FORMAT) as archive:
for name, content in rewritten:
member = tarfile.TarInfo(name)
member.mode = 0o644
member.mtime = 0
member.size = len(content)
archive.addfile(member, io.BytesIO(content))
with tarfile.open(target, "r:gz") as archive:
package_member = next(member for member in archive if member.name == "package/package.json")
version = json.loads(archive.extractfile(package_member).read())["version"]
release = {
"package": {
"version": version,
"sha256": hashlib.sha256(target.read_bytes()).hexdigest(),
"bytes": target.stat().st_size,
}
}
return target, release
def _inner_package_bytes(self):
with tarfile.open(self.artifacts[0], "r:gz") as archive:
member = next(item for item in archive if item.name.endswith("/package.tgz"))
source = archive.extractfile(member)
return source.read()
if __name__ == "__main__":
unittest.main(verbosity=2)