NODEDC_PLATFORM/infra/deploy-runner/build-n8n-private-extension...

287 lines
12 KiB
JavaScript

#!/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;
}