NODEDC_PLATFORM/infra/deploy-runner/build-engine-n8n-private-ex...

488 lines
21 KiB
JavaScript

#!/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 { 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-geozone-replace-20260720-017.tgz"),
);
const predecessorArtifact = resolve(
process.env.NODEDC_N8N_EXTENSION_PREDECESSOR_ARTIFACT
|| join(artifactRoot, "nodedc-engine-n8n-private-extension-20260718-008.tgz"),
);
const stageArtifactSha256 = "0c680d2436917eb6f2416c5c855f2e01dd59ed50bedb1e36456a7405f3f595af";
const predecessorArtifactSha256 = "5ef46404dfd7753b4ffd14c5e9b9666f96d2c9875c8fe470714cdea1c6decdce";
const releaseId = "0.1.5-3c8ae53f010d7c88";
const packageVersion = "0.1.5";
const packageSha256 = "3c8ae53f010d7c88b6e90cb8fc4929e7d0880089ad8c3a9edb876ce034786743";
const n8nVersion = "2.3.2";
const baseImage = "docker.n8n.io/n8nio/n8n:2.3.2";
const architecture = "amd64";
const generatedAt = "2026-07-21T10:30:00.000Z";
const previouslyIssuedTransitionIds = new Set([
"20260715-002",
"20260716-003",
"20260717-004",
"20260717-005",
"20260718-006",
"20260718-007",
"20260718-008",
]);
const transitionId = readTransitionId(process.argv.slice(2), process.env.NODEDC_N8N_TRANSITION_ID);
const activationId = `engine-n8n-private-extension-${transitionId}`;
const rollbackId = `engine-n8n-private-extension-rollback-${transitionId}`;
const transitionRoot = "nodedc-source/services/n8n/private-extensions";
const descriptorRel = `${transitionRoot}/ndc-activation.json`;
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 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",
"ndcProviderRotatingAccessApi",
];
const predecessorCredentialTypes = [...expectedCredentialTypes];
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"],
["dist/credentials/NdcProviderRotatingAccessApi.credentials.js", "NdcProviderRotatingAccessApi"],
];
await mkdir(artifactRoot, { recursive: true });
await assertArtifactTargetFresh(join(artifactRoot, `nodedc-${activationId}.tgz`));
await assertArtifactTargetFresh(join(artifactRoot, `nodedc-${rollbackId}.tgz`));
assertSha(await readFile(stageArtifact), stageArtifactSha256, "staging artifact");
assertSha(await readFile(predecessorArtifact), predecessorArtifactSha256, "predecessor 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");
assertPublishNodeV2(privateNodes[0]);
const predecessorDescriptor = JSON.parse(predecessorArtifactFile(descriptorRel));
const predecessorNodes = JSON.parse(predecessorArtifactFile(nodesCatalogRel));
const predecessorCredentials = JSON.parse(predecessorArtifactFile(credentialsCatalogRel));
const predecessorMeta = JSON.parse(predecessorArtifactFile(metaRel));
assertPredecessorCatalogs(
predecessorDescriptor,
predecessorNodes,
predecessorCredentials,
predecessorMeta,
);
const baselineNodes = predecessorNodes.filter(
(item) => !String(item?.name || "").startsWith("n8n-nodes-ndc."),
);
const baselineCredentials = predecessorCredentials.filter(
(item) => !expectedCredentialTypes.includes(String(item?.name || "")),
);
assertBaselineCatalogs(baselineNodes, baselineCredentials);
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 target = {
releaseId,
packageVersion,
packageSha256,
};
const predecessor = {
releaseId: predecessorDescriptor.releaseId,
packageVersion: predecessorDescriptor.packageVersion,
packageSha256: predecessorDescriptor.packageSha256,
};
const activationDescriptor = descriptor(
target,
predecessor.releaseId,
predecessor.releaseId,
expectedCredentialTypes,
);
const rollbackDescriptor = descriptor(
predecessor,
releaseId,
releaseId,
predecessorCredentialTypes,
);
const override = composeOverride(target);
const rollbackOverride = composeOverride(predecessor);
const generatedRoot = join(work, "generated-engine-payload");
await writeJson(join(generatedRoot, nodesCatalogRel), activeNodes);
await writeJson(join(generatedRoot, credentialsCatalogRel), activeCredentials);
await writeJson(join(generatedRoot, metaRel), activeMeta);
await writeJson(join(generatedRoot, descriptorRel), activationDescriptor);
await writeFile(join(generatedRoot, overrideRel), override, "utf8");
await mkdir(join(generatedRoot, iconRoot), { recursive: true });
await cp(join(packageRoot, "dist/icons/ndc.svg"), join(generatedRoot, iconRel), { force: false });
await cp(join(packageRoot, "dist/icons/ndc.dark.svg"), join(generatedRoot, darkIconRel), { force: false });
const activationEntries = [
descriptorRel,
overrideRel,
nodesCatalogRel,
credentialsCatalogRel,
metaRel,
iconRel,
darkIconRel,
];
const activationArtifact = await buildArtifact(work, activationId, activationEntries, async (payload) => {
for (const rel of activationEntries) {
await cp(join(generatedRoot, rel), join(payload, rel), { recursive: true, force: false });
}
});
const rollbackEntries = [...activationEntries];
const rollbackArtifact = await buildArtifact(work, rollbackId, rollbackEntries, async (payload) => {
await writeJson(join(payload, descriptorRel), rollbackDescriptor);
await writeFile(join(payload, overrideRel), rollbackOverride, "utf8");
await writeJson(join(payload, nodesCatalogRel), predecessorNodes);
await writeJson(join(payload, credentialsCatalogRel), predecessorCredentials);
await writeJson(join(payload, metaRel), predecessorMeta);
await mkdir(join(payload, iconRoot), { recursive: true });
await cp(join(engineRoot, iconRel), join(payload, iconRel), { force: false });
await cp(join(engineRoot, darkIconRel), join(payload, darkIconRel), { force: false });
});
console.log(JSON.stringify({
ok: true,
transitionId,
releaseId,
packageSha256,
nodeTypes: expectedNodeTypes,
credentialTypes: expectedCredentialTypes,
activation: activationArtifact,
rollback: rollbackArtifact,
}, null, 2));
} finally {
await rm(work, { recursive: true, force: true });
}
function descriptor(target, expectedCurrent, rollbackBaseline, credentialTypes) {
return {
schemaVersion: "nodedc.engine-n8n-private-extension-transition/v1",
action: "activate",
releaseId: target.releaseId,
packageVersion: target.packageVersion,
packageSha256: target.packageSha256,
n8nVersion,
baseImage,
baseImageArchitecture: architecture,
baseImageIdentityPolicy: "running-container-and-local-tag-must-match",
sealedReleaseRelativePath: `n8n-private-extensions/releases/n8n-nodes-ndc/${target.releaseId}/package`,
composeOverride: overrideRel,
runtimePackagePath,
topologyServices: ["n8n"],
expectedCurrent,
expectedNodeTypes,
expectedCredentialTypes: credentialTypes,
rollbackBaseline,
};
}
function readTransitionId(args, environmentValue) {
if (args.length > 1) throw new Error("transition_id_argument_count_invalid");
const argumentValue = args[0] || "";
const envValue = String(environmentValue || "").trim();
if (argumentValue && envValue && argumentValue !== envValue) {
throw new Error("transition_id_sources_conflict");
}
const value = argumentValue || envValue;
if (!value) throw new Error("transition_id_required");
const match = /^(\d{4})(\d{2})(\d{2})-([0-9]{3})$/.exec(value);
if (!match || match[4] === "000") throw new Error("transition_id_invalid");
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
const parsed = new Date(Date.UTC(year, month - 1, day));
if (parsed.getUTCFullYear() !== year
|| parsed.getUTCMonth() !== month - 1
|| parsed.getUTCDate() !== day) {
throw new Error("transition_id_invalid");
}
if (previouslyIssuedTransitionIds.has(value)) {
throw new Error("transition_id_already_issued");
}
return value;
}
async function assertArtifactTargetFresh(path) {
try {
await lstat(path);
} catch (error) {
if (error?.code === "ENOENT") return;
throw error;
}
throw new Error("transition_artifact_already_exists");
}
function composeOverride(target) {
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)});";
const sealedReleaseRelativePath = `n8n-private-extensions/releases/n8n-nodes-ndc/${target.releaseId}/package`;
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: ${target.releaseId}`,
` nodedc.n8n-private-extension.package-sha256: ${target.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 assertPredecessorCatalogs(descriptorValue, nodes, credentials, meta) {
if (descriptorValue?.action !== "activate"
|| descriptorValue?.releaseId !== "0.1.4-59dc9f7882721d6a"
|| descriptorValue?.packageVersion !== "0.1.4"
|| descriptorValue?.packageSha256 !== "59dc9f7882721d6a5e2ae84ffa6aa8cd9bc5432f5904d24e70e363573ed2757f") {
throw new Error("predecessor_descriptor_mismatch");
}
if (!Array.isArray(nodes) || nodes.length !== 437
|| !Array.isArray(credentials) || credentials.length !== 389
|| meta?.n8nVersion !== n8nVersion
|| meta?.source !== "n8n-core+n8n-nodes-ndc@0.1.4"
|| meta?.nodeCount !== 437
|| meta?.credentialCount !== 389) {
throw new Error("predecessor_catalog_mismatch");
}
assertExact(
nodes.filter((item) => String(item?.name || "").startsWith("n8n-nodes-ndc.")).map((item) => item.name),
expectedNodeTypes,
"predecessor node types",
);
assertExact(
credentials.filter((item) => predecessorCredentialTypes.includes(String(item?.name || ""))).map((item) => item.name),
predecessorCredentialTypes,
"predecessor credential types",
);
}
function assertPublishNodeV2(node) {
if (JSON.stringify(node?.version) !== JSON.stringify([1, 2])) {
throw new Error("publish_node_versions_mismatch");
}
const property = node?.properties?.find((item) => item?.name === "publishMode");
const values = property?.options?.map((item) => item?.value);
if (JSON.stringify(values) !== JSON.stringify(["upsert", "replace"])
|| !property?.displayOptions?.show?.["@version"]?.includes(2)) {
throw new Error("publish_node_replace_contract_missing");
}
}
function assertBaselineCatalogs(nodes, credentials) {
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");
}
}
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 predecessorArtifactFile(rel) {
const script = [
"import pathlib,sys,tarfile",
"archive=pathlib.Path(sys.argv[1])",
"member='payload/'+sys.argv[2]",
"with tarfile.open(archive,'r:gz') as source:",
" extracted=source.extractfile(member)",
" if extracted is None: raise SystemExit('predecessor member missing')",
" sys.stdout.buffer.write(extracted.read())",
].join("\n");
return run("python3", ["-c", script, predecessorArtifact, rel]).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;
}