feat(deploy): register managed platform runtime components
This commit is contained in:
parent
fedaf24098
commit
567f1550ab
|
|
@ -16,6 +16,9 @@ build/
|
||||||
.next/
|
.next/
|
||||||
coverage/
|
coverage/
|
||||||
|
|
||||||
|
# canonical deploy packages are transferred through the NAS inbox, not Git
|
||||||
|
infra/deploy-artifacts/
|
||||||
|
|
||||||
# logs
|
# logs
|
||||||
*.log
|
*.log
|
||||||
logs/
|
logs/
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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.
|
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`.
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,35 @@ NODEDC_INTERNAL_ACCESS_TOKEN=change-me-generate-with-infra-scripts-init-dev-env
|
||||||
COOKIE_DOMAIN=.local.nodedc
|
COOKIE_DOMAIN=.local.nodedc
|
||||||
COOKIE_SECURE=false
|
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 core
|
||||||
NOTIFICATION_PG_DB=nodedc_notifications
|
NOTIFICATION_PG_DB=nodedc_notifications
|
||||||
NOTIFICATION_PG_USER=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_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_TOKEN=replace-with-ops-agent-gateway-internal-token
|
||||||
AI_WORKSPACE_OPS_ENTITLEMENT_REQUIRED=false
|
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_ENABLED=true
|
||||||
AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ID=local-dev
|
AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ID=local-dev
|
||||||
|
AI_WORKSPACE_ONTOLOGY_MCP_ENABLED=true
|
||||||
|
|
||||||
# AI Workspace Hub for downloaded Codex workers.
|
# AI Workspace Hub for downloaded Codex workers.
|
||||||
# Default local development may use the deployed AI Hub only as a relay for remote 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_PUBLIC_URL=wss://ai-hub.nodedc.ru/api/ai-workspace/hub
|
||||||
AI_WORKSPACE_HUB_INTERNAL_URL=https://ai-hub.nodedc.ru
|
AI_WORKSPACE_HUB_INTERNAL_URL=https://ai-hub.nodedc.ru
|
||||||
AI_WORKSPACE_HUB_FALLBACK_URLS=
|
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.
|
# 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.
|
# Never copy it into workflow metadata, Git, frontend code, or a runtime cache key.
|
||||||
|
|
|
||||||
|
|
@ -78,18 +78,11 @@ Generated Authentik bootstrap credentials are stored only in `infra/.env`.
|
||||||
|
|
||||||
## Map Gateway и offline TileCache
|
## 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
|
Для реального 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`.
|
||||||
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`.
|
|
||||||
|
|
||||||
5. Bootstrap local Authentik groups and OIDC applications:
|
5. Bootstrap local Authentik groups and OIDC applications:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,13 @@ GROUP_SPECS = [
|
||||||
("nodedc:taskmanager:admin", False),
|
("nodedc:taskmanager:admin", False),
|
||||||
("nodedc:taskmanager:user", False),
|
("nodedc:taskmanager:user", False),
|
||||||
("nodedc:bim:access", 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 = [
|
APP_SPECS = [
|
||||||
|
|
@ -121,6 +128,10 @@ def ensure_user_groups(groups):
|
||||||
user.groups.add(authentik_admins)
|
user.groups.add(authentik_admins)
|
||||||
|
|
||||||
for name in groups:
|
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])
|
user.groups.add(groups[name])
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,164 @@ Supported components in this source:
|
||||||
- `tasker`
|
- `tasker`
|
||||||
- `ops-agents`
|
- `ops-agents`
|
||||||
- `bim-viewer`
|
- `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:
|
Install or update the root-owned live runner on Synology:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { cp, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { dirname, join, resolve } from "node:path";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const platformRoot = resolve(scriptDir, "../..");
|
||||||
|
const artifactDir = resolve(scriptDir, "../deploy-artifacts");
|
||||||
|
const [patchId = "dc-amd-proxy-bootstrap-20260715-001", ...extra] = process.argv.slice(2);
|
||||||
|
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) throw new Error("usage: build-dc-amd-proxy-artifact.mjs [patch-id]");
|
||||||
|
|
||||||
|
const files = [
|
||||||
|
["services/dc-amd-proxy/Dockerfile", "Dockerfile"],
|
||||||
|
["services/dc-amd-proxy/README.md", "README.md"],
|
||||||
|
["services/dc-amd-proxy/docker-compose.yml", "docker-compose.yml"],
|
||||||
|
["services/dc-amd-proxy/package.json", "package.json"],
|
||||||
|
["services/dc-amd-proxy/server.mjs", "server.mjs"],
|
||||||
|
];
|
||||||
|
const stage = await mkdtemp(join(tmpdir(), "nodedc-dc-amd-proxy-artifact-"));
|
||||||
|
const payload = join(stage, "payload");
|
||||||
|
const target = join(artifactDir, `nodedc-${patchId}.tgz`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mkdir(payload, { recursive: true });
|
||||||
|
for (const [source, destination] of files) await copySafe(resolve(platformRoot, source), join(payload, destination));
|
||||||
|
await writeFile(join(stage, "manifest.env"), `id=${patchId}\ncomponent=dc-amd-proxy\ntype=app-overlay\n`, "utf8");
|
||||||
|
await writeFile(join(stage, "files.txt"), `${files.map(([, destination]) => destination).join("\n")}\n`, "utf8");
|
||||||
|
await mkdir(artifactDir, { recursive: true });
|
||||||
|
const tar = spawnSync("python3", ["-c", "import sys,tarfile\nwith tarfile.open(sys.argv[1],'w:gz',format=tarfile.PAX_FORMAT) as a:\n [a.add(n,arcname=n,recursive=True) for n in ('manifest.env','files.txt','payload')]", target], { cwd: stage, encoding: "utf8" });
|
||||||
|
if (tar.status !== 0) throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
|
||||||
|
console.log(JSON.stringify({ ok: true, patchId, artifact: target, sha256: createHash("sha256").update(await readFile(target)).digest("hex") }, null, 2));
|
||||||
|
} finally {
|
||||||
|
await rm(stage, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copySafe(source, destination) {
|
||||||
|
const info = await lstat(source);
|
||||||
|
if (info.isSymbolicLink() || !info.isFile()) throw new Error(`source_file_rejected:${source}`);
|
||||||
|
await mkdir(dirname(destination), { recursive: true });
|
||||||
|
await cp(source, destination, { force: true });
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,344 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { createRequire, Module } from "node:module";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { dirname, join, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const platformRoot = resolve(here, "../..");
|
||||||
|
const engineRoot = resolve(platformRoot, "../NODEDC_ENGINE_INFRA");
|
||||||
|
const artifactRoot = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || join(here, "../deploy-artifacts"));
|
||||||
|
const stageArtifact = resolve(
|
||||||
|
process.env.NODEDC_N8N_EXTENSION_STAGE_ARTIFACT
|
||||||
|
|| join(artifactRoot, "nodedc-n8n-private-extension-n8n-nodes-ndc-release-20260716-003.tgz"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const stageArtifactSha256 = "4601c16c57e5182996adf18d0163837511b27ab3f7cfdf97eac679418ee2d078";
|
||||||
|
const releaseId = "0.1.2-05e4b38b14b4a019";
|
||||||
|
const packageVersion = "0.1.2";
|
||||||
|
const packageSha256 = "05e4b38b14b4a019ce1f6eee27b9e320094cb3560903bd68074966b3a1267af5";
|
||||||
|
const n8nVersion = "2.3.2";
|
||||||
|
const baseImage = "docker.n8n.io/n8nio/n8n:2.3.2";
|
||||||
|
const architecture = "amd64";
|
||||||
|
const generatedAt = "2026-07-15T21:51:41.000Z";
|
||||||
|
const activationId = "engine-n8n-private-extension-20260716-003";
|
||||||
|
const rollbackId = "engine-n8n-private-extension-rollback-20260716-003";
|
||||||
|
|
||||||
|
const transitionRoot = "nodedc-source/services/n8n/private-extensions";
|
||||||
|
const descriptorRel = `${transitionRoot}/ndc-activation.json`;
|
||||||
|
const overrideRel = `${transitionRoot}/docker-compose.ndc-private-extension.yml`;
|
||||||
|
const schemaRoot = "nodedc-source/server/assets/n8n/schema/v2.3.2";
|
||||||
|
const nodesCatalogRel = `${schemaRoot}/nodes.catalog.json`;
|
||||||
|
const credentialsCatalogRel = `${schemaRoot}/credentials.catalog.json`;
|
||||||
|
const metaRel = `${schemaRoot}/meta.json`;
|
||||||
|
const iconRoot = "nodedc-source/server/assets/n8n/icons";
|
||||||
|
const iconRel = `${iconRoot}/ndc.svg`;
|
||||||
|
const darkIconRel = `${iconRoot}/ndc.dark.svg`;
|
||||||
|
const sealedReleaseRelativePath = `n8n-private-extensions/releases/n8n-nodes-ndc/${releaseId}/package`;
|
||||||
|
const runtimePackagePath = "/home/node/.n8n/nodes/node_modules/n8n-nodes-ndc";
|
||||||
|
|
||||||
|
const expectedNodeTypes = [
|
||||||
|
"n8n-nodes-ndc.ndcDataProductPublish",
|
||||||
|
"n8n-nodes-ndc.ndcDataProductRead",
|
||||||
|
"n8n-nodes-ndc.ndcFoundryBinding",
|
||||||
|
];
|
||||||
|
const expectedCredentialTypes = [
|
||||||
|
"ndcDataProductWriterApi",
|
||||||
|
"ndcDataProductReaderApi",
|
||||||
|
"ndcFoundryBindingApi",
|
||||||
|
];
|
||||||
|
const nodeModules = [
|
||||||
|
["dist/nodes/NdcDataProductPublish/NdcDataProductPublish.node.js", "NdcDataProductPublish"],
|
||||||
|
["dist/nodes/NdcDataProductRead/NdcDataProductRead.node.js", "NdcDataProductRead"],
|
||||||
|
["dist/nodes/NdcFoundryBinding/NdcFoundryBinding.node.js", "NdcFoundryBinding"],
|
||||||
|
];
|
||||||
|
const credentialModules = [
|
||||||
|
["dist/credentials/NdcDataProductWriterApi.credentials.js", "NdcDataProductWriterApi"],
|
||||||
|
["dist/credentials/NdcDataProductReaderApi.credentials.js", "NdcDataProductReaderApi"],
|
||||||
|
["dist/credentials/NdcFoundryBindingApi.credentials.js", "NdcFoundryBindingApi"],
|
||||||
|
];
|
||||||
|
|
||||||
|
await mkdir(artifactRoot, { recursive: true });
|
||||||
|
assertSha(await readFile(stageArtifact), stageArtifactSha256, "staging artifact");
|
||||||
|
assertEngineBaseline(await readFile(join(engineRoot, "docker-compose.yml"), "utf8"));
|
||||||
|
|
||||||
|
const work = await mkdtemp(join(tmpdir(), "nodedc-engine-n8n-sealed-"));
|
||||||
|
try {
|
||||||
|
extractArchive(stageArtifact, work);
|
||||||
|
const stagedRelease = join(work, "payload", "releases", "n8n-nodes-ndc", releaseId);
|
||||||
|
const release = JSON.parse(await readFile(join(stagedRelease, "release.json"), "utf8"));
|
||||||
|
assertRelease(release);
|
||||||
|
assertSha(await readFile(join(stagedRelease, "package.tgz")), packageSha256, "private package");
|
||||||
|
|
||||||
|
const unpacked = join(work, "unpacked");
|
||||||
|
await mkdir(unpacked);
|
||||||
|
extractArchive(join(stagedRelease, "package.tgz"), unpacked);
|
||||||
|
const packageRoot = join(unpacked, "package");
|
||||||
|
const packageJson = JSON.parse(await readFile(join(packageRoot, "package.json"), "utf8"));
|
||||||
|
assertPackage(packageJson);
|
||||||
|
|
||||||
|
const devNodeModules = join(platformRoot, "packages", "n8n-nodes-ndc", "node_modules");
|
||||||
|
const nodePath = String(process.env.NODE_PATH || "").split(":").filter(Boolean);
|
||||||
|
if (!nodePath.includes(devNodeModules)) nodePath.unshift(devNodeModules);
|
||||||
|
process.env.NODE_PATH = nodePath.join(":");
|
||||||
|
Module._initPaths();
|
||||||
|
const packageRequire = createRequire(join(packageRoot, "package.json"));
|
||||||
|
const privateNodes = nodeModules.map(([path, className], index) => {
|
||||||
|
const NodeClass = packageRequire(join(packageRoot, path))[className];
|
||||||
|
if (typeof NodeClass !== "function") throw new Error(`node_class_missing:${className}`);
|
||||||
|
const description = structuredClone(new NodeClass().description);
|
||||||
|
description.name = expectedNodeTypes[index];
|
||||||
|
description.icon = { light: "file:ndc.svg", dark: "file:ndc.dark.svg" };
|
||||||
|
if (Object.prototype.hasOwnProperty.call(description, "usableAsTool")) {
|
||||||
|
throw new Error(`tool_variant_forbidden:${description.name}`);
|
||||||
|
}
|
||||||
|
return description;
|
||||||
|
});
|
||||||
|
const privateCredentials = credentialModules.map(([path, className]) => {
|
||||||
|
const CredentialClass = packageRequire(join(packageRoot, path))[className];
|
||||||
|
if (typeof CredentialClass !== "function") throw new Error(`credential_class_missing:${className}`);
|
||||||
|
const description = structuredClone(new CredentialClass());
|
||||||
|
description.icon = { light: "file:ndc.svg", dark: "file:ndc.dark.svg" };
|
||||||
|
return description;
|
||||||
|
});
|
||||||
|
assertExact(privateNodes.map((item) => item.name), expectedNodeTypes, "node types");
|
||||||
|
assertExact(privateCredentials.map((item) => item.name), expectedCredentialTypes, "credential types");
|
||||||
|
|
||||||
|
const baselineNodes = JSON.parse(gitFile(nodesCatalogRel));
|
||||||
|
const baselineCredentials = JSON.parse(gitFile(credentialsCatalogRel));
|
||||||
|
const baselineMeta = JSON.parse(gitFile(metaRel));
|
||||||
|
assertBaselineCatalogs(baselineNodes, baselineCredentials, baselineMeta);
|
||||||
|
const activeNodes = [...baselineNodes, ...privateNodes];
|
||||||
|
const activeCredentials = [...baselineCredentials, ...privateCredentials];
|
||||||
|
const activeMeta = {
|
||||||
|
n8nVersion,
|
||||||
|
generatedAt,
|
||||||
|
source: `n8n-core+n8n-nodes-ndc@${packageVersion}`,
|
||||||
|
nodeCount: activeNodes.length,
|
||||||
|
credentialCount: activeCredentials.length,
|
||||||
|
};
|
||||||
|
|
||||||
|
const activationDescriptor = descriptor("activate", expectedNodeTypes, expectedCredentialTypes, "verified_inactive");
|
||||||
|
const rollbackDescriptor = descriptor("rollback-inactive", [], [], releaseId);
|
||||||
|
const override = composeOverride();
|
||||||
|
|
||||||
|
await writeJson(join(engineRoot, nodesCatalogRel), activeNodes);
|
||||||
|
await writeJson(join(engineRoot, credentialsCatalogRel), activeCredentials);
|
||||||
|
await writeJson(join(engineRoot, metaRel), activeMeta);
|
||||||
|
await writeJson(join(engineRoot, descriptorRel), activationDescriptor);
|
||||||
|
await writeFile(join(engineRoot, overrideRel), override, "utf8");
|
||||||
|
await cp(join(packageRoot, "dist/icons/ndc.svg"), join(engineRoot, iconRel), { force: true });
|
||||||
|
await cp(join(packageRoot, "dist/icons/ndc.dark.svg"), join(engineRoot, darkIconRel), { force: true });
|
||||||
|
|
||||||
|
const activationEntries = [
|
||||||
|
descriptorRel,
|
||||||
|
overrideRel,
|
||||||
|
nodesCatalogRel,
|
||||||
|
credentialsCatalogRel,
|
||||||
|
metaRel,
|
||||||
|
iconRel,
|
||||||
|
darkIconRel,
|
||||||
|
];
|
||||||
|
const activationArtifact = await buildArtifact(work, activationId, activationEntries, async (payload) => {
|
||||||
|
for (const rel of activationEntries) {
|
||||||
|
await cp(join(engineRoot, rel), join(payload, rel), { recursive: true, force: false });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const rollbackEntries = [descriptorRel, nodesCatalogRel, credentialsCatalogRel, metaRel];
|
||||||
|
const rollbackArtifact = await buildArtifact(work, rollbackId, rollbackEntries, async (payload) => {
|
||||||
|
await writeJson(join(payload, descriptorRel), rollbackDescriptor);
|
||||||
|
await mkdir(join(payload, schemaRoot), { recursive: true });
|
||||||
|
await writeFile(join(payload, nodesCatalogRel), `${JSON.stringify(baselineNodes, null, 2)}\n`, "utf8");
|
||||||
|
await writeFile(join(payload, credentialsCatalogRel), `${JSON.stringify(baselineCredentials, null, 2)}\n`, "utf8");
|
||||||
|
await writeFile(join(payload, metaRel), `${JSON.stringify(baselineMeta, null, 2)}\n`, "utf8");
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
ok: true,
|
||||||
|
releaseId,
|
||||||
|
packageSha256,
|
||||||
|
nodeTypes: expectedNodeTypes,
|
||||||
|
credentialTypes: expectedCredentialTypes,
|
||||||
|
activation: activationArtifact,
|
||||||
|
rollback: rollbackArtifact,
|
||||||
|
}, null, 2));
|
||||||
|
} finally {
|
||||||
|
await rm(work, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function descriptor(action, nodeTypes, credentialTypes, expectedCurrent) {
|
||||||
|
return {
|
||||||
|
schemaVersion: "nodedc.engine-n8n-private-extension-transition/v1",
|
||||||
|
action,
|
||||||
|
releaseId,
|
||||||
|
packageVersion,
|
||||||
|
packageSha256,
|
||||||
|
n8nVersion,
|
||||||
|
baseImage,
|
||||||
|
baseImageArchitecture: architecture,
|
||||||
|
baseImageIdentityPolicy: "running-container-and-local-tag-must-match",
|
||||||
|
sealedReleaseRelativePath,
|
||||||
|
composeOverride: overrideRel,
|
||||||
|
runtimePackagePath,
|
||||||
|
topologyServices: ["n8n"],
|
||||||
|
expectedCurrent,
|
||||||
|
expectedNodeTypes: nodeTypes,
|
||||||
|
expectedCredentialTypes: credentialTypes,
|
||||||
|
rollbackBaseline: "verified_inactive",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function composeOverride() {
|
||||||
|
const health = "const http=require('http');const req=http.get('http://127.0.0.1:5678/healthz/readiness',r=>{r.resume();process.exit(r.statusCode===200?0:1)});req.on('error',()=>process.exit(1));req.setTimeout(4000,()=>{req.destroy();process.exit(1)});";
|
||||||
|
return [
|
||||||
|
"services:",
|
||||||
|
" n8n:",
|
||||||
|
` image: ${baseImage}`,
|
||||||
|
" platform: linux/amd64",
|
||||||
|
" pull_policy: never",
|
||||||
|
" environment:",
|
||||||
|
" N8N_USER_FOLDER: /home/node",
|
||||||
|
" N8N_COMMUNITY_PACKAGES_ENABLED: \"true\"",
|
||||||
|
" N8N_COMMUNITY_PACKAGES_PREVENT_LOADING: \"false\"",
|
||||||
|
" N8N_REINSTALL_MISSING_PACKAGES: \"false\"",
|
||||||
|
" volumes:",
|
||||||
|
` - /volume2/nodedc-demo/${sealedReleaseRelativePath}:${runtimePackagePath}:ro`,
|
||||||
|
" healthcheck:",
|
||||||
|
` test: ${JSON.stringify(["CMD", "node", "-e", health])}`,
|
||||||
|
" interval: 10s",
|
||||||
|
" timeout: 5s",
|
||||||
|
" retries: 30",
|
||||||
|
" start_period: 30s",
|
||||||
|
" labels:",
|
||||||
|
` nodedc.n8n-private-extension.release: ${releaseId}`,
|
||||||
|
` nodedc.n8n-private-extension.package-sha256: ${packageSha256}`,
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildArtifact(workRoot, id, entries, populate) {
|
||||||
|
const stage = join(workRoot, id);
|
||||||
|
const payload = join(stage, "payload");
|
||||||
|
await mkdir(payload, { recursive: true });
|
||||||
|
await populate(payload);
|
||||||
|
await writeFile(join(stage, "manifest.env"), `id=${id}\ncomponent=engine\ntype=app-overlay\n`, "utf8");
|
||||||
|
await writeFile(join(stage, "files.txt"), `${entries.join("\n")}\n`, "utf8");
|
||||||
|
const artifact = join(artifactRoot, `nodedc-${id}.tgz`);
|
||||||
|
run("python3", ["-c", canonicalTarScript(), artifact, stage]);
|
||||||
|
return { id, artifact, sha256: sha(await readFile(artifact)), entries };
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertRelease(value) {
|
||||||
|
if (value?.releaseId !== releaseId
|
||||||
|
|| value?.package?.name !== "n8n-nodes-ndc"
|
||||||
|
|| value?.package?.version !== packageVersion
|
||||||
|
|| value?.package?.sha256 !== packageSha256
|
||||||
|
|| value?.storage?.relativePath !== `releases/n8n-nodes-ndc/${releaseId}`) {
|
||||||
|
throw new Error("staged_release_identity_mismatch");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertPackage(value) {
|
||||||
|
if (value.name !== "n8n-nodes-ndc" || value.version !== packageVersion || value.private !== true) {
|
||||||
|
throw new Error("package_identity_mismatch");
|
||||||
|
}
|
||||||
|
if (value.dependencies !== undefined) throw new Error("runtime_dependencies_forbidden");
|
||||||
|
for (const name of ["preinstall", "install", "postinstall", "prepare", "prepack", "postpack"]) {
|
||||||
|
if (value.scripts?.[name] !== undefined) throw new Error(`lifecycle_forbidden:${name}`);
|
||||||
|
}
|
||||||
|
assertExact(value.n8n?.nodes, nodeModules.map(([path]) => path), "package nodes");
|
||||||
|
assertExact(value.n8n?.credentials, credentialModules.map(([path]) => path), "package credentials");
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertBaselineCatalogs(nodes, credentials, meta) {
|
||||||
|
if (!Array.isArray(nodes) || nodes.length !== 434 || nodes.some((item) => String(item?.name || "").startsWith("n8n-nodes-ndc."))) {
|
||||||
|
throw new Error("baseline_node_catalog_mismatch");
|
||||||
|
}
|
||||||
|
if (!Array.isArray(credentials) || credentials.length !== 385
|
||||||
|
|| credentials.some((item) => expectedCredentialTypes.includes(String(item?.name || "")))) {
|
||||||
|
throw new Error("baseline_credential_catalog_mismatch");
|
||||||
|
}
|
||||||
|
if (meta?.n8nVersion !== n8nVersion || meta?.nodeCount !== 434 || meta?.credentialCount !== 385) {
|
||||||
|
throw new Error("baseline_meta_mismatch");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertEngineBaseline(compose) {
|
||||||
|
const exactImage = `image: docker.n8n.io/n8nio/n8n:\${N8N_IMAGE_TAG:-${n8nVersion}}`;
|
||||||
|
if (!compose.includes(exactImage)) throw new Error("engine_n8n_version_mismatch");
|
||||||
|
if ((compose.match(/^ n8n:\s*$/gm) || []).length !== 1) throw new Error("engine_n8n_topology_mismatch");
|
||||||
|
if (/^ n8n-(?:worker|webhook)|^ (?:worker|webhook):/gm.test(compose)) throw new Error("unexpected_n8n_process_service");
|
||||||
|
if (compose.includes("N8N_CUSTOM_EXTENSIONS") || compose.includes("CUSTOM.")) throw new Error("custom_extension_loader_forbidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertExact(actual, expected, label) {
|
||||||
|
if (!Array.isArray(actual) || JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||||
|
throw new Error(`${label.replaceAll(" ", "_")}_mismatch`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertSha(bytes, expected, label) {
|
||||||
|
const actual = sha(bytes);
|
||||||
|
if (actual !== expected) throw new Error(`${label.replaceAll(" ", "_")}_sha256_mismatch:${actual}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function gitFile(rel) {
|
||||||
|
return run("git", ["show", `HEAD:${rel}`], engineRoot).stdout;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeJson(path, value) {
|
||||||
|
await mkdir(dirname(path), { recursive: true });
|
||||||
|
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractArchive(archive, destination) {
|
||||||
|
const script = [
|
||||||
|
"import pathlib, sys, tarfile",
|
||||||
|
"src=pathlib.Path(sys.argv[1]); dst=pathlib.Path(sys.argv[2]).resolve()",
|
||||||
|
"with tarfile.open(src, 'r:gz') as tf:",
|
||||||
|
" for m in tf:",
|
||||||
|
" p=pathlib.PurePosixPath(m.name)",
|
||||||
|
" if p.is_absolute() or '..' in p.parts or any(x.startswith('._') for x in p.parts) or not (m.isfile() or m.isdir()): raise SystemExit('unsafe archive member')",
|
||||||
|
" target=dst.joinpath(*p.parts)",
|
||||||
|
" target.mkdir(parents=True, exist_ok=True) if m.isdir() else target.parent.mkdir(parents=True, exist_ok=True)",
|
||||||
|
" if m.isfile():",
|
||||||
|
" source=tf.extractfile(m)",
|
||||||
|
" with open(target, 'xb') as out: out.write(source.read())",
|
||||||
|
].join("\n");
|
||||||
|
run("python3", ["-c", script, archive, destination]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function canonicalTarScript() {
|
||||||
|
return [
|
||||||
|
"import gzip, io, pathlib, sys, tarfile",
|
||||||
|
"root=pathlib.Path(sys.argv[2])",
|
||||||
|
"with open(sys.argv[1], 'wb') as out:",
|
||||||
|
" with gzip.GzipFile(filename='', mode='wb', fileobj=out, compresslevel=9, mtime=0) as gz:",
|
||||||
|
" with tarfile.open(fileobj=gz, mode='w', format=tarfile.PAX_FORMAT) as tar:",
|
||||||
|
" for top in ('manifest.env','files.txt','payload'):",
|
||||||
|
" p=root/top; paths=[p] + (sorted(p.rglob('*')) if p.is_dir() else [])",
|
||||||
|
" for x in paths:",
|
||||||
|
" info=tar.gettarinfo(str(x), arcname=x.relative_to(root).as_posix())",
|
||||||
|
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
|
||||||
|
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info, src if info.isfile() else None)",
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function sha(bytes) {
|
||||||
|
return createHash("sha256").update(bytes).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(command, args, cwd) {
|
||||||
|
const result = spawnSync(command, args, {
|
||||||
|
cwd,
|
||||||
|
encoding: "utf8",
|
||||||
|
maxBuffer: 128 * 1024 * 1024,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
if (result.status !== 0) throw new Error(`${command}_failed:${result.stderr || result.stdout}`);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { cp, lstat, mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { dirname, join, relative, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const platformRoot = resolve(scriptDir, "../..");
|
||||||
|
const artifactDir = resolve(scriptDir, "../deploy-artifacts");
|
||||||
|
const [patchId = "external-data-plane-20260714-001", ...extra] = process.argv.slice(2);
|
||||||
|
|
||||||
|
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
|
||||||
|
throw new Error("usage: build-external-data-plane-artifact.mjs [patch-id]");
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = [
|
||||||
|
["infra/synology/docker-compose.external-data-plane.yml", "platform/docker-compose.external-data-plane.yml"],
|
||||||
|
["services/external-data-plane", "platform/services/external-data-plane"],
|
||||||
|
["packages/external-provider-contract", "platform/packages/external-provider-contract"],
|
||||||
|
];
|
||||||
|
const ignoredBasenames = new Set([".DS_Store", ".git", "node_modules"]);
|
||||||
|
const stage = await mkdtemp(join(tmpdir(), "nodedc-external-data-plane-artifact-"));
|
||||||
|
const payload = join(stage, "payload");
|
||||||
|
const target = join(artifactDir, `nodedc-platform-${patchId}.tgz`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mkdir(payload, { recursive: true });
|
||||||
|
for (const [sourceRelative, destinationRelative] of files) {
|
||||||
|
await copySafe(resolve(platformRoot, sourceRelative), join(payload, destinationRelative));
|
||||||
|
}
|
||||||
|
await writeFile(join(stage, "manifest.env"), `id=${patchId}\ncomponent=platform\ntype=app-overlay\n`, "utf8");
|
||||||
|
await writeFile(join(stage, "files.txt"), `${files.map(([, destination]) => destination).join("\n")}\n`, "utf8");
|
||||||
|
await mkdir(artifactDir, { recursive: true });
|
||||||
|
|
||||||
|
const tar = spawnSync("python3", ["-c", [
|
||||||
|
"import sys, tarfile",
|
||||||
|
"with tarfile.open(sys.argv[1], 'w:gz', format=tarfile.PAX_FORMAT) as archive:",
|
||||||
|
" [archive.add(name, arcname=name, recursive=True) for name in ('manifest.env', 'files.txt', 'payload')]",
|
||||||
|
].join("\n"), target], { cwd: stage, encoding: "utf8" });
|
||||||
|
if (tar.status !== 0) throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
|
||||||
|
|
||||||
|
const digest = createHash("sha256").update(await (await import("node:fs/promises")).readFile(target)).digest("hex");
|
||||||
|
console.log(JSON.stringify({ ok: true, patchId, artifact: target, sha256: digest }, null, 2));
|
||||||
|
} finally {
|
||||||
|
await rm(stage, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copySafe(source, destination) {
|
||||||
|
const sourceStat = await lstat(source);
|
||||||
|
if (sourceStat.isSymbolicLink()) throw new Error(`source_symlink_rejected:${source}`);
|
||||||
|
if (sourceStat.isFile()) {
|
||||||
|
await mkdir(dirname(destination), { recursive: true });
|
||||||
|
await cp(source, destination, { force: true, verbatimSymlinks: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!sourceStat.isDirectory()) throw new Error(`source_type_rejected:${source}`);
|
||||||
|
|
||||||
|
await mkdir(destination, { recursive: true });
|
||||||
|
for (const entry of await readdir(source, { withFileTypes: true })) {
|
||||||
|
if (ignoredBasenames.has(entry.name) || entry.name.startsWith(".env")) continue;
|
||||||
|
const childSource = join(source, entry.name);
|
||||||
|
const childDestination = join(destination, entry.name);
|
||||||
|
if (entry.isSymbolicLink()) throw new Error(`source_symlink_rejected:${relative(platformRoot, childSource)}`);
|
||||||
|
await copySafe(childSource, childDestination);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { cp, lstat, mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { dirname, join, relative, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const platformRoot = resolve(scriptDir, "../..");
|
||||||
|
const artifactDir = resolve(scriptDir, "../deploy-artifacts");
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const gatewayOnly = args.includes("--gateway-only");
|
||||||
|
const positionalArgs = args.filter((argument) => argument !== "--gateway-only");
|
||||||
|
if (positionalArgs.length > 1) {
|
||||||
|
throw new Error("usage: build-gelios-data-plane-artifact.mjs [patch-id] [--gateway-only]");
|
||||||
|
}
|
||||||
|
const patchId = positionalArgs[0] || "gelios-data-plane-20260713-001";
|
||||||
|
|
||||||
|
if (!/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
|
||||||
|
throw new Error("patch_id_must_contain_only_letters_digits_dot_underscore_hyphen");
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullDataPlaneFiles = [
|
||||||
|
["infra/synology/docker-compose.platform-http.yml", "platform/docker-compose.platform-http.yml"],
|
||||||
|
["services/ontology-core", "platform/ontology-core"],
|
||||||
|
["services/ai-workspace-hub", "platform/ai-workspace-hub"],
|
||||||
|
["services/ai-workspace-assistant", "platform/ai-workspace-assistant"],
|
||||||
|
["services/gelios-gateway", "platform/gelios-gateway"],
|
||||||
|
];
|
||||||
|
// A policy/code update to an already deployed Gelios data plane must not
|
||||||
|
// carry the common Platform compose file. The deploy runner treats that file
|
||||||
|
// as a whole-Platform change. The existing Gelios service already uses the
|
||||||
|
// live Platform env_file, so this overlay can safely recreate only Gelios.
|
||||||
|
const files = gatewayOnly
|
||||||
|
? [["services/gelios-gateway", "platform/gelios-gateway"]]
|
||||||
|
: fullDataPlaneFiles;
|
||||||
|
|
||||||
|
const ignoredBasenames = new Set([".DS_Store", ".git", "node_modules"]);
|
||||||
|
const stage = await mkdtemp(join(tmpdir(), "nodedc-gelios-artifact-"));
|
||||||
|
const payload = join(stage, "payload");
|
||||||
|
const target = join(artifactDir, `nodedc-platform-${patchId}.tgz`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mkdir(payload, { recursive: true });
|
||||||
|
|
||||||
|
for (const [sourceRelative, destinationRelative] of files) {
|
||||||
|
const source = resolve(platformRoot, sourceRelative);
|
||||||
|
const destination = join(payload, destinationRelative);
|
||||||
|
await copySafe(source, destination);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!gatewayOnly) {
|
||||||
|
// The Assistant imports the deterministic catalog as a local sibling at
|
||||||
|
// runtime. Its production Dockerfile therefore expects this directory
|
||||||
|
// inside the Assistant build context. Keep the deploy artifact equivalent
|
||||||
|
// to the canonical Synology staging layout without making a second source
|
||||||
|
// copy in the repository.
|
||||||
|
await copySafe(
|
||||||
|
resolve(platformRoot, "services/ontology-core"),
|
||||||
|
join(payload, "platform/ai-workspace-assistant/ontology-core"),
|
||||||
|
);
|
||||||
|
// This nested copy is source-only build input for the Assistant. The nested
|
||||||
|
// service Dockerfile is neither used nor allowed by the production runner.
|
||||||
|
await rm(join(payload, "platform/ai-workspace-assistant/ontology-core/Dockerfile"), { force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeFile(join(stage, "manifest.env"), `id=${patchId}\ncomponent=platform\ntype=app-overlay\n`, "utf8");
|
||||||
|
await writeFile(join(stage, "files.txt"), `${files.map(([, destination]) => destination).join("\n")}\n`, "utf8");
|
||||||
|
await mkdir(artifactDir, { recursive: true });
|
||||||
|
|
||||||
|
// macOS bsdtar includes AppleDouble sidecar files for extended attributes.
|
||||||
|
// The root runner rejects those as unexpected members, so use stdlib tarfile
|
||||||
|
// to generate a portable, data-only archive instead.
|
||||||
|
const tar = spawnSync("python3", ["-c", [
|
||||||
|
"import sys, tarfile",
|
||||||
|
"with tarfile.open(sys.argv[1], 'w:gz', format=tarfile.PAX_FORMAT) as archive:",
|
||||||
|
" [archive.add(name, arcname=name, recursive=True) for name in ('manifest.env', 'files.txt', 'payload')]",
|
||||||
|
].join("\n"), target], {
|
||||||
|
cwd: stage,
|
||||||
|
encoding: "utf8",
|
||||||
|
});
|
||||||
|
if (tar.status !== 0) throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
|
||||||
|
|
||||||
|
const digest = createHash("sha256").update(await (await import("node:fs/promises")).readFile(target)).digest("hex");
|
||||||
|
console.log(JSON.stringify({ ok: true, patchId, gatewayOnly, artifact: target, sha256: digest }, null, 2));
|
||||||
|
} finally {
|
||||||
|
await rm(stage, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copySafe(source, destination) {
|
||||||
|
const sourceStat = await lstat(source);
|
||||||
|
if (sourceStat.isSymbolicLink()) throw new Error(`source_symlink_rejected:${source}`);
|
||||||
|
if (sourceStat.isFile()) {
|
||||||
|
await mkdir(dirname(destination), { recursive: true });
|
||||||
|
await cp(source, destination, { force: true, verbatimSymlinks: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!sourceStat.isDirectory()) throw new Error(`source_type_rejected:${source}`);
|
||||||
|
|
||||||
|
await mkdir(destination, { recursive: true });
|
||||||
|
for (const entry of await readdir(source, { withFileTypes: true })) {
|
||||||
|
if (ignoredBasenames.has(entry.name) || entry.name.startsWith(".env")) continue;
|
||||||
|
const childSource = join(source, entry.name);
|
||||||
|
const childDestination = join(destination, entry.name);
|
||||||
|
if (entry.isSymbolicLink()) throw new Error(`source_symlink_rejected:${relative(platformRoot, childSource)}`);
|
||||||
|
await copySafe(childSource, childDestination);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { cp, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { dirname, join, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const platformRoot = resolve(scriptDir, "../..");
|
||||||
|
const launcherRoot = resolve(platformRoot, "../../data/nodedc_launcher");
|
||||||
|
const artifactDir = resolve(scriptDir, "../deploy-artifacts");
|
||||||
|
const patchId = process.argv[2] || "module-foundry-hub-registration-20260714-001";
|
||||||
|
const profile = process.argv[3] || "registration";
|
||||||
|
|
||||||
|
if (!/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
|
||||||
|
throw new Error("patch_id_must_contain_only_letters_digits_dot_underscore_hyphen");
|
||||||
|
}
|
||||||
|
|
||||||
|
// DCPLATFORM-21: the production runner overlays this payload onto the existing
|
||||||
|
// Launcher source and recreates only the `launcher` service. Keep corrective
|
||||||
|
// patches to their exact reviewed file set instead of re-sending unrelated
|
||||||
|
// registration sources.
|
||||||
|
const registrationFiles = [
|
||||||
|
"server/authentik-sync.mjs",
|
||||||
|
"server/control-plane-store.mjs",
|
||||||
|
"server/dev-server.mjs",
|
||||||
|
"src/entities/service/types.ts",
|
||||||
|
];
|
||||||
|
|
||||||
|
const filesByProfile = {
|
||||||
|
registration: registrationFiles,
|
||||||
|
"handoff-fix": ["server/dev-server.mjs"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const files = filesByProfile[profile];
|
||||||
|
if (!files) {
|
||||||
|
throw new Error(`unknown_profile:${profile}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stage = await mkdtemp(join(tmpdir(), "nodedc-launcher-foundry-artifact-"));
|
||||||
|
const payloadRoot = join(stage, "payload");
|
||||||
|
const artifact = join(artifactDir, `launcher-${patchId}.tgz`);
|
||||||
|
const checksum = `${artifact}.sha256`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mkdir(payloadRoot, { recursive: true });
|
||||||
|
|
||||||
|
for (const relativePath of files) {
|
||||||
|
const source = resolve(launcherRoot, relativePath);
|
||||||
|
const destination = join(payloadRoot, relativePath);
|
||||||
|
const stat = await lstat(source);
|
||||||
|
if (!stat.isFile() || stat.isSymbolicLink()) {
|
||||||
|
throw new Error(`source_file_rejected:${relativePath}`);
|
||||||
|
}
|
||||||
|
await mkdir(dirname(destination), { recursive: true });
|
||||||
|
await cp(source, destination, { force: true, verbatimSymlinks: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeFile(
|
||||||
|
join(stage, "manifest.env"),
|
||||||
|
`id=${patchId}\ncomponent=launcher\ntype=app-overlay\n`,
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
await writeFile(join(stage, "files.txt"), `${files.join("\n")}\n`, "utf8");
|
||||||
|
await mkdir(artifactDir, { recursive: true });
|
||||||
|
|
||||||
|
// macOS bsdtar may emit AppleDouble `._*` sidecars. DCPLATFORM-21 rejects
|
||||||
|
// them, therefore create a data-only POSIX archive through stdlib tarfile.
|
||||||
|
const tar = spawnSync("python3", ["-c", [
|
||||||
|
"import sys, tarfile",
|
||||||
|
"with tarfile.open(sys.argv[1], 'w:gz', format=tarfile.PAX_FORMAT) as archive:",
|
||||||
|
" [archive.add(name, arcname=name, recursive=True) for name in ('manifest.env', 'files.txt', 'payload')]",
|
||||||
|
].join("\n"), artifact], {
|
||||||
|
cwd: stage,
|
||||||
|
encoding: "utf8",
|
||||||
|
});
|
||||||
|
if (tar.status !== 0) throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
|
||||||
|
|
||||||
|
const digest = createHash("sha256").update(await readFile(artifact)).digest("hex");
|
||||||
|
await writeFile(checksum, `${digest} ${artifact.split("/").at(-1)}\n`, "utf8");
|
||||||
|
console.log(JSON.stringify({ ok: true, patchId, profile, artifact, checksum, sha256: digest, files }, null, 2));
|
||||||
|
} finally {
|
||||||
|
await rm(stage, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { cp, lstat, mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { dirname, join, relative, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const platformRoot = resolve(scriptDir, "../..");
|
||||||
|
const artifactDir = resolve(scriptDir, "../deploy-artifacts");
|
||||||
|
const [patchId = "platform-map-gateway-20260714-001", ...extra] = process.argv.slice(2);
|
||||||
|
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) throw new Error("usage: build-map-gateway-artifact.mjs [patch-id]");
|
||||||
|
|
||||||
|
const files = [
|
||||||
|
["infra/synology/docker-compose.platform-http.yml", "platform/docker-compose.platform-http.yml"],
|
||||||
|
["services/map-gateway", "platform/services/map-gateway"],
|
||||||
|
];
|
||||||
|
const ignored = new Set([".DS_Store", ".git", "node_modules"]);
|
||||||
|
const stage = await mkdtemp(join(tmpdir(), "nodedc-map-gateway-artifact-"));
|
||||||
|
const payload = join(stage, "payload");
|
||||||
|
const target = join(artifactDir, `nodedc-platform-${patchId}.tgz`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mkdir(payload, { recursive: true });
|
||||||
|
for (const [source, destination] of files) await copySafe(resolve(platformRoot, source), join(payload, destination));
|
||||||
|
await writeFile(join(stage, "manifest.env"), `id=${patchId}\ncomponent=platform\ntype=app-overlay\n`, "utf8");
|
||||||
|
await writeFile(join(stage, "files.txt"), `${files.map(([, destination]) => destination).join("\n")}\n`, "utf8");
|
||||||
|
await mkdir(artifactDir, { recursive: true });
|
||||||
|
const tar = spawnSync("python3", ["-c", "import sys,tarfile\nwith tarfile.open(sys.argv[1],'w:gz',format=tarfile.PAX_FORMAT) as a:\n [a.add(n,arcname=n,recursive=True) for n in ('manifest.env','files.txt','payload')]", target], { cwd: stage, encoding: "utf8" });
|
||||||
|
if (tar.status !== 0) throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
|
||||||
|
console.log(JSON.stringify({ ok: true, patchId, artifact: target, sha256: createHash("sha256").update(await readFile(target)).digest("hex") }, null, 2));
|
||||||
|
} finally {
|
||||||
|
await rm(stage, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copySafe(source, destination) {
|
||||||
|
const info = await lstat(source);
|
||||||
|
if (info.isSymbolicLink()) throw new Error(`source_symlink_rejected:${source}`);
|
||||||
|
if (info.isFile()) { await mkdir(dirname(destination), { recursive: true }); await cp(source, destination, { force: true }); return; }
|
||||||
|
if (!info.isDirectory()) throw new Error(`source_type_rejected:${source}`);
|
||||||
|
await mkdir(destination, { recursive: true });
|
||||||
|
for (const entry of await readdir(source, { withFileTypes: true })) {
|
||||||
|
if (ignored.has(entry.name) || entry.name.startsWith(".env")) continue;
|
||||||
|
const child = join(source, entry.name);
|
||||||
|
if (entry.isSymbolicLink()) throw new Error(`source_symlink_rejected:${relative(platformRoot, child)}`);
|
||||||
|
await copySafe(child, join(destination, entry.name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { cp, lstat, mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { dirname, join, relative, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const platformRoot = resolve(scriptDir, "../..");
|
||||||
|
const workspaceRoot = resolve(platformRoot, "..");
|
||||||
|
const foundryRoot = resolve(workspaceRoot, "NODEDC_DESIGN_GUIDELINE");
|
||||||
|
const artifactDir = resolve(scriptDir, "../deploy-artifacts");
|
||||||
|
const patchId = process.argv[2] || "module-foundry-bootstrap-20260714-001";
|
||||||
|
|
||||||
|
if (!/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
|
||||||
|
throw new Error("patch_id_must_contain_only_letters_digits_dot_underscore_hyphen");
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = [
|
||||||
|
".dockerignore",
|
||||||
|
".env.example",
|
||||||
|
".gitignore",
|
||||||
|
"Dockerfile",
|
||||||
|
"package.json",
|
||||||
|
"package-lock.json",
|
||||||
|
"tsconfig.base.json",
|
||||||
|
"infra/docker-compose.module-foundry.yml",
|
||||||
|
"apps",
|
||||||
|
"packages",
|
||||||
|
"registry",
|
||||||
|
"runtime-seed",
|
||||||
|
"scripts",
|
||||||
|
"server",
|
||||||
|
];
|
||||||
|
const ignoredBasenames = new Set([".DS_Store", ".git", "node_modules", "runtime-data", "dist"]);
|
||||||
|
const stage = await mkdtemp(join(tmpdir(), "nodedc-module-foundry-artifact-"));
|
||||||
|
const payload = join(stage, "payload");
|
||||||
|
const target = join(artifactDir, `nodedc-module-foundry-${patchId}.tgz`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mkdir(payload, { recursive: true });
|
||||||
|
for (const sourceRelative of files) {
|
||||||
|
await copySafe(resolve(foundryRoot, sourceRelative), join(payload, sourceRelative));
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeFile(join(stage, "manifest.env"), `id=${patchId}\ncomponent=module-foundry\ntype=app-overlay\n`, "utf8");
|
||||||
|
await writeFile(join(stage, "files.txt"), `${files.join("\n")}\n`, "utf8");
|
||||||
|
await mkdir(artifactDir, { recursive: true });
|
||||||
|
|
||||||
|
const tar = spawnSync("python3", ["-c", [
|
||||||
|
"import sys, tarfile",
|
||||||
|
"with tarfile.open(sys.argv[1], 'w:gz', format=tarfile.PAX_FORMAT) as archive:",
|
||||||
|
" [archive.add(name, arcname=name, recursive=True) for name in ('manifest.env', 'files.txt', 'payload')]",
|
||||||
|
].join("\n"), target], {
|
||||||
|
cwd: stage,
|
||||||
|
encoding: "utf8",
|
||||||
|
});
|
||||||
|
if (tar.status !== 0) throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
|
||||||
|
|
||||||
|
const digest = createHash("sha256").update(await readFile(target)).digest("hex");
|
||||||
|
console.log(JSON.stringify({ ok: true, patchId, artifact: target, sha256: digest }, null, 2));
|
||||||
|
} finally {
|
||||||
|
await rm(stage, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copySafe(source, destination) {
|
||||||
|
const sourceStat = await lstat(source);
|
||||||
|
if (sourceStat.isSymbolicLink()) throw new Error(`source_symlink_rejected:${relative(foundryRoot, source)}`);
|
||||||
|
if (sourceStat.isFile()) {
|
||||||
|
await mkdir(dirname(destination), { recursive: true });
|
||||||
|
await cp(source, destination, { force: true, verbatimSymlinks: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!sourceStat.isDirectory()) throw new Error(`source_type_rejected:${source}`);
|
||||||
|
|
||||||
|
await mkdir(destination, { recursive: true });
|
||||||
|
for (const entry of await readdir(source, { withFileTypes: true })) {
|
||||||
|
if (ignoredBasenames.has(entry.name) || entry.name.startsWith(".env") || entry.name.endsWith(".tsbuildinfo")) continue;
|
||||||
|
const childSource = join(source, entry.name);
|
||||||
|
const childDestination = join(destination, entry.name);
|
||||||
|
if (entry.isSymbolicLink()) throw new Error(`source_symlink_rejected:${relative(foundryRoot, childSource)}`);
|
||||||
|
await copySafe(childSource, childDestination);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,286 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { dirname, join, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const platformRoot = resolve(scriptDir, "../..");
|
||||||
|
const packageRoot = resolve(platformRoot, "packages/n8n-nodes-ndc");
|
||||||
|
const artifactDir = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts"));
|
||||||
|
const requireModule = createRequire(import.meta.url);
|
||||||
|
const expectedPackageVersion = "0.1.2";
|
||||||
|
const [patchId = "n8n-nodes-ndc-release-20260716-003", ...extra] = process.argv.slice(2);
|
||||||
|
const expectedRuntimeNodes = [
|
||||||
|
{
|
||||||
|
file: "dist/nodes/NdcDataProductPublish/NdcDataProductPublish.node.js",
|
||||||
|
exportName: "NdcDataProductPublish",
|
||||||
|
name: "ndcDataProductPublish",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: "dist/nodes/NdcDataProductRead/NdcDataProductRead.node.js",
|
||||||
|
exportName: "NdcDataProductRead",
|
||||||
|
name: "ndcDataProductRead",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: "dist/nodes/NdcFoundryBinding/NdcFoundryBinding.node.js",
|
||||||
|
exportName: "NdcFoundryBinding",
|
||||||
|
name: "ndcFoundryBinding",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const expectedN8nNodes = expectedRuntimeNodes.map((node) => node.file);
|
||||||
|
const expectedRuntimeNodeTypes = expectedRuntimeNodes.map((node) => `n8n-nodes-ndc.${node.name}`);
|
||||||
|
const expectedN8nCredentials = [
|
||||||
|
"dist/credentials/NdcDataProductWriterApi.credentials.js",
|
||||||
|
"dist/credentials/NdcDataProductReaderApi.credentials.js",
|
||||||
|
"dist/credentials/NdcFoundryBindingApi.credentials.js",
|
||||||
|
];
|
||||||
|
const expectedCredentialTypes = [
|
||||||
|
"ndcDataProductWriterApi",
|
||||||
|
"ndcDataProductReaderApi",
|
||||||
|
"ndcFoundryBindingApi",
|
||||||
|
];
|
||||||
|
|
||||||
|
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
|
||||||
|
throw new Error("usage: build-n8n-private-extension-artifact.mjs [patch-id]");
|
||||||
|
}
|
||||||
|
|
||||||
|
const packageJson = JSON.parse(await readFile(join(packageRoot, "package.json"), "utf8"));
|
||||||
|
assertPackageSourcePolicy(packageJson);
|
||||||
|
run("npm", ["test"], packageRoot);
|
||||||
|
run("npm", ["run", "lint"], packageRoot);
|
||||||
|
assertRuntimeNodePolicy(packageJson);
|
||||||
|
|
||||||
|
const stage = await mkdtemp(join(tmpdir(), "nodedc-n8n-private-extension-"));
|
||||||
|
const packDir = join(stage, "pack");
|
||||||
|
const payload = join(stage, "payload");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mkdir(packDir, { recursive: true });
|
||||||
|
const pack = run("npm", ["pack", "--ignore-scripts", "--json", "--pack-destination", packDir], packageRoot);
|
||||||
|
const packResult = JSON.parse(pack.stdout);
|
||||||
|
if (!Array.isArray(packResult) || packResult.length !== 1) throw new Error("npm_pack_result_invalid");
|
||||||
|
const metadata = packResult[0];
|
||||||
|
assertPackedFilePolicy(metadata, packageJson);
|
||||||
|
|
||||||
|
const packedPath = join(packDir, metadata.filename);
|
||||||
|
const canonicalPackedPath = join(packDir, "n8n-nodes-ndc.canonical.tgz");
|
||||||
|
const canonicalPackageScript = [
|
||||||
|
"import gzip, io, sys, tarfile",
|
||||||
|
"members = []",
|
||||||
|
"with tarfile.open(sys.argv[1], 'r:gz') as source:",
|
||||||
|
" for original in source:",
|
||||||
|
" if not (original.isfile() or original.isdir()):",
|
||||||
|
" raise SystemExit('unsupported npm package member')",
|
||||||
|
" content = b''",
|
||||||
|
" if original.isfile():",
|
||||||
|
" extracted = source.extractfile(original)",
|
||||||
|
" if extracted is None:",
|
||||||
|
" raise SystemExit('unreadable npm package member')",
|
||||||
|
" content = extracted.read()",
|
||||||
|
" members.append((original.name, original.isdir(), content))",
|
||||||
|
"with open(sys.argv[2], 'wb') as output:",
|
||||||
|
" with gzip.GzipFile(filename='', mode='wb', fileobj=output, compresslevel=9, mtime=0) as compressed:",
|
||||||
|
" with tarfile.open(fileobj=compressed, mode='w', format=tarfile.PAX_FORMAT) as target:",
|
||||||
|
" for name, is_dir, content in sorted(members, key=lambda item: item[0]):",
|
||||||
|
" member = tarfile.TarInfo(name)",
|
||||||
|
" member.uid = member.gid = 0",
|
||||||
|
" member.uname = member.gname = 'root'",
|
||||||
|
" member.mtime = 0",
|
||||||
|
" member.mode = 0o755 if is_dir else 0o644",
|
||||||
|
" member.type = tarfile.DIRTYPE if is_dir else tarfile.REGTYPE",
|
||||||
|
" member.size = 0 if is_dir else len(content)",
|
||||||
|
" target.addfile(member, None if is_dir else io.BytesIO(content))",
|
||||||
|
].join("\n");
|
||||||
|
run("python3", ["-c", canonicalPackageScript, packedPath, canonicalPackedPath], stage);
|
||||||
|
const packageBytes = await readFile(canonicalPackedPath);
|
||||||
|
const packageSha256 = createHash("sha256").update(packageBytes).digest("hex");
|
||||||
|
const releaseId = `${packageJson.version}-${packageSha256.slice(0, 16)}`;
|
||||||
|
const relativeReleasePath = `releases/n8n-nodes-ndc/${releaseId}`;
|
||||||
|
const releaseDir = join(payload, relativeReleasePath);
|
||||||
|
await mkdir(releaseDir, { recursive: true });
|
||||||
|
await cp(canonicalPackedPath, join(releaseDir, "package.tgz"), { force: false });
|
||||||
|
|
||||||
|
const rollbackBaselinePolicy = {
|
||||||
|
allowed: [
|
||||||
|
"previous_verified_immutable_release",
|
||||||
|
"verified_inactive",
|
||||||
|
],
|
||||||
|
firstActivation: "verified_inactive",
|
||||||
|
requiresPreActivationVerification: true,
|
||||||
|
};
|
||||||
|
const release = {
|
||||||
|
schemaVersion: "nodedc.n8n-private-extension-release/v2",
|
||||||
|
releaseId,
|
||||||
|
package: {
|
||||||
|
name: "n8n-nodes-ndc",
|
||||||
|
version: packageJson.version,
|
||||||
|
sha256: packageSha256,
|
||||||
|
bytes: packageBytes.byteLength,
|
||||||
|
runtimeTypePrefix: "n8n-nodes-ndc.",
|
||||||
|
},
|
||||||
|
storage: {
|
||||||
|
relativePath: relativeReleasePath,
|
||||||
|
immutable: true,
|
||||||
|
},
|
||||||
|
activation: {
|
||||||
|
owner: "engine",
|
||||||
|
status: "blocked_pending_engine_owned_mount",
|
||||||
|
requiredCommunityPackagePath: "/home/node/.n8n/nodes/node_modules/n8n-nodes-ndc",
|
||||||
|
requiresAtomicReleaseSwitch: true,
|
||||||
|
requiresAllN8nProcessesRestart: true,
|
||||||
|
requiresMcpSchemaAcceptance: true,
|
||||||
|
rollbackBaselinePolicy,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const rollback = {
|
||||||
|
schemaVersion: "nodedc.n8n-private-extension-rollback/v2",
|
||||||
|
releaseId,
|
||||||
|
packageSha256,
|
||||||
|
mode: "engine-owned-atomic-release-switch",
|
||||||
|
baselinePolicy: rollbackBaselinePolicy,
|
||||||
|
steps: [
|
||||||
|
"select_verified_previous_release_or_preverified_inactive_baseline",
|
||||||
|
"switch_engine_owned_mount_atomically",
|
||||||
|
"restart_all_n8n_processes",
|
||||||
|
"verify_mcp_schema_state_matches_selected_baseline",
|
||||||
|
],
|
||||||
|
forbidden: [
|
||||||
|
"delete_active_release",
|
||||||
|
"mutate_engine_core",
|
||||||
|
"live_npm_install",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
await writeFile(join(releaseDir, "release.json"), `${JSON.stringify(release, null, 2)}\n`, "utf8");
|
||||||
|
await writeFile(join(releaseDir, "rollback.json"), `${JSON.stringify(rollback, null, 2)}\n`, "utf8");
|
||||||
|
await writeFile(join(stage, "manifest.env"), `id=${patchId}\ncomponent=n8n-private-extension\ntype=app-overlay\n`, "utf8");
|
||||||
|
await writeFile(join(stage, "files.txt"), `${relativeReleasePath}\n`, "utf8");
|
||||||
|
|
||||||
|
await mkdir(artifactDir, { recursive: true });
|
||||||
|
const target = join(artifactDir, `nodedc-n8n-private-extension-${patchId}.tgz`);
|
||||||
|
const tarScript = [
|
||||||
|
"import gzip, os, pathlib, sys, tarfile",
|
||||||
|
"root = pathlib.Path(sys.argv[2])",
|
||||||
|
"def clean(info):",
|
||||||
|
" info.uid = info.gid = 0",
|
||||||
|
" info.uname = info.gname = 'root'",
|
||||||
|
" info.mtime = 0",
|
||||||
|
" info.mode = 0o755 if info.isdir() else 0o644",
|
||||||
|
" return info",
|
||||||
|
"with open(sys.argv[1], 'wb') as output:",
|
||||||
|
" with gzip.GzipFile(filename='', mode='wb', fileobj=output, compresslevel=9, mtime=0) as compressed:",
|
||||||
|
" with tarfile.open(fileobj=compressed, mode='w', format=tarfile.PAX_FORMAT) as archive:",
|
||||||
|
" for top in ('manifest.env', 'files.txt', 'payload'):",
|
||||||
|
" path = root / top",
|
||||||
|
" archive.add(path, arcname=top, recursive=False, filter=clean)",
|
||||||
|
" if path.is_dir():",
|
||||||
|
" for child in sorted(path.rglob('*'), key=lambda item: item.as_posix()):",
|
||||||
|
" archive.add(child, arcname=child.relative_to(root).as_posix(), recursive=False, filter=clean)",
|
||||||
|
].join("\n");
|
||||||
|
run("python3", ["-c", tarScript, target, stage], stage);
|
||||||
|
|
||||||
|
const artifactSha256 = createHash("sha256").update(await readFile(target)).digest("hex");
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
ok: true,
|
||||||
|
patchId,
|
||||||
|
artifact: target,
|
||||||
|
artifactSha256,
|
||||||
|
releaseId,
|
||||||
|
packageSha256,
|
||||||
|
nodeTypes: expectedRuntimeNodeTypes,
|
||||||
|
credentialTypes: expectedCredentialTypes,
|
||||||
|
activation: "blocked_pending_engine_owned_mount",
|
||||||
|
}, null, 2));
|
||||||
|
} finally {
|
||||||
|
await rm(stage, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertPackageSourcePolicy(value) {
|
||||||
|
if (value.name !== "n8n-nodes-ndc") throw new Error("package_name_invalid");
|
||||||
|
if (value.version !== expectedPackageVersion) throw new Error("package_version_invalid");
|
||||||
|
if (value.private !== true) throw new Error("package_must_remain_private");
|
||||||
|
if (value.dependencies !== undefined) throw new Error("runtime_dependencies_forbidden");
|
||||||
|
for (const lifecycle of ["preinstall", "install", "postinstall", "prepack", "prepare", "postpack"]) {
|
||||||
|
if (value.scripts?.[lifecycle] !== undefined) throw new Error(`lifecycle_script_forbidden:${lifecycle}`);
|
||||||
|
}
|
||||||
|
assertExactRegistration(value.n8n?.nodes, expectedN8nNodes, "n8n_nodes");
|
||||||
|
assertExactRegistration(value.n8n?.credentials, expectedN8nCredentials, "n8n_credentials");
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertRuntimeNodePolicy(packageJson) {
|
||||||
|
const observedTypes = [];
|
||||||
|
for (const expected of expectedRuntimeNodes) {
|
||||||
|
const loaded = requireModule(join(packageRoot, expected.file));
|
||||||
|
const NodeClass = loaded?.[expected.exportName];
|
||||||
|
if (typeof NodeClass !== "function") throw new Error(`runtime_node_export_missing:${expected.exportName}`);
|
||||||
|
const description = new NodeClass()?.description;
|
||||||
|
if (!description || description.name !== expected.name) {
|
||||||
|
throw new Error(`runtime_node_name_mismatch:${expected.exportName}`);
|
||||||
|
}
|
||||||
|
if ("usableAsTool" in description) {
|
||||||
|
throw new Error(`runtime_tool_variant_forbidden:${expected.name}`);
|
||||||
|
}
|
||||||
|
observedTypes.push(`${packageJson.name}.${description.name}`);
|
||||||
|
}
|
||||||
|
if (JSON.stringify(observedTypes) !== JSON.stringify(expectedRuntimeNodeTypes)) {
|
||||||
|
throw new Error("runtime_node_types_mismatch");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertExactRegistration(actual, expected, label) {
|
||||||
|
if (!Array.isArray(actual)) throw new Error(`${label}_missing`);
|
||||||
|
if (actual.length !== new Set(actual).size) throw new Error(`${label}_duplicate`);
|
||||||
|
if (actual.length !== expected.length || expected.some((value) => !actual.includes(value))) {
|
||||||
|
throw new Error(`${label}_mismatch`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertPackedFilePolicy(metadata, sourcePackage) {
|
||||||
|
if (metadata.name !== sourcePackage.name || metadata.version !== sourcePackage.version) {
|
||||||
|
throw new Error("npm_pack_identity_mismatch");
|
||||||
|
}
|
||||||
|
if (!Number.isSafeInteger(metadata.size) || metadata.size < 1024 || metadata.size > 32 * 1024 * 1024) {
|
||||||
|
throw new Error("npm_pack_size_invalid");
|
||||||
|
}
|
||||||
|
if (!Array.isArray(metadata.files) || metadata.files.length > 512) throw new Error("npm_pack_file_list_invalid");
|
||||||
|
const paths = new Set();
|
||||||
|
for (const file of metadata.files) {
|
||||||
|
if (!file || typeof file.path !== "string" || paths.has(file.path)) throw new Error("npm_pack_file_invalid");
|
||||||
|
paths.add(file.path);
|
||||||
|
if (!(file.path === "README.md" || file.path === "package.json" || file.path.startsWith("dist/"))) {
|
||||||
|
throw new Error(`npm_pack_path_forbidden:${file.path}`);
|
||||||
|
}
|
||||||
|
const parts = file.path.split("/");
|
||||||
|
if (
|
||||||
|
file.path.includes("\\")
|
||||||
|
|| parts.some((part) => !part || part === "." || part === ".." || part.startsWith("."))
|
||||||
|
|| file.mode !== 0o644
|
||||||
|
) {
|
||||||
|
throw new Error(`npm_pack_path_unsafe:${file.path}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertExactRegistration(
|
||||||
|
[...paths].filter((value) => /^dist\/nodes\/.+\.node\.js$/.test(value)),
|
||||||
|
expectedN8nNodes,
|
||||||
|
"npm_pack_nodes",
|
||||||
|
);
|
||||||
|
assertExactRegistration(
|
||||||
|
[...paths].filter((value) => /^dist\/credentials\/.+\.credentials\.js$/.test(value)),
|
||||||
|
expectedN8nCredentials,
|
||||||
|
"npm_pack_credentials",
|
||||||
|
);
|
||||||
|
for (const registered of [...sourcePackage.n8n.nodes, ...sourcePackage.n8n.credentials]) {
|
||||||
|
if (!paths.has(registered)) throw new Error(`npm_pack_registration_missing:${registered}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(command, args, cwd) {
|
||||||
|
const result = spawnSync(command, args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
||||||
|
if (result.status !== 0) {
|
||||||
|
throw new Error(`${command}_failed:${result.stderr || result.stdout}`);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,285 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
import importlib.machinery
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import tarfile
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||||
|
PLATFORM_ROOT = SCRIPT_DIR.parent.parent
|
||||||
|
ENGINE_ROOT = PLATFORM_ROOT.parent / "NODEDC_ENGINE_INFRA"
|
||||||
|
RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy"
|
||||||
|
BUILDER_PATH = SCRIPT_DIR / "build-engine-n8n-private-extension-artifact.mjs"
|
||||||
|
STAGE_ARTIFACT = (
|
||||||
|
PLATFORM_ROOT
|
||||||
|
/ "infra/deploy-artifacts"
|
||||||
|
/ "nodedc-n8n-private-extension-n8n-nodes-ndc-release-20260716-003.tgz"
|
||||||
|
)
|
||||||
|
EXPECTED_NODES = [
|
||||||
|
"n8n-nodes-ndc.ndcDataProductPublish",
|
||||||
|
"n8n-nodes-ndc.ndcDataProductRead",
|
||||||
|
"n8n-nodes-ndc.ndcFoundryBinding",
|
||||||
|
]
|
||||||
|
EXPECTED_CREDENTIALS = [
|
||||||
|
"ndcDataProductWriterApi",
|
||||||
|
"ndcDataProductReaderApi",
|
||||||
|
"ndcFoundryBindingApi",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def load_runner():
|
||||||
|
loader = importlib.machinery.SourceFileLoader("nodedc_engine_deploy_under_test", str(RUNNER_PATH))
|
||||||
|
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
RUNNER = load_runner()
|
||||||
|
|
||||||
|
|
||||||
|
class EngineN8nPrivateExtensionTest(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.temporary = tempfile.TemporaryDirectory(prefix="nodedc-engine-n8n-policy-")
|
||||||
|
cls.root = Path(cls.temporary.name)
|
||||||
|
cls.results = []
|
||||||
|
for index in range(2):
|
||||||
|
output = cls.root / f"build-{index}"
|
||||||
|
output.mkdir()
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(output)
|
||||||
|
env["NODEDC_N8N_EXTENSION_STAGE_ARTIFACT"] = str(STAGE_ARTIFACT)
|
||||||
|
result = subprocess.run(
|
||||||
|
["node", str(BUILDER_PATH)],
|
||||||
|
cwd=PLATFORM_ROOT,
|
||||||
|
env=env,
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
cls.results.append(json.loads(result.stdout))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def tearDownClass(cls):
|
||||||
|
cls.temporary.cleanup()
|
||||||
|
|
||||||
|
def artifact(self, build_index, kind):
|
||||||
|
return Path(self.results[build_index][kind]["artifact"])
|
||||||
|
|
||||||
|
def test_engine_artifacts_are_byte_reproducible(self):
|
||||||
|
for kind in ("activation", "rollback"):
|
||||||
|
first = self.artifact(0, kind).read_bytes()
|
||||||
|
second = self.artifact(1, kind).read_bytes()
|
||||||
|
self.assertEqual(first, second)
|
||||||
|
self.assertEqual(self.results[0][kind]["sha256"], self.results[1][kind]["sha256"])
|
||||||
|
self.assertEqual(first[4:8], b"\0\0\0\0")
|
||||||
|
self.assertEqual(first[3] & 0x08, 0)
|
||||||
|
|
||||||
|
def test_activation_and_rollback_pass_strict_runner_policy(self):
|
||||||
|
expected = {"activation": ("activate", 7), "rollback": ("rollback-inactive", 4)}
|
||||||
|
for kind, (action, entry_count) in expected.items():
|
||||||
|
with self.subTest(kind=kind), tempfile.TemporaryDirectory() as directory:
|
||||||
|
manifest, entries, payload = RUNNER.load_artifact(
|
||||||
|
self.artifact(0, kind),
|
||||||
|
Path(directory),
|
||||||
|
)
|
||||||
|
descriptor = RUNNER.read_engine_n8n_transition_descriptor(
|
||||||
|
payload / RUNNER.ENGINE_N8N_TRANSITION_DESCRIPTOR_REL
|
||||||
|
)
|
||||||
|
self.assertEqual(manifest["component"], "engine")
|
||||||
|
self.assertEqual(descriptor["action"], action)
|
||||||
|
self.assertEqual(len(entries), entry_count)
|
||||||
|
self.assertEqual(RUNNER.component_services("engine", entries), ("n8n",))
|
||||||
|
self.assertEqual(RUNNER.component_builds("engine", entries), ())
|
||||||
|
self.assertFalse(RUNNER.component_publish_dist("engine", entries))
|
||||||
|
|
||||||
|
def test_activation_catalog_is_exact_three_without_tool_variants(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
_manifest, _entries, payload = RUNNER.load_artifact(
|
||||||
|
self.artifact(0, "activation"),
|
||||||
|
Path(directory),
|
||||||
|
)
|
||||||
|
nodes = json.loads((payload / RUNNER.ENGINE_N8N_NODES_CATALOG_REL).read_text())
|
||||||
|
credentials = json.loads((payload / RUNNER.ENGINE_N8N_CREDENTIALS_CATALOG_REL).read_text())
|
||||||
|
private_nodes = [item for item in nodes if item.get("name", "").startswith("n8n-nodes-ndc.")]
|
||||||
|
private_credentials = [item for item in credentials if item.get("name") in EXPECTED_CREDENTIALS]
|
||||||
|
self.assertEqual([item["name"] for item in private_nodes], EXPECTED_NODES)
|
||||||
|
self.assertEqual([item["name"] for item in private_credentials], EXPECTED_CREDENTIALS)
|
||||||
|
self.assertTrue(all("usableAsTool" not in item for item in private_nodes))
|
||||||
|
self.assertEqual((len(nodes), len(credentials)), (437, 388))
|
||||||
|
|
||||||
|
def test_rollback_catalog_restores_verified_inactive_baseline(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
_manifest, _entries, payload = RUNNER.load_artifact(
|
||||||
|
self.artifact(0, "rollback"),
|
||||||
|
Path(directory),
|
||||||
|
)
|
||||||
|
nodes = json.loads((payload / RUNNER.ENGINE_N8N_NODES_CATALOG_REL).read_text())
|
||||||
|
credentials = json.loads((payload / RUNNER.ENGINE_N8N_CREDENTIALS_CATALOG_REL).read_text())
|
||||||
|
self.assertEqual([item for item in nodes if item.get("name", "").startswith("n8n-nodes-ndc.")], [])
|
||||||
|
self.assertEqual([item for item in credentials if item.get("name") in EXPECTED_CREDENTIALS], [])
|
||||||
|
self.assertEqual((len(nodes), len(credentials)), (434, 385))
|
||||||
|
|
||||||
|
def test_compose_override_is_runner_derived_and_offline(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
_manifest, _entries, payload = RUNNER.load_artifact(
|
||||||
|
self.artifact(0, "activation"),
|
||||||
|
Path(directory),
|
||||||
|
)
|
||||||
|
descriptor = RUNNER.read_engine_n8n_transition_descriptor(
|
||||||
|
payload / RUNNER.ENGINE_N8N_TRANSITION_DESCRIPTOR_REL
|
||||||
|
)
|
||||||
|
override = (payload / RUNNER.ENGINE_N8N_COMPOSE_OVERRIDE_REL).read_text()
|
||||||
|
self.assertEqual(override, RUNNER.expected_engine_n8n_compose_override(descriptor))
|
||||||
|
self.assertIn("pull_policy: never", override)
|
||||||
|
self.assertIn("N8N_USER_FOLDER: /home/node", override)
|
||||||
|
self.assertIn(":/home/node/.n8n/nodes/node_modules/n8n-nodes-ndc:ro", override)
|
||||||
|
self.assertNotIn("N8N_CUSTOM_EXTENSIONS", override)
|
||||||
|
self.assertNotIn("build:", override)
|
||||||
|
|
||||||
|
def test_unknown_descriptor_key_is_rejected(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
work = Path(directory)
|
||||||
|
RUNNER.safe_extract(self.artifact(0, "activation"), work)
|
||||||
|
descriptor_path = work / "payload" / RUNNER.ENGINE_N8N_TRANSITION_DESCRIPTOR_REL
|
||||||
|
descriptor = json.loads(descriptor_path.read_text())
|
||||||
|
descriptor["unexpected"] = True
|
||||||
|
descriptor_path.write_text(json.dumps(descriptor))
|
||||||
|
with self.assertRaisesRegex(RUNNER.DeployError, "keys mismatch"):
|
||||||
|
RUNNER.validate_engine_n8n_transition(
|
||||||
|
work / "payload",
|
||||||
|
RUNNER.parse_files_list(work / "files.txt"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_core_catalog_substitution_is_rejected_even_when_counts_match(self):
|
||||||
|
cases = (
|
||||||
|
("activation", RUNNER.ENGINE_N8N_NODES_CATALOG_REL, "node"),
|
||||||
|
("activation", RUNNER.ENGINE_N8N_CREDENTIALS_CATALOG_REL, "credential"),
|
||||||
|
("rollback", RUNNER.ENGINE_N8N_NODES_CATALOG_REL, "node"),
|
||||||
|
("rollback", RUNNER.ENGINE_N8N_CREDENTIALS_CATALOG_REL, "credential"),
|
||||||
|
)
|
||||||
|
for kind, relative_path, label in cases:
|
||||||
|
with self.subTest(kind=kind, catalog=label), tempfile.TemporaryDirectory() as directory:
|
||||||
|
work = Path(directory)
|
||||||
|
RUNNER.safe_extract(self.artifact(0, kind), work)
|
||||||
|
catalog_path = work / "payload" / relative_path
|
||||||
|
catalog = json.loads(catalog_path.read_text())
|
||||||
|
if label == "node":
|
||||||
|
candidate = next(
|
||||||
|
item for item in catalog
|
||||||
|
if not item.get("name", "").startswith("n8n-nodes-ndc.")
|
||||||
|
)
|
||||||
|
candidate["name"] = "n8n-nodes-unreviewed.hiddenNode"
|
||||||
|
else:
|
||||||
|
candidate = next(
|
||||||
|
item for item in catalog
|
||||||
|
if item.get("name") not in EXPECTED_CREDENTIALS
|
||||||
|
)
|
||||||
|
candidate["name"] = "unreviewedCredential"
|
||||||
|
catalog_path.write_text(json.dumps(catalog, indent=2) + "\n")
|
||||||
|
with self.assertRaisesRegex(RUNNER.DeployError, "catalog sha256 mismatch"):
|
||||||
|
RUNNER.validate_engine_n8n_transition(
|
||||||
|
work / "payload",
|
||||||
|
RUNNER.parse_files_list(work / "files.txt"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_old_engine_artifact_is_rejected_by_descriptor_gate(self):
|
||||||
|
old = PLATFORM_ROOT / "infra/deploy-artifacts/nodedc-engine-n8n-private-extension-20260715-002.tgz"
|
||||||
|
if not old.is_file():
|
||||||
|
self.skipTest("rejected historical artifact not present")
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
with self.assertRaisesRegex(RUNNER.DeployError, "canonical transition descriptor"):
|
||||||
|
RUNNER.load_artifact(old, Path(directory))
|
||||||
|
|
||||||
|
def test_engine_source_keeps_base_compose_and_separate_override(self):
|
||||||
|
original_root = RUNNER.COMPONENTS["engine"]["payload_root"]
|
||||||
|
original_compose = RUNNER.COMPONENTS["engine"]["compose_root"]
|
||||||
|
try:
|
||||||
|
RUNNER.COMPONENTS["engine"]["payload_root"] = ENGINE_ROOT
|
||||||
|
RUNNER.COMPONENTS["engine"]["compose_root"] = ENGINE_ROOT
|
||||||
|
RUNNER.validate_engine_n8n_base_compose_source()
|
||||||
|
finally:
|
||||||
|
RUNNER.COMPONENTS["engine"]["payload_root"] = original_root
|
||||||
|
RUNNER.COMPONENTS["engine"]["compose_root"] = original_compose
|
||||||
|
|
||||||
|
def test_additional_n8n_runtime_service_is_rejected(self):
|
||||||
|
original_root = RUNNER.COMPONENTS["engine"]["payload_root"]
|
||||||
|
original_compose = RUNNER.COMPONENTS["engine"]["compose_root"]
|
||||||
|
try:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
engine_root = Path(directory)
|
||||||
|
compose = (ENGINE_ROOT / "docker-compose.yml").read_text()
|
||||||
|
compose += (
|
||||||
|
"\n n8n-worker-2:\n"
|
||||||
|
" image: docker.n8n.io/n8nio/n8n:${N8N_IMAGE_TAG:-2.3.2}\n"
|
||||||
|
)
|
||||||
|
(engine_root / "docker-compose.yml").write_text(compose)
|
||||||
|
RUNNER.COMPONENTS["engine"]["payload_root"] = engine_root
|
||||||
|
RUNNER.COMPONENTS["engine"]["compose_root"] = engine_root
|
||||||
|
with self.assertRaisesRegex(RUNNER.DeployError, "exact service topology mismatch"):
|
||||||
|
RUNNER.validate_engine_n8n_base_compose_source()
|
||||||
|
finally:
|
||||||
|
RUNNER.COMPONENTS["engine"]["payload_root"] = original_root
|
||||||
|
RUNNER.COMPONENTS["engine"]["compose_root"] = original_compose
|
||||||
|
|
||||||
|
def test_sealed_release_exact_set_includes_implicit_directories(self):
|
||||||
|
release_relative = Path(
|
||||||
|
"payload/releases/n8n-nodes-ndc/0.1.2-05e4b38b14b4a019"
|
||||||
|
)
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
work = Path(directory)
|
||||||
|
outer = work / "outer"
|
||||||
|
RUNNER.safe_extract(STAGE_ARTIFACT, outer)
|
||||||
|
staged_release = outer / release_relative
|
||||||
|
sealed_release = work / "sealed"
|
||||||
|
sealed_release.mkdir()
|
||||||
|
for filename in ("package.tgz", "release.json", "rollback.json"):
|
||||||
|
(sealed_release / filename).write_bytes(
|
||||||
|
(staged_release / filename).read_bytes()
|
||||||
|
)
|
||||||
|
|
||||||
|
expected_paths = {
|
||||||
|
"package",
|
||||||
|
"package.tgz",
|
||||||
|
"release.json",
|
||||||
|
"rollback.json",
|
||||||
|
}
|
||||||
|
with tarfile.open(staged_release / "package.tgz", "r:gz") as archive:
|
||||||
|
for member in archive:
|
||||||
|
RUNNER.add_engine_n8n_sealed_member_paths(
|
||||||
|
expected_paths,
|
||||||
|
member.name,
|
||||||
|
)
|
||||||
|
target = sealed_release.joinpath(*Path(member.name).parts)
|
||||||
|
if member.isdir():
|
||||||
|
target.mkdir(parents=True, exist_ok=True)
|
||||||
|
continue
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
source = archive.extractfile(member)
|
||||||
|
self.assertIsNotNone(source)
|
||||||
|
target.write_bytes(source.read())
|
||||||
|
|
||||||
|
actual_paths = {
|
||||||
|
path.relative_to(sealed_release).as_posix()
|
||||||
|
for path in sealed_release.rglob("*")
|
||||||
|
}
|
||||||
|
self.assertEqual(actual_paths, expected_paths)
|
||||||
|
self.assertIn("package/dist/nodes", expected_paths)
|
||||||
|
self.assertIn("package/dist/credentials", expected_paths)
|
||||||
|
|
||||||
|
def test_tar_members_have_no_appledouble_or_special_types(self):
|
||||||
|
for kind in ("activation", "rollback"):
|
||||||
|
with tarfile.open(self.artifact(0, kind), "r:gz") as archive:
|
||||||
|
for member in archive:
|
||||||
|
self.assertFalse(Path(member.name).name.startswith("._"))
|
||||||
|
self.assertTrue(member.isfile() or member.isdir())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main(verbosity=2)
|
||||||
|
|
@ -0,0 +1,260 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
import hashlib
|
||||||
|
import importlib.machinery
|
||||||
|
import importlib.util
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tarfile
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||||
|
PLATFORM_ROOT = SCRIPT_DIR.parent.parent
|
||||||
|
RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy"
|
||||||
|
BUILDER_PATH = SCRIPT_DIR / "build-n8n-private-extension-artifact.mjs"
|
||||||
|
PATCH_ID = "n8n-nodes-ndc-release-20260716-003"
|
||||||
|
EXPECTED_NODES = [
|
||||||
|
"dist/nodes/NdcDataProductPublish/NdcDataProductPublish.node.js",
|
||||||
|
"dist/nodes/NdcDataProductRead/NdcDataProductRead.node.js",
|
||||||
|
"dist/nodes/NdcFoundryBinding/NdcFoundryBinding.node.js",
|
||||||
|
]
|
||||||
|
EXPECTED_NODE_TYPES = [
|
||||||
|
"n8n-nodes-ndc.ndcDataProductPublish",
|
||||||
|
"n8n-nodes-ndc.ndcDataProductRead",
|
||||||
|
"n8n-nodes-ndc.ndcFoundryBinding",
|
||||||
|
]
|
||||||
|
EXPECTED_CREDENTIALS = [
|
||||||
|
"dist/credentials/NdcDataProductWriterApi.credentials.js",
|
||||||
|
"dist/credentials/NdcDataProductReaderApi.credentials.js",
|
||||||
|
"dist/credentials/NdcFoundryBindingApi.credentials.js",
|
||||||
|
]
|
||||||
|
EXPECTED_CREDENTIAL_TYPES = [
|
||||||
|
"ndcDataProductWriterApi",
|
||||||
|
"ndcDataProductReaderApi",
|
||||||
|
"ndcFoundryBindingApi",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def load_runner():
|
||||||
|
loader = importlib.machinery.SourceFileLoader("nodedc_deploy_under_test", str(RUNNER_PATH))
|
||||||
|
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
RUNNER = load_runner()
|
||||||
|
|
||||||
|
|
||||||
|
class N8nPrivateExtensionPolicyTest(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.temporary = tempfile.TemporaryDirectory(prefix="nodedc-extension-policy-")
|
||||||
|
cls.root = Path(cls.temporary.name)
|
||||||
|
cls.artifacts = []
|
||||||
|
cls.results = []
|
||||||
|
for index in range(2):
|
||||||
|
output_dir = cls.root / f"build-{index}"
|
||||||
|
output_dir.mkdir()
|
||||||
|
environment = os.environ.copy()
|
||||||
|
environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(output_dir)
|
||||||
|
command = ["node", str(BUILDER_PATH)]
|
||||||
|
if index == 0:
|
||||||
|
command.append(PATCH_ID)
|
||||||
|
result = subprocess.run(
|
||||||
|
command,
|
||||||
|
cwd=PLATFORM_ROOT,
|
||||||
|
env=environment,
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
metadata = json.loads(result.stdout)
|
||||||
|
artifact = Path(metadata["artifact"])
|
||||||
|
cls.results.append(metadata)
|
||||||
|
cls.artifacts.append(artifact)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def tearDownClass(cls):
|
||||||
|
cls.temporary.cleanup()
|
||||||
|
|
||||||
|
def test_runner_source_compiles(self):
|
||||||
|
compile(RUNNER_PATH.read_text(encoding="utf-8"), str(RUNNER_PATH), "exec")
|
||||||
|
|
||||||
|
def test_builder_is_byte_reproducible(self):
|
||||||
|
first = self.artifacts[0].read_bytes()
|
||||||
|
second = self.artifacts[1].read_bytes()
|
||||||
|
self.assertEqual(first, second)
|
||||||
|
self.assertEqual(self.results[0]["artifactSha256"], self.results[1]["artifactSha256"])
|
||||||
|
self.assertEqual(self.results[0]["patchId"], PATCH_ID)
|
||||||
|
self.assertEqual(self.results[0]["nodeTypes"], EXPECTED_NODE_TYPES)
|
||||||
|
self.assertEqual(self.results[0]["credentialTypes"], EXPECTED_CREDENTIAL_TYPES)
|
||||||
|
self.assertEqual(first[4:8], b"\0\0\0\0", "gzip mtime must be zero")
|
||||||
|
self.assertEqual(first[3] & 0x08, 0, "gzip header must not carry a host filename")
|
||||||
|
|
||||||
|
inner = self._inner_package_bytes()
|
||||||
|
self.assertEqual(inner[4:8], b"\0\0\0\0", "inner package gzip mtime must be zero")
|
||||||
|
self.assertEqual(inner[3] & 0x08, 0, "inner package gzip header must not carry a host filename")
|
||||||
|
|
||||||
|
def test_positive_release_passes_runner_policy(self):
|
||||||
|
with tempfile.TemporaryDirectory(prefix="nodedc-extension-load-") as directory:
|
||||||
|
manifest, entries, _payload = RUNNER.load_artifact(self.artifacts[0], Path(directory))
|
||||||
|
self.assertEqual(manifest["component"], "n8n-private-extension")
|
||||||
|
self.assertEqual(len(entries), 1)
|
||||||
|
self.assertRegex(entries[0], r"^releases/n8n-nodes-ndc/0\.1\.2-[a-f0-9]{16}$")
|
||||||
|
|
||||||
|
with tarfile.open(self.artifacts[0], "r:gz") as archive:
|
||||||
|
names = archive.getnames()
|
||||||
|
self.assertFalse(any(Path(name).name.startswith("._") for name in names))
|
||||||
|
self.assertFalse(any(".." in Path(name).parts for name in names))
|
||||||
|
|
||||||
|
def test_release_has_exactly_three_non_tool_runtime_types(self):
|
||||||
|
package = self._inner_package_bytes()
|
||||||
|
with tarfile.open(fileobj=io.BytesIO(package), mode="r:gz") as archive:
|
||||||
|
package_json_member = archive.getmember("package/package.json")
|
||||||
|
package_json = json.loads(archive.extractfile(package_json_member).read())
|
||||||
|
self.assertEqual(package_json["version"], "0.1.2")
|
||||||
|
self.assertEqual(package_json["n8n"]["nodes"], EXPECTED_NODES)
|
||||||
|
self.assertEqual(package_json["n8n"]["credentials"], EXPECTED_CREDENTIALS)
|
||||||
|
for node_path in EXPECTED_NODES:
|
||||||
|
source = archive.extractfile(archive.getmember(f"package/{node_path}")).read().decode("utf-8")
|
||||||
|
self.assertNotRegex(source, r"\busableAsTool\b")
|
||||||
|
|
||||||
|
def test_inner_package_stream_and_members_are_canonical(self):
|
||||||
|
package = self._inner_package_bytes()
|
||||||
|
self.assertEqual(package[4:8], b"\0\0\0\0")
|
||||||
|
self.assertEqual(package[3] & 0x08, 0)
|
||||||
|
with tarfile.open(fileobj=io.BytesIO(package), mode="r:gz") as archive:
|
||||||
|
members = archive.getmembers()
|
||||||
|
self.assertEqual([member.name for member in members], sorted(member.name for member in members))
|
||||||
|
for member in members:
|
||||||
|
self.assertEqual((member.uid, member.gid, member.uname, member.gname), (0, 0, "root", "root"))
|
||||||
|
self.assertEqual(member.mtime, 0)
|
||||||
|
self.assertEqual(member.mode, 0o755 if member.isdir() else 0o644)
|
||||||
|
|
||||||
|
def test_v2_first_activation_uses_verified_inactive_baseline(self):
|
||||||
|
with tempfile.TemporaryDirectory(prefix="nodedc-extension-v2-") as directory:
|
||||||
|
work = Path(directory)
|
||||||
|
RUNNER.safe_extract(self.artifacts[0], work)
|
||||||
|
entries = RUNNER.parse_files_list(work / "files.txt")
|
||||||
|
release_dir = work / "payload" / entries[0]
|
||||||
|
release_path = release_dir / "release.json"
|
||||||
|
rollback_path = release_dir / "rollback.json"
|
||||||
|
release = json.loads(release_path.read_text(encoding="utf-8"))
|
||||||
|
rollback = json.loads(rollback_path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
expected_policy = {
|
||||||
|
"allowed": ["previous_verified_immutable_release", "verified_inactive"],
|
||||||
|
"firstActivation": "verified_inactive",
|
||||||
|
"requiresPreActivationVerification": True,
|
||||||
|
}
|
||||||
|
self.assertEqual(release["schemaVersion"], "nodedc.n8n-private-extension-release/v2")
|
||||||
|
self.assertEqual(release["package"]["version"], "0.1.2")
|
||||||
|
self.assertEqual(release["activation"]["rollbackBaselinePolicy"], expected_policy)
|
||||||
|
self.assertEqual(rollback["schemaVersion"], "nodedc.n8n-private-extension-rollback/v2")
|
||||||
|
self.assertEqual(rollback["baselinePolicy"], expected_policy)
|
||||||
|
|
||||||
|
rollback["baselinePolicy"]["requiresPreActivationVerification"] = False
|
||||||
|
rollback_path.write_text(json.dumps(rollback), encoding="utf-8")
|
||||||
|
with self.assertRaisesRegex(RUNNER.DeployError, "rollback manifest mismatch"):
|
||||||
|
RUNNER.validate_n8n_private_extension_release(work / "payload", entries)
|
||||||
|
|
||||||
|
def test_outer_appledouble_and_traversal_are_rejected(self):
|
||||||
|
with self.assertRaisesRegex(RUNNER.DeployError, "unexpected tar member"):
|
||||||
|
RUNNER.validate_tar_member(tarfile.TarInfo("._manifest.env"))
|
||||||
|
with self.assertRaisesRegex(RUNNER.DeployError, "path escape rejected"):
|
||||||
|
RUNNER.validate_tar_member(tarfile.TarInfo("payload/../escape"))
|
||||||
|
|
||||||
|
def test_extra_custom_node_is_rejected(self):
|
||||||
|
def mutate(package_json):
|
||||||
|
package_json["n8n"]["nodes"].append("dist/nodes/Extra/Extra.node.js")
|
||||||
|
|
||||||
|
package_path, release = self._tampered_package(
|
||||||
|
mutate,
|
||||||
|
{"package/dist/nodes/Extra/Extra.node.js": b"module.exports = {};\n"},
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(RUNNER.DeployError, "n8n.nodes registration mismatch"):
|
||||||
|
RUNNER.validate_n8n_package_tarball(package_path, release)
|
||||||
|
|
||||||
|
def test_lifecycle_script_is_rejected(self):
|
||||||
|
def mutate(package_json):
|
||||||
|
package_json.setdefault("scripts", {})["install"] = "echo forbidden"
|
||||||
|
|
||||||
|
package_path, release = self._tampered_package(mutate)
|
||||||
|
with self.assertRaisesRegex(RUNNER.DeployError, "lifecycle script is forbidden"):
|
||||||
|
RUNNER.validate_n8n_package_tarball(package_path, release)
|
||||||
|
|
||||||
|
def test_inner_appledouble_is_rejected(self):
|
||||||
|
package_path, release = self._tampered_package(
|
||||||
|
lambda _package_json: None,
|
||||||
|
{"package/dist/._NdcDataProductPublish.node.js": b"forbidden\n"},
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(RUNNER.DeployError, "hidden member rejected"):
|
||||||
|
RUNNER.validate_n8n_package_tarball(package_path, release)
|
||||||
|
|
||||||
|
def test_tool_variant_marker_is_rejected_by_runner(self):
|
||||||
|
target = EXPECTED_NODES[0]
|
||||||
|
package_path, release = self._tampered_package(
|
||||||
|
lambda _package_json: None,
|
||||||
|
file_mutations={target: lambda source: source + b"\n// usableAsTool: true\n"},
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(RUNNER.DeployError, "would generate a tool variant"):
|
||||||
|
RUNNER.validate_n8n_package_tarball(package_path, release)
|
||||||
|
|
||||||
|
def _tampered_package(self, mutate, extra_files=None, file_mutations=None):
|
||||||
|
package_bytes = self._inner_package_bytes()
|
||||||
|
members = []
|
||||||
|
with tarfile.open(fileobj=io.BytesIO(package_bytes), mode="r:gz") as archive:
|
||||||
|
for member in archive:
|
||||||
|
if not member.isfile():
|
||||||
|
continue
|
||||||
|
source = archive.extractfile(member)
|
||||||
|
members.append((member.name, source.read()))
|
||||||
|
|
||||||
|
rewritten = []
|
||||||
|
for name, content in members:
|
||||||
|
if name == "package/package.json":
|
||||||
|
package_json = json.loads(content)
|
||||||
|
mutate(package_json)
|
||||||
|
content = (json.dumps(package_json, sort_keys=True) + "\n").encode("utf-8")
|
||||||
|
package_rel = name.removeprefix("package/")
|
||||||
|
if package_rel in (file_mutations or {}):
|
||||||
|
content = file_mutations[package_rel](content)
|
||||||
|
rewritten.append((name, content))
|
||||||
|
rewritten.extend((extra_files or {}).items())
|
||||||
|
|
||||||
|
target = self.root / f"tampered-{len(list(self.root.glob('tampered-*.tgz')))}.tgz"
|
||||||
|
with tarfile.open(target, "w:gz", format=tarfile.PAX_FORMAT) as archive:
|
||||||
|
for name, content in rewritten:
|
||||||
|
member = tarfile.TarInfo(name)
|
||||||
|
member.mode = 0o644
|
||||||
|
member.mtime = 0
|
||||||
|
member.size = len(content)
|
||||||
|
archive.addfile(member, io.BytesIO(content))
|
||||||
|
|
||||||
|
with tarfile.open(target, "r:gz") as archive:
|
||||||
|
package_member = next(member for member in archive if member.name == "package/package.json")
|
||||||
|
version = json.loads(archive.extractfile(package_member).read())["version"]
|
||||||
|
release = {
|
||||||
|
"package": {
|
||||||
|
"version": version,
|
||||||
|
"sha256": hashlib.sha256(target.read_bytes()).hexdigest(),
|
||||||
|
"bytes": target.stat().st_size,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return target, release
|
||||||
|
|
||||||
|
def _inner_package_bytes(self):
|
||||||
|
with tarfile.open(self.artifacts[0], "r:gz") as archive:
|
||||||
|
member = next(item for item in archive if item.name.endswith("/package.tgz"))
|
||||||
|
source = archive.extractfile(member)
|
||||||
|
return source.read()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main(verbosity=2)
|
||||||
|
|
@ -152,9 +152,11 @@ services:
|
||||||
AI_WORKSPACE_HUB_FALLBACK_URLS: ${AI_WORKSPACE_HUB_FALLBACK_URLS:-}
|
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_ENABLED: ${AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ENABLED:-true}
|
||||||
AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ID: ${AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ID:-local-dev}
|
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_URL: ${AI_WORKSPACE_OPS_ENTITLEMENT_URL:-}
|
||||||
AI_WORKSPACE_OPS_ENTITLEMENT_TOKEN: ${AI_WORKSPACE_OPS_ENTITLEMENT_TOKEN:-}
|
AI_WORKSPACE_OPS_ENTITLEMENT_TOKEN: ${AI_WORKSPACE_OPS_ENTITLEMENT_TOKEN:-}
|
||||||
AI_WORKSPACE_OPS_ENTITLEMENT_REQUIRED: ${AI_WORKSPACE_OPS_ENTITLEMENT_REQUIRED:-false}
|
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_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_WORKSPACE_SLUG: ${AI_WORKSPACE_OPS_DEFAULT_WORKSPACE_SLUG:-}
|
||||||
AI_WORKSPACE_OPS_DEFAULT_PROJECT_ID: ${AI_WORKSPACE_OPS_DEFAULT_PROJECT_ID:-}
|
AI_WORKSPACE_OPS_DEFAULT_PROJECT_ID: ${AI_WORKSPACE_OPS_DEFAULT_PROJECT_ID:-}
|
||||||
|
|
@ -168,6 +170,86 @@ services:
|
||||||
ai-workspace-postgres:
|
ai-workspace-postgres:
|
||||||
condition: service_healthy
|
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:
|
ai-workspace-hub:
|
||||||
image: nodedc/ai-workspace-hub:local
|
image: nodedc/ai-workspace-hub:local
|
||||||
build:
|
build:
|
||||||
|
|
@ -180,10 +262,14 @@ services:
|
||||||
AI_WORKSPACE_HUB_WS_PATH: /api/ai-workspace/hub
|
AI_WORKSPACE_HUB_WS_PATH: /api/ai-workspace/hub
|
||||||
NODEDC_INTERNAL_ACCESS_TOKEN: ${NODEDC_INTERNAL_ACCESS_TOKEN:-}
|
NODEDC_INTERNAL_ACCESS_TOKEN: ${NODEDC_INTERNAL_ACCESS_TOKEN:-}
|
||||||
NODEDC_AI_WORKSPACE_ASSISTANT_URL: http://ai-workspace-assistant:18082
|
NODEDC_AI_WORKSPACE_ASSISTANT_URL: http://ai-workspace-assistant:18082
|
||||||
|
NODEDC_ONTOLOGY_CORE_URL: http://ontology-core:18104
|
||||||
expose:
|
expose:
|
||||||
- "18081"
|
- "18081"
|
||||||
ports:
|
ports:
|
||||||
- "${AI_WORKSPACE_HUB_HOST_BIND:-127.0.0.1:18081}:18081"
|
- "${AI_WORKSPACE_HUB_HOST_BIND:-127.0.0.1:18081}:18081"
|
||||||
|
depends_on:
|
||||||
|
ontology-core:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
map-gateway:
|
map-gateway:
|
||||||
image: nodedc/map-gateway:local
|
image: nodedc/map-gateway:local
|
||||||
|
|
@ -194,6 +280,10 @@ services:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
PORT: 18103
|
PORT: 18103
|
||||||
CESIUM_ION_TOKEN: ${CESIUM_ION_TOKEN:-}
|
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}
|
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}
|
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
|
# Offline access is selected explicitly with the cache profile, rather
|
||||||
|
|
@ -236,6 +326,7 @@ volumes:
|
||||||
authentik-certs:
|
authentik-certs:
|
||||||
notification-database:
|
notification-database:
|
||||||
ai-workspace-database:
|
ai-workspace-database:
|
||||||
|
gelios-database:
|
||||||
caddy-data:
|
caddy-data:
|
||||||
caddy-config:
|
caddy-config:
|
||||||
# Cache data is runtime state, not Compose lifecycle state. These volumes are
|
# Cache data is runtime state, not Compose lifecycle state. These volumes are
|
||||||
|
|
@ -247,3 +338,7 @@ volumes:
|
||||||
map-offline-snapshot:
|
map-offline-snapshot:
|
||||||
external: true
|
external: true
|
||||||
name: ${NODEDC_MAP_OFFLINE_SNAPSHOT_VOLUME:-nodedc-platform_map-offline-snapshot}
|
name: ${NODEDC_MAP_OFFLINE_SNAPSHOT_VOLUME:-nodedc-platform_map-offline-snapshot}
|
||||||
|
|
||||||
|
networks:
|
||||||
|
gelios-data:
|
||||||
|
internal: true
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,7 @@ run_sh_quiet() {
|
||||||
section "Static syntax"
|
section "Static syntax"
|
||||||
run_cmd "Hub server syntax" node --check "$HUB_DIR/src/server.mjs"
|
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 "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_cmd "Local environment safety checker syntax" node --check "$INFRA_DIR/scripts/check-local-environment-safety.mjs"
|
||||||
run_sh "Shell scripts syntax" "$PLATFORM_ROOT" \
|
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"
|
"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"
|
section "AI Workspace smokes"
|
||||||
run_sh_quiet "Assistant run-profile smoke" "$ASSISTANT_DIR" "npm run smoke:run-profile"
|
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
|
if [ -d "$ONTOLOGY_DIR" ]; then
|
||||||
run_sh_quiet "Ontology assistant caller smoke" "$ONTOLOGY_DIR" "npm run smoke:assistant-caller"
|
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
|
else
|
||||||
skip "Ontology assistant caller smoke (missing $ONTOLOGY_DIR)"
|
skip "Ontology assistant caller smoke (missing $ONTOLOGY_DIR)"
|
||||||
fi
|
fi
|
||||||
|
|
|
||||||
|
|
@ -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_DOCKER_NETWORK=nodedc-demo_default
|
||||||
NODEDC_ENGINE_INTERNAL_URL=http://nodedc-demo-nodedc-backend-1:3001
|
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_ISSUER=https://id.nodedc.ru/application/o/launcher/
|
||||||
LAUNCHER_OIDC_CLIENT_ID=nodedc-launcher
|
LAUNCHER_OIDC_CLIENT_ID=nodedc-launcher
|
||||||
LAUNCHER_OIDC_CLIENT_SECRET=replace-with-random-synology-secret
|
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_DOMAIN=.nas.nodedc
|
||||||
COOKIE_SECURE=false
|
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_DB=nodedc_notifications
|
||||||
NOTIFICATION_PG_USER=nodedc_notifications
|
NOTIFICATION_PG_USER=nodedc_notifications
|
||||||
NOTIFICATION_PG_PASS=replace-with-random-synology-secret
|
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_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_TOKEN=replace-with-ops-agent-gateway-internal-token
|
||||||
AI_WORKSPACE_OPS_ENTITLEMENT_REQUIRED=false
|
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_TOKEN=replace-with-random-synology-secret
|
||||||
AI_WORKSPACE_HUB_HOST_BIND=0.0.0.0:18081
|
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_PUBLIC_URL=wss://ai-hub.nodedc.ru/api/ai-workspace/hub
|
||||||
AI_WORKSPACE_HUB_INTERNAL_URL=https://ai-hub.nodedc.ru
|
AI_WORKSPACE_HUB_INTERNAL_URL=https://ai-hub.nodedc.ru
|
||||||
AI_WORKSPACE_HUB_FALLBACK_URLS=
|
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
|
||||||
|
|
|
||||||
|
|
@ -202,6 +202,26 @@ TASKER_SYNC_SOURCE=1 ./infra/synology/deploy-current.sh
|
||||||
|
|
||||||
Если emergency-fix был сделан прямо на Synology в этих env-файлах, перенести sanitized-значение в `.env.synology.example`/docs, а секрет оставить только в live env.
|
Если 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
|
## AI Hub relay-only deploy
|
||||||
|
|
||||||
Для локального тестирования с внешней Codex-машиной нельзя деплоить Launcher, Engine, Ops, Authentik или Tasker. Единственная допустимая удалённая точка в этой схеме — `ai-workspace-hub`, потому что удалённый worker должен иметь публичный WebSocket/HTTP relay.
|
Для локального тестирования с внешней Codex-машиной нельзя деплоить Launcher, Engine, Ops, Authentik или Tasker. Единственная допустимая удалённая точка в этой схеме — `ai-workspace-hub`, потому что удалённый worker должен иметь публичный WebSocket/HTTP relay.
|
||||||
|
|
|
||||||
|
|
@ -34,12 +34,28 @@ echo "== ai workspace assistant image build =="
|
||||||
cd "${PLATFORM_DIR}/ai-workspace-assistant"
|
cd "${PLATFORM_DIR}/ai-workspace-assistant"
|
||||||
"${DOCKER_BIN}" build --no-cache -t nodedc/ai-workspace-assistant:local .
|
"${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 =="
|
echo "== platform services recreate =="
|
||||||
cd "${PLATFORM_DIR}"
|
cd "${PLATFORM_DIR}"
|
||||||
"${DOCKER_BIN}" compose \
|
"${DOCKER_BIN}" compose \
|
||||||
--env-file "${ENV_FILE}" \
|
--env-file "${ENV_FILE}" \
|
||||||
-f "${COMPOSE_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 =="
|
echo "== notification core health check =="
|
||||||
"${DOCKER_BIN}" exec nodedc-platform-notification-core-1 sh -lc \
|
"${DOCKER_BIN}" exec nodedc-platform-notification-core-1 sh -lc \
|
||||||
|
|
|
||||||
|
|
@ -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/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-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/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_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/"
|
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
|
- Notification Core source/config: platform/notification-core, platform compose/env
|
||||||
- AI Workspace Hub source/config: platform/ai-workspace-hub, 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
|
- 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
|
- Tasker runtime config: tasker/plane-app/.env.synology, compose, Synology override
|
||||||
- Ops Agents Gateway runtime config: ops-agents/.env, compose
|
- Ops Agents Gateway runtime config: ops-agents/.env, compose
|
||||||
- BIM Viewer runtime data/config: bim-viewer/source/server/data, .env, compose
|
- BIM Viewer runtime data/config: bim-viewer/source/server/data, .env, compose
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,22 @@ rsync -av "${RSYNC_METADATA_ARGS[@]}" --delete \
|
||||||
"${PLATFORM_REPO}/services/ontology-core/" \
|
"${PLATFORM_REPO}/services/ontology-core/" \
|
||||||
"${NAS_ROOT}/platform/ai-workspace-assistant/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
|
if [[ "${SYNC_AUTHENTIK_TEMPLATES}" == "1" ]]; then
|
||||||
mkdir -p "${NAS_ROOT}/authentik/custom-templates"
|
mkdir -p "${NAS_ROOT}/authentik/custom-templates"
|
||||||
rsync -av "${RSYNC_METADATA_ARGS[@]}" --delete \
|
rsync -av "${RSYNC_METADATA_ARGS[@]}" --delete \
|
||||||
|
|
@ -240,7 +256,20 @@ fi
|
||||||
|
|
||||||
cat <<'EOF'
|
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
|
cd /volume1/docker/nodedc-platform/platform
|
||||||
sudo bash apply-current-runtime.sh
|
sudo bash apply-current-runtime.sh
|
||||||
|
|
@ -276,14 +305,14 @@ cd /volume1/docker/nodedc-platform/platform
|
||||||
sudo /usr/local/bin/docker compose \
|
sudo /usr/local/bin/docker compose \
|
||||||
--env-file /volume1/docker/nodedc-platform/platform/.env.synology \
|
--env-file /volume1/docker/nodedc-platform/platform/.env.synology \
|
||||||
-f /volume1/docker/nodedc-platform/platform/docker-compose.platform-http.yml \
|
-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:
|
Optional Platform/Auth infra apply, only after deliberate compose/proxy/Auth templates changes:
|
||||||
|
|
||||||
sudo /usr/local/bin/docker compose \
|
sudo /usr/local/bin/docker compose \
|
||||||
--env-file /volume1/docker/nodedc-platform/platform/.env.synology \
|
--env-file /volume1/docker/nodedc-platform/platform/.env.synology \
|
||||||
-f /volume1/docker/nodedc-platform/platform/docker-compose.platform-http.yml \
|
-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.
|
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:
|
NODE.DC login CSS must be template-scoped, not stored in Brand.branding_custom_css:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
@ -142,6 +142,8 @@ services:
|
||||||
AI_WORKSPACE_HUB_INTERNAL_URL: ${AI_WORKSPACE_HUB_INTERNAL_URL:-https://ai-hub.nodedc.ru}
|
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_TOKEN: ${AI_WORKSPACE_HUB_TOKEN:?ai workspace hub token required}
|
||||||
AI_WORKSPACE_HUB_FALLBACK_URLS: ${AI_WORKSPACE_HUB_FALLBACK_URLS:-}
|
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
|
NDC_ONTOLOGY_LAUNCHER_BASE_URL: http://launcher:5173
|
||||||
expose:
|
expose:
|
||||||
- "18082"
|
- "18082"
|
||||||
|
|
@ -154,6 +156,160 @@ services:
|
||||||
- identity
|
- identity
|
||||||
- engine
|
- 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:
|
ai-workspace-hub:
|
||||||
image: nodedc/ai-workspace-hub:local
|
image: nodedc/ai-workspace-hub:local
|
||||||
build:
|
build:
|
||||||
|
|
@ -167,11 +323,15 @@ services:
|
||||||
AI_WORKSPACE_HUB_TOKEN: ${AI_WORKSPACE_HUB_TOKEN:?ai workspace hub token required}
|
AI_WORKSPACE_HUB_TOKEN: ${AI_WORKSPACE_HUB_TOKEN:?ai workspace hub token required}
|
||||||
NODEDC_INTERNAL_ACCESS_TOKEN: ${NODEDC_INTERNAL_ACCESS_TOKEN:-}
|
NODEDC_INTERNAL_ACCESS_TOKEN: ${NODEDC_INTERNAL_ACCESS_TOKEN:-}
|
||||||
NODEDC_AI_WORKSPACE_ASSISTANT_URL: http://ai-workspace-assistant:18082
|
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
|
AI_WORKSPACE_HUB_WS_PATH: /api/ai-workspace/hub
|
||||||
expose:
|
expose:
|
||||||
- "18081"
|
- "18081"
|
||||||
ports:
|
ports:
|
||||||
- "${AI_WORKSPACE_HUB_HOST_BIND:-0.0.0.0:18081}:18081"
|
- "${AI_WORKSPACE_HUB_HOST_BIND:-0.0.0.0:18081}:18081"
|
||||||
|
depends_on:
|
||||||
|
ontology-core:
|
||||||
|
condition: service_healthy
|
||||||
networks:
|
networks:
|
||||||
- edge
|
- edge
|
||||||
- engine
|
- engine
|
||||||
|
|
@ -256,6 +416,11 @@ networks:
|
||||||
name: ${NODEDC_ENGINE_DOCKER_NETWORK:-nodedc-demo_default}
|
name: ${NODEDC_ENGINE_DOCKER_NETWORK:-nodedc-demo_default}
|
||||||
identity:
|
identity:
|
||||||
internal: true
|
internal: true
|
||||||
|
gelios-data:
|
||||||
|
internal: true
|
||||||
|
map-egress:
|
||||||
|
external: true
|
||||||
|
name: nodedc-map-egress
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
authentik-database:
|
authentik-database:
|
||||||
|
|
@ -263,5 +428,6 @@ volumes:
|
||||||
authentik-certs:
|
authentik-certs:
|
||||||
notification-database:
|
notification-database:
|
||||||
ai-workspace-database:
|
ai-workspace-database:
|
||||||
|
gelios-database:
|
||||||
caddy-data:
|
caddy-data:
|
||||||
caddy-config:
|
caddy-config:
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
|
|
@ -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
|
||||||
|
|
@ -63,6 +63,14 @@ echo "== ai workspace assistant health check =="
|
||||||
"${DOCKER_BIN}" exec nodedc-platform-ai-workspace-assistant-1 sh -lc \
|
"${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); })'"'"''
|
'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 =="
|
echo "== ai workspace assistant hub target check =="
|
||||||
"${DOCKER_BIN}" exec nodedc-platform-ai-workspace-assistant-1 sh -lc \
|
"${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'
|
'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'
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue