feat(deploy): register managed platform runtime components

This commit is contained in:
Codex 2026-07-16 02:25:31 +03:00
parent fedaf24098
commit 567f1550ab
29 changed files with 4413 additions and 40 deletions

3
.gitignore vendored
View File

@ -16,6 +16,9 @@ build/
.next/
coverage/
# canonical deploy packages are transferred through the NAS inbox, not Git
infra/deploy-artifacts/
# logs
*.log
logs/

View File

@ -48,3 +48,5 @@ AI Workspace Assistant живёт в `services/ai-workspace-assistant` как о
Ontology Core живёт в `services/ontology-core` как docs-first семантический слой для canonical entities, relations, aliases, guardrails, evidence, первого resolver MVP между OPS и ENGINE и policy MVP для NDC Core Assistant access. Он не владеет доменными БД HUB/OPS/ENGINE и не заменяет Launcher/HUB roles или OPS Gateway enforcement.
Map Gateway живёт в `services/map-gateway` как общий platform boundary для provider credentials, безопасного asset endpoint exchange, tile/3D Tiles proxy и persistent offline TileCache. Runtime cache не является source artifact и хранится в named volume/object storage, а не в Git.
Внешние бизнес-поставщики подключаются по `packages/external-provider-contract`: adapter конкретного API принадлежит изолированному NDC Agent L2 workflow, а Platform даёт один provider-neutral External Data Plane для raw retention, canonical facts, current/history projections и scoped read products. Connection instance несёт tenant scope, credential reference и collection profile, но не secret value. Provider/domain mapping, entity filtering и renderer logic не попадают в Platform Data Plane; writer capability привязана к immutable EDP binding и выдаётся только отдельному Engine provisioner, не shared internal bearer. Подробный канон — `docs/ADR_L2_OWNED_EXTERNAL_CONNECTORS.md`.

View File

@ -51,6 +51,35 @@ NODEDC_INTERNAL_ACCESS_TOKEN=change-me-generate-with-infra-scripts-init-dev-env
COOKIE_DOMAIN=.local.nodedc
COOKIE_SECURE=false
# External Data Plane — provider-neutral storage owned by the Platform. This
# password is a database credential only; never reuse NODEDC_INTERNAL_ACCESS_TOKEN.
EXTERNAL_DATA_PLANE_TIMESCALE_IMAGE=timescale/timescaledb-ha:pg16.14-ts2.28.2-all
EXTERNAL_DATA_PLANE_PG_DB=nodedc_data_plane
EXTERNAL_DATA_PLANE_PG_USER=nodedc_data_plane
EXTERNAL_DATA_PLANE_PG_PASS=change-me-generate-with-infra-scripts-init-dev-env
EXTERNAL_DATA_PLANE_HOST_BIND=127.0.0.1:18106
EXTERNAL_DATA_PLANE_DATABASE_POOL_SIZE=10
EXTERNAL_DATA_PLANE_RAW_RETENTION_DAYS=14
EXTERNAL_DATA_PLANE_MAX_BATCH_BYTES=5242880
EXTERNAL_DATA_PLANE_MAX_FACTS_PER_PUBLISH=5000
EXTERNAL_DATA_PLANE_MAX_ATTRIBUTES_BYTES_PER_FACT=65536
EXTERNAL_DATA_PLANE_MAX_PATCH_OPERATIONS=500
EXTERNAL_DATA_PLANE_MAX_PATCH_BYTES=262144
EXTERNAL_DATA_PLANE_PATCH_RETENTION_MS=3600000
EXTERNAL_DATA_PLANE_RECEIPT_RETENTION_MS=604800000
EXTERNAL_DATA_PLANE_RETENTION_DELETE_LIMIT=10000
EXTERNAL_DATA_PLANE_STREAM_HEARTBEAT_MS=20000
EXTERNAL_DATA_PLANE_STREAM_POLL_MS=1000
EXTERNAL_DATA_PLANE_MAX_READER_STREAMS=10
EXTERNAL_DATA_PLANE_WRITER_BINDING_MAX_TTL_DAYS=90
EXTERNAL_DATA_PLANE_MAX_FUTURE_SKEW_SECONDS=300
EXTERNAL_DATA_PLANE_RETENTION_SWEEP_MS=3600000
EXTERNAL_DATA_PLANE_LEGACY_INTAKE_ENABLED=false
# The writer-provisioner secret is not an env value. The root-owned deploy
# runner keeps it in /volume1/docker/nodedc-platform/secrets/external-data-plane-provisioner/
# and mounts it
# only into External Data Plane and the future dedicated Engine provisioner.
# notification core
NOTIFICATION_PG_DB=nodedc_notifications
NOTIFICATION_PG_USER=nodedc_notifications
@ -66,8 +95,15 @@ NODEDC_AI_WORKSPACE_ASSISTANT_URL=http://ai-workspace-assistant:18082
AI_WORKSPACE_OPS_ENTITLEMENT_URL=http://host.docker.internal:4100/api/internal/v1/ai-workspace/entitlements
AI_WORKSPACE_OPS_ENTITLEMENT_TOKEN=replace-with-ops-agent-gateway-internal-token
AI_WORKSPACE_OPS_ENTITLEMENT_REQUIRED=false
# Add Module Foundry only after its domain, Launcher handoff and Authentik group
# are verified. Preserve existing adapters when adding this JSON member:
# {"module-foundry":{"url":"https://<foundry-domain>/api/ai-workspace/entitlements","required":false}}
# The generic adapter reuses the existing NODE.DC internal server credential;
# never define a separate Foundry token for a browser or worker.
AI_WORKSPACE_ENTITLEMENT_ADAPTERS_JSON=
AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ENABLED=true
AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ID=local-dev
AI_WORKSPACE_ONTOLOGY_MCP_ENABLED=true
# AI Workspace Hub for downloaded Codex workers.
# Default local development may use the deployed AI Hub only as a relay for remote Codex workers.
@ -78,6 +114,32 @@ AI_WORKSPACE_HUB_HOST_BIND=127.0.0.1:18081
AI_WORKSPACE_HUB_PUBLIC_URL=wss://ai-hub.nodedc.ru/api/ai-workspace/hub
AI_WORKSPACE_HUB_INTERNAL_URL=https://ai-hub.nodedc.ru
AI_WORKSPACE_HUB_FALLBACK_URLS=
# The worker receives a per-run, pairing-bound read-only Ontology MCP URL through AI Hub.
# Do not put Ontology Core tokens or catalog paths in worker configuration.
AI_WORKSPACE_ONTOLOGY_MCP_PUBLIC_URL=
ONTOLOGY_CORE_HOST_BIND=127.0.0.1:18104
# Gelios Gateway — storage/read service. Provider credentials belong to the
# protected Engine Collector and are never configured in this service.
GELIOS_TIMESCALE_IMAGE=timescale/timescaledb-ha:pg16.14-ts2.28.2-all
GELIOS_PG_DB=nodedc_gelios
GELIOS_PG_USER=nodedc_gelios
GELIOS_PG_PASS=change-me-generate-with-infra-scripts-init-dev-env
# URL-encode reserved characters in GELIOS_PG_PASS when forming this URL.
GELIOS_DATABASE_URL=postgresql://nodedc_gelios:change-me-generate-with-infra-scripts-init-dev-env@gelios-postgres:5432/nodedc_gelios
GELIOS_GATEWAY_HOST_BIND=127.0.0.1:18105
# Explicit tenant and connection identifiers are deployment configuration;
# do not encode a customer or pilot name in source defaults.
GELIOS_TENANT_ID=replace-with-tenant-id
GELIOS_CONNECTION_ID=gelios-connection-id
# `allowlist` accepts only GELIOS_ALLOWED_UNIT_IDS. `all` accepts every unit
# returned by this already-approved tenant + connection, including future units.
GELIOS_UNIT_SCOPE=allowlist
GELIOS_ALLOWED_UNIT_IDS=
GELIOS_INTAKE_ENABLED=false
GELIOS_RAW_RETENTION_DAYS=14
# Presentation state only; it does not change provider collection cadence.
GELIOS_POSITION_STALE_AFTER_MS=300000
# map gateway — keep the actual ion token only in local/staging environment files or Docker secrets.
# Never copy it into workflow metadata, Git, frontend code, or a runtime cache key.

View File

@ -78,18 +78,11 @@ Generated Authentik bootstrap credentials are stored only in `infra/.env`.
## Map Gateway и offline TileCache
`map-gateway` добавлен как общий платформенный сервис на `127.0.0.1:18103`. Его mutable live-cache монтируется как внешний named Docker volume `nodedc-platform_map-live-tile-cache`; offline snapshot — как `nodedc-platform_map-offline-snapshot`. Они не пишутся в `platform/`, не попадают в Git и не включаются в Docker image.
`map-gateway` добавлен как общий платформенный сервис на `127.0.0.1:18103`. На NAS его mutable live-cache лежит в `/volume1/docker/nodedc-platform/map-gateway/live-tile-cache`; read-only offline snapshot — рядом в `offline-snapshot`. Это host bind mounts, поэтому папки видны через SMB как `nodedc-platform/map-gateway/`, но не попадают в Git, Docker image или deployment artifact.
Оба тома намеренно объявлены `external`: `docker compose down -v` не должен уничтожать уже записанные тайлы. На чистой машине они создаются один раз до первого запуска:
Обе папки создаёт root-owned `nodedc-deploy` при первом Map Gateway artifact. Agent не создаёт их напрямую через SMB и не кладёт в artifact. `docker compose down -v` их не удаляет. Очистка допустима только отдельной явно согласованной root-операцией при остановленном Gateway.
```bash
docker volume create nodedc-platform_map-live-tile-cache
docker volume create nodedc-platform_map-offline-snapshot
```
Очистка кэша — только осознанной командой `docker volume rm nodedc-platform_map-live-tile-cache` при остановленном Gateway.
Для реального ion terrain/buildings положите `CESIUM_ION_TOKEN` только в неотслеживаемый `infra/.env` или deployment secret. Studio получает asset-scoped endpoint token, а master token остаётся внутри Gateway. Детали API, cache modes и production access boundary описаны в `services/map-gateway/README.md`.
Для реального ion terrain/buildings положите `CESIUM_ION_TOKEN` только в неотслеживаемый `infra/.env` или deployment secret. Browser получает лишь публичный provider URL через same-origin Foundry proxy; Gateway добавляет asset credential только в исходящем private request. Детали API, cache modes и production access boundary описаны в `services/map-gateway/README.md`.
5. Bootstrap local Authentik groups and OIDC applications:

View File

@ -27,6 +27,13 @@ GROUP_SPECS = [
("nodedc:taskmanager:admin", False),
("nodedc:taskmanager:user", False),
("nodedc:bim:access", False),
# Module Foundry roles travel through the existing Authentik `groups`
# claim and Launcher handoff. `access` remains a backward-compatible
# member role until every existing assignment is migrated.
("nodedc:module-foundry:admin", False),
("nodedc:module-foundry:user", False),
("nodedc:module-foundry:blocked", False),
("nodedc:module-foundry:access", False),
]
APP_SPECS = [
@ -121,6 +128,10 @@ def ensure_user_groups(groups):
user.groups.add(authentik_admins)
for name in groups:
# The bootstrap owner must remain capable of entering Foundry after
# the first provisioning run. Blocking is an explicit operator action,
# never a default membership of the bootstrap principal.
if name != "nodedc:module-foundry:blocked":
user.groups.add(groups[name])
return user

View File

@ -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:

View File

@ -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 });
}

View File

@ -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;
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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 });
}

View File

@ -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));
}
}

View File

@ -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);
}
}

View File

@ -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;
}

File diff suppressed because it is too large Load Diff

View File

@ -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)

View File

@ -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)

View File

@ -152,9 +152,11 @@ services:
AI_WORKSPACE_HUB_FALLBACK_URLS: ${AI_WORKSPACE_HUB_FALLBACK_URLS:-}
AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ENABLED: ${AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ENABLED:-true}
AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ID: ${AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ID:-local-dev}
AI_WORKSPACE_ONTOLOGY_MCP_ENABLED: ${AI_WORKSPACE_ONTOLOGY_MCP_ENABLED:-true}
AI_WORKSPACE_OPS_ENTITLEMENT_URL: ${AI_WORKSPACE_OPS_ENTITLEMENT_URL:-}
AI_WORKSPACE_OPS_ENTITLEMENT_TOKEN: ${AI_WORKSPACE_OPS_ENTITLEMENT_TOKEN:-}
AI_WORKSPACE_OPS_ENTITLEMENT_REQUIRED: ${AI_WORKSPACE_OPS_ENTITLEMENT_REQUIRED:-false}
AI_WORKSPACE_ENTITLEMENT_ADAPTERS_JSON: ${AI_WORKSPACE_ENTITLEMENT_ADAPTERS_JSON:-}
AI_WORKSPACE_OPS_GATEWAY_BASE_URL: ${AI_WORKSPACE_OPS_GATEWAY_BASE_URL:-}
AI_WORKSPACE_OPS_DEFAULT_WORKSPACE_SLUG: ${AI_WORKSPACE_OPS_DEFAULT_WORKSPACE_SLUG:-}
AI_WORKSPACE_OPS_DEFAULT_PROJECT_ID: ${AI_WORKSPACE_OPS_DEFAULT_PROJECT_ID:-}
@ -168,6 +170,86 @@ services:
ai-workspace-postgres:
condition: service_healthy
ontology-core:
image: nodedc/ontology-core:local
build:
context: ../services/ontology-core
restart: unless-stopped
environment:
NODE_ENV: production
PORT: 18104
NODEDC_INTERNAL_ACCESS_TOKEN: ${NODEDC_INTERNAL_ACCESS_TOKEN:-}
expose:
- "18104"
healthcheck:
test:
- CMD
- node
- -e
- "fetch('http://127.0.0.1:18104/healthz').then((response) => process.exit(response.ok ? 0 : 1)).catch(() => process.exit(1))"
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
# External-provider telemetry is isolated from Platform identity state. The
# database is not published; only the Gateway is exposed on the local host
# for operator checks and Engine development.
gelios-postgres:
image: ${GELIOS_TIMESCALE_IMAGE:-timescale/timescaledb-ha:pg16.14-ts2.28.2-all}
restart: unless-stopped
environment:
POSTGRES_DB: ${GELIOS_PG_DB:-nodedc_gelios}
POSTGRES_USER: ${GELIOS_PG_USER:-nodedc_gelios}
POSTGRES_PASSWORD: ${GELIOS_PG_PASS:-change-me-generate-with-infra-scripts-init-dev-env}
healthcheck:
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
interval: 30s
timeout: 5s
retries: 5
start_period: 30s
volumes:
- gelios-database:/home/postgres/pgdata/data
networks:
- gelios-data
gelios-gateway:
image: nodedc/gelios-gateway:local
build:
context: ../services/gelios-gateway
restart: unless-stopped
environment:
NODE_ENV: production
PORT: 18105
DATABASE_URL: ${GELIOS_DATABASE_URL:-postgresql://nodedc_gelios:change-me-generate-with-infra-scripts-init-dev-env@gelios-postgres:5432/nodedc_gelios}
NODEDC_INTERNAL_ACCESS_TOKEN: ${NODEDC_INTERNAL_ACCESS_TOKEN:-}
GELIOS_TENANT_ID: ${GELIOS_TENANT_ID:-robot2b}
GELIOS_CONNECTION_ID: ${GELIOS_CONNECTION_ID:-gelios-primary}
GELIOS_UNIT_SCOPE: ${GELIOS_UNIT_SCOPE:-allowlist}
GELIOS_ALLOWED_UNIT_IDS: ${GELIOS_ALLOWED_UNIT_IDS:-}
GELIOS_INTAKE_ENABLED: ${GELIOS_INTAKE_ENABLED:-false}
GELIOS_RAW_RETENTION_DAYS: ${GELIOS_RAW_RETENTION_DAYS:-14}
expose:
- "18105"
ports:
- "${GELIOS_GATEWAY_HOST_BIND:-127.0.0.1:18105}:18105"
depends_on:
gelios-postgres:
condition: service_healthy
healthcheck:
test:
- CMD
- node
- -e
- "fetch('http://127.0.0.1:18105/healthz').then((response) => process.exit(response.ok ? 0 : 1)).catch(() => process.exit(1))"
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
networks:
- default
- gelios-data
ai-workspace-hub:
image: nodedc/ai-workspace-hub:local
build:
@ -180,10 +262,14 @@ services:
AI_WORKSPACE_HUB_WS_PATH: /api/ai-workspace/hub
NODEDC_INTERNAL_ACCESS_TOKEN: ${NODEDC_INTERNAL_ACCESS_TOKEN:-}
NODEDC_AI_WORKSPACE_ASSISTANT_URL: http://ai-workspace-assistant:18082
NODEDC_ONTOLOGY_CORE_URL: http://ontology-core:18104
expose:
- "18081"
ports:
- "${AI_WORKSPACE_HUB_HOST_BIND:-127.0.0.1:18081}:18081"
depends_on:
ontology-core:
condition: service_healthy
map-gateway:
image: nodedc/map-gateway:local
@ -194,6 +280,10 @@ services:
NODE_ENV: production
PORT: 18103
CESIUM_ION_TOKEN: ${CESIUM_ION_TOKEN:-}
# Foundry signs its private token-admin requests with the existing
# Platform internal credential. The value stays outside this compose
# file; this line only wires the local development runtime.
NODEDC_INTERNAL_ACCESS_TOKEN: ${NODEDC_INTERNAL_ACCESS_TOKEN:-}
CESIUM_ION_ASSET_ALLOWLIST: ${CESIUM_ION_ASSET_ALLOWLIST:-1,2,96188}
MAP_GATEWAY_UPSTREAM_ALLOWLIST: ${MAP_GATEWAY_UPSTREAM_ALLOWLIST:-api.cesium.com,assets.ion.cesium.com,tile.openstreetmap.org,dev.virtualearth.net,ecn.t0.tiles.virtualearth.net,ecn.t1.tiles.virtualearth.net,ecn.t2.tiles.virtualearth.net,ecn.t3.tiles.virtualearth.net}
# Offline access is selected explicitly with the cache profile, rather
@ -236,6 +326,7 @@ volumes:
authentik-certs:
notification-database:
ai-workspace-database:
gelios-database:
caddy-data:
caddy-config:
# Cache data is runtime state, not Compose lifecycle state. These volumes are
@ -247,3 +338,7 @@ volumes:
map-offline-snapshot:
external: true
name: ${NODEDC_MAP_OFFLINE_SNAPSHOT_VOLUME:-nodedc-platform_map-offline-snapshot}
networks:
gelios-data:
internal: true

View File

@ -93,6 +93,7 @@ run_sh_quiet() {
section "Static syntax"
run_cmd "Hub server syntax" node --check "$HUB_DIR/src/server.mjs"
run_cmd "Assistant server syntax" node --check "$ASSISTANT_DIR/src/server.mjs"
run_cmd "Ontology MCP server syntax" node --check "$ONTOLOGY_DIR/src/mcp-server.mjs"
run_cmd "Local environment safety checker syntax" node --check "$INFRA_DIR/scripts/check-local-environment-safety.mjs"
run_sh "Shell scripts syntax" "$PLATFORM_ROOT" \
"sh -n infra/scripts/init-dev-env.sh infra/scripts/check-local-test-system.sh infra/scripts/check-ai-workspace-topology.sh infra/scripts/check-ai-workspace-config-contract.sh infra/scripts/check-ai-workspace-release-gates.sh"
@ -128,8 +129,10 @@ fi
section "AI Workspace smokes"
run_sh_quiet "Assistant run-profile smoke" "$ASSISTANT_DIR" "npm run smoke:run-profile"
run_sh_quiet "AI Hub Ontology MCP proxy smoke" "$HUB_DIR" "npm run smoke:ontology-mcp-proxy"
if [ -d "$ONTOLOGY_DIR" ]; then
run_sh_quiet "Ontology assistant caller smoke" "$ONTOLOGY_DIR" "npm run smoke:assistant-caller"
run_sh_quiet "Ontology MCP smoke" "$ONTOLOGY_DIR" "npm run smoke:mcp"
else
skip "Ontology assistant caller smoke (missing $ONTOLOGY_DIR)"
fi

View File

@ -33,6 +33,24 @@ TASK_INTERNAL_LOGOUT_URL=https://ops.nodedc.ru/api/internal/nodedc/logout/
NODEDC_ENGINE_DOCKER_NETWORK=nodedc-demo_default
NODEDC_ENGINE_INTERNAL_URL=http://nodedc-demo-nodedc-backend-1:3001
# Platform Map Gateway — private service and one shared persistent TileCache.
# Optional one-time legacy bootstrap only. Preferred operation: an authorized
# Foundry admin saves the token through Page Library → Platform settings; it is
# then stored in the private NAS cache volume and never exposed to Foundry UI,
# deploy artifact or browser env. Do not paste a real token into this example.
CESIUM_ION_TOKEN=
CESIUM_ION_ASSET_ALLOWLIST=1,2,96188
MAP_CACHE_MODE=readwrite
MAP_CACHE_MAX_MB=20480
MAP_GATEWAY_HOST_BIND=127.0.0.1:18103
# Created by the root-owned deploy runner on first Map Gateway rollout.
# These are NAS paths, visible through SMB as nodedc-platform/map-gateway/.
MAP_LIVE_CACHE_HOST_DIR=/volume1/docker/nodedc-platform/map-gateway/live-tile-cache
MAP_OFFLINE_SNAPSHOT_HOST_DIR=/volume1/docker/nodedc-platform/map-gateway/offline-snapshot
MAP_GATEWAY_UPSTREAM_ALLOWLIST=api.cesium.com,assets.ion.cesium.com,tile.openstreetmap.org,dev.virtualearth.net,ecn.t0.tiles.virtualearth.net,ecn.t1.tiles.virtualearth.net,ecn.t2.tiles.virtualearth.net,ecn.t3.tiles.virtualearth.net
MAP_GATEWAY_LEGACY_CACHE_HOSTS=
MAP_GATEWAY_OFFLINE_PROVIDER_ALLOWLIST=
LAUNCHER_OIDC_ISSUER=https://id.nodedc.ru/application/o/launcher/
LAUNCHER_OIDC_CLIENT_ID=nodedc-launcher
LAUNCHER_OIDC_CLIENT_SECRET=replace-with-random-synology-secret
@ -55,6 +73,34 @@ SESSION_SECRET=replace-with-random-synology-secret
COOKIE_DOMAIN=.nas.nodedc
COOKIE_SECURE=false
# External Data Plane — provider-neutral storage. Generate a distinct database
# password; it must never reuse NODEDC_INTERNAL_ACCESS_TOKEN or a provider key.
EXTERNAL_DATA_PLANE_TIMESCALE_IMAGE=timescale/timescaledb-ha:pg16.14-ts2.28.2-all
EXTERNAL_DATA_PLANE_PG_DB=nodedc_data_plane
EXTERNAL_DATA_PLANE_PG_USER=nodedc_data_plane
EXTERNAL_DATA_PLANE_PG_PASS=replace-with-random-synology-secret
EXTERNAL_DATA_PLANE_HOST_BIND=127.0.0.1:18106
EXTERNAL_DATA_PLANE_DATABASE_POOL_SIZE=10
EXTERNAL_DATA_PLANE_RAW_RETENTION_DAYS=14
EXTERNAL_DATA_PLANE_MAX_BATCH_BYTES=5242880
EXTERNAL_DATA_PLANE_MAX_FACTS_PER_PUBLISH=5000
EXTERNAL_DATA_PLANE_MAX_ATTRIBUTES_BYTES_PER_FACT=65536
EXTERNAL_DATA_PLANE_MAX_PATCH_OPERATIONS=500
EXTERNAL_DATA_PLANE_MAX_PATCH_BYTES=262144
EXTERNAL_DATA_PLANE_PATCH_RETENTION_MS=3600000
EXTERNAL_DATA_PLANE_RECEIPT_RETENTION_MS=604800000
EXTERNAL_DATA_PLANE_RETENTION_DELETE_LIMIT=10000
EXTERNAL_DATA_PLANE_STREAM_HEARTBEAT_MS=20000
EXTERNAL_DATA_PLANE_STREAM_POLL_MS=1000
EXTERNAL_DATA_PLANE_MAX_READER_STREAMS=10
EXTERNAL_DATA_PLANE_WRITER_BINDING_MAX_TTL_DAYS=90
EXTERNAL_DATA_PLANE_MAX_FUTURE_SKEW_SECONDS=300
EXTERNAL_DATA_PLANE_RETENTION_SWEEP_MS=3600000
EXTERNAL_DATA_PLANE_LEGACY_INTAKE_ENABLED=false
# The writer-provisioner secret is a root-owned file under
# /volume1/docker/nodedc-platform/secrets/external-data-plane-provisioner/,
# never a shared .env value.
NOTIFICATION_PG_DB=nodedc_notifications
NOTIFICATION_PG_USER=nodedc_notifications
NOTIFICATION_PG_PASS=replace-with-random-synology-secret
@ -68,9 +114,41 @@ NODEDC_AI_WORKSPACE_ASSISTANT_URL=http://ai-workspace-assistant:18082
AI_WORKSPACE_OPS_ENTITLEMENT_URL=http://172.22.0.222:18190/api/internal/v1/ai-workspace/entitlements
AI_WORKSPACE_OPS_ENTITLEMENT_TOKEN=replace-with-ops-agent-gateway-internal-token
AI_WORKSPACE_OPS_ENTITLEMENT_REQUIRED=false
# Set only after Foundry passes Launcher handoff and Authentik access checks.
# Keep every existing entitlement adapter in this same JSON object. The adapter
# uses the existing NODE.DC internal service credential; no Foundry browser or
# worker secret is required.
# {"module-foundry":{"url":"https://foundry.nodedc.ru/api/ai-workspace/entitlements","required":false}}
AI_WORKSPACE_ENTITLEMENT_ADAPTERS_JSON=
AI_WORKSPACE_HUB_TOKEN=replace-with-random-synology-secret
AI_WORKSPACE_HUB_HOST_BIND=0.0.0.0:18081
AI_WORKSPACE_HUB_PUBLIC_URL=wss://ai-hub.nodedc.ru/api/ai-workspace/hub
AI_WORKSPACE_HUB_INTERNAL_URL=https://ai-hub.nodedc.ru
AI_WORKSPACE_HUB_FALLBACK_URLS=
AI_WORKSPACE_ONTOLOGY_MCP_ENABLED=true
# Optional public HTTP origin for the dynamic worker MCP URL. Normally it is
# derived from AI_WORKSPACE_HUB_PUBLIC_URL, so leave this empty.
AI_WORKSPACE_ONTOLOGY_MCP_PUBLIC_URL=
ONTOLOGY_CORE_HOST_BIND=127.0.0.1:18104
# Gelios Gateway — storage/read service. Provider credentials stay in the
# protected Engine Collector. Intake remains disabled until scope is approved.
GELIOS_TIMESCALE_IMAGE=timescale/timescaledb-ha:pg16.14-ts2.28.2-all
GELIOS_PG_DB=nodedc_gelios
GELIOS_PG_USER=nodedc_gelios
GELIOS_PG_PASS=replace-with-random-synology-secret
# URL-encode reserved characters in GELIOS_PG_PASS when forming this URL.
GELIOS_DATABASE_URL=postgresql://nodedc_gelios:replace-with-url-encoded-synology-secret@gelios-postgres:5432/nodedc_gelios
GELIOS_GATEWAY_HOST_BIND=127.0.0.1:18105
# Explicit tenant and connection identifiers are deployment configuration;
# do not encode a customer or pilot name in source defaults.
GELIOS_TENANT_ID=replace-with-tenant-id
GELIOS_CONNECTION_ID=gelios-connection-id
# `all` accepts every unit returned by this approved tenant + connection.
GELIOS_UNIT_SCOPE=allowlist
GELIOS_ALLOWED_UNIT_IDS=
GELIOS_INTAKE_ENABLED=false
GELIOS_RAW_RETENTION_DAYS=14
# Presentation state only; it does not change provider collection cadence.
GELIOS_POSITION_STALE_AFTER_MS=300000

View File

@ -202,6 +202,26 @@ TASKER_SYNC_SOURCE=1 ./infra/synology/deploy-current.sh
Если emergency-fix был сделан прямо на Synology в этих env-файлах, перенести sanitized-значение в `.env.synology.example`/docs, а секрет оставить только в live env.
### Gelios: сбор всех units доверенного подключения
`GELIOS_UNIT_SCOPE=all` означает **все текущие и будущие units только одной уже
настроенной пары `GELIOS_TENANT_ID` + `GELIOS_CONNECTION_ID`**. Это не wildcard
на другие tenants или providers. Provider credential остаётся только в Engine.
Пропавший из очередного ответа unit не удаляется: его stable provider ID и
`last_seen_at` сохраняются; видимость на карте — отдельная логика витрины.
Перед каноническим `nodedc-deploy apply` включить политику на Synology (скрипт
создаёт backup и не выводит секреты):
```bash
sudo bash /volume1/docker/nodedc-deploy/inbox/prepare-gelios-all-units-env.sh
```
Затем применить узкий Platform-артефакт, собранный с `--gateway-only`, через
`nodedc-deploy`. В его plan должны быть только `gelios-postgres` и
`gelios-gateway`; общий `docker-compose` в такой архив не входит. После apply
Gateway начинает принимать весь состав units этого подключения.
## AI Hub relay-only deploy
Для локального тестирования с внешней Codex-машиной нельзя деплоить Launcher, Engine, Ops, Authentik или Tasker. Единственная допустимая удалённая точка в этой схеме — `ai-workspace-hub`, потому что удалённый worker должен иметь публичный WebSocket/HTTP relay.

View File

@ -34,12 +34,28 @@ echo "== ai workspace assistant image build =="
cd "${PLATFORM_DIR}/ai-workspace-assistant"
"${DOCKER_BIN}" build --no-cache -t nodedc/ai-workspace-assistant:local .
echo "== ontology core image build =="
cd "${PLATFORM_DIR}/ontology-core"
"${DOCKER_BIN}" build --no-cache -t nodedc/ontology-core:local .
echo "== gelios gateway image build =="
cd "${PLATFORM_DIR}/gelios-gateway"
"${DOCKER_BIN}" build --no-cache -t nodedc/gelios-gateway:local .
echo "== platform services recreate =="
cd "${PLATFORM_DIR}"
"${DOCKER_BIN}" compose \
--env-file "${ENV_FILE}" \
-f "${COMPOSE_FILE}" \
up -d --force-recreate reverse-proxy authentik-server authentik-worker notification-postgres notification-core ai-workspace-postgres ai-workspace-assistant ai-workspace-hub launcher
up -d --force-recreate reverse-proxy authentik-server authentik-worker notification-postgres notification-core ai-workspace-postgres ontology-core ai-workspace-assistant ai-workspace-hub gelios-postgres gelios-gateway launcher
echo "== ontology core health check =="
"${DOCKER_BIN}" exec nodedc-platform-ontology-core-1 sh -lc \
'node -e '"'"'fetch("http://127.0.0.1:18104/healthz").then(async (response) => { console.log(await response.text()); process.exit(response.ok ? 0 : 1); }).catch((error) => { console.error(error); process.exit(1); })'"'"''
echo "== gelios gateway health check =="
"${DOCKER_BIN}" exec nodedc-platform-gelios-gateway-1 sh -lc \
'node -e '"'"'fetch("http://127.0.0.1:18105/healthz").then(async (response) => { console.log(await response.text()); process.exit(response.ok ? 0 : 1); }).catch((error) => { console.error(error); process.exit(1); })'"'"''
echo "== notification core health check =="
"${DOCKER_BIN}" exec nodedc-platform-notification-core-1 sh -lc \

View File

@ -48,6 +48,7 @@ rsync_dir "${NAS_ROOT}/platform/authentik/" "${BACKUP_DIR}/files/platform/authen
rsync_dir "${NAS_ROOT}/platform/notification-core/" "${BACKUP_DIR}/files/platform/notification-core/"
rsync_dir "${NAS_ROOT}/platform/ai-workspace-hub/" "${BACKUP_DIR}/files/platform/ai-workspace-hub/"
rsync_dir "${NAS_ROOT}/platform/ai-workspace-assistant/" "${BACKUP_DIR}/files/platform/ai-workspace-assistant/"
rsync_dir "${NAS_ROOT}/platform/ontology-core/" "${BACKUP_DIR}/files/platform/ontology-core/"
rsync_dir "${NAS_ROOT}/bim-viewer/source/server/data/" "${BACKUP_DIR}/files/bim-viewer/server-data/"
rsync_file "${NAS_ROOT}/platform/.env.synology" "${BACKUP_DIR}/files/platform/"
@ -81,6 +82,7 @@ Contains:
- Notification Core source/config: platform/notification-core, platform compose/env
- AI Workspace Hub source/config: platform/ai-workspace-hub, platform compose/env
- AI Workspace Assistant source/config: platform/ai-workspace-assistant, platform compose/env
- Ontology Core source/catalog: platform/ontology-core, platform compose/env
- Tasker runtime config: tasker/plane-app/.env.synology, compose, Synology override
- Ops Agents Gateway runtime config: ops-agents/.env, compose
- BIM Viewer runtime data/config: bim-viewer/source/server/data, .env, compose

View File

@ -84,6 +84,22 @@ rsync -av "${RSYNC_METADATA_ARGS[@]}" --delete \
"${PLATFORM_REPO}/services/ontology-core/" \
"${NAS_ROOT}/platform/ai-workspace-assistant/ontology-core/"
mkdir -p "${NAS_ROOT}/platform/ontology-core"
rsync -av "${RSYNC_METADATA_ARGS[@]}" --delete \
--exclude='node_modules/' \
--exclude='.env' \
--exclude='.env.*' \
"${PLATFORM_REPO}/services/ontology-core/" \
"${NAS_ROOT}/platform/ontology-core/"
mkdir -p "${NAS_ROOT}/platform/gelios-gateway"
rsync -av "${RSYNC_METADATA_ARGS[@]}" --delete \
--exclude='node_modules/' \
--exclude='.env' \
--exclude='.env.*' \
"${PLATFORM_REPO}/services/gelios-gateway/" \
"${NAS_ROOT}/platform/gelios-gateway/"
if [[ "${SYNC_AUTHENTIK_TEMPLATES}" == "1" ]]; then
mkdir -p "${NAS_ROOT}/authentik/custom-templates"
rsync -av "${RSYNC_METADATA_ARGS[@]}" --delete \
@ -240,7 +256,20 @@ fi
cat <<'EOF'
Synced files to NAS mount. Run on Synology to apply runtime changes:
Synced source to the NAS mount and staged the root-owned runner candidate.
For a canonical deploy, do not use the legacy direct runtime scripts below.
An administrator first installs/verifies the staged runner, then plans and
applies a data-only artifact from /volume1/docker/nodedc-deploy/inbox:
sudo install -o root -g root -m 0755 \
/volume1/docker/nodedc-deploy/runner-install/nodedc-deploy \
/usr/local/sbin/nodedc-deploy
sudo /usr/local/sbin/nodedc-deploy verify-install
sudo /usr/local/sbin/nodedc-deploy plan /volume1/docker/nodedc-deploy/inbox/<artifact>.tgz
sudo /usr/local/sbin/nodedc-deploy apply /volume1/docker/nodedc-deploy/inbox/<artifact>.tgz
Legacy direct runtime scripts (emergency/manual recovery only):
cd /volume1/docker/nodedc-platform/platform
sudo bash apply-current-runtime.sh
@ -276,14 +305,14 @@ cd /volume1/docker/nodedc-platform/platform
sudo /usr/local/bin/docker compose \
--env-file /volume1/docker/nodedc-platform/platform/.env.synology \
-f /volume1/docker/nodedc-platform/platform/docker-compose.platform-http.yml \
up -d --build --force-recreate --no-deps ai-workspace-hub ai-workspace-assistant notification-core launcher
up -d --build --force-recreate --no-deps ontology-core ai-workspace-hub ai-workspace-assistant notification-core launcher
Optional Platform/Auth infra apply, only after deliberate compose/proxy/Auth templates changes:
sudo /usr/local/bin/docker compose \
--env-file /volume1/docker/nodedc-platform/platform/.env.synology \
-f /volume1/docker/nodedc-platform/platform/docker-compose.platform-http.yml \
up -d --build --force-recreate --no-deps reverse-proxy authentik-server authentik-worker ai-workspace-hub ai-workspace-assistant notification-core launcher
up -d --build --force-recreate --no-deps reverse-proxy authentik-server authentik-worker ontology-core ai-workspace-hub ai-workspace-assistant notification-core launcher
After Authentik template rollout, clear global Brand custom CSS from the live DB.
NODE.DC login CSS must be template-scoped, not stored in Brand.branding_custom_css:

View File

@ -0,0 +1,96 @@
name: nodedc-platform
# This is intentionally an overlay, not a provider service. It is composed
# with docker-compose.platform-http.yml by the canonical deploy runner.
services:
external-data-plane-postgres:
image: ${EXTERNAL_DATA_PLANE_TIMESCALE_IMAGE:-timescale/timescaledb-ha:pg16.14-ts2.28.2-all}
restart: unless-stopped
env_file:
- ${NODEDC_SYNOLOGY_ENV_FILE:-.env.synology}
environment:
POSTGRES_DB: ${EXTERNAL_DATA_PLANE_PG_DB:-nodedc_data_plane}
POSTGRES_USER: ${EXTERNAL_DATA_PLANE_PG_USER:-nodedc_data_plane}
POSTGRES_PASSWORD: ${EXTERNAL_DATA_PLANE_PG_PASS:?external data plane database password required}
healthcheck:
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
interval: 30s
timeout: 5s
retries: 5
start_period: 30s
volumes:
- external-data-plane-database:/home/postgres/pgdata/data
networks:
- external-data-plane
external-data-plane:
image: nodedc/external-data-plane:local
restart: unless-stopped
env_file:
- ${NODEDC_SYNOLOGY_ENV_FILE:-.env.synology}
environment:
NODE_ENV: production
PORT: 18106
EXTERNAL_DATA_PLANE_DATABASE_URL: postgresql://${EXTERNAL_DATA_PLANE_PG_USER:-nodedc_data_plane}:${EXTERNAL_DATA_PLANE_PG_PASS:?external data plane database password required}@external-data-plane-postgres:5432/${EXTERNAL_DATA_PLANE_PG_DB:-nodedc_data_plane}
EXTERNAL_DATA_PLANE_DATABASE_POOL_SIZE: ${EXTERNAL_DATA_PLANE_DATABASE_POOL_SIZE:-10}
EXTERNAL_DATA_PLANE_RAW_RETENTION_DAYS: ${EXTERNAL_DATA_PLANE_RAW_RETENTION_DAYS:-14}
EXTERNAL_DATA_PLANE_MAX_BATCH_BYTES: ${EXTERNAL_DATA_PLANE_MAX_BATCH_BYTES:-5242880}
EXTERNAL_DATA_PLANE_MAX_FACTS_PER_PUBLISH: ${EXTERNAL_DATA_PLANE_MAX_FACTS_PER_PUBLISH:-5000}
EXTERNAL_DATA_PLANE_MAX_ATTRIBUTES_BYTES_PER_FACT: ${EXTERNAL_DATA_PLANE_MAX_ATTRIBUTES_BYTES_PER_FACT:-65536}
EXTERNAL_DATA_PLANE_MAX_PATCH_OPERATIONS: ${EXTERNAL_DATA_PLANE_MAX_PATCH_OPERATIONS:-500}
EXTERNAL_DATA_PLANE_MAX_PATCH_BYTES: ${EXTERNAL_DATA_PLANE_MAX_PATCH_BYTES:-262144}
EXTERNAL_DATA_PLANE_PATCH_RETENTION_MS: ${EXTERNAL_DATA_PLANE_PATCH_RETENTION_MS:-3600000}
EXTERNAL_DATA_PLANE_RECEIPT_RETENTION_MS: ${EXTERNAL_DATA_PLANE_RECEIPT_RETENTION_MS:-604800000}
EXTERNAL_DATA_PLANE_RETENTION_DELETE_LIMIT: ${EXTERNAL_DATA_PLANE_RETENTION_DELETE_LIMIT:-10000}
EXTERNAL_DATA_PLANE_STREAM_HEARTBEAT_MS: ${EXTERNAL_DATA_PLANE_STREAM_HEARTBEAT_MS:-20000}
EXTERNAL_DATA_PLANE_STREAM_POLL_MS: ${EXTERNAL_DATA_PLANE_STREAM_POLL_MS:-1000}
EXTERNAL_DATA_PLANE_MAX_READER_STREAMS: ${EXTERNAL_DATA_PLANE_MAX_READER_STREAMS:-10}
EXTERNAL_DATA_PLANE_WRITER_BINDING_MAX_TTL_DAYS: ${EXTERNAL_DATA_PLANE_WRITER_BINDING_MAX_TTL_DAYS:-90}
EXTERNAL_DATA_PLANE_MAX_FUTURE_SKEW_SECONDS: ${EXTERNAL_DATA_PLANE_MAX_FUTURE_SKEW_SECONDS:-300}
EXTERNAL_DATA_PLANE_RETENTION_SWEEP_MS: ${EXTERNAL_DATA_PLANE_RETENTION_SWEEP_MS:-3600000}
# Migration-only shared-token routes stay closed in a canonical install.
EXTERNAL_DATA_PLANE_LEGACY_INTAKE_ENABLED: ${EXTERNAL_DATA_PLANE_LEGACY_INTAKE_ENABLED:-false}
NODEDC_INTERNAL_ACCESS_TOKEN: ${NODEDC_INTERNAL_ACCESS_TOKEN:-}
# Runner-owned secret file. This credential must never live in the
# shared .env.synology that is inherited by unrelated services.
EXTERNAL_DATA_PLANE_PROVISIONER_TOKEN_FILE: /run/nodedc-secrets/external-data-plane-provisioner-token
# This stays false until the dedicated Engine provisioner is deployed and
# receives the same read-only secret mount.
EXTERNAL_DATA_PLANE_PROVISIONING_ENABLED: "false"
volumes:
- type: bind
source: /volume1/docker/nodedc-platform/secrets/external-data-plane-provisioner/token
target: /run/nodedc-secrets/external-data-plane-provisioner-token
read_only: true
bind:
create_host_path: false
expose:
- "18106"
ports:
- "${EXTERNAL_DATA_PLANE_HOST_BIND:-127.0.0.1:18106}:18106"
depends_on:
external-data-plane-postgres:
condition: service_healthy
healthcheck:
test:
- CMD
- node
- -e
- "fetch('http://127.0.0.1:18106/healthz').then((response) => process.exit(response.ok ? 0 : 1)).catch(() => process.exit(1))"
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
networks:
- engine
- external-data-plane
networks:
engine:
external: true
name: ${NODEDC_ENGINE_DOCKER_NETWORK:-nodedc-demo_default}
external-data-plane:
internal: true
volumes:
external-data-plane-database:

View File

@ -142,6 +142,8 @@ services:
AI_WORKSPACE_HUB_INTERNAL_URL: ${AI_WORKSPACE_HUB_INTERNAL_URL:-https://ai-hub.nodedc.ru}
AI_WORKSPACE_HUB_TOKEN: ${AI_WORKSPACE_HUB_TOKEN:?ai workspace hub token required}
AI_WORKSPACE_HUB_FALLBACK_URLS: ${AI_WORKSPACE_HUB_FALLBACK_URLS:-}
AI_WORKSPACE_ONTOLOGY_MCP_ENABLED: ${AI_WORKSPACE_ONTOLOGY_MCP_ENABLED:-true}
AI_WORKSPACE_ENTITLEMENT_ADAPTERS_JSON: ${AI_WORKSPACE_ENTITLEMENT_ADAPTERS_JSON:-}
NDC_ONTOLOGY_LAUNCHER_BASE_URL: http://launcher:5173
expose:
- "18082"
@ -154,6 +156,160 @@ services:
- identity
- engine
ontology-core:
image: nodedc/ontology-core:local
build:
context: ./ontology-core
restart: unless-stopped
env_file:
- ${NODEDC_SYNOLOGY_ENV_FILE:-.env.synology}
environment:
NODE_ENV: production
PORT: 18104
NODEDC_INTERNAL_ACCESS_TOKEN: ${NODEDC_INTERNAL_ACCESS_TOKEN:-}
expose:
- "18104"
ports:
- "${ONTOLOGY_CORE_HOST_BIND:-127.0.0.1:18104}:18104"
healthcheck:
test:
- CMD
- node
- -e
- "fetch('http://127.0.0.1:18104/healthz').then((response) => process.exit(response.ok ? 0 : 1)).catch(() => process.exit(1))"
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
networks:
- engine
# The provider database is private. Gateway is the only component that can
# reach it; the host bind is loopback-only for audited operator diagnostics.
gelios-postgres:
image: ${GELIOS_TIMESCALE_IMAGE:-timescale/timescaledb-ha:pg16.14-ts2.28.2-all}
restart: unless-stopped
env_file:
- ${NODEDC_SYNOLOGY_ENV_FILE:-.env.synology}
environment:
POSTGRES_DB: ${GELIOS_PG_DB:-nodedc_gelios}
POSTGRES_USER: ${GELIOS_PG_USER:-nodedc_gelios}
POSTGRES_PASSWORD: ${GELIOS_PG_PASS:?gelios database password required}
healthcheck:
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
interval: 30s
timeout: 5s
retries: 5
start_period: 30s
volumes:
- gelios-database:/home/postgres/pgdata/data
networks:
- gelios-data
gelios-gateway:
image: nodedc/gelios-gateway:local
build:
context: ./gelios-gateway
restart: unless-stopped
env_file:
- ${NODEDC_SYNOLOGY_ENV_FILE:-.env.synology}
environment:
NODE_ENV: production
PORT: 18105
DATABASE_URL: ${GELIOS_DATABASE_URL:?gelios database URL required}
NODEDC_INTERNAL_ACCESS_TOKEN: ${NODEDC_INTERNAL_ACCESS_TOKEN:-}
GELIOS_TENANT_ID: ${GELIOS_TENANT_ID:?gelios tenant id required}
GELIOS_CONNECTION_ID: ${GELIOS_CONNECTION_ID:?gelios connection id required}
GELIOS_UNIT_SCOPE: ${GELIOS_UNIT_SCOPE:-allowlist}
GELIOS_ALLOWED_UNIT_IDS: ${GELIOS_ALLOWED_UNIT_IDS:-}
GELIOS_INTAKE_ENABLED: ${GELIOS_INTAKE_ENABLED:-false}
GELIOS_RAW_RETENTION_DAYS: ${GELIOS_RAW_RETENTION_DAYS:-14}
GELIOS_POSITION_STALE_AFTER_MS: ${GELIOS_POSITION_STALE_AFTER_MS:-300000}
expose:
- "18105"
ports:
- "${GELIOS_GATEWAY_HOST_BIND:-127.0.0.1:18105}:18105"
depends_on:
gelios-postgres:
condition: service_healthy
healthcheck:
test:
- CMD
- node
- -e
- "fetch('http://127.0.0.1:18105/healthz').then((response) => process.exit(response.ok ? 0 : 1)).catch(() => process.exit(1))"
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
networks:
- engine
- gelios-data
# One private Platform gateway and one NAS-resident persistent cache serve
# every Foundry Application/Map Page instance. It is not published to a browser.
map-gateway:
image: nodedc/map-gateway:local
build:
context: ../../services/map-gateway
restart: unless-stopped
env_file:
- ${NODEDC_SYNOLOGY_ENV_FILE:-.env.synology}
environment:
NODE_ENV: production
PORT: 18103
# Optional legacy bootstrap. The canonical mutable value, when an
# authorized Foundry admin saves it, is the private NAS secret file
# inside MAP_CACHE_DIR and never a deploy artifact or browser env.
CESIUM_ION_TOKEN: ${CESIUM_ION_TOKEN:-}
# Runner-owned file mount; the secret itself is never an env value.
NODEDC_MAP_GATEWAY_ADMIN_SECRET_FILE: /run/nodedc-secrets/map-gateway-admin-secret
# DC AMD Proxy preserves the AMD workstation's VPN-only Cesium route.
# The fixed NAS LAN listener is authenticated by the same runner-managed
# file token; neither token is a browser or Foundry env.
MAP_GATEWAY_EGRESS_URL: http://172.22.0.222:8790
MAP_GATEWAY_EGRESS_PROXY_TOKEN_FILE: /run/nodedc-secrets/map-egress-proxy-token
CESIUM_ION_ASSET_ALLOWLIST: ${CESIUM_ION_ASSET_ALLOWLIST:-1,2,96188}
MAP_CACHE_DIR: /var/lib/nodedc-map-live-cache
MAP_OFFLINE_SNAPSHOT_DIR: /var/lib/nodedc-map-offline-snapshot
MAP_CACHE_MODE: ${MAP_CACHE_MODE:-readwrite}
MAP_CACHE_MAX_MB: ${MAP_CACHE_MAX_MB:-20480}
MAP_GATEWAY_ALLOW_ANONYMOUS: "false"
MAP_GATEWAY_TRUSTED_SUBJECT_HEADER: x-nodedc-user-id
MAP_GATEWAY_UPSTREAM_ALLOWLIST: ${MAP_GATEWAY_UPSTREAM_ALLOWLIST:-api.cesium.com,assets.ion.cesium.com,tile.openstreetmap.org,dev.virtualearth.net,ecn.t0.tiles.virtualearth.net,ecn.t1.tiles.virtualearth.net,ecn.t2.tiles.virtualearth.net,ecn.t3.tiles.virtualearth.net}
MAP_GATEWAY_LEGACY_CACHE_HOSTS: ${MAP_GATEWAY_LEGACY_CACHE_HOSTS:-}
MAP_GATEWAY_OFFLINE_PROVIDER_ALLOWLIST: ${MAP_GATEWAY_OFFLINE_PROVIDER_ALLOWLIST:-}
volumes:
- type: bind
source: ${MAP_LIVE_CACHE_HOST_DIR:-/volume1/docker/nodedc-platform/map-gateway/live-tile-cache}
target: /var/lib/nodedc-map-live-cache
- type: bind
source: ${MAP_OFFLINE_SNAPSHOT_HOST_DIR:-/volume1/docker/nodedc-platform/map-gateway/offline-snapshot}
target: /var/lib/nodedc-map-offline-snapshot
read_only: true
- type: bind
source: /volume1/docker/nodedc-platform/secrets/map-gateway-admin-secret
target: /run/nodedc-secrets/map-gateway-admin-secret
read_only: true
- type: bind
source: /volume1/docker/nodedc-platform/secrets/map-egress-proxy-token
target: /run/nodedc-secrets/map-egress-proxy-token
read_only: true
# Operator-only loopback binding for the root-owned deploy healthcheck.
# Foundry reaches the service over the private Docker network; browsers
# never receive this address.
ports:
- "${MAP_GATEWAY_HOST_BIND:-127.0.0.1:18103}:18103"
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:18103/healthz',{headers:{'x-nodedc-user-id':'healthcheck'}}).then((response)=>process.exit(response.ok?0:1)).catch(()=>process.exit(1))"]
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
networks:
- engine
- map-egress
ai-workspace-hub:
image: nodedc/ai-workspace-hub:local
build:
@ -167,11 +323,15 @@ services:
AI_WORKSPACE_HUB_TOKEN: ${AI_WORKSPACE_HUB_TOKEN:?ai workspace hub token required}
NODEDC_INTERNAL_ACCESS_TOKEN: ${NODEDC_INTERNAL_ACCESS_TOKEN:-}
NODEDC_AI_WORKSPACE_ASSISTANT_URL: http://ai-workspace-assistant:18082
NODEDC_ONTOLOGY_CORE_URL: http://ontology-core:18104
AI_WORKSPACE_HUB_WS_PATH: /api/ai-workspace/hub
expose:
- "18081"
ports:
- "${AI_WORKSPACE_HUB_HOST_BIND:-0.0.0.0:18081}:18081"
depends_on:
ontology-core:
condition: service_healthy
networks:
- edge
- engine
@ -256,6 +416,11 @@ networks:
name: ${NODEDC_ENGINE_DOCKER_NETWORK:-nodedc-demo_default}
identity:
internal: true
gelios-data:
internal: true
map-egress:
external: true
name: nodedc-map-egress
volumes:
authentik-database:
@ -263,5 +428,6 @@ volumes:
authentik-certs:
notification-database:
ai-workspace-database:
gelios-database:
caddy-data:
caddy-config:

View File

@ -0,0 +1,39 @@
#!/bin/sh
set -eu
ENV_FILE=${1:-/volume1/docker/nodedc-platform/platform/.env.synology}
if [ ! -f "$ENV_FILE" ]; then
echo "ERROR: env file not found: $ENV_FILE" >&2
exit 1
fi
if grep -q '^EXTERNAL_DATA_PLANE_PG_PASS=' "$ENV_FILE"; then
echo "External Data Plane database credential already exists; no change made."
exit 0
fi
if ! command -v openssl >/dev/null 2>&1; then
echo "ERROR: openssl is required to generate the database password." >&2
exit 1
fi
umask 077
TMP_FILE=$(mktemp "${ENV_FILE}.external-data-plane.XXXXXX")
trap 'rm -f "$TMP_FILE"' EXIT HUP INT TERM
PASSWORD=$(openssl rand -hex 32)
cp "$ENV_FILE" "$TMP_FILE"
printf '\n# External Data Plane — generated locally; do not reuse an internal API token.\n' >> "$TMP_FILE"
printf 'EXTERNAL_DATA_PLANE_TIMESCALE_IMAGE=timescale/timescaledb-ha:pg16.14-ts2.28.2-all\n' >> "$TMP_FILE"
printf 'EXTERNAL_DATA_PLANE_PG_DB=nodedc_data_plane\n' >> "$TMP_FILE"
printf 'EXTERNAL_DATA_PLANE_PG_USER=nodedc_data_plane\n' >> "$TMP_FILE"
printf 'EXTERNAL_DATA_PLANE_PG_PASS=%s\n' "$PASSWORD" >> "$TMP_FILE"
printf 'EXTERNAL_DATA_PLANE_HOST_BIND=127.0.0.1:18106\n' >> "$TMP_FILE"
printf 'EXTERNAL_DATA_PLANE_DATABASE_POOL_SIZE=10\n' >> "$TMP_FILE"
printf 'EXTERNAL_DATA_PLANE_RAW_RETENTION_DAYS=14\n' >> "$TMP_FILE"
printf 'EXTERNAL_DATA_PLANE_MAX_BATCH_BYTES=5242880\n' >> "$TMP_FILE"
mv "$TMP_FILE" "$ENV_FILE"
trap - EXIT HUP INT TERM
echo "External Data Plane database configuration was added. The generated password was not printed."

View File

@ -0,0 +1,81 @@
#!/usr/bin/env bash
set -euo pipefail
# This script is intentionally run on the Synology host, before the canonical
# platform artifact apply. It changes collection policy only; provider
# credentials remain in the protected Engine credential store.
NAS_ROOT="${NAS_ROOT:-/volume1/docker/nodedc-platform}"
PLATFORM_ENV="${PLATFORM_ENV:-${NAS_ROOT}/platform/.env.synology}"
BACKUP_ROOT="${BACKUP_ROOT:-/volume1/docker/nodedc-deploy/backups/gelios-policy}"
TIMESTAMP="${TIMESTAMP:-$(date +%Y%m%d-%H%M%S)}"
BACKUP_DIR="${BACKUP_DIR:-${BACKUP_ROOT}/${TIMESTAMP}}"
read_env() {
local file="$1"
local key="$2"
awk -F= -v key="${key}" '
$0 ~ "^[[:space:]]*#" { next }
$1 == key {
value = substr($0, index($0, "=") + 1)
gsub(/^[[:space:]]+|[[:space:]]+$/, "", value)
gsub(/^"|"$/, "", value)
print value
exit
}
' "${file}"
}
set_env_value() {
local file="$1"
local key="$2"
local value="$3"
local tmp
tmp="$(mktemp "${file}.tmp.XXXXXX")"
awk -v key="${key}" -v value="${value}" '
BEGIN { replaced = 0 }
$0 ~ "^[[:space:]]*#" { print; next }
index($0, key "=") == 1 {
print key "=" value
replaced = 1
next
}
{ print }
END {
if (!replaced) print key "=" value
}
' "${file}" > "${tmp}"
chmod 600 "${tmp}"
mv "${tmp}" "${file}"
}
if [[ ! -f "${PLATFORM_ENV}" ]]; then
echo "missing Platform env: ${PLATFORM_ENV}" >&2
exit 1
fi
tenant_id="$(read_env "${PLATFORM_ENV}" GELIOS_TENANT_ID || true)"
connection_id="$(read_env "${PLATFORM_ENV}" GELIOS_CONNECTION_ID || true)"
if [[ -z "${tenant_id}" || -z "${connection_id}" || "${tenant_id}" == replace-with-* || "${connection_id}" == replace-with-* ]]; then
echo "GELIOS_TENANT_ID and GELIOS_CONNECTION_ID must be configured before enabling all-unit collection" >&2
exit 1
fi
mkdir -p "${BACKUP_DIR}"
backup_path="${BACKUP_DIR}/platform.env.synology.before-gelios-all-units"
cp "${PLATFORM_ENV}" "${backup_path}"
chmod 600 "${backup_path}"
# `all` is constrained to the one tenant + connection validated above; it is
# not a wildcard across Gelios tenants or other providers. Existing stable
# unit rows are never deleted by this policy.
set_env_value "${PLATFORM_ENV}" GELIOS_UNIT_SCOPE "all"
set_env_value "${PLATFORM_ENV}" GELIOS_INTAKE_ENABLED "true"
cat <<EOF
gelios-all-units-env-prep-ok
backup_path=${backup_path}
updated_platform_env=${PLATFORM_ENV}
unit_scope=all
intake_enabled=true
secrets_printed=false
EOF

View File

@ -63,6 +63,14 @@ echo "== ai workspace assistant health check =="
"${DOCKER_BIN}" exec nodedc-platform-ai-workspace-assistant-1 sh -lc \
'node -e '"'"'fetch("http://127.0.0.1:18082/healthz").then(async (response) => { console.log(await response.text()); process.exit(response.ok ? 0 : 1); }).catch((error) => { console.error(error); process.exit(1); })'"'"''
echo "== ontology core health check =="
"${DOCKER_BIN}" exec nodedc-platform-ontology-core-1 sh -lc \
'node -e '"'"'fetch("http://127.0.0.1:18104/healthz").then(async (response) => { console.log(await response.text()); process.exit(response.ok ? 0 : 1); }).catch((error) => { console.error(error); process.exit(1); })'"'"''
echo "== gelios gateway health check =="
"${DOCKER_BIN}" exec nodedc-platform-gelios-gateway-1 sh -lc \
'node -e '"'"'fetch("http://127.0.0.1:18105/healthz").then(async (response) => { console.log(await response.text()); process.exit(response.ok ? 0 : 1); }).catch((error) => { console.error(error); process.exit(1); })'"'"''
echo "== ai workspace assistant hub target check =="
"${DOCKER_BIN}" exec nodedc-platform-ai-workspace-assistant-1 sh -lc \
'test "$AI_WORKSPACE_HUB_PUBLIC_URL" = "wss://ai-hub.nodedc.ru/api/ai-workspace/hub" && test -z "$AI_WORKSPACE_HUB_FALLBACK_URLS" && echo ai-workspace-prod-hub-target-ok'