Files
NODEDC_PLATFORM/infra/deploy-runner/README.md
T

934 lines
48 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# NODE.DC deploy runner
This directory stores the versioned source for the Synology canonical deploy runner.
Live runner:
```text
/usr/local/sbin/nodedc-deploy
```
Synology staging candidate:
```text
/volume1/docker/nodedc-deploy/runner-install/nodedc-deploy
```
The runner accepts data-only app-overlay artifacts from:
```text
/volume1/docker/nodedc-deploy/inbox
```
## Dedicated Device Edge runner
The Debian Device Edge is a separate root-owned deployment domain. It does not
use the Synology runner, inbox, state or backup tree. Its live runner and fixed
roots are:
```text
/usr/local/sbin/nodedc-edge-deploy
/home/ndcsudo/nodedc-device-edge/deploy/inbox
/home/ndcsudo/nodedc-device-edge/source
/var/lib/nodedc-edge-deploy
```
`nodedc-edge-deploy` accepts only `component=device-edge`, validates an exact
five-entry payload and can build/recreate only `device-edge-relay`. The existing
`device-edge-backhaul` and `tailnet` containers are identity-snapshotted before
the transition and must remain byte-for-byte runtime-equivalent through apply
or automatic rollback. It has no registry entry in the Synology runner.
The first ingress transition uses a Docker IPvlan L2 address on the Mini's
single Ethernet parent. It publishes no Docker host port and preserves the
Amnezia host full tunnel; only the relay container receives a LAN-routable
address. The fixed IPv4 is a runner/Compose/descriptor constant:
`192.168.71.253`. Router evidence on 2026-08-04 proves the Deco DHCP pool is
`192.168.68.50` through `192.168.71.250`, so the address is explicitly outside
the pool. Router port-forwarding/firewall remains a separate manual gate.
Build and test the transition source:
```bash
PYTHONDONTWRITEBYTECODE=1 \
python3 infra/deploy-runner/test_device_edge_ingress_artifact.py
npm test --prefix device-plane
```
After the fixed address is approved, build the production artifact with a fresh
transition id, stage it into the Edge inbox, then use only the canonical pair:
```bash
sudo /usr/local/sbin/nodedc-edge-deploy plan \
/home/ndcsudo/nodedc-device-edge/deploy/inbox/<artifact>.tgz
sudo /usr/local/sbin/nodedc-edge-deploy apply \
/home/ndcsudo/nodedc-device-edge/deploy/inbox/<artifact>.tgz
```
The apply acceptance checks the exact IPvlan parent/subnet/gateway/address,
absence of host port publication, internal relay health, private backhaul
reachability, preserved VPN routes and unchanged backhaul/tailnet container
identity. Gelios and Device Plane command transport are outside this domain.
Supported components in this source:
- `engine`
- `launcher`
- `platform`
- `tasker`
- `ops-agents`
- `bim-viewer`
- `n8n-private-extension`
- `module-foundry`
- `device-plane`
- `gitea`
- `proxy-contur`
- `dc-amd-proxy`
## Fresh Gitea installation
`gitea` is a one-time, fresh-install-only component. Its artifact contains
only the reviewed Compose file and strict deployment descriptor; it contains
no database, repository, user, token, key, hook, runtime data or secret. Build
the deterministic artifact locally with:
```bash
node infra/deploy-runner/build-gitea-fresh-install-artifact.mjs \
gitea-fresh-install-YYYYMMDD-NNN
```
The runtime is fixed to Gitea `1.27.1-rootless` by exact digest and
`linux/amd64`. The runner never pulls it: the exact image must first be loaded
through a separately reviewed trusted image-acquisition step. Compose is
invoked with `--pull never --no-deps`, uses `network_mode: none`, publishes no
ports, and serves only the host-mounted Unix socket
`/volume1/docker/nodedc-gitea/socket/gitea.sock`. It exposes no SSH port, uses
fresh SQLite, enables no LFS, and mounts two runner-generated secrets by URI.
Registration and password Basic auth are disabled, while MFA is enforced;
hooks, webhooks, migrations, packages, Actions, OAuth2/OpenID, federation and
update checks are disabled. HTTP Git with PAT remains the intended trusted
client path; PAT clone/push acceptance belongs to the later reviewed
user/repository restoration transition, not this empty installation. LFS may
only be enabled by that later transition after its secret and restart behavior
has been accepted against the pinned runtime.
Plan and apply fail closed unless all live prerequisites are already true:
- `/volume1/docker/nodedc-gitea`, the `nodedc-gitea` project, and any stale
`nodedc-gitea_internal` network are absent; TCP/4022 is closed and Docker
publishes neither TCP/3000 nor TCP/4022;
- Docker Server is exactly 24.0.2, Compose is at least 2.20.1, and the exact
pinned image ID and RepoDigest are local for linux/amd64 with image user
`1000:1000`;
- the separate root-owned Nginx bridge
`/usr/local/etc/nginx/conf.d/http.nodedc-gitea-uds.conf` matches its exact
reviewed bytes, owner/mode and SHA-256; Nginx 1.23.1 configuration validates,
and only its root/uid-1023 processes own `127.0.0.1:3000`. The bridge proxies
to the Unix socket, preserves Host/X-Real-IP/XFF/XFP, rate-limits login and
bounds concurrent requests without limiting Git request bodies;
- DSM reverse-proxy UUID `5bc46027-0307-4261-af7e-4f94a3c508c9` persistently
and in generated nginx config routes `git.dcserve.ru:443` to
`127.0.0.1:3000`; the generated vhost must also retain its exact host guard
and canonical Host/X-Real-IP/XFF/XFP forwarding directives, so the UDS
bridge never trusts a client-supplied `X-Real-IP`;
- the emergency broad INPUT DROP for TCP/3000 has been removed, while the
legacy `172.22.0.222:3000` OUTPUT DROP and TCP/4022 INPUT DROP remain;
- any legacy container named `gitea` is stopped with restart policy `no`.
Changing DSM reverse proxy/firewall rules, acquiring evidence from the old
root, restoring trusted users/repositories, and loading the pinned image are
separate reviewed transitions. This component never reads or mounts
`/volume1/docker/gitea` and never starts the legacy container. Failed apply
stops/removes only the `nodedc-gitea` candidate, proves that it is absent, and
quarantines the complete newly created root (including data/config/socket/secrets) for
evidence, and restores the absent source/root state without deleting runtime
evidence. If candidate absence cannot be proven, rollback preserves the root in
place and records reconciliation-required instead of disconnecting live bind
mounts.
Run the policy suite before publishing:
```bash
PYTHONDONTWRITEBYTECODE=1 \
python3 infra/deploy-runner/test_gitea_fresh_install.py -v
```
## Gitea incident salvage (clean-state recovery)
The additive `gitea-incident-salvage` slice is the reviewed recovery design for
incident `gitea-20260814`. It does not upgrade or mount the compromised state.
The candidate starts with a new SQLite database, new config, new runtime
secrets, new email addresses and new password hashes. The exact v2 decision
bundle is embedded as inert, digest-bound data:
- 2 new active identities (`dctouch`, admin; `SILVER`, non-admin), each with a
new root-contained one-time password and mandatory password change;
- 8 new locked identities with random discarded passwords, inactive,
restricted and prohibited from login;
- 45 exact repositories: 32 private `dctouch` repositories and 13 public
`SILVER` repositories. The other 962 legacy users and 2013 legacy repository
rows are not imported into the clean database.
The successor v3 descriptor embeds the owner-confirmed, canonical
`confirmed-disposition-v1.json` and the additive
`confirmed-closure-disposition-v1.json`. Both are bound to the exact snapshot,
database, identity decision, reference manifest, unsupported-state report,
schema catalog and semantic-topics hashes. The original disposition's 105
reference decisions are explicit rows, not namespace wildcards: 85 normal
heads, 4 tags and 4 wiki heads are eligible for later live reconstruction; the
exact 5 pull refs and 7 remote refs are sealed archive-only evidence and can
never be auto-promoted. The closure disposition authorizes only bounded
plan-time inventory and records the target policy for access, collaboration,
issue/PR metadata, attachments, releases, labels, projects, units, Packages
and Actions. Observed hashes do not become activation authority, and the new
closure-report digest is deliberately unpinned until separate review.
Build the deterministic review artifact locally with:
```bash
node infra/deploy-runner/build-gitea-incident-salvage-artifact.mjs \
gitea-incident-salvage-subrelation-closure-20260814-006
```
The original v1 descriptor and `...salvage-20260814-001.tgz` remain immutable.
The exact `...salvage-disposition-20260814-002.tgz` also remains immutable and
must not be retried: its plan failed safely because it assumed every nonempty
`repository.topics` TEXT value was a JSON array. A bounded immutable-database
classification proved all 45 kept rows are instead exact four-byte canonical
JSON `null`, Gitea's semantic empty representation. The `...topics-...-003`
artifact is the forward fix with a new patch id and digest.
That exact `...topics-...-003` artifact is now also an immutable predecessor.
The local pre-audit `...closure-...-004` draft must not be staged: review found
orphan-join and nullable-Actions gaps. The local
`...closure-reviewed-...-005` artifact is also stale and must not be staged:
its primary closure was fail-closed, but it retained a broad polymorphic-
subrelation blocker. The additive `...subrelation-closure-...-006` successor
closes the concrete comment/history/merger relations in the pinned snapshot
schema and is the only artifact produced by the current builder.
The successor builder refuses to overwrite an existing artifact basename and
publishes from an exclusively created same-directory temporary file using an
atomic no-clobber hard link. A failed build removes only its private temporary
file. The disposition is sorted, compact ASCII JSON with one terminal LF.
The runtime pin is
`docker.gitea.com/gitea:1.27.2-rootless@sha256:7de5f49a…3abd2c`, with exact
linux/amd64 OCI image ID `sha256:272085a8…bc5c4c6`. It runs as `1000:1000`,
uses the direct Gitea binary, `network_mode: none`, no Docker ports, no SSH and
no LFS. Candidate restart policy is `no`. The existing reviewed Nginx bridge
cannot reach the candidate while the socket parent is mode `0700`; publication
is an explicit post-acceptance change to `0750`, followed by public TLS and
smart-HTTP checks. Only after those checks may restart policy become
`unless-stopped`. The transition never changes or enforces two-factor
authentication.
The snapshot boundary is exact: UUID
`f5a3fe3a-93ea-bb4d-847f-6221a6bcbc9f`, immutable SQLite SHA-256
`8db9e74a…1a13052`, and the 972/2058 decisions must recompute from the database
row by row. The runner enumerates only each v2 `repo_relative_path` and
`wiki_relative_path`. It rejects symlinks, special files, hard links, NOCOW,
alternates, shallow/replace/grafts/worktrees, promisor state, unknown object
material and incomplete pack/index pairs. One preloaded mount inventory also
guards the `objects` and `refs` roots plus every traversed directory and file:
all must remain on the trusted snapshot device, outside nested mountpoints and,
for directories, outside nested Btrfs subvolumes. Traversal errors fail closed.
Forbidden-path probes (`objects/info/alternates`, grafts, replace refs and the
other listed sentinels) lstat each path component without following links and
validate every ancestor before probing its child; a symlink or mount boundary
therefore stops the plan before any lookup can escape the snapshot tree.
Only three exact derived-file classes observed in the bounded 45-repository
inventory are excluded: dumb-HTTP `objects/info/packs`,
`objects/pack/pack-<oid>.bitmap` with its exact complete pack/index pair, and
monolithic `objects/info/commit-graph`. Each must be a bounded regular,
single-link, non-NOCOW file. The reference manifest records its exact path,
class and byte size, but the runner neither reads nor copies the excluded
bytes. Unobserved `.rev`, MIDX and split commit-graph layouts stay fail-closed;
`.promisor`, `.mtimes`, `.keep`, unknown and temporary lookalikes are not
classified as disposable accelerators.
The same inventory found receive-pack quarantine directories matching only an
exact direct child `objects/tmp_objdir-incoming-[A-Za-z0-9]{6}`. A match must
retain the reviewed uid/gid `1000:1000`, mode `0755`, single-link and non-NOCOW
directory metadata. Its exact path and lstat fields are recorded, then the
subtree is pruned without reading, hashing, copying or reflinking contents; the
sealed read-only incident snapshot retains the forensic copy. Any name,
metadata, mount or subvolume drift remains a hard stop. A reviewed reference/OID
manifest is required; raw HEAD/refs files are never copied. The intended data
path is same-filesystem per-file `--reflink=always`, with no byte-copy fallback
and a bounded exclusive-allocation gate.
This slice is intentionally **NO-GO for apply** at the current review point.
`plan` inventories and prints the exact ref-manifest digest, byte length and
canonical compact JSON. The JSON is the reviewable, hash-bound reference/OID
artifact: it contains approved repository identities, ref names/OIDs, object
totals and stat-bound derived/quarantine exclusions, but no Git object bytes,
hooks or config. The same plan prints the canonical unsupported-state report
and a separate incident-closure report, each with its SHA-256, byte length and
compact ASCII JSON. The closure report is evidence only: its disposition keeps
`expectedSha256` and `expectedBytes` null and retains an explicit review-pin
blocker.
The reviewed plan evidence bound by the disposition is reference manifest
`9cddaf0e…a311` (36,010 bytes), unsupported-state report
`4b2cecf8…106ac` (119,468 bytes), and schema catalog
`b5e3b677…b6db`. The report has exact 45 repository rows with empty
`schema_missing`, `schema_mismatch`, and `anomalies` arrays. These facts close
only the owner's material-state decision; fsck/reachability, physical blob
inventory, sanitized archive creation and candidate acceptance remain separate
verifiers.
The unsupported-state report has one deterministic row for every one of the
45 kept repository IDs, including explicit zeroes. It separates direct
repository-relation counts, directional base/head PR counts, repo-unit type
counts, text-metadata presence booleans and denormalized numeric hints; it does
not add overlapping relations into a misleading grand total. LFS rows are
reported per repository with validated logical byte sums and distinct-OID
aggregates, including size conflicts and sharing with non-kept repositories.
Every association sharing a kept OID must also have an integer repository ID
that resolves to the exact snapshot `repository` table; invalid and orphan
owners make shared/unique byte aggregates unavailable and add a data-anomaly
blocker.
Attachments are reported per repository with validated logical byte sums and
explicit, overlapping issue/comment/release/unlinked link splits. Neither
category claims that a physical blob exists; no title, body, note, comment,
config, URL, token, key, secret, hash payload or blob content is selected or
exported.
The report also embeds the exact `PRAGMA table_xinfo` catalog and explicit
label-to-table-to-repository-column map for a code-owned allowlist of relevant
tables. `sqlite_master` must prove an ordinary, non-virtual table before any
row query; SQLite 3.37+ adds `PRAGMA table_list` ordinary-main-table proof. On
an older compatible SQLite, the weaker object-kind capability is recorded and
adds a coverage blocker. `query_only=ON` and `trusted_schema=OFF` are read back
before the snapshot connection is accepted. Only column identifiers and
structural metadata are exported, never DDL, defaults or row values. The v3
closure inventory follows code-owned, schema-attested primary joins for the
issue/PR dependency tables (comments, assignees, content histories, labels, issue
users/watches, reactions, reviews/state, dependencies, notifications,
stopwatches, tracked time and auto-merge), plus projects/boards, releases,
attachments, Packages and Actions. It emits only IDs required to prove joins,
actor classes, counts, declared sizes and SQLite byte lengths; it never selects
or prints text bodies, names, URLs, package metadata, workflow payloads,
credentials, tokens, secrets or logs. Package and Actions target features stay
disabled and no legacy row is importable. The successor additionally closes
every concrete FK-like comment field in the pinned schema (labels, projects,
milestones, tracked time, assignees, dependency, review and cross-reference),
content-history-to-comment, review-team and pull-merger provenance. Target
repository and local actor IDs are classified through the exact 2058/972
decisions. Migrated external-author IDs are never treated as local users and
are represented only by presence counts and author-name byte lengths. A
nonzero comment-assignee or review-reviewer team must resolve to an exact team
and organization; because no organization/team allowlist exists, its row,
team and organization IDs enter a sealed HOLD inventory and add the narrowly
scoped `issue-pr-team-mapping-hold` blocker. Orphans, conflicting user/team
identities, invalid cross-reference pairs and schema/type drift fail closed.
The report is still not a physical-file or sanitized-archive verifier, so
those separate blockers remain explicit.
The schema-catalog digest is recorded as observed evidence but deliberately
remains unpinned as reviewed activation authority. Schema absence/type drift,
invalid numeric/OID state or aggregate overflow is a hard error. `apply` fails
before creating `/volume1/docker/nodedc-gitea` until all of the following are
registered:
- root-owned Btrfs proof that the named snapshot UUID is read-only;
- exact stopped/restart-`no` legacy container image ID, image ref and sole
`/volume1/docker/gitea:/data` bind inspect;
- a separately verified exact reference/OID manifest digest, fsck,
reachability and selected-object reconstruction;
- immutable sanitized archives for issues, PRs, comments, releases, labels,
projects and repository descriptions, with no legacy row import;
- physical attachment inventory and reachable LFS pointer/OID/size/SHA proof;
- kept-user mapping before any collaboration recreation; and
- package/Actions schema plus physical zero closure and target unit-policy
acceptance.
All 25 direct repository relations have explicit dispositions, including the
15 exact zero-and-drop categories. Pull base/head counts remain directional
views of the same five PRs. All repository numeric hints are dropped and
recomputed. Repo-unit types 1–10 are total: clean types 1–5 and 8 are recreated
once per repository; external wiki/tracker are forbidden; Packages (9) and
Actions (10) are absent in the target and remain globally disabled. Legacy
unit config is never imported. The topics verifier accepts exact JSON `null`
as semantic empty state, or a canonical JSON array of sorted unique lowercase
names matching `^[a-z0-9][-.a-z0-9]*$` with at most 35 UTF-8 bytes. SQL NULL,
whitespace/case variants, quoted `"null"`, objects, numbers, booleans and
noncanonical arrays fail closed. The current snapshot must prove exactly 45
serialized JSON nulls, zero serialized arrays, zero material topics and zero
`repo_topic` rows; target cache is rebuilt from relations.
The activation hook remains frozen until these blockers are closed and the
full clean-database recreation, reflink reconstruction, doctor/fsck,
credential-table zeroing, rollback and public acceptance path has its own
runtime smoke. The current reviewed source identities are:
- runner `nodedc-deploy` SHA-256
`c766985aa02fe911fa5a873717276f3503d9cecded448e41dc6945030dcbce9e`;
- deterministic builder SHA-256
`b92076583aeab854015a05deb9d942c8dfcf3ca475fd3d52115d402d6f61acca`;
- salvage policy test SHA-256
`99b96b6179286a78e7d53ee6a923fc092e3471a8cece382a472449d455ad212c`;
- fresh-install policy test SHA-256
`20653c640142c25e7d2ef712048acc7889c56e1d519e2d03b5cad4127e48947c`;
- confirmed disposition SHA-256
`0a066724bcf6e4933133db6cab6cc273393e3c262dd00dda0bbf9ceebd84f78c`;
- confirmed closure disposition SHA-256
`7ed66d9848268431a703fe24b22c41afbaa7c5ff48949604d6fc448d93e0d243`;
- successor v3 descriptor SHA-256
`9b98eb1a1640fd5569cf051a621837379b167eff4527313a43a0a851e7cc181a`;
- deterministic local review artifact
`nodedc-gitea-gitea-incident-salvage-subrelation-closure-20260814-006.tgz`
SHA-256
`d3e598cf892b1371912dcd9cef64caa991dd23613e48fe979d0f95d493c23d53`.
Only after that exact runner is separately reviewed, promoted to the
root-owned runner path and its installed SHA-256 is re-attested may the exact
artifact be staged into the canonical inbox for a canonical `plan` command.
Staging is evidence collection only; it grants no authority to run `apply`.
The legacy identity is now pinned to the observed ref `gitea/gitea:latest`
and immutable image ID
`sha256:bf95d9a45ce4fe38b027d051cdc4a4bc531513489fa6244af4074efbb1c376d6`.
The mutable tag is provenance only. Every plan re-inspects exact name
`/gitea`, stopped state, restart `no`, and the sole RW bind
`/volume1/docker/gitea:/data`; drift is a hard stop.
The successor plan must report these exact remaining blockers:
- `attachment-physical-verifier-pending`;
- `candidate-root-activation-hard-frozen`;
- `closure-report-review-pin-pending`;
- `collaboration-kept-user-mapping-verifier-pending`;
- `forensic-ref-archive-verifier-pending`;
- `issue-pr-metadata-sanitized-archive-verifier-pending`;
- `lfs-reachable-pointer-physical-verifier-pending`;
- `package-action-physical-closure-verifier-pending`;
- `reference-manifest-fsck-reachability-verifier-pending`;
- `repository-object-reconstruction-verifier-pending`;
- `target-unit-policy-acceptance-pending`;
- `unsupported-schema-catalog-verifier-pending`.
Any other blocker or identity drift is also a stop. `apply` remains prohibited
and hard-frozen before root creation until a subsequent reviewed runner
revision closes every blocker and implements and smoke-tests the complete
activation/rollback path. The successor files must be independently reviewed
before any runner promotion or inbox staging.
Run the bounded policy suite with:
```bash
PYTHONDONTWRITEBYTECODE=1 \
python3 infra/deploy-runner/test_gitea_incident_salvage.py -v
```
The frozen local cutoff passes 54 incident-salvage tests and 26 fresh-install
tests, plus Python compilation and `git diff --check`.
`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 active predecessor is package version `0.1.4` at immutable release
`0.1.4-59dc9f7882721d6a`. Package `0.1.5` adds the provider-neutral complete
snapshot replace mode used by `map.zones.current.v2`; its Platform staging
remains inert until a separately reviewed Engine-owned transition selects the
exact digest and accepts the updated MCP node schema.
The paired Engine activation is built by
`build-engine-n8n-private-extension-artifact.mjs`. It deliberately does not
copy from or write generated files into the dirty Engine worktree, and it does
not build an image. A fresh transition id is mandatory and previously issued
ids are rejected:
```bash
node infra/deploy-runner/build-engine-n8n-private-extension-artifact.mjs 20260721-005
```
The builder emits 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.5-3c8ae53f010d7c88/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`. The live runtime contract remains the successfully
deployed generation-003 contract and does not set `NODE_PATH`. Only the
runner's isolated `node -e` package-loader probe temporarily initializes the
dependency tree bundled inside the exact n8n base image; this reproduces n8n's
own loader without redefining the live Compose state. The override 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
the complete 434/385 inactive baseline and a digest registry for every
reviewed 437/388 active release, so a same-count substitution of any built-in
or private schema is rejected. Upgrade `0.1.2 -> 0.1.3` is accepted only when
the verified live predecessor, descriptor, package mount and catalog all agree.
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 paired rollback artifact returns `0.1.3` to the
verified immutable `0.1.2` release and its exact catalogs; it does not invent
an inactive baseline for an already-active upgrade.
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
```
The source-less Engine MCP control-plane update is a separate exact slice:
```bash
node infra/deploy-runner/build-engine-mcp-control-plane-artifact.mjs 20260717-001
```
Its six-entry registry contains only the Engine Agent gateway, verified graph
patch route, Codex installer source/package and the active node-intelligence
descriptor with the new gateway digest. It force-recreates only the existing
`nodedc-backend`. The node-intelligence image/service, n8n, L1, credentials,
databases and volumes are outside the slice. The runner proves the installed
predecessor digests, exact installer archive/source equality, bounded change
session policy, no-effect/post-write graph barriers, active immutable backend
runtime and descriptor equality. The ordinary overlay backup is the automatic
rollback source; rollback restores the predecessor gateway and descriptor
together before recreating the same backend service.
The successor autonomy/provider-v5 slice keeps MCP authority explicit without
turning every write into a second permission dialogue. MCP tool availability is
the capability boundary, while the user's current objective is the intent
boundary; Engine L2 may act autonomously only inside their intersection. A
graph `plan` remains a mandatory machine barrier whose target, diff, revision
and blockers are inspected by the agent. It is not a repeated approval prompt
after an implementation objective is already authorized. Retries must add new
evidence or change the attempted variant, and three identical failures without
new evidence or state change are a critical stop. The slice also advances the
installer to `0.1.6` and adds `gelios.provider.v5 ->
fleet.positions.current.v4` beside the immutable v4/v3 rollback line:
```bash
node infra/deploy-runner/build-engine-mcp-autonomy-provider-v5-artifact.mjs \
20260720-004
```
Its seven-entry allowlist recreates only `nodedc-backend`; n8n, L1, databases,
credential values and the node-intelligence image remain outside the change.
The runner accepts both the exact target and the exact predecessor after an
automatic rollback, and rejects every mixed state.
For `platform` artifacts, the allowlist includes the versioned Ontology Core,
the frozen legacy Gelios compatibility service, and the provider-neutral
External Data Plane sources. The Gelios service remains reproducible only to
protect its existing database/workflow; it is not a template for a provider
integration. An External Data Plane artifact builds only its image and
force-recreates only `external-data-plane`; the already healthy
`external-data-plane-postgres` container and its Timescale volume are an
independent deploy prerequisite and are never selected by an EDP application
artifact. The reviewed Compose source pins the Timescale image, named volume
and target, internal database-only network, absence of database host ports,
healthy dependency, localhost-only EDP bind and the three read-only
provisioner/trust mounts in the reviewed Compose source. The runner does not
reinterpret version-dependent `docker compose config` JSON as a second deploy
schema. Its canonical enforcement remains the artifact/path allowlist plus
hard-coded build command, selected service set, runtime-secret preparation and
health acceptance. Post-apply acceptance also requires
`database=ready`; the managed Foundry slice additionally requires
`foundryReaderBindingProvisioning=digest+server-resolved-source`. A first-rollout failure removes only the candidate EDP
container without volumes and restores the source overlay. It never contains a
provider credential, provider endpoint,
collection schedule or command
transport. Its contract payload is an exact provider-neutral runtime subset;
`providers/*`, mappings, fixtures and tests are excluded and do not trigger an
EDP rebuild. Database credentials remain root-owned live `.env.synology`
configuration and must not reuse `NODEDC_INTERNAL_ACCESS_TOKEN`.
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 and is mounted
read-only only into External Data Plane.
Manual one-time binding issuance and digest-only managed writer ensure are
independently disabled by default. The target control-plane operation generates
and stores the capability inside native NDC L2 Credentials and sends only its
digest to EDP; users and MCP consumers receive only an opaque compatible
reference/status. The runner-owned bearer above authenticates only legacy
plaintext issuance and is intentionally rejected by managed ensure/revoke.
Managed requests use a deployment Ed25519 Engine service key; EDP mounts only
the public-key trust directory read-only. On every reviewed Engine apply and on
an EDP runtime apply, this runner creates or validates one matching Ed25519 pair:
the Engine-only private key is `root:root 0400`, while the EDP trust copy is
`root:11006 0440`. Public-only crash state, key mismatch, a non-Ed25519 key,
symlinks and permissive modes fail closed. `plan` discloses both paths without
printing key material. The private key must not be broadened into an L2 graph,
MCP surface, artifact or shared-token boundary.
Module Foundry has a separate Ed25519 managed-provisioner identity for
target-scoped Data Product consumer grants. On a relevant `platform` or
`module-foundry` apply, the runner creates or validates the Foundry-only private
key at
`/volume1/docker/nodedc-platform/secrets/foundry-edp-managed-provisioner/private-key.pem`
as `root:root 0400` and the matching EDP trust copy at
`/volume1/docker/nodedc-platform/trust/foundry-managed-provisioner/public-key.pem`
as `root:11006 0440`. Foundry generates the opaque reader token only inside its
persistent private runtime; its signed EDP request contains only a SHA-256
digest and Data Product id. EDP resolves the unique active writer source scope
server-side and fails closed on missing or ambiguous coverage. No provider,
tenant, connection, token, private key or endpoint is admitted to the Foundry
MCP plan, application state or browser response. The Engine signing identity,
native n8n credentials and legacy issuance bearer cannot call this endpoint.
The reviewed Engine source candidate has dedicated server-derived MCP
plan/apply handling for the exact `ndcDataProductWriterApi` + Data Product
Publish tuple. It is separate from the generic HTTP safe-ref path and accepts no
caller-provided provider/scope/credential identity, capability, generation or
service URL. The production managed flag remains false during staging. In the
explicitly confirmed activation window it is set to true immediately before
the Platform EDP artifact; runner acceptance then requires EDP `/healthz` to
report managed provisioning `enabled`. At that point the old Engine still has
no signer mount, so the endpoint remains usable only after the separately
accepted Engine artifact. Root/UI transfer is emergency
self-hosted diagnostics only, not the user journey or acceptance
path. A provider-specific daemon remains forbidden.
Build the narrow Engine source artifact with:
```bash
node infra/deploy-runner/build-engine-data-product-publish-grant-artifact.mjs \
engine-data-product-publish-grant-YYYYMMDD-NNN
```
The builder includes exactly the pinned provider-security catalog, the
Publish-grant service, Engine Agent scope/gateway wiring, the existing n8n
adapter and the reviewed additive backend runtime overlay. It deliberately
excludes base Compose, frontend/dist, runtime data, tests, native credentials,
the NDC L2 process and the legacy generic credential-sink route/core. The
runner fixes the runtime action to `nodedc-backend` with `--no-deps` and
`--pull never`; n8n, nginx app, database services and volumes are not selected.
Acceptance requires backend health, the active immutable backend identity and
the exact additive mount inventory. Any failure restores the touched source and
recreates only the previous verified backend runtime.
Existing Engine Agents created before Publish-grant support store the original
nine scopes as the complete developer profile. They must not require a new
agent, setup command or device credential when the profile gains a server-owned
capability. Build the compatibility artifact that introduces the durable named
`full-developer` profile with:
```bash
node infra/deploy-runner/build-engine-agent-full-grant-migration-artifact.mjs \
engine-agent-full-grant-migration-YYYYMMDD-NNN
```
This follow-up slice contains exactly
`nodedc-source/server/engineAgents/store.js`. The runner pins its predecessor to
the successfully applied Publish generation, requires the installed Publish
overlay and active immutable backend, recreates only `nodedc-backend`, and
proves the exact candidate SHA, named profile and current expanded runtime
scope view. Store schema v1 is migrated atomically to v2 only when a grant
contains the complete legacy nine-scope developer bundle. From then on the
profile name is the authorization authority and its scope list is derived on
every read, so capabilities deliberately added to `full-developer` immediately
apply to existing full grants without token or store migrations. Partial grants
become `custom` and remain exact; they are never elevated. The artifact does not
touch UI, n8n, credentials, agent tokens, workflow graphs, databases or runtime
payloads.
## Engine L2 node-intelligence transition
The node-intelligence transition is an additive Engine-owned deployment domain.
It does not merge Ontology, provider APIs or Ops into the Engine MCP. It pins the
reviewed upstream `n8n-mcp` implementation at package `2.33.2`, commit
`974a9fb3492fe2c4984ee0549085d531cdc6242a`, and exposes only the safe NDC L2
projection through the existing Engine Agent gateway. Upstream management and
write tools are not forwarded.
Production never clones, pulls, installs or builds this dependency. The builder
saves the reviewed `linux/amd64` image once, embeds that exact archive in a
data-only activation artifact and records both the archive and image-config
digests. The runner validates every inner blob, revision label, entrypoint,
command and platform before an offline `docker image load`; Compose then uses
the fixed tag with `pull_policy: never` and `--pull never`.
Build a fresh activation/rollback pair:
```bash
node infra/deploy-runner/build-engine-node-intelligence-artifacts.mjs \
YYYYMMDD-NNN
```
The activation exact set is:
- `nodedc-source/server/nodeIntelligence`
- `nodedc-source/server/routes/engineAgentGateway.js`
- `nodedc-source/services/node-intelligence`
The runtime adds only `nodedc-node-intelligence` and recreates the existing
`nodedc-backend`; n8n, UI, databases, L1, provider services and volumes are not
selected. The sidecar has no host port, runs as `11007:11007`, has a read-only
root filesystem, drops all capabilities and receives no Engine or provider
credential. Its independent MCP bearer is created by the root-owned runner as a
read-only file and mounted only into the sidecar and backend. It never appears
in an artifact, shared environment file, log or MCP response.
Acceptance requires the exact image/container/mount/security inventory,
immutable backend identity and live authenticated `get_node`, `validate_node`
and `validate_workflow` calls. Any apply failure restores source and runtime
automatically. The separate rollback artifact first removes only the sidecar
without volumes, restores the pinned inactive gateway, and recreates only the
verified backend. Loaded images and failed candidate source are retained for
audit rather than destructively deleted.
Stage the reviewed runner under a unique candidate name first:
```text
/volume1/docker/nodedc-deploy/runner-install/candidates/
nodedc-deploy.engine-node-intelligence-YYYYMMDD-NNN
```
Do not overwrite the canonical staging candidate while another deploy may be in
flight. After the no-lock/no-process gate, promote the exact candidate in a
standalone root step, run `verify-install`, then invoke a fresh process for
activation `plan` and only then `apply`. The generated plan/apply runbook carries
the runner, activation and rollback SHA-256 values and is staged beside the
unique runner candidate.
`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`.
When the managed reader-grant source is present, Module Foundry acceptance also
requires `/healthz` to report the dedicated Ed25519 provisioner as configured.
The check is source-aware: if an apply rolls back to the prior source, rollback
acceptance uses that prior health contract instead of falsely requiring a
feature which the restored generation does not contain.
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:
```bash
# First verify that no deploy process is active and state/deploy.lock is absent.
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
```
Runner promotion is a standalone admin step, never an app-overlay artifact and
never part of a running apply. A Python process already executing the old file
keeps the old code in memory; always invoke a fresh verified process afterward.
Normal service deploys must still use explicit artifacts:
```bash
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
```
## Device Plane foundation
`device-plane` is an additive component rooted at:
```text
/volume1/docker/nodedc-device-plane
```
Its fixed Compose project is `nodedc-device-plane`. Ordinary application
artifacts may select only `device-control-core` and `device-gateway`, always
with `--no-deps`. `device-postgres` and the named
`nodedc-device-plane-postgres-data` volume are durable prerequisites and are
never selected or recreated by an application overlay.
The sole exception is the exact one-time bootstrap artifact containing only
the reviewed Compose file and
`deployment/device-postgres-bootstrap-v1.json`. Its preflight requires both the
Compose database container and named volume to be absent. It selects only
`device-postgres`; a failed activation may remove that candidate container but
never the volume. Any pre-existing container or volume is an ambiguity and
fails closed.
The runner creates or validates three root-owned secret files outside the
artifact: the PostgreSQL password, Gateway-to-Core token and restricted
identifier pepper. The manifest cannot choose their paths or values.
The foundation publishes only loopback health endpoints on `18120` and
`18121`. Raw device ingress `9921`, discovery ingest and outbound command
transport remain disabled. The first application artifact must not be built or
staged until this runner candidate is separately reviewed, promoted and proven
by a fresh `verify-install`.
Build and test the deterministic data-only artifact contract locally:
```bash
python3 -m unittest -v \
infra.deploy-runner.test_device_plane_registry \
infra.deploy-runner.test_device_plane_artifact
node infra/deploy-runner/build-device-plane-artifact.mjs \
device-plane-foundation-YYYYMMDD-NNN
```
Any failed first activation removes only candidate Core/Gateway containers,
never volumes, restores the source overlay and retains PostgreSQL state.
The rollback baseline is taken from an explicit pre-apply Docker service
inventory stored as `runtime-before.json` in the backup. The presence of the
shared Compose file never implies that Core or Gateway existed before apply.
The exact `device-plane-foundation-20260725-001` failed activation is recovered
only through the registered
`deployment/device-plane-foundation-recovery-v1.json` transition. Its artifact
must reproduce the failed foundation source byte-for-byte. The runner validates
the exact failed archive, journal, backup partition, partial live source and
healthy observed Core/Gateway/PostgreSQL generations. Recovery publishes source
and performs read-only runtime acceptance; it does not build, recreate, restart
or remove containers. A failed recovery restores source only and leaves runtime
unchanged.
Build and test that incident-specific deterministic recovery artifact:
```bash
python3 -m unittest -v \
infra.deploy-runner.test_device_plane_foundation_recovery_artifact
node infra/deploy-runner/build-device-plane-foundation-recovery-artifact.mjs \
device-plane-foundation-recovery-20260725-002
```
The recovery `device-plane-foundation-recovery-20260725-002` is also terminal
failed. Docker Engine 24 does not materialize requested host port mappings when
the container is attached only to an `internal` network. Its registered
successor is the exact
`deployment/device-plane-foundation-network-publication-v1.json` transition.
It keeps PostgreSQL exclusively on the existing internal private network and
adds a second non-internal bridge only to Control Core and Gateway. Masquerade
is disabled on that bridge; the only published ports remain
`127.0.0.1:18120` and `127.0.0.1:18121`. Device TCP `9921`, public ingress,
discovery ingest and command transport remain disabled.
This successor has an empty build set and force-recreates only
`device-control-core` and `device-gateway` with `--no-deps` from their exact
existing `pull_policy: never` images. Its predecessor barrier validates the
terminal failed archive/journal/backup, partial source, exact running
generations, absent actual port mappings, internal private network and absence
of the control network. Acceptance requires new stateless container
generations, the unchanged PostgreSQL generation and volume, exact actual
loopback mappings, healthy HTTP contracts and a closed `9921`.
Rollback removes only the two stateless candidate containers and the newly
created unused control network, restores the partial source predecessor and
recreates the internal-only Core/Gateway runtime from the sealed predecessor
Compose bytes. PostgreSQL and its volume are never selected.
Build and test the deterministic network-publication successor:
```bash
python3 -m unittest -v \
infra.deploy-runner.test_device_plane_foundation_network_publication_artifact
node \
infra/deploy-runner/build-device-plane-foundation-network-publication-artifact.mjs \
device-plane-foundation-network-publication-20260725-003
```
After the runner is promoted and freshly verified, bootstrap the durable
prerequisite with a separate artifact before planning the application:
```bash
node infra/deploy-runner/build-device-plane-postgres-bootstrap-artifact.mjs \
device-plane-postgres-bootstrap-YYYYMMDD-NNN
```