feat(n8n): add rotating provider access credential
This commit is contained in:
parent
3c5d8f6cef
commit
0611a88971
|
|
@ -13,19 +13,30 @@ const engineRoot = resolve(platformRoot, "../NODEDC_ENGINE_INFRA");
|
||||||
const artifactRoot = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || join(here, "../deploy-artifacts"));
|
const artifactRoot = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || join(here, "../deploy-artifacts"));
|
||||||
const stageArtifact = resolve(
|
const stageArtifact = resolve(
|
||||||
process.env.NODEDC_N8N_EXTENSION_STAGE_ARTIFACT
|
process.env.NODEDC_N8N_EXTENSION_STAGE_ARTIFACT
|
||||||
|| join(artifactRoot, "nodedc-n8n-private-extension-n8n-nodes-ndc-history-read-20260717-004.tgz"),
|
|| join(artifactRoot, "nodedc-n8n-private-extension-n8n-nodes-ndc-provider-rotating-access-20260718-006.tgz"),
|
||||||
|
);
|
||||||
|
const predecessorArtifact = resolve(
|
||||||
|
process.env.NODEDC_N8N_EXTENSION_PREDECESSOR_ARTIFACT
|
||||||
|
|| join(artifactRoot, "nodedc-engine-n8n-private-extension-20260717-005.tgz"),
|
||||||
);
|
);
|
||||||
|
|
||||||
const stageArtifactSha256 = "a78f8250704e4893eab727cdb862b69ce45a84310fbb73ecd3dfd968b72d12ee";
|
const stageArtifactSha256 = "2699e53c482f2d2bec6d13709a7e9b9a0bb3791ad2018c142e9bc4e7bf371e4a";
|
||||||
const releaseId = "0.1.3-3354149b5245e39a";
|
const predecessorArtifactSha256 = "f0f687ddcc1bc006db56c75e73c8213e99298c31c2d881cde078e5ddc9600172";
|
||||||
const packageVersion = "0.1.3";
|
const releaseId = "0.1.4-59dc9f7882721d6a";
|
||||||
const packageSha256 = "3354149b5245e39a10f6f03a4b43796602d7e57ea1f91dcc6cb6774ead97501d";
|
const packageVersion = "0.1.4";
|
||||||
|
const packageSha256 = "59dc9f7882721d6a5e2ae84ffa6aa8cd9bc5432f5904d24e70e363573ed2757f";
|
||||||
const n8nVersion = "2.3.2";
|
const n8nVersion = "2.3.2";
|
||||||
const baseImage = "docker.n8n.io/n8nio/n8n:2.3.2";
|
const baseImage = "docker.n8n.io/n8nio/n8n:2.3.2";
|
||||||
const architecture = "amd64";
|
const architecture = "amd64";
|
||||||
const generatedAt = "2026-07-17T21:00:00.000Z";
|
const generatedAt = "2026-07-18T21:00:00.000Z";
|
||||||
const predecessorGitRevision = "96ad46e3c62943f818233481957c62e5088ae35c";
|
const previouslyIssuedTransitionIds = new Set([
|
||||||
const previouslyIssuedTransitionIds = new Set(["20260715-002", "20260716-003", "20260717-004"]);
|
"20260715-002",
|
||||||
|
"20260716-003",
|
||||||
|
"20260717-004",
|
||||||
|
"20260717-005",
|
||||||
|
"20260718-006",
|
||||||
|
"20260718-007",
|
||||||
|
]);
|
||||||
const transitionId = readTransitionId(process.argv.slice(2), process.env.NODEDC_N8N_TRANSITION_ID);
|
const transitionId = readTransitionId(process.argv.slice(2), process.env.NODEDC_N8N_TRANSITION_ID);
|
||||||
const activationId = `engine-n8n-private-extension-${transitionId}`;
|
const activationId = `engine-n8n-private-extension-${transitionId}`;
|
||||||
const rollbackId = `engine-n8n-private-extension-rollback-${transitionId}`;
|
const rollbackId = `engine-n8n-private-extension-rollback-${transitionId}`;
|
||||||
|
|
@ -51,7 +62,9 @@ const expectedCredentialTypes = [
|
||||||
"ndcDataProductWriterApi",
|
"ndcDataProductWriterApi",
|
||||||
"ndcDataProductReaderApi",
|
"ndcDataProductReaderApi",
|
||||||
"ndcFoundryBindingApi",
|
"ndcFoundryBindingApi",
|
||||||
|
"ndcProviderRotatingAccessApi",
|
||||||
];
|
];
|
||||||
|
const predecessorCredentialTypes = expectedCredentialTypes.slice(0, 3);
|
||||||
const nodeModules = [
|
const nodeModules = [
|
||||||
["dist/nodes/NdcDataProductPublish/NdcDataProductPublish.node.js", "NdcDataProductPublish"],
|
["dist/nodes/NdcDataProductPublish/NdcDataProductPublish.node.js", "NdcDataProductPublish"],
|
||||||
["dist/nodes/NdcDataProductRead/NdcDataProductRead.node.js", "NdcDataProductRead"],
|
["dist/nodes/NdcDataProductRead/NdcDataProductRead.node.js", "NdcDataProductRead"],
|
||||||
|
|
@ -61,12 +74,14 @@ const credentialModules = [
|
||||||
["dist/credentials/NdcDataProductWriterApi.credentials.js", "NdcDataProductWriterApi"],
|
["dist/credentials/NdcDataProductWriterApi.credentials.js", "NdcDataProductWriterApi"],
|
||||||
["dist/credentials/NdcDataProductReaderApi.credentials.js", "NdcDataProductReaderApi"],
|
["dist/credentials/NdcDataProductReaderApi.credentials.js", "NdcDataProductReaderApi"],
|
||||||
["dist/credentials/NdcFoundryBindingApi.credentials.js", "NdcFoundryBindingApi"],
|
["dist/credentials/NdcFoundryBindingApi.credentials.js", "NdcFoundryBindingApi"],
|
||||||
|
["dist/credentials/NdcProviderRotatingAccessApi.credentials.js", "NdcProviderRotatingAccessApi"],
|
||||||
];
|
];
|
||||||
|
|
||||||
await mkdir(artifactRoot, { recursive: true });
|
await mkdir(artifactRoot, { recursive: true });
|
||||||
await assertArtifactTargetFresh(join(artifactRoot, `nodedc-${activationId}.tgz`));
|
await assertArtifactTargetFresh(join(artifactRoot, `nodedc-${activationId}.tgz`));
|
||||||
await assertArtifactTargetFresh(join(artifactRoot, `nodedc-${rollbackId}.tgz`));
|
await assertArtifactTargetFresh(join(artifactRoot, `nodedc-${rollbackId}.tgz`));
|
||||||
assertSha(await readFile(stageArtifact), stageArtifactSha256, "staging artifact");
|
assertSha(await readFile(stageArtifact), stageArtifactSha256, "staging artifact");
|
||||||
|
assertSha(await readFile(predecessorArtifact), predecessorArtifactSha256, "predecessor artifact");
|
||||||
assertEngineBaseline(await readFile(join(engineRoot, "docker-compose.yml"), "utf8"));
|
assertEngineBaseline(await readFile(join(engineRoot, "docker-compose.yml"), "utf8"));
|
||||||
|
|
||||||
const work = await mkdtemp(join(tmpdir(), "nodedc-engine-n8n-sealed-"));
|
const work = await mkdtemp(join(tmpdir(), "nodedc-engine-n8n-sealed-"));
|
||||||
|
|
@ -111,10 +126,10 @@ try {
|
||||||
assertExact(privateNodes.map((item) => item.name), expectedNodeTypes, "node types");
|
assertExact(privateNodes.map((item) => item.name), expectedNodeTypes, "node types");
|
||||||
assertExact(privateCredentials.map((item) => item.name), expectedCredentialTypes, "credential types");
|
assertExact(privateCredentials.map((item) => item.name), expectedCredentialTypes, "credential types");
|
||||||
|
|
||||||
const predecessorDescriptor = JSON.parse(gitFile(descriptorRel));
|
const predecessorDescriptor = JSON.parse(predecessorArtifactFile(descriptorRel));
|
||||||
const predecessorNodes = JSON.parse(gitFile(nodesCatalogRel));
|
const predecessorNodes = JSON.parse(predecessorArtifactFile(nodesCatalogRel));
|
||||||
const predecessorCredentials = JSON.parse(gitFile(credentialsCatalogRel));
|
const predecessorCredentials = JSON.parse(predecessorArtifactFile(credentialsCatalogRel));
|
||||||
const predecessorMeta = JSON.parse(gitFile(metaRel));
|
const predecessorMeta = JSON.parse(predecessorArtifactFile(metaRel));
|
||||||
assertPredecessorCatalogs(
|
assertPredecessorCatalogs(
|
||||||
predecessorDescriptor,
|
predecessorDescriptor,
|
||||||
predecessorNodes,
|
predecessorNodes,
|
||||||
|
|
@ -148,8 +163,18 @@ try {
|
||||||
packageVersion: predecessorDescriptor.packageVersion,
|
packageVersion: predecessorDescriptor.packageVersion,
|
||||||
packageSha256: predecessorDescriptor.packageSha256,
|
packageSha256: predecessorDescriptor.packageSha256,
|
||||||
};
|
};
|
||||||
const activationDescriptor = descriptor(target, predecessor.releaseId, predecessor.releaseId);
|
const activationDescriptor = descriptor(
|
||||||
const rollbackDescriptor = descriptor(predecessor, releaseId, releaseId);
|
target,
|
||||||
|
predecessor.releaseId,
|
||||||
|
predecessor.releaseId,
|
||||||
|
expectedCredentialTypes,
|
||||||
|
);
|
||||||
|
const rollbackDescriptor = descriptor(
|
||||||
|
predecessor,
|
||||||
|
releaseId,
|
||||||
|
releaseId,
|
||||||
|
predecessorCredentialTypes,
|
||||||
|
);
|
||||||
const override = composeOverride(target);
|
const override = composeOverride(target);
|
||||||
const rollbackOverride = composeOverride(predecessor);
|
const rollbackOverride = composeOverride(predecessor);
|
||||||
|
|
||||||
|
|
@ -204,7 +229,7 @@ try {
|
||||||
await rm(work, { recursive: true, force: true });
|
await rm(work, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
function descriptor(target, expectedCurrent, rollbackBaseline) {
|
function descriptor(target, expectedCurrent, rollbackBaseline, credentialTypes) {
|
||||||
return {
|
return {
|
||||||
schemaVersion: "nodedc.engine-n8n-private-extension-transition/v1",
|
schemaVersion: "nodedc.engine-n8n-private-extension-transition/v1",
|
||||||
action: "activate",
|
action: "activate",
|
||||||
|
|
@ -221,7 +246,7 @@ function descriptor(target, expectedCurrent, rollbackBaseline) {
|
||||||
topologyServices: ["n8n"],
|
topologyServices: ["n8n"],
|
||||||
expectedCurrent,
|
expectedCurrent,
|
||||||
expectedNodeTypes,
|
expectedNodeTypes,
|
||||||
expectedCredentialTypes,
|
expectedCredentialTypes: credentialTypes,
|
||||||
rollbackBaseline,
|
rollbackBaseline,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -327,15 +352,15 @@ function assertPackage(value) {
|
||||||
|
|
||||||
function assertPredecessorCatalogs(descriptorValue, nodes, credentials, meta) {
|
function assertPredecessorCatalogs(descriptorValue, nodes, credentials, meta) {
|
||||||
if (descriptorValue?.action !== "activate"
|
if (descriptorValue?.action !== "activate"
|
||||||
|| descriptorValue?.releaseId !== "0.1.2-05e4b38b14b4a019"
|
|| descriptorValue?.releaseId !== "0.1.3-3354149b5245e39a"
|
||||||
|| descriptorValue?.packageVersion !== "0.1.2"
|
|| descriptorValue?.packageVersion !== "0.1.3"
|
||||||
|| descriptorValue?.packageSha256 !== "05e4b38b14b4a019ce1f6eee27b9e320094cb3560903bd68074966b3a1267af5") {
|
|| descriptorValue?.packageSha256 !== "3354149b5245e39a10f6f03a4b43796602d7e57ea1f91dcc6cb6774ead97501d") {
|
||||||
throw new Error("predecessor_descriptor_mismatch");
|
throw new Error("predecessor_descriptor_mismatch");
|
||||||
}
|
}
|
||||||
if (!Array.isArray(nodes) || nodes.length !== 437
|
if (!Array.isArray(nodes) || nodes.length !== 437
|
||||||
|| !Array.isArray(credentials) || credentials.length !== 388
|
|| !Array.isArray(credentials) || credentials.length !== 388
|
||||||
|| meta?.n8nVersion !== n8nVersion
|
|| meta?.n8nVersion !== n8nVersion
|
||||||
|| meta?.source !== "n8n-core+n8n-nodes-ndc@0.1.2"
|
|| meta?.source !== "n8n-core+n8n-nodes-ndc@0.1.3"
|
||||||
|| meta?.nodeCount !== 437
|
|| meta?.nodeCount !== 437
|
||||||
|| meta?.credentialCount !== 388) {
|
|| meta?.credentialCount !== 388) {
|
||||||
throw new Error("predecessor_catalog_mismatch");
|
throw new Error("predecessor_catalog_mismatch");
|
||||||
|
|
@ -346,8 +371,8 @@ function assertPredecessorCatalogs(descriptorValue, nodes, credentials, meta) {
|
||||||
"predecessor node types",
|
"predecessor node types",
|
||||||
);
|
);
|
||||||
assertExact(
|
assertExact(
|
||||||
credentials.filter((item) => expectedCredentialTypes.includes(String(item?.name || ""))).map((item) => item.name),
|
credentials.filter((item) => predecessorCredentialTypes.includes(String(item?.name || ""))).map((item) => item.name),
|
||||||
expectedCredentialTypes,
|
predecessorCredentialTypes,
|
||||||
"predecessor credential types",
|
"predecessor credential types",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -381,8 +406,17 @@ function assertSha(bytes, expected, label) {
|
||||||
if (actual !== expected) throw new Error(`${label.replaceAll(" ", "_")}_sha256_mismatch:${actual}`);
|
if (actual !== expected) throw new Error(`${label.replaceAll(" ", "_")}_sha256_mismatch:${actual}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function gitFile(rel) {
|
function predecessorArtifactFile(rel) {
|
||||||
return run("git", ["show", `${predecessorGitRevision}:${rel}`], engineRoot).stdout;
|
const script = [
|
||||||
|
"import pathlib,sys,tarfile",
|
||||||
|
"archive=pathlib.Path(sys.argv[1])",
|
||||||
|
"member='payload/'+sys.argv[2]",
|
||||||
|
"with tarfile.open(archive,'r:gz') as source:",
|
||||||
|
" extracted=source.extractfile(member)",
|
||||||
|
" if extracted is None: raise SystemExit('predecessor member missing')",
|
||||||
|
" sys.stdout.buffer.write(extracted.read())",
|
||||||
|
].join("\n");
|
||||||
|
return run("python3", ["-c", script, predecessorArtifact, rel]).stdout;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function writeJson(path, value) {
|
async function writeJson(path, value) {
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,8 @@ const platformRoot = resolve(scriptDir, "../..");
|
||||||
const packageRoot = resolve(platformRoot, "packages/n8n-nodes-ndc");
|
const packageRoot = resolve(platformRoot, "packages/n8n-nodes-ndc");
|
||||||
const artifactDir = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts"));
|
const artifactDir = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts"));
|
||||||
const requireModule = createRequire(import.meta.url);
|
const requireModule = createRequire(import.meta.url);
|
||||||
const expectedPackageVersion = "0.1.3";
|
const expectedPackageVersion = "0.1.4";
|
||||||
const [patchId = "n8n-nodes-ndc-history-read-20260717-004", ...extra] = process.argv.slice(2);
|
const [patchId = "n8n-nodes-ndc-provider-rotating-access-20260718-006", ...extra] = process.argv.slice(2);
|
||||||
const expectedRuntimeNodes = [
|
const expectedRuntimeNodes = [
|
||||||
{
|
{
|
||||||
file: "dist/nodes/NdcDataProductPublish/NdcDataProductPublish.node.js",
|
file: "dist/nodes/NdcDataProductPublish/NdcDataProductPublish.node.js",
|
||||||
|
|
@ -37,11 +37,13 @@ const expectedN8nCredentials = [
|
||||||
"dist/credentials/NdcDataProductWriterApi.credentials.js",
|
"dist/credentials/NdcDataProductWriterApi.credentials.js",
|
||||||
"dist/credentials/NdcDataProductReaderApi.credentials.js",
|
"dist/credentials/NdcDataProductReaderApi.credentials.js",
|
||||||
"dist/credentials/NdcFoundryBindingApi.credentials.js",
|
"dist/credentials/NdcFoundryBindingApi.credentials.js",
|
||||||
|
"dist/credentials/NdcProviderRotatingAccessApi.credentials.js",
|
||||||
];
|
];
|
||||||
const expectedCredentialTypes = [
|
const expectedCredentialTypes = [
|
||||||
"ndcDataProductWriterApi",
|
"ndcDataProductWriterApi",
|
||||||
"ndcDataProductReaderApi",
|
"ndcDataProductReaderApi",
|
||||||
"ndcFoundryBindingApi",
|
"ndcFoundryBindingApi",
|
||||||
|
"ndcProviderRotatingAccessApi",
|
||||||
];
|
];
|
||||||
|
|
||||||
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
|
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
|
||||||
|
|
|
||||||
|
|
@ -208,6 +208,11 @@ ENGINE_N8N_RELEASE_CATALOG_JSON_SHA256 = {
|
||||||
"credentials": "a26824ffc0e15db857c792153de3417febc0dbba4880380d6c8cd544b3c80f4d",
|
"credentials": "a26824ffc0e15db857c792153de3417febc0dbba4880380d6c8cd544b3c80f4d",
|
||||||
"meta": "b8aa60c0d919573626fa0694a1e4ae9ede015325a5d312e3e05d5ac293084aa7",
|
"meta": "b8aa60c0d919573626fa0694a1e4ae9ede015325a5d312e3e05d5ac293084aa7",
|
||||||
},
|
},
|
||||||
|
"0.1.4-59dc9f7882721d6a": {
|
||||||
|
"nodes": "b0a5215699a6e691b8cfc505ab457d5632ef6d83adb3537520d0b60760ba16b2",
|
||||||
|
"credentials": "af8ada839070a4c24b9b10981a751c2b2db651dced1392333d7a4ef13de4564e",
|
||||||
|
"meta": "8fadbc594c5d14b1d53ab69b677f00823949410b9a37f4e43ecc321e89d68983",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
ENGINE_N8N_INACTIVE_NODES_CATALOG_JSON_SHA256 = "b70d9d8130d498c55de46a5d0758c844f1242b70803457b2291ab3a9de8056f2"
|
ENGINE_N8N_INACTIVE_NODES_CATALOG_JSON_SHA256 = "b70d9d8130d498c55de46a5d0758c844f1242b70803457b2291ab3a9de8056f2"
|
||||||
ENGINE_N8N_INACTIVE_CREDENTIALS_CATALOG_JSON_SHA256 = "680e9f52aac791efbd38e3bd99bd51ef5cded9756d867897c6c2755850e87b50"
|
ENGINE_N8N_INACTIVE_CREDENTIALS_CATALOG_JSON_SHA256 = "680e9f52aac791efbd38e3bd99bd51ef5cded9756d867897c6c2755850e87b50"
|
||||||
|
|
@ -236,6 +241,7 @@ N8N_PRIVATE_EXTENSION_CREDENTIALS = (
|
||||||
"dist/credentials/NdcDataProductWriterApi.credentials.js",
|
"dist/credentials/NdcDataProductWriterApi.credentials.js",
|
||||||
"dist/credentials/NdcDataProductReaderApi.credentials.js",
|
"dist/credentials/NdcDataProductReaderApi.credentials.js",
|
||||||
"dist/credentials/NdcFoundryBindingApi.credentials.js",
|
"dist/credentials/NdcFoundryBindingApi.credentials.js",
|
||||||
|
"dist/credentials/NdcProviderRotatingAccessApi.credentials.js",
|
||||||
)
|
)
|
||||||
ENGINE_N8N_NODE_TYPES = (
|
ENGINE_N8N_NODE_TYPES = (
|
||||||
"n8n-nodes-ndc.ndcDataProductPublish",
|
"n8n-nodes-ndc.ndcDataProductPublish",
|
||||||
|
|
@ -246,7 +252,13 @@ ENGINE_N8N_CREDENTIAL_TYPES = (
|
||||||
"ndcDataProductWriterApi",
|
"ndcDataProductWriterApi",
|
||||||
"ndcDataProductReaderApi",
|
"ndcDataProductReaderApi",
|
||||||
"ndcFoundryBindingApi",
|
"ndcFoundryBindingApi",
|
||||||
|
"ndcProviderRotatingAccessApi",
|
||||||
)
|
)
|
||||||
|
ENGINE_N8N_CREDENTIAL_TYPES_BY_RELEASE = {
|
||||||
|
"0.1.2-05e4b38b14b4a019": ENGINE_N8N_CREDENTIAL_TYPES[:3],
|
||||||
|
"0.1.3-3354149b5245e39a": ENGINE_N8N_CREDENTIAL_TYPES[:3],
|
||||||
|
"0.1.4-59dc9f7882721d6a": ENGINE_N8N_CREDENTIAL_TYPES,
|
||||||
|
}
|
||||||
ENGINE_CREDENTIAL_SINK_ARTIFACT_ENTRIES = (
|
ENGINE_CREDENTIAL_SINK_ARTIFACT_ENTRIES = (
|
||||||
"nodedc-source/server/credentialPolicies/ndcPrivateNode.js",
|
"nodedc-source/server/credentialPolicies/ndcPrivateNode.js",
|
||||||
"nodedc-source/server/credentialSink",
|
"nodedc-source/server/credentialSink",
|
||||||
|
|
@ -2191,6 +2203,13 @@ def is_engine_n8n_transition(component, entries):
|
||||||
return component == "engine" and entries is not None and ENGINE_N8N_TRANSITION_DESCRIPTOR_REL in entries
|
return component == "engine" and entries is not None and ENGINE_N8N_TRANSITION_DESCRIPTOR_REL in entries
|
||||||
|
|
||||||
|
|
||||||
|
def engine_n8n_credential_types_for_release(release_id):
|
||||||
|
expected = ENGINE_N8N_CREDENTIAL_TYPES_BY_RELEASE.get(release_id)
|
||||||
|
if expected is None:
|
||||||
|
die("Engine n8n credential catalog release is not registered")
|
||||||
|
return expected
|
||||||
|
|
||||||
|
|
||||||
def read_engine_n8n_transition_descriptor(path, label="Engine n8n transition descriptor"):
|
def read_engine_n8n_transition_descriptor(path, label="Engine n8n transition descriptor"):
|
||||||
descriptor = read_strict_json(path, label)
|
descriptor = read_strict_json(path, label)
|
||||||
require_exact_json_keys(
|
require_exact_json_keys(
|
||||||
|
|
@ -2259,7 +2278,9 @@ def read_engine_n8n_transition_descriptor(path, label="Engine n8n transition des
|
||||||
die("Engine n8n activation rollback baseline mismatch")
|
die("Engine n8n activation rollback baseline mismatch")
|
||||||
if descriptor.get("expectedNodeTypes") != list(ENGINE_N8N_NODE_TYPES):
|
if descriptor.get("expectedNodeTypes") != list(ENGINE_N8N_NODE_TYPES):
|
||||||
die("Engine n8n activation node type set mismatch")
|
die("Engine n8n activation node type set mismatch")
|
||||||
if descriptor.get("expectedCredentialTypes") != list(ENGINE_N8N_CREDENTIAL_TYPES):
|
if descriptor.get("expectedCredentialTypes") != list(
|
||||||
|
engine_n8n_credential_types_for_release(release_id)
|
||||||
|
):
|
||||||
die("Engine n8n activation credential type set mismatch")
|
die("Engine n8n activation credential type set mismatch")
|
||||||
else:
|
else:
|
||||||
if descriptor.get("expectedCurrent") != release_id:
|
if descriptor.get("expectedCurrent") != release_id:
|
||||||
|
|
@ -2332,7 +2353,11 @@ def validate_engine_n8n_catalog_payload(payload_dir, descriptor):
|
||||||
if isinstance(item, dict) and str(item.get("name") or "") in ENGINE_N8N_CREDENTIAL_TYPES
|
if isinstance(item, dict) and str(item.get("name") or "") in ENGINE_N8N_CREDENTIAL_TYPES
|
||||||
]
|
]
|
||||||
expected_node_types = list(ENGINE_N8N_NODE_TYPES) if action == "activate" else []
|
expected_node_types = list(ENGINE_N8N_NODE_TYPES) if action == "activate" else []
|
||||||
expected_credential_types = list(ENGINE_N8N_CREDENTIAL_TYPES) if action == "activate" else []
|
expected_credential_types = (
|
||||||
|
list(engine_n8n_credential_types_for_release(descriptor["releaseId"]))
|
||||||
|
if action == "activate"
|
||||||
|
else []
|
||||||
|
)
|
||||||
if [item.get("name") for item in private_nodes] != expected_node_types:
|
if [item.get("name") for item in private_nodes] != expected_node_types:
|
||||||
die("Engine n8n pinned node catalog exact set mismatch")
|
die("Engine n8n pinned node catalog exact set mismatch")
|
||||||
if [item.get("name") for item in private_credentials] != expected_credential_types:
|
if [item.get("name") for item in private_credentials] != expected_credential_types:
|
||||||
|
|
@ -2340,7 +2365,7 @@ def validate_engine_n8n_catalog_payload(payload_dir, descriptor):
|
||||||
if any("usableAsTool" in item for item in private_nodes):
|
if any("usableAsTool" in item for item in private_nodes):
|
||||||
die("Engine n8n pinned node catalog would generate tool variants")
|
die("Engine n8n pinned node catalog would generate tool variants")
|
||||||
if action == "activate":
|
if action == "activate":
|
||||||
if len(nodes) != 437 or len(credentials) != 388:
|
if len(nodes) != 437 or len(credentials) != 385 + len(expected_credential_types):
|
||||||
die("Engine n8n activation catalog count mismatch")
|
die("Engine n8n activation catalog count mismatch")
|
||||||
if any(not str(item.get("displayName") or "").startswith("NDC ") for item in private_nodes):
|
if any(not str(item.get("displayName") or "").startswith("NDC ") for item in private_nodes):
|
||||||
die("Engine n8n private node visible name mismatch")
|
die("Engine n8n private node visible name mismatch")
|
||||||
|
|
@ -5694,7 +5719,11 @@ def preflight_engine_n8n_transition(descriptor, enforce_expected_current):
|
||||||
current_types = current_catalog["node_types"]
|
current_types = current_catalog["node_types"]
|
||||||
current_credentials = current_catalog["credential_types"]
|
current_credentials = current_catalog["credential_types"]
|
||||||
expected_current_types = list(ENGINE_N8N_NODE_TYPES) if current_state != "verified_inactive" else []
|
expected_current_types = list(ENGINE_N8N_NODE_TYPES) if current_state != "verified_inactive" else []
|
||||||
expected_current_credentials = list(ENGINE_N8N_CREDENTIAL_TYPES) if current_state != "verified_inactive" else []
|
expected_current_credentials = (
|
||||||
|
list(engine_n8n_credential_types_for_release(current_state))
|
||||||
|
if current_state != "verified_inactive"
|
||||||
|
else []
|
||||||
|
)
|
||||||
if current_types != expected_current_types:
|
if current_types != expected_current_types:
|
||||||
die("Engine n8n current live private-node set does not match its transition state")
|
die("Engine n8n current live private-node set does not match its transition state")
|
||||||
if current_credentials != expected_current_credentials:
|
if current_credentials != expected_current_credentials:
|
||||||
|
|
@ -5929,7 +5958,11 @@ def accept_engine_n8n_runtime(descriptor):
|
||||||
die("Engine n8n private extension loader log acceptance failed")
|
die("Engine n8n private extension loader log acceptance failed")
|
||||||
|
|
||||||
expected_types = list(ENGINE_N8N_NODE_TYPES) if descriptor["action"] == "activate" else []
|
expected_types = list(ENGINE_N8N_NODE_TYPES) if descriptor["action"] == "activate" else []
|
||||||
expected_credentials = list(ENGINE_N8N_CREDENTIAL_TYPES) if descriptor["action"] == "activate" else []
|
expected_credentials = (
|
||||||
|
list(engine_n8n_credential_types_for_release(descriptor["releaseId"]))
|
||||||
|
if descriptor["action"] == "activate"
|
||||||
|
else []
|
||||||
|
)
|
||||||
loader_catalog = engine_n8n_private_loader_catalog(container_id)
|
loader_catalog = engine_n8n_private_loader_catalog(container_id)
|
||||||
if loader_catalog["node_types"] != expected_types:
|
if loader_catalog["node_types"] != expected_types:
|
||||||
die("Engine n8n live package-loader node catalog acceptance failed")
|
die("Engine n8n live package-loader node catalog acceptance failed")
|
||||||
|
|
|
||||||
|
|
@ -16,17 +16,24 @@ from unittest import mock
|
||||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||||
PLATFORM_ROOT = SCRIPT_DIR.parent.parent
|
PLATFORM_ROOT = SCRIPT_DIR.parent.parent
|
||||||
ENGINE_ROOT = PLATFORM_ROOT.parent / "NODEDC_ENGINE_INFRA"
|
ENGINE_ROOT = PLATFORM_ROOT.parent / "NODEDC_ENGINE_INFRA"
|
||||||
RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy"
|
RUNNER_PATH = Path(
|
||||||
|
os.environ.get("NODEDC_DEPLOY_RUNNER_UNDER_TEST", SCRIPT_DIR / "nodedc-deploy")
|
||||||
|
).resolve()
|
||||||
BUILDER_PATH = SCRIPT_DIR / "build-engine-n8n-private-extension-artifact.mjs"
|
BUILDER_PATH = SCRIPT_DIR / "build-engine-n8n-private-extension-artifact.mjs"
|
||||||
STAGE_ARTIFACT = (
|
STAGE_ARTIFACT = (
|
||||||
PLATFORM_ROOT
|
PLATFORM_ROOT
|
||||||
/ "infra/deploy-artifacts"
|
/ "infra/deploy-artifacts"
|
||||||
/ "nodedc-n8n-private-extension-n8n-nodes-ndc-history-read-20260717-004.tgz"
|
/ "nodedc-n8n-private-extension-n8n-nodes-ndc-provider-rotating-access-20260718-006.tgz"
|
||||||
)
|
)
|
||||||
TRANSITION_ID = "20260717-005"
|
PREDECESSOR_ARTIFACT = (
|
||||||
SEALED_RELEASE_ID = "0.1.3-3354149b5245e39a"
|
PLATFORM_ROOT
|
||||||
SEALED_PACKAGE_SHA256 = "3354149b5245e39a10f6f03a4b43796602d7e57ea1f91dcc6cb6774ead97501d"
|
/ "infra/deploy-artifacts"
|
||||||
PREDECESSOR_RELEASE_ID = "0.1.2-05e4b38b14b4a019"
|
/ "nodedc-engine-n8n-private-extension-20260717-005.tgz"
|
||||||
|
)
|
||||||
|
TRANSITION_ID = "20260718-008"
|
||||||
|
SEALED_RELEASE_ID = "0.1.4-59dc9f7882721d6a"
|
||||||
|
SEALED_PACKAGE_SHA256 = "59dc9f7882721d6a5e2ae84ffa6aa8cd9bc5432f5904d24e70e363573ed2757f"
|
||||||
|
PREDECESSOR_RELEASE_ID = "0.1.3-3354149b5245e39a"
|
||||||
BUILDER_ENGINE_PATHS = (
|
BUILDER_ENGINE_PATHS = (
|
||||||
"nodedc-source/server/assets/n8n/schema/v2.3.2/nodes.catalog.json",
|
"nodedc-source/server/assets/n8n/schema/v2.3.2/nodes.catalog.json",
|
||||||
"nodedc-source/server/assets/n8n/schema/v2.3.2/credentials.catalog.json",
|
"nodedc-source/server/assets/n8n/schema/v2.3.2/credentials.catalog.json",
|
||||||
|
|
@ -45,14 +52,16 @@ EXPECTED_CREDENTIALS = [
|
||||||
"ndcDataProductWriterApi",
|
"ndcDataProductWriterApi",
|
||||||
"ndcDataProductReaderApi",
|
"ndcDataProductReaderApi",
|
||||||
"ndcFoundryBindingApi",
|
"ndcFoundryBindingApi",
|
||||||
|
"ndcProviderRotatingAccessApi",
|
||||||
]
|
]
|
||||||
|
PREDECESSOR_CREDENTIALS = EXPECTED_CREDENTIALS[:3]
|
||||||
PRIVATE_EXTENSION_CANON_FUNCTION_SHA256 = {
|
PRIVATE_EXTENSION_CANON_FUNCTION_SHA256 = {
|
||||||
"expected_engine_n8n_compose_override": "8ee93f3e7c407f5557c711119236449a440d73a7fcac1bbfa50a09e6a13c1cff",
|
"expected_engine_n8n_compose_override": "8ee93f3e7c407f5557c711119236449a440d73a7fcac1bbfa50a09e6a13c1cff",
|
||||||
"engine_n8n_package_loader_probe_script": "2b3b3c96fad6e5e48ebea74426b21bc7eea6b6fe0e786b537da3366d18aedd9b",
|
"engine_n8n_package_loader_probe_script": "2b3b3c96fad6e5e48ebea74426b21bc7eea6b6fe0e786b537da3366d18aedd9b",
|
||||||
"engine_n8n_private_loader_catalog": "a2ae389b4ef215900cf967b863d01056bd2a8eec55554566d0f6005e4e0bcbac",
|
"engine_n8n_private_loader_catalog": "a2ae389b4ef215900cf967b863d01056bd2a8eec55554566d0f6005e4e0bcbac",
|
||||||
"validate_engine_n8n_base_compose_source": "adacc5b20e52684cbc427362ddba5a6d2e13ef2091a89859e2b8f7bbf2e20458",
|
"validate_engine_n8n_base_compose_source": "adacc5b20e52684cbc427362ddba5a6d2e13ef2091a89859e2b8f7bbf2e20458",
|
||||||
"preflight_engine_n8n_transition": "3d574acb081fb752e45cc0c3286d3ae4596dcee83a85e2c3fee81ac65ec796e0",
|
"preflight_engine_n8n_transition": "25bf33b3be0916a9dd82f064a710060eaf6bd7d87b3614ee0065af07ddbb446e",
|
||||||
"accept_engine_n8n_runtime": "1a2614a465161e3833e84aca402817682da1a538bdb2aecc4af66b665497338d",
|
"accept_engine_n8n_runtime": "bc49275adb2d5ab4fd98abd07b9059a197d023e03b40145b6fc910b70b2f738b",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -107,6 +116,7 @@ class EngineN8nPrivateExtensionTest(unittest.TestCase):
|
||||||
env.pop("NODEDC_N8N_TRANSITION_ID", None)
|
env.pop("NODEDC_N8N_TRANSITION_ID", None)
|
||||||
env["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(output)
|
env["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(output)
|
||||||
env["NODEDC_N8N_EXTENSION_STAGE_ARTIFACT"] = str(STAGE_ARTIFACT)
|
env["NODEDC_N8N_EXTENSION_STAGE_ARTIFACT"] = str(STAGE_ARTIFACT)
|
||||||
|
env["NODEDC_N8N_EXTENSION_PREDECESSOR_ARTIFACT"] = str(PREDECESSOR_ARTIFACT)
|
||||||
command = ["node", str(BUILDER_PATH)]
|
command = ["node", str(BUILDER_PATH)]
|
||||||
if index == 0:
|
if index == 0:
|
||||||
command.append(TRANSITION_ID)
|
command.append(TRANSITION_ID)
|
||||||
|
|
@ -165,8 +175,8 @@ class EngineN8nPrivateExtensionTest(unittest.TestCase):
|
||||||
def test_transition_id_rejects_missing_invalid_and_previously_issued_values(self):
|
def test_transition_id_rejects_missing_invalid_and_previously_issued_values(self):
|
||||||
cases = (
|
cases = (
|
||||||
([], "transition_id_required"),
|
([], "transition_id_required"),
|
||||||
(["20260717-004"], "transition_id_already_issued"),
|
(["20260718-007"], "transition_id_already_issued"),
|
||||||
(["engine-n8n-private-extension-20260717-005"], "transition_id_invalid"),
|
(["engine-n8n-private-extension-20260718-008"], "transition_id_invalid"),
|
||||||
(["20260230-004"], "transition_id_invalid"),
|
(["20260230-004"], "transition_id_invalid"),
|
||||||
(["20260717-000"], "transition_id_invalid"),
|
(["20260717-000"], "transition_id_invalid"),
|
||||||
([TRANSITION_ID, "unexpected-second-id"], "transition_id_argument_count_invalid"),
|
([TRANSITION_ID, "unexpected-second-id"], "transition_id_argument_count_invalid"),
|
||||||
|
|
@ -191,6 +201,7 @@ class EngineN8nPrivateExtensionTest(unittest.TestCase):
|
||||||
env.pop("NODEDC_N8N_TRANSITION_ID", None)
|
env.pop("NODEDC_N8N_TRANSITION_ID", None)
|
||||||
env["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(self.artifact(0, "activation").parent)
|
env["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(self.artifact(0, "activation").parent)
|
||||||
env["NODEDC_N8N_EXTENSION_STAGE_ARTIFACT"] = str(STAGE_ARTIFACT)
|
env["NODEDC_N8N_EXTENSION_STAGE_ARTIFACT"] = str(STAGE_ARTIFACT)
|
||||||
|
env["NODEDC_N8N_EXTENSION_PREDECESSOR_ARTIFACT"] = str(PREDECESSOR_ARTIFACT)
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["node", str(BUILDER_PATH), TRANSITION_ID],
|
["node", str(BUILDER_PATH), TRANSITION_ID],
|
||||||
cwd=PLATFORM_ROOT,
|
cwd=PLATFORM_ROOT,
|
||||||
|
|
@ -233,7 +244,7 @@ class EngineN8nPrivateExtensionTest(unittest.TestCase):
|
||||||
self.assertEqual([item["name"] for item in private_nodes], EXPECTED_NODES)
|
self.assertEqual([item["name"] for item in private_nodes], EXPECTED_NODES)
|
||||||
self.assertEqual([item["name"] for item in private_credentials], EXPECTED_CREDENTIALS)
|
self.assertEqual([item["name"] for item in private_credentials], EXPECTED_CREDENTIALS)
|
||||||
self.assertTrue(all("usableAsTool" not in item for item in private_nodes))
|
self.assertTrue(all("usableAsTool" not in item for item in private_nodes))
|
||||||
self.assertEqual((len(nodes), len(credentials)), (437, 388))
|
self.assertEqual((len(nodes), len(credentials)), (437, 389))
|
||||||
|
|
||||||
def test_rollback_catalog_restores_verified_predecessor_release(self):
|
def test_rollback_catalog_restores_verified_predecessor_release(self):
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
|
@ -253,8 +264,8 @@ class EngineN8nPrivateExtensionTest(unittest.TestCase):
|
||||||
EXPECTED_NODES,
|
EXPECTED_NODES,
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
[item.get("name") for item in credentials if item.get("name") in EXPECTED_CREDENTIALS],
|
[item.get("name") for item in credentials if item.get("name") in PREDECESSOR_CREDENTIALS],
|
||||||
EXPECTED_CREDENTIALS,
|
PREDECESSOR_CREDENTIALS,
|
||||||
)
|
)
|
||||||
self.assertEqual((len(nodes), len(credentials)), (437, 388))
|
self.assertEqual((len(nodes), len(credentials)), (437, 388))
|
||||||
|
|
||||||
|
|
@ -271,7 +282,7 @@ class EngineN8nPrivateExtensionTest(unittest.TestCase):
|
||||||
self.assertEqual(override, RUNNER.expected_engine_n8n_compose_override(descriptor))
|
self.assertEqual(override, RUNNER.expected_engine_n8n_compose_override(descriptor))
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
hashlib.sha256(override.encode("utf-8")).hexdigest(),
|
hashlib.sha256(override.encode("utf-8")).hexdigest(),
|
||||||
"256142c14ae295bf9be4d74fdc8dcd6e134e4ded8a264f15e66b38e85503b589",
|
"d6d1249e93b5bed5e697715b6b773c7efa4ac234f12eab57d868407e6bb5390e",
|
||||||
)
|
)
|
||||||
self.assertIn("pull_policy: never", override)
|
self.assertIn("pull_policy: never", override)
|
||||||
self.assertIn("N8N_USER_FOLDER: /home/node", override)
|
self.assertIn("N8N_USER_FOLDER: /home/node", override)
|
||||||
|
|
@ -563,7 +574,7 @@ class EngineN8nPrivateExtensionTest(unittest.TestCase):
|
||||||
|
|
||||||
def test_sealed_release_exact_set_includes_implicit_directories(self):
|
def test_sealed_release_exact_set_includes_implicit_directories(self):
|
||||||
release_relative = Path(
|
release_relative = Path(
|
||||||
"payload/releases/n8n-nodes-ndc/0.1.3-3354149b5245e39a"
|
"payload/releases/n8n-nodes-ndc/0.1.4-59dc9f7882721d6a"
|
||||||
)
|
)
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
work = Path(directory)
|
work = Path(directory)
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,11 @@ from pathlib import Path
|
||||||
|
|
||||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||||
PLATFORM_ROOT = SCRIPT_DIR.parent.parent
|
PLATFORM_ROOT = SCRIPT_DIR.parent.parent
|
||||||
RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy"
|
RUNNER_PATH = Path(
|
||||||
|
os.environ.get("NODEDC_DEPLOY_RUNNER_UNDER_TEST", SCRIPT_DIR / "nodedc-deploy")
|
||||||
|
).resolve()
|
||||||
BUILDER_PATH = SCRIPT_DIR / "build-n8n-private-extension-artifact.mjs"
|
BUILDER_PATH = SCRIPT_DIR / "build-n8n-private-extension-artifact.mjs"
|
||||||
PATCH_ID = "n8n-nodes-ndc-history-read-20260717-004"
|
PATCH_ID = "n8n-nodes-ndc-provider-rotating-access-20260718-006"
|
||||||
EXPECTED_NODES = [
|
EXPECTED_NODES = [
|
||||||
"dist/nodes/NdcDataProductPublish/NdcDataProductPublish.node.js",
|
"dist/nodes/NdcDataProductPublish/NdcDataProductPublish.node.js",
|
||||||
"dist/nodes/NdcDataProductRead/NdcDataProductRead.node.js",
|
"dist/nodes/NdcDataProductRead/NdcDataProductRead.node.js",
|
||||||
|
|
@ -32,11 +34,13 @@ EXPECTED_CREDENTIALS = [
|
||||||
"dist/credentials/NdcDataProductWriterApi.credentials.js",
|
"dist/credentials/NdcDataProductWriterApi.credentials.js",
|
||||||
"dist/credentials/NdcDataProductReaderApi.credentials.js",
|
"dist/credentials/NdcDataProductReaderApi.credentials.js",
|
||||||
"dist/credentials/NdcFoundryBindingApi.credentials.js",
|
"dist/credentials/NdcFoundryBindingApi.credentials.js",
|
||||||
|
"dist/credentials/NdcProviderRotatingAccessApi.credentials.js",
|
||||||
]
|
]
|
||||||
EXPECTED_CREDENTIAL_TYPES = [
|
EXPECTED_CREDENTIAL_TYPES = [
|
||||||
"ndcDataProductWriterApi",
|
"ndcDataProductWriterApi",
|
||||||
"ndcDataProductReaderApi",
|
"ndcDataProductReaderApi",
|
||||||
"ndcFoundryBindingApi",
|
"ndcFoundryBindingApi",
|
||||||
|
"ndcProviderRotatingAccessApi",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -106,7 +110,7 @@ class N8nPrivateExtensionPolicyTest(unittest.TestCase):
|
||||||
manifest, entries, _payload = RUNNER.load_artifact(self.artifacts[0], Path(directory))
|
manifest, entries, _payload = RUNNER.load_artifact(self.artifacts[0], Path(directory))
|
||||||
self.assertEqual(manifest["component"], "n8n-private-extension")
|
self.assertEqual(manifest["component"], "n8n-private-extension")
|
||||||
self.assertEqual(len(entries), 1)
|
self.assertEqual(len(entries), 1)
|
||||||
self.assertRegex(entries[0], r"^releases/n8n-nodes-ndc/0\.1\.3-[a-f0-9]{16}$")
|
self.assertRegex(entries[0], r"^releases/n8n-nodes-ndc/0\.1\.4-[a-f0-9]{16}$")
|
||||||
|
|
||||||
with tarfile.open(self.artifacts[0], "r:gz") as archive:
|
with tarfile.open(self.artifacts[0], "r:gz") as archive:
|
||||||
names = archive.getnames()
|
names = archive.getnames()
|
||||||
|
|
@ -118,7 +122,7 @@ class N8nPrivateExtensionPolicyTest(unittest.TestCase):
|
||||||
with tarfile.open(fileobj=io.BytesIO(package), mode="r:gz") as archive:
|
with tarfile.open(fileobj=io.BytesIO(package), mode="r:gz") as archive:
|
||||||
package_json_member = archive.getmember("package/package.json")
|
package_json_member = archive.getmember("package/package.json")
|
||||||
package_json = json.loads(archive.extractfile(package_json_member).read())
|
package_json = json.loads(archive.extractfile(package_json_member).read())
|
||||||
self.assertEqual(package_json["version"], "0.1.3")
|
self.assertEqual(package_json["version"], "0.1.4")
|
||||||
self.assertEqual(package_json["n8n"]["nodes"], EXPECTED_NODES)
|
self.assertEqual(package_json["n8n"]["nodes"], EXPECTED_NODES)
|
||||||
self.assertEqual(package_json["n8n"]["credentials"], EXPECTED_CREDENTIALS)
|
self.assertEqual(package_json["n8n"]["credentials"], EXPECTED_CREDENTIALS)
|
||||||
for node_path in EXPECTED_NODES:
|
for node_path in EXPECTED_NODES:
|
||||||
|
|
@ -154,7 +158,7 @@ class N8nPrivateExtensionPolicyTest(unittest.TestCase):
|
||||||
"requiresPreActivationVerification": True,
|
"requiresPreActivationVerification": True,
|
||||||
}
|
}
|
||||||
self.assertEqual(release["schemaVersion"], "nodedc.n8n-private-extension-release/v2")
|
self.assertEqual(release["schemaVersion"], "nodedc.n8n-private-extension-release/v2")
|
||||||
self.assertEqual(release["package"]["version"], "0.1.3")
|
self.assertEqual(release["package"]["version"], "0.1.4")
|
||||||
self.assertEqual(release["activation"]["rollbackBaselinePolicy"], expected_policy)
|
self.assertEqual(release["activation"]["rollbackBaselinePolicy"], expected_policy)
|
||||||
self.assertEqual(rollback["schemaVersion"], "nodedc.n8n-private-extension-rollback/v2")
|
self.assertEqual(rollback["schemaVersion"], "nodedc.n8n-private-extension-rollback/v2")
|
||||||
self.assertEqual(rollback["baselinePolicy"], expected_policy)
|
self.assertEqual(rollback["baselinePolicy"], expected_policy)
|
||||||
|
|
|
||||||
|
|
@ -30,8 +30,16 @@ The defaults are `http://external-data-plane:18106` and
|
||||||
`http://nodedc-module-foundry:3333`. They are provider-neutral Platform service
|
`http://nodedc-module-foundry:3333`. They are provider-neutral Platform service
|
||||||
addresses, not workflow configuration.
|
addresses, not workflow configuration.
|
||||||
|
|
||||||
The three credential types contain only one password-protected opaque
|
The writer, reader, and Foundry credential types contain only one
|
||||||
capability. Writer, reader, and Foundry capabilities are intentionally distinct.
|
password-protected opaque capability and are intentionally distinct. The
|
||||||
|
separate `NDC Provider Rotating Access API` credential keeps the Gelios REST access
|
||||||
|
and refresh pair inside native Engine Credentials. It exchanges the refresh
|
||||||
|
token only against the fixed Gelios refresh endpoint, persists both rotated
|
||||||
|
tokens through the supported expirable-credential lifecycle, and injects only
|
||||||
|
the current access token into provider requests. Concurrent refresh attempts in
|
||||||
|
the single-service L2 runtime are coalesced and briefly replay the same rotated
|
||||||
|
pair so a stale caller cannot immediately spend the invalidated predecessor
|
||||||
|
refresh token again.
|
||||||
Provider identity, product version, ontology revision, persistence policy, and
|
Provider identity, product version, ontology revision, persistence policy, and
|
||||||
tenant scope are materialized by the receiving service from the grant. The node
|
tenant scope are materialized by the receiving service from the grant. The node
|
||||||
never accepts them from a workflow.
|
never accepts them from a workflow.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,182 @@
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
IAuthenticateGeneric,
|
||||||
|
ICredentialDataDecryptedObject,
|
||||||
|
ICredentialTestRequest,
|
||||||
|
ICredentialType,
|
||||||
|
IHttpRequestHelper,
|
||||||
|
IHttpRequestOptions,
|
||||||
|
INodeProperties,
|
||||||
|
Icon,
|
||||||
|
} from 'n8n-workflow';
|
||||||
|
|
||||||
|
const GELIOS_API_BASE_URL = 'https://api.geliospro.com';
|
||||||
|
const GELIOS_REFRESH_URL = `${GELIOS_API_BASE_URL}/api/v1/auth/refresh`;
|
||||||
|
const RECENT_ROTATION_TTL_MS = 30_000;
|
||||||
|
const MAX_RECENT_ROTATIONS = 64;
|
||||||
|
|
||||||
|
interface RotatedTokens extends ICredentialDataDecryptedObject {
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
|
accessExpiresAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RecentRotation {
|
||||||
|
expiresAt: number;
|
||||||
|
tokens: RotatedTokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshInFlight = new Map<string, Promise<RotatedTokens>>();
|
||||||
|
const recentRotations = new Map<string, RecentRotation>();
|
||||||
|
|
||||||
|
export class NdcProviderRotatingAccessApi implements ICredentialType {
|
||||||
|
name = 'ndcProviderRotatingAccessApi';
|
||||||
|
displayName = 'NDC Provider Rotating Access API';
|
||||||
|
icon: Icon = {
|
||||||
|
light: 'file:../icons/ndc.svg',
|
||||||
|
dark: 'file:../icons/ndc.dark.svg',
|
||||||
|
};
|
||||||
|
documentationUrl = '';
|
||||||
|
properties: INodeProperties[] = [
|
||||||
|
{
|
||||||
|
displayName: 'Refresh Token',
|
||||||
|
name: 'refreshToken',
|
||||||
|
type: 'string',
|
||||||
|
typeOptions: { password: true },
|
||||||
|
default: '',
|
||||||
|
required: true,
|
||||||
|
description: 'Rotating refresh token issued by the approved provider profile',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Access Token',
|
||||||
|
name: 'accessToken',
|
||||||
|
type: 'hidden',
|
||||||
|
typeOptions: {
|
||||||
|
expirable: true,
|
||||||
|
password: true,
|
||||||
|
},
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Access Token Expires At',
|
||||||
|
name: 'accessExpiresAt',
|
||||||
|
type: 'hidden',
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
async preAuthentication(
|
||||||
|
this: IHttpRequestHelper,
|
||||||
|
credentials: ICredentialDataDecryptedObject,
|
||||||
|
): Promise<ICredentialDataDecryptedObject> {
|
||||||
|
const refreshToken = credentials.refreshToken;
|
||||||
|
if (typeof refreshToken !== 'string' || refreshToken.trim() === '') {
|
||||||
|
throw new Error('provider_refresh_token_required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = createHash('sha256').update(refreshToken, 'utf8').digest('hex');
|
||||||
|
const now = Date.now();
|
||||||
|
pruneRecentRotations(now);
|
||||||
|
const recent = recentRotations.get(key);
|
||||||
|
if (recent && recent.expiresAt > now) return { ...recent.tokens };
|
||||||
|
|
||||||
|
let pending = refreshInFlight.get(key);
|
||||||
|
if (!pending) {
|
||||||
|
pending = rotateTokens(this, refreshToken)
|
||||||
|
.then((tokens) => {
|
||||||
|
rememberRotation(key, tokens);
|
||||||
|
return tokens;
|
||||||
|
})
|
||||||
|
.finally(() => refreshInFlight.delete(key));
|
||||||
|
refreshInFlight.set(key, pending);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...(await pending) };
|
||||||
|
}
|
||||||
|
|
||||||
|
authenticate: IAuthenticateGeneric = {
|
||||||
|
type: 'generic',
|
||||||
|
properties: {
|
||||||
|
headers: {
|
||||||
|
Authorization: '=Bearer {{$credentials.accessToken}}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
get test(): ICredentialTestRequest {
|
||||||
|
return {
|
||||||
|
request: {
|
||||||
|
method: 'GET',
|
||||||
|
url: `${GELIOS_API_BASE_URL}/api/v1/auth`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rotateTokens(
|
||||||
|
helper: IHttpRequestHelper,
|
||||||
|
refreshToken: string,
|
||||||
|
): Promise<RotatedTokens> {
|
||||||
|
let response: unknown;
|
||||||
|
try {
|
||||||
|
response = await helper.helpers.httpRequest({
|
||||||
|
method: 'POST',
|
||||||
|
url: GELIOS_REFRESH_URL,
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: { refresh_token: refreshToken },
|
||||||
|
json: true,
|
||||||
|
} satisfies IHttpRequestOptions);
|
||||||
|
} catch {
|
||||||
|
throw new Error('provider_access_refresh_failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isRecord(response)) throw new Error('provider_access_refresh_failed');
|
||||||
|
const accessToken = response.access_token;
|
||||||
|
const nextRefreshToken = response.refresh_token;
|
||||||
|
const tokenType = response.token_type;
|
||||||
|
const expiresIn = response.expires_in;
|
||||||
|
if (
|
||||||
|
typeof accessToken !== 'string'
|
||||||
|
|| accessToken.trim() === ''
|
||||||
|
|| typeof nextRefreshToken !== 'string'
|
||||||
|
|| nextRefreshToken.trim() === ''
|
||||||
|
|| typeof tokenType !== 'string'
|
||||||
|
|| tokenType.toLowerCase() !== 'bearer'
|
||||||
|
|| !Number.isSafeInteger(expiresIn)
|
||||||
|
|| Number(expiresIn) <= 0
|
||||||
|
) {
|
||||||
|
throw new Error('provider_access_refresh_failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessToken,
|
||||||
|
refreshToken: nextRefreshToken,
|
||||||
|
accessExpiresAt: new Date(Date.now() + Number(expiresIn) * 1000).toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneRecentRotations(now: number): void {
|
||||||
|
for (const [key, value] of recentRotations) {
|
||||||
|
if (value.expiresAt <= now) recentRotations.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rememberRotation(key: string, tokens: RotatedTokens): void {
|
||||||
|
while (recentRotations.size >= MAX_RECENT_ROTATIONS) {
|
||||||
|
const oldest = recentRotations.keys().next().value as string | undefined;
|
||||||
|
if (!oldest) break;
|
||||||
|
recentRotations.delete(oldest);
|
||||||
|
}
|
||||||
|
recentRotations.set(key, {
|
||||||
|
expiresAt: Date.now() + RECENT_ROTATION_TTL_MS,
|
||||||
|
tokens: { ...tokens },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "n8n-nodes-ndc",
|
"name": "n8n-nodes-ndc",
|
||||||
"version": "0.1.3",
|
"version": "0.1.4",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "n8n-nodes-ndc",
|
"name": "n8n-nodes-ndc",
|
||||||
"version": "0.1.3",
|
"version": "0.1.4",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@n8n/node-cli": "0.39.3",
|
"@n8n/node-cli": "0.39.3",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "n8n-nodes-ndc",
|
"name": "n8n-nodes-ndc",
|
||||||
"version": "0.1.3",
|
"version": "0.1.4",
|
||||||
"description": "Private NODE.DC nodes for scoped data products and Foundry bindings.",
|
"description": "Private NODE.DC nodes for scoped data products and Foundry bindings.",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
|
|
@ -24,7 +24,8 @@
|
||||||
"credentials": [
|
"credentials": [
|
||||||
"dist/credentials/NdcDataProductWriterApi.credentials.js",
|
"dist/credentials/NdcDataProductWriterApi.credentials.js",
|
||||||
"dist/credentials/NdcDataProductReaderApi.credentials.js",
|
"dist/credentials/NdcDataProductReaderApi.credentials.js",
|
||||||
"dist/credentials/NdcFoundryBindingApi.credentials.js"
|
"dist/credentials/NdcFoundryBindingApi.credentials.js",
|
||||||
|
"dist/credentials/NdcProviderRotatingAccessApi.credentials.js"
|
||||||
],
|
],
|
||||||
"nodes": [
|
"nodes": [
|
||||||
"dist/nodes/NdcDataProductPublish/NdcDataProductPublish.node.js",
|
"dist/nodes/NdcDataProductPublish/NdcDataProductPublish.node.js",
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,10 @@ const nodeSpecs = [
|
||||||
['NdcFoundryBinding', 'ndcFoundryBinding', 'NDC Foundry Binding'],
|
['NdcFoundryBinding', 'ndcFoundryBinding', 'NDC Foundry Binding'],
|
||||||
];
|
];
|
||||||
const credentialSpecs = [
|
const credentialSpecs = [
|
||||||
['NdcDataProductWriterApi', 'ndcDataProductWriterApi'],
|
['NdcDataProductWriterApi', 'ndcDataProductWriterApi', 'opaque'],
|
||||||
['NdcDataProductReaderApi', 'ndcDataProductReaderApi'],
|
['NdcDataProductReaderApi', 'ndcDataProductReaderApi', 'opaque'],
|
||||||
['NdcFoundryBindingApi', 'ndcFoundryBindingApi'],
|
['NdcFoundryBindingApi', 'ndcFoundryBindingApi', 'opaque'],
|
||||||
|
['NdcProviderRotatingAccessApi', 'ndcProviderRotatingAccessApi', 'rotating'],
|
||||||
];
|
];
|
||||||
|
|
||||||
const forbiddenNodeParameter = /(url|provider|tenant|connection|token|secret|password|credential)/i;
|
const forbiddenNodeParameter = /(url|provider|tenant|connection|token|secret|password|credential)/i;
|
||||||
|
|
@ -67,19 +68,34 @@ async function main() {
|
||||||
assertProductSurfaceHasNdcBrand(node.description, className);
|
assertProductSurfaceHasNdcBrand(node.description, className);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [className, internalName] of credentialSpecs) {
|
const credentials = new Map();
|
||||||
|
for (const [className, internalName, kind] of credentialSpecs) {
|
||||||
const modulePath = path.join(packageRoot, 'dist', 'credentials', `${className}.credentials.js`);
|
const modulePath = path.join(packageRoot, 'dist', 'credentials', `${className}.credentials.js`);
|
||||||
const CredentialClass = require(modulePath)[className];
|
const CredentialClass = require(modulePath)[className];
|
||||||
const credential = new CredentialClass();
|
const credential = new CredentialClass();
|
||||||
|
credentials.set(className, credential);
|
||||||
assert.match(credential.displayName, /^NDC /);
|
assert.match(credential.displayName, /^NDC /);
|
||||||
assert.equal(credential.name, internalName);
|
assert.equal(credential.name, internalName);
|
||||||
assert.deepEqual(credential.icon, {
|
assert.deepEqual(credential.icon, {
|
||||||
light: 'file:../icons/ndc.svg',
|
light: 'file:../icons/ndc.svg',
|
||||||
dark: 'file:../icons/ndc.dark.svg',
|
dark: 'file:../icons/ndc.dark.svg',
|
||||||
});
|
});
|
||||||
assert.deepEqual(credential.properties.map((property) => property.name), ['capability']);
|
if (kind === 'opaque') {
|
||||||
assert.equal(credential.properties[0].typeOptions.password, true);
|
assert.deepEqual(credential.properties.map((property) => property.name), ['capability']);
|
||||||
assert.equal(credential.authenticate.properties.headers.Authorization, '=Bearer {{$credentials.capability}}');
|
assert.equal(credential.properties[0].typeOptions.password, true);
|
||||||
|
assert.equal(credential.authenticate.properties.headers.Authorization, '=Bearer {{$credentials.capability}}');
|
||||||
|
} else {
|
||||||
|
assert.deepEqual(
|
||||||
|
credential.properties.map((property) => property.name),
|
||||||
|
['refreshToken', 'accessToken', 'accessExpiresAt'],
|
||||||
|
);
|
||||||
|
assert.equal(credential.properties[0].typeOptions.password, true);
|
||||||
|
assert.equal(credential.properties[1].type, 'hidden');
|
||||||
|
assert.equal(credential.properties[1].typeOptions.password, true);
|
||||||
|
assert.equal(credential.properties[1].typeOptions.expirable, true);
|
||||||
|
assert.equal(credential.authenticate.properties.headers.Authorization, '=Bearer {{$credentials.accessToken}}');
|
||||||
|
assert.equal(credential.test.request.url, 'https://api.geliospro.com/api/v1/auth');
|
||||||
|
}
|
||||||
assertProductSurfaceHasNdcBrand({
|
assertProductSurfaceHasNdcBrand({
|
||||||
displayName: credential.displayName,
|
displayName: credential.displayName,
|
||||||
documentationUrl: credential.documentationUrl,
|
documentationUrl: credential.documentationUrl,
|
||||||
|
|
@ -87,6 +103,8 @@ async function main() {
|
||||||
}, className);
|
}, className);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await assertProviderRotatingCredential(credentials.get('NdcProviderRotatingAccessApi'));
|
||||||
|
|
||||||
for (const icon of ['ndc.svg', 'ndc.dark.svg']) {
|
for (const icon of ['ndc.svg', 'ndc.dark.svg']) {
|
||||||
const iconPath = path.join(packageRoot, 'dist', 'icons', icon);
|
const iconPath = path.join(packageRoot, 'dist', 'icons', icon);
|
||||||
assert.equal(fs.existsSync(iconPath), true, `${icon} was not copied`);
|
assert.equal(fs.existsSync(iconPath), true, `${icon} was not copied`);
|
||||||
|
|
@ -187,6 +205,57 @@ async function main() {
|
||||||
console.log('n8n-nodes-ndc package policy: ok');
|
console.log('n8n-nodes-ndc package policy: ok');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function assertProviderRotatingCredential(credential) {
|
||||||
|
let requestCount = 0;
|
||||||
|
let releaseRequest;
|
||||||
|
const request = new Promise((resolve) => { releaseRequest = resolve; });
|
||||||
|
const helper = {
|
||||||
|
helpers: {
|
||||||
|
async httpRequest(options) {
|
||||||
|
requestCount += 1;
|
||||||
|
assert.equal(options.method, 'POST');
|
||||||
|
assert.equal(options.url, 'https://api.geliospro.com/api/v1/auth/refresh');
|
||||||
|
assert.deepEqual(options.body, { refresh_token: 'refresh-old-single-flight' });
|
||||||
|
assert.equal(options.headers.Authorization, undefined);
|
||||||
|
return request;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const credentials = { refreshToken: 'refresh-old-single-flight', accessToken: '' };
|
||||||
|
const first = credential.preAuthentication.call(helper, credentials);
|
||||||
|
const second = credential.preAuthentication.call(helper, credentials);
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
assert.equal(requestCount, 1, 'rotating refresh must be single-flight inside the one-service L2 runtime');
|
||||||
|
releaseRequest({
|
||||||
|
access_token: 'access-new',
|
||||||
|
refresh_token: 'refresh-new',
|
||||||
|
token_type: 'Bearer',
|
||||||
|
expires_in: 3600,
|
||||||
|
});
|
||||||
|
const [firstTokens, secondTokens] = await Promise.all([first, second]);
|
||||||
|
assert.deepEqual(firstTokens, secondTokens);
|
||||||
|
assert.equal(firstTokens.accessToken, 'access-new');
|
||||||
|
assert.equal(firstTokens.refreshToken, 'refresh-new');
|
||||||
|
assert.match(firstTokens.accessExpiresAt, /^\d{4}-\d{2}-\d{2}T/);
|
||||||
|
|
||||||
|
const replay = await credential.preAuthentication.call(helper, credentials);
|
||||||
|
assert.deepEqual(replay, firstTokens);
|
||||||
|
assert.equal(requestCount, 1, 'a stale concurrent caller must reuse the recent rotation result');
|
||||||
|
|
||||||
|
const leakedRefresh = 'refresh-token-must-not-leak';
|
||||||
|
await assert.rejects(
|
||||||
|
credential.preAuthentication.call({
|
||||||
|
helpers: {
|
||||||
|
async httpRequest() {
|
||||||
|
throw new Error(`remote rejected ${leakedRefresh}`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, { refreshToken: leakedRefresh, accessToken: '' }),
|
||||||
|
(error) => error?.message === 'provider_access_refresh_failed'
|
||||||
|
&& !error.message.includes(leakedRefresh),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function assertProductSurfaceHasNdcBrand(surface, className) {
|
function assertProductSurfaceHasNdcBrand(surface, className) {
|
||||||
const visibleStrings = [];
|
const visibleStrings = [];
|
||||||
collectVisibleStrings(surface, visibleStrings);
|
collectVisibleStrings(surface, visibleStrings);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue