934 lines
48 KiB
Markdown
934 lines
48 KiB
Markdown
# 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
|
||
```
|