feat(deploy): register managed platform runtime components
This commit is contained in:
@@ -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;
|
||||
}
|
||||
+1921
-25
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)
|
||||
Reference in New Issue
Block a user