345 lines
15 KiB
JavaScript
345 lines
15 KiB
JavaScript
#!/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;
|
|
}
|