@bongos/core 1.19.854 → 1.19.855

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.bongos-core.json CHANGED
@@ -2,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.854",
6
- "core_contract": "1.19.854",
7
- "source_commit": "9048e308289cb5583549d6c2baefdcf15f992409",
5
+ "core_version": "1.19.855",
6
+ "core_contract": "1.19.855",
7
+ "source_commit": "ea9bbb312aedb67961f643df3927bd9c3611d451",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-17T21:24:11.133Z",
9
+ "built_at": "2026-09-17T21:30:54.480Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
- "docs_redacted": 497,
12
+ "docs_redacted": 498,
13
13
  "agent_docs_stubbed": 26,
14
- "functional_verbatim": 2279,
14
+ "functional_verbatim": 2280,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2802,
20
- "tree_sha256": "131a9a8838d352e2a854548f61384dd844e5c2ad280f4af89a4d907462f90cec",
19
+ "file_count": 2804,
20
+ "tree_sha256": "f9cc99e0ebd1bf3f55d448b2204c056c832f6360a4941393b640f1db34e99093",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -2019,10 +2019,15 @@
2019
2019
  "mode": "0000644",
2020
2020
  "sha256": "4571564a9bf5ccb08996bd1fae58b69945c6cccbc9ce3b58c634acc087cb7698"
2021
2021
  },
2022
+ {
2023
+ "path": "docs/adr/0306-instance-supervision-is-a-seam-with-systemd-as-the-default.md",
2024
+ "mode": "0000644",
2025
+ "sha256": "00d74b19e9574186fbe354e410f6acd9e84db60f11a550905e95ac9071b89aa6"
2026
+ },
2022
2027
  {
2023
2028
  "path": "docs/adr/README.md",
2024
2029
  "mode": "0000644",
2025
- "sha256": "00361bb3425bda49646f676baac8ef458bb3c5c32f128767c269e96985d41ccf"
2030
+ "sha256": "d65a10ca2341a236fb6397a84907ed3cc250f0ea72ea5ec4c3e9bfca6a9d6cb2"
2026
2031
  },
2027
2032
  {
2028
2033
  "path": "docs/api-reference.md",
@@ -2587,7 +2592,7 @@
2587
2592
  {
2588
2593
  "path": "docs/module-api-changelog.md",
2589
2594
  "mode": "0000644",
2590
- "sha256": "b8e548e543bbe1dc32e042a3b81735bf6681976a1c2dbd2aed964902766ec117"
2595
+ "sha256": "e272f0cf7f1826cdfedeb48806e9cec9caf6b926793d5308d5de9e137d47cb76"
2591
2596
  },
2592
2597
  {
2593
2598
  "path": "docs/modules-contract.md",
@@ -2817,7 +2822,7 @@
2817
2822
  {
2818
2823
  "path": "docs/recipes/self-host.md",
2819
2824
  "mode": "0000644",
2820
- "sha256": "135f3942a01e50af87c4645f8920b25e0b779aac45cb5101b39e62cacfa59168"
2825
+ "sha256": "853761ea1330806204c35bcdda680af64fac1f096be1a1296d9d59ed9d54fabd"
2821
2826
  },
2822
2827
  {
2823
2828
  "path": "docs/recipes/server-bring-up.md",
@@ -7912,12 +7917,12 @@
7912
7917
  {
7913
7918
  "path": "package-lock.json",
7914
7919
  "mode": "0000644",
7915
- "sha256": "79f7b3c7784020460b50dfa7da87eb875e7b16fe577054c32c66c6aac23ba9b3"
7920
+ "sha256": "7e2895e4342846fb96f477f37493b4f813d0562e1882a7ce204f54c2787bc815"
7916
7921
  },
7917
7922
  {
7918
7923
  "path": "package.json",
7919
7924
  "mode": "0000644",
7920
- "sha256": "5822641b3fd9ddb6a4ce55e3843b31bc8cc6cef6a0c1eba7aadf87ad8073b87a"
7925
+ "sha256": "11f9ed62bce433c5f300faa1c4c052b810276df34ec1dcdca2d022a6e04e6370"
7921
7926
  },
7922
7927
  {
7923
7928
  "path": "public-docs/index.html",
@@ -8817,7 +8822,7 @@
8817
8822
  {
8818
8823
  "path": "scripts/gds/provision-config.js",
8819
8824
  "mode": "0000644",
8820
- "sha256": "b5ec038d150da93b4c54eeb864586a70e46610655ccb5bf10bd1adaa0432a305"
8825
+ "sha256": "d9d76ed5992ea84b347e0dbe37c78dda014df62208772ff00a2987d34ec27059"
8821
8826
  },
8822
8827
  {
8823
8828
  "path": "scripts/gds/provision-core-upgrade.js",
@@ -8834,6 +8839,11 @@
8834
8839
  "mode": "0000644",
8835
8840
  "sha256": "f1ca176ff8cdb8de65940af1e30316db600cb5df529abbc997697abf1cbc2c2d"
8836
8841
  },
8842
+ {
8843
+ "path": "scripts/gds/provision-process.js",
8844
+ "mode": "0000644",
8845
+ "sha256": "210d5135b157363499dc34bb8ac34eb7f661a61d8ba3d23e6a64953e5893559e"
8846
+ },
8837
8847
  {
8838
8848
  "path": "scripts/gds/provision-repo-auth.js",
8839
8849
  "mode": "0000644",
@@ -8847,7 +8857,7 @@
8847
8857
  {
8848
8858
  "path": "scripts/gds/provision-teardown.js",
8849
8859
  "mode": "0000644",
8850
- "sha256": "df4187852cf0f75a12571607af95782d16f1655aa4314152078cb9ee9b3d0f7c"
8860
+ "sha256": "9722d4099de9596df0e597e7e373c510dea312d8929c231705885e074cb0af12"
8851
8861
  },
8852
8862
  {
8853
8863
  "path": "scripts/gds/provision-units.js",
@@ -8857,7 +8867,7 @@
8857
8867
  {
8858
8868
  "path": "scripts/gds/provision.js",
8859
8869
  "mode": "0000644",
8860
- "sha256": "cbf44d415e3c5bb803e68af7b70ebfb08e7d0ab2388cc7f17a114512f29662e8"
8870
+ "sha256": "71d4edd0e5653207a535e6a0febca39a19ba281ffce7f248049b826dc5ab3b84"
8861
8871
  },
8862
8872
  {
8863
8873
  "path": "scripts/gds/publish-credential-check.js",
@@ -9857,7 +9867,7 @@
9857
9867
  {
9858
9868
  "path": "src/module-api.js",
9859
9869
  "mode": "0000644",
9860
- "sha256": "9dbc00468675431bc3646ba3326916ac68b50d221922861c91425a8b96ff4cd8"
9870
+ "sha256": "210e06e3e19060630a7d73a98aeceb948eb47b6690435d5ad8b624b0b1f05bc2"
9861
9871
  },
9862
9872
  {
9863
9873
  "path": "src/module-loader/catalog.js",
@@ -12737,7 +12747,7 @@
12737
12747
  {
12738
12748
  "path": "tests/provision.mjs",
12739
12749
  "mode": "0000644",
12740
- "sha256": "edcb3a67c97291be694f06cf092233dbd78dbb56898547bc6ccb562e7c27da6b"
12750
+ "sha256": "396c26ec06858534e692d0ce1452d1944ea573743e6955df8e5ac22a655bfcdb"
12741
12751
  },
12742
12752
  {
12743
12753
  "path": "tests/provision_caddy_wiring.mjs",
@@ -0,0 +1,61 @@
1
+ # ADR 0306 — Instance supervision is a seam, with systemd as the default
2
+
3
+ **Status:** accepted · **Date:** 2026-09-17 · **Task:** [task 1003997](https://cloudbongos.com/builders#/task/1003997) (goal 1000106 — BONGOS-V2)
4
+
5
+ **Related:** [ADR 0111](0111-instance-hosting-provisioning-module.md) (the standup runbook this seam sits inside, and the enqueue-only trust boundary) · [ADR 0128](0128-provisioning-runner-privilege-and-db-auth-model.md) (`PROVISION_SUDO` — the escalation knob this ADR leaves alone) · [ADR 0130](0130-provision-remote-exec-over-ssh.md) (the precedent for adding a dimension to the injected exec seam) · [ADR 0281](0281-an-instance-identity-is-its-own-unix-account-and-pg-role.md) (`User=`/`Group=` is how instance isolation is enforced) · [ADR 0285](0285-a-shared-box-holds-about-twelve-projects-per-gb-and-memory-is-the-wall.md) (every live project is `hosting_shape='standalone'`) · [ADR 0303](0303-provisioning-verifies-the-base-caddyfile-and-never-edits-it.md) (detect-and-refuse rather than auto-edit shared host config)
6
+
7
+ ## Context
8
+
9
+ `provision-units.js` hardcoded `/etc/systemd/system` in three path functions, and the
10
+ runner reached for `systemctl` inline at fifteen call sites across `provision.js` and
11
+ `provision-teardown.js`. Supervision was not a choice anywhere — it was a string.
12
+
13
+ That is fine on the boxes we run today: every live project is `hosting_shape='standalone'`
14
+ on a systemd host (ADR 0285). It stops being fine the moment the plane is a container. The
15
+ self-host image runs `USER node`, installs no `sudo`, and boots no systemd, so the standup's
16
+ service step cannot run there at all.
17
+
18
+ **A directory knob does not fix this.** Pointing the unit path somewhere writable still
19
+ emits `systemctl daemon-reload` into a process namespace where systemd is not PID 1. The
20
+ thing that varies is not *where the unit file goes*; it is *what supervises the process*.
21
+
22
+ **systemd `--user` was considered and rejected.** It addresses an unprivileged VM — a user
23
+ session bus, `~/.config/systemd/user/` — but a plain container runs no systemd at all, user
24
+ bus included. It would have fixed a shape we do not have while leaving the one we do have
25
+ untouched.
26
+
27
+ ## Decision
28
+
29
+ **Instance supervision is a seam with two implementations, and systemd is the default.**
30
+
31
+ A process manager answers four questions about an instance's long-running service: install
32
+ and enable it, restart it, remove it, and install its nightly backup schedule. Those four
33
+ operations move behind `scripts/gds/provision-process.js`; the call sites ask the resolved
34
+ manager rather than composing `systemctl` strings.
35
+
36
+ **Selection is explicit, then probed, then defaulted:**
37
+
38
+ 1. `PROVISION_PROCESS_MANAGER` when set (an unknown value is a loud error, never a silent fallback);
39
+ 2. otherwise systemd when `/run/systemd/system` exists — the same check `sd_booted(3)` makes, and the one systemd itself documents for "was this host booted under systemd";
40
+ 3. otherwise supervisord.
41
+
42
+ **The live box is unchanged by construction.** It boots systemd, so the probe selects
43
+ systemd, and the systemd implementation emits byte-identical commands and unit paths to
44
+ what the inline strings emitted. The existing composition assertions in `tests/provision.mjs`
45
+ are the proof: they pin the exact strings and were not rewritten for this change.
46
+
47
+ **What the seam deliberately does NOT cover:**
48
+
49
+ - **Caddy reload.** `systemctl reload caddy` is shared-proxy config, governed by ADR 0303, and the container has no Caddy at all — that is [task 1004017](https://cloudbongos.com/builders#/task/1004017), not this seam.
50
+ - **The dedicated-droplet cloud-init path.** A DigitalOcean droplet always boots systemd; baking unit files into cloud-init stays systemd-specific on purpose.
51
+ - **Per-instance unix accounts.** `useradd` cannot run under `USER node`, so ADR 0281's isolation has no container equivalent yet — [task 1004018](https://cloudbongos.com/builders#/task/1004018).
52
+
53
+ ## Consequences
54
+
55
+ - **Isolation must be restated per manager.** systemd enforces ADR 0281 with `User=`/`Group=`; supervisord's equivalent is its `user=` directive, and the supervisord implementation sets it from the same `instanceUser(inst)`. A future third manager has to answer this question explicitly or it silently drops the boundary that stops one instance reading a sibling's `/proc/<pid>/environ`.
56
+
57
+ - **supervisord has no timers, so the nightly backup is skipped there — loudly.** supervisord supervises processes, not schedules. Rather than re-implement cron badly, the supervisord implementation installs no backup schedule and logs the exact `crontab` line an operator can add instead. A container self-host therefore has no automatic DB backup until that is addressed; the skip is visible in the standup log, not silent.
58
+
59
+ - **The seam is two implementations wide, not N.** It exists because we have two real planes, not as speculative pluggability. A third manager is a new implementation in one file, but nothing is abstracted further in anticipation of one.
60
+
61
+ - **This alone does not make container standup work.** It removes one of four blockers. The self-host container still has no ingress (task 1004017), no per-instance identity (task 1004018), and a backup directory owned by a hardcoded `APP_USER = 'lars'` ([task 1002719](https://cloudbongos.com/builders#/task/1002719)). Task 1003997's done-when was narrowed to this seam accordingly, so the ledger does not record a self-host story that is not there yet.
@@ -397,3 +397,4 @@ This keeps the decision history honest and traceable.
397
397
  | 0303 | [**Provisioning verifies the base Caddyfile, and never edits it** ([task 1002860](https://cloudbongos.com/builders#/task/1002860), goal 1000106, from idea 1000524). The runner assumed the box's `/etc/caddy/Caddyfile` carried the `on_demand_tls` tls-check ask and `import /etc/caddy/sites/*.caddy`, and reloaded with `allowFail` — a box missing either provisioned "successfully" and the instance was unreachable. It now reads Caddy's RESOLVED config (`caddy adapt`: unprivileged, no key access, indifferent to how the directives are written) before touching the box (the ask) and after writing the snippet (the domain is served ⇒ the import reaches it), refusing with the exact line to add, and the reload is loud. Rejected: editing the base file (`on_demand_tls` must sit inside the global block an append cannot place, and a bad write takes down the control plane's own apex); `caddy validate` (loads certificate keys, needs root).](0303-provisioning-verifies-the-base-caddyfile-and-never-edits-it.md) | provisioning / infra |
398
398
  | 0304 | [**Routine reads run without a prompt; anything a flag can turn into a write is exact-form** ([task 1003992](https://cloudbongos.com/builders#/task/1003992), goal 1000095). The shipped `.claude/settings.json` pre-allowed six commands, so every `gh pr view` or `git fetch` asked a non-technical builder to approve a command they cannot evaluate. The read-only subset is now pre-allowed — `git status`, `gh pr view/checks/list`, `gh run view/list` and the gds CLIs audited read-only under every flag as prefix rules; `git branch` and `git fetch` as exact forms only, because a flag turns either into a write or an exec — and the instance starter mirrors it rule for rule. `git diff/log/show` get NO rule: the harness already runs their read-only forms unprompted with a flag check, and a prefix rule switched that check off (`--output=<path>` writes anywhere, `diff --no-index` reads deny-listed files — both reproduced in review). A test pins the whole allow-list, that no rule covers a command git-guard blocks, and that no rule covers a write dressed as a read. Rejected: `Bash(git:*)`, prefix-plus-`ask` for diff/log/show (argument patterns are evaded by quoting), prefix rules for fetch/branch, `bypassPermissions` everywhere.](0304-routine-reads-run-without-a-prompt-and-anything-a-flag-can-turn-into-a-write-is-exact-form.md) | GDS / sessions / permissions |
399
399
  | 0305 | [**A recipe lives with what it documents** ([task 1002156](https://cloudbongos.com/builders#/task/1002156), goal 1000091, from idea 536 / idea 537). Part A of the 2026-07-10 architecture review, held until the owner confirmed direction. The binary core-vs-instance split in `isPublishable()` has collapsed to one side: all **36** files in `docs/recipes/` and all **4** in `docs/research/` publish, and the denylist rows that once held the instance-local half name files that no longer exist. Worse, it is producing wrong docs — four published recipes document the dev-box that goal 1000091 is deleting, and none of the nine removal tasks covers them ([task 1004013](https://cloudbongos.com/builders#/task/1004013)). The cause is not neglect: **nothing structurally ties a recipe to what it documents**. So the rule is ownership, not portability — a recipe lives in the same deletable unit as its subject. Three classes: core methodology stays in `docs/recipes/`; feature-coupled moves to `modules/<key>/recipes/` and dies with its module; instance-operational stays behind the publish gate. `scripts/` is unchanged (it is the CLI, not documentation with a portability question); `docs/research/` becomes instance-local (situated artifacts, and the likeliest brand-name leak). **The thesis is rejected in its literal form**: a shared recipe is not a NEW asset class parallel to modules but a directory *inside* one, reusing the manifest attribution, submit→review→accept, security accept-gate, default-off enablement and orphaning policy that ADR 0107/0135 already ship — `modules/ui-design/docs/design-contract.md` is the undesigned precedent. Rejected: a parallel registry (a second manifest, queue, attribution model and orphaning policy, to distribute Markdown; Supermarket/Galaxy distribute executables with dependency resolution, which is what a module already is); classify-and-keep-the-binary-split (treats the symptom, restores the local half only until the next module ships a recipe); per-recipe portability front-matter (no build step reads it, no deletion touches it); deleting `docs/research/` (live inputs to open goals). Decides direction only — no file moves; the sweep and a fitness check for a recipe outliving its module are follow-on.](0305-a-recipe-lives-with-what-it-documents.md) | modules / upstreaming |
400
+ | 0306 | [**Instance supervision is a seam, with systemd as the default** ([task 1003997](https://cloudbongos.com/builders#/task/1003997), goal 1000106, F13 from the task-1002718 QA walk). `provision-units.js` hardcoded `/etc/systemd/system` in three path functions and the runner reached for `systemctl` inline at fifteen call sites, so a standup was impossible on any plane without systemd — the self-host image runs `USER node`, installs no `sudo` and boots no systemd. **A directory knob does not fix this**: pointing the unit path somewhere writable still emits `daemon-reload` into a namespace where systemd is not PID 1. What varies is not where the unit file goes, it is what supervises the process. Chosen: four operations (install+enable, install backup schedule, restart, remove) move behind `scripts/gds/provision-process.js`, taking the `{ exec, writeFile, sudoP, log }` io the runbook already threads, so dry-run, remote-ssh and logging behaviour carry over untouched (ADR 0130). Selection is explicit (`PROVISION_PROCESS_MANAGER`, an unknown value throws) → probed (`/run/systemd/system`, the `sd_booted(3)` check) → supervisord. **The live box is unchanged by construction** — it boots systemd, and the systemd implementation emits byte-identical commands; the untouched composition assertions in `tests/provision.mjs` are the proof. Rejected: systemd `--user` units (fix an unprivileged VM we do not have, while a plain container has no user bus either). Consequences: isolation is restated per manager (systemd `User=` → supervisord `user=`, ADR 0281) or it silently drops; supervisord has no timers, so it installs no backup schedule and prints the `crontab` line instead of re-implementing cron; the seam is two implementations wide, not speculatively N. **This removes one of four container blockers** — ingress ([task 1004017](https://cloudbongos.com/builders#/task/1004017)), per-instance identity ([task 1004018](https://cloudbongos.com/builders#/task/1004018)) and the hardcoded backup-dir owner ([task 1002719](https://cloudbongos.com/builders#/task/1002719)) remain, so task 1003997's done-when was narrowed to the seam rather than claiming a self-host story that is not there.](0306-instance-supervision-is-a-seam-with-systemd-as-the-default.md) | infra / provisioning |
@@ -2182,5 +2182,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
2182
2182
  landed since 1.19.852 with no explicit bump. run 35275456041. (task 1002620)
2183
2183
  1.19.854 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2184
2184
  landed since 1.19.853 with no explicit bump. run 35276402271. (task 1002620)
2185
+ 1.19.855 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2186
+ landed since 1.19.854 with no explicit bump. run 35277047800. (task 1002620)
2185
2187
  ---------------------------------------------------------------------------
2186
2188
  ```
@@ -39,7 +39,7 @@ Author `config/branding.json` from the neutral starter — see [the branding con
39
39
 
40
40
  **Or scaffold it with `bongos init`** ([task #1204](https://example.com/builders#/task/1204)): a short interview (or `bongos init --from spec.json` for a hands-free / CI run) that writes both config files, **seeds your first version + its done-when goal**, and **files the human-only kickoff steps** (register a GitHub OAuth app, first owner sign-in, repo binding, admission) as real tracked tasks — so a fresh instance has a claimable queue on day one instead of an empty hall. It never invents your branding (it points you at the fill prompt for that). Preview the whole plan with `bongos init --dry-run`.
41
41
 
42
- **Production hosting, optional (the `provisioning` module — [ADR 0111](../adr/0111-instance-hosting-provisioning-module.md) §3).** If you enable the `provisioning` module and answer the interview's hosting questions (shape — co-tenant `$0` or a dedicated droplet — bring-your-own domain, credential ref, tier), init turns those answers into a real **provisioning request** (a queued `provision` intent the control-plane runner drains) instead of only filing a manual "production topology" task. **A queued request does nothing until a runner drains it:** on the control plane that is a systemd timer running `node scripts/gds/provision.js run-intents --apply`. This Docker image now carries the tools the scaffold needs — git, and a core it can package out of its own `.git`-less checkout (task 1003995) — but the standup leg still assumes root + systemd, which a container has not got (task 1003997), so on a compose self-host a new project waits indefinitely. The two irreducibly-human legs — the GitHub OAuth app and the first owner sign-in — stay guided/manual. If the module isn't enabled on the target instance, init falls back to the manual production-topology task, so nothing is silently skipped.
42
+ **Production hosting, optional (the `provisioning` module — [ADR 0111](../adr/0111-instance-hosting-provisioning-module.md) §3).** If you enable the `provisioning` module and answer the interview's hosting questions (shape — co-tenant `$0` or a dedicated droplet — bring-your-own domain, credential ref, tier), init turns those answers into a real **provisioning request** (a queued `provision` intent the control-plane runner drains) instead of only filing a manual "production topology" task. **A queued request does nothing until a runner drains it:** on the control plane that is a systemd timer running `node scripts/gds/provision.js run-intents --apply`. This Docker image now carries the tools the scaffold needs — git, and a core it can package out of its own `.git`-less checkout (task 1003995) — and supervision is no longer systemd-only: the runner picks a process manager, falling back to supervisord where systemd is not running (task 1003997, [ADR 0306](../adr/0306-instance-supervision-is-a-seam-with-systemd-as-the-default.md)). Three standup legs still assume a systemd box, so on a compose self-host a new project waits indefinitely: the container has no reverse proxy for a child instance's front door (task 1004017), cannot create the per-instance unix account that isolates one instance from another (task 1004018), and installs its backup dir as a hardcoded founder user (task 1002719). Note supervisord has no timer concept either, so a container-hosted instance gets no automatic DB backup — the standup log prints the `crontab` line to add instead. The two irreducibly-human legs — the GitHub OAuth app and the first owner sign-in — stay guided/manual. If the module isn't enabled on the target instance, init falls back to the manual production-topology task, so nothing is silently skipped.
43
43
 
44
44
  ## Configuration surface
45
45
 
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.854",
3
+ "version": "1.19.855",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.854",
9
+ "version": "1.19.855",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.854",
3
+ "version": "1.19.855",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -63,6 +63,10 @@ const CONFIG = {
63
63
  // `<db>-<ts>.sql.gz`, so each instance's files + retention prune stay scoped to
64
64
  // its own DB. Owned by the app user, mode 0750. (task 2213, productizes 2210.)
65
65
  backupDir: process.env.PROVISION_BACKUP_DIR || '/var/backups/gds',
66
+ // Where supervisord program configs land when supervision is supervisord rather than
67
+ // systemd (ADR 0306). Unused on a systemd box. No sudo is applied to writes here: the
68
+ // planes that select supervisord run unprivileged and already own this dir.
69
+ supervisorConfDir: process.env.PROVISION_SUPERVISOR_CONF_DIR || '/etc/supervisor/conf.d',
66
70
  // Dedicated-droplet defaults (only used for hosting_shape='dedicated').
67
71
  region: process.env.PROVISION_REGION || 'nyc3',
68
72
  baseImage: process.env.PROVISION_BASE_IMAGE || 'ubuntu-24-04-x64',
@@ -0,0 +1,167 @@
1
+ // scripts/gds/provision-process.js — WHAT SUPERVISES an instance's long-running
2
+ // service, as a seam rather than a hardcoded string (task 1003997, ADR 0306).
3
+ //
4
+ // The standup used to reach for `systemctl` inline at fifteen call sites, which is fine
5
+ // on a systemd box and impossible anywhere else: the self-host image runs USER node with
6
+ // no sudo and boots no systemd at all. Pointing the unit path somewhere writable does not
7
+ // help — what varies is not where the unit file goes, it is what runs the process.
8
+ //
9
+ // A manager answers four questions: install+enable the service, install its nightly
10
+ // backup schedule, restart it, remove it. Each takes the instance row and an `io` of the
11
+ // SAME shape the runbook already threads around — { exec, writeFile, sudoP, log } — so
12
+ // the dry-run, remote-ssh and logging behaviour of makeExec/makeWriteFile carries over
13
+ // untouched (ADR 0130). Nothing here executes anything itself.
14
+ //
15
+ // systemd is the default and its commands are BYTE-IDENTICAL to the strings it replaced;
16
+ // tests/provision.mjs pins them. The live box boots systemd, so it keeps systemd.
17
+
18
+ 'use strict';
19
+
20
+ const fs = require('node:fs');
21
+ const path = require('node:path');
22
+ const { CONFIG, REPO_ROOT } = require('./provision-config.js');
23
+ const { APP_USER, CORE_PKG_DIR, instanceUser, standaloneRoot } = require('./provision-repo.js');
24
+ const {
25
+ backupScriptPath, backupService, backupServicePath, backupTimer, backupTimerPath,
26
+ backupUnitName, serviceUnit, serviceUnitPath,
27
+ } = require('./provision-units.js');
28
+
29
+ // ── systemd — the default, and what every live box runs ──────────────────────
30
+ const systemd = {
31
+ key: 'systemd',
32
+
33
+ installService(inst, { exec, writeFile, sudoP }) {
34
+ writeFile(serviceUnitPath(inst), serviceUnit(inst), { mode: 0o644, sudo: true });
35
+ exec(`${sudoP}systemctl daemon-reload`);
36
+ exec(`${sudoP}systemctl enable --now ${inst.slug}`);
37
+ },
38
+
39
+ // The backup DIR belongs to this step, not to the runbook: it exists only to serve the
40
+ // schedule, so a manager that installs no schedule must not create it either.
41
+ installBackup(inst, { exec, writeFile, sudoP }) {
42
+ exec(`${sudoP}install -d -o ${APP_USER} -g ${APP_USER} -m 0750 ${CONFIG.backupDir}`);
43
+ writeFile(backupServicePath(inst), backupService(inst), { mode: 0o644, sudo: true });
44
+ writeFile(backupTimerPath(inst), backupTimer(inst), { mode: 0o644, sudo: true });
45
+ exec(`${sudoP}systemctl daemon-reload`);
46
+ exec(`${sudoP}systemctl enable --now ${backupUnitName(inst)}.timer`);
47
+ return { installed: true };
48
+ },
49
+
50
+ restartService(inst, { exec, sudoP }) {
51
+ exec(`${sudoP}systemctl restart ${inst.slug}`);
52
+ },
53
+
54
+ // Teardown. Every step allowFail: a half-provisioned instance must still tear down.
55
+ // reset-failed is not optional (task 1003940): a removed unit's FAILED state outlives
56
+ // its file, so without it `systemctl --failed` lists torn-down ghosts for weeks.
57
+ removeService(inst, { exec, sudoP }) {
58
+ exec(`${sudoP}systemctl disable --now ${inst.slug}`, { allowFail: true });
59
+ exec(`${sudoP}rm -f ${serviceUnitPath(inst)}`, { allowFail: true });
60
+ exec(`${sudoP}systemctl disable --now ${backupUnitName(inst)}.timer`, { allowFail: true });
61
+ exec(`${sudoP}rm -f ${backupServicePath(inst)} ${backupTimerPath(inst)}`, { allowFail: true });
62
+ exec(`${sudoP}systemctl daemon-reload`, { allowFail: true });
63
+ exec(`${sudoP}systemctl reset-failed ${inst.slug}.service ${backupUnitName(inst)}.service`, { allowFail: true });
64
+ },
65
+ };
66
+
67
+ // ── supervisord — the container implementation ───────────────────────────────
68
+ // No sudo anywhere: the self-host image runs as an unprivileged user that already owns
69
+ // its conf dir, so escalation would fail rather than help.
70
+ function supervisorConfPath(inst) {
71
+ return path.posix.join(CONFIG.supervisorConfDir, `${inst.slug}.conf`);
72
+ }
73
+
74
+ // The ExecStart equivalent, resolved the same way serviceUnit resolves it so the two
75
+ // cannot drift: a standalone runs out of its own pinned core, anything else out of this
76
+ // checkout.
77
+ function programCommand(inst) {
78
+ return inst.hosting_shape === 'standalone'
79
+ ? `/usr/bin/node ${CORE_PKG_DIR}/src/platform-server.js`
80
+ : '/usr/bin/node src/platform-server.js';
81
+ }
82
+
83
+ function supervisorProgram(inst, { etcBase = CONFIG.etcBase } = {}) {
84
+ const workDir = inst.hosting_shape === 'standalone' ? standaloneRoot(inst) : REPO_ROOT;
85
+ return `[program:${inst.slug}]
86
+ ; Cloud Bongos instance — ${inst.slug} (provisioned by provision.js, ADR 0306)
87
+ command=${programCommand(inst)}
88
+ directory=${workDir}
89
+ ; The isolation systemd spells User=/Group= (ADR 0281). A shared uid would let every
90
+ ; instance on this plane read its siblings' /proc/<pid>/environ — every OAuth cred in
91
+ ; the env file below. Dropping this line silently removes that boundary.
92
+ user=${instanceUser(inst)}
93
+ environment=HOME="${path.posix.join(etcBase, inst.slug)}",PGDATABASE="${inst.db_name || inst.slug}",PORT="${inst.port}"
94
+ autostart=true
95
+ autorestart=true
96
+ startsecs=3
97
+ stdout_logfile=/var/log/supervisor/${inst.slug}.log
98
+ redirect_stderr=true
99
+ `;
100
+ }
101
+
102
+ const supervisord = {
103
+ key: 'supervisord',
104
+
105
+ installService(inst, { exec, writeFile }) {
106
+ writeFile(supervisorConfPath(inst), supervisorProgram(inst), { mode: 0o644 });
107
+ exec('supervisorctl reread');
108
+ exec('supervisorctl update');
109
+ exec(`supervisorctl start ${inst.slug}`);
110
+ },
111
+
112
+ // supervisord supervises processes, not schedules — it has no timer concept. Rather
113
+ // than re-implement cron badly, install nothing and hand the operator the exact line
114
+ // (ADR 0306). LOUD on purpose: a self-host with no automatic DB backup must not be a
115
+ // thing anyone discovers after they need one.
116
+ installBackup(inst, { log }) {
117
+ // The env the dump needs is LIFTED from the systemd unit rather than retyped, so the
118
+ // crontab line and the unit cannot drift and the variable names live in one place.
119
+ const env = backupService(inst).split('\n')
120
+ .filter((l) => l.startsWith('Environment='))
121
+ .map((l) => l.slice('Environment='.length))
122
+ .join(' ');
123
+ log(` (no backup schedule: ${supervisord.key} has no timers — add this to a crontab instead)`);
124
+ log(` 0 4 * * * ${env} /bin/bash ${backupScriptPath(inst)}`);
125
+ return { installed: false, reason: 'supervisord has no timer concept' };
126
+ },
127
+
128
+ restartService(inst, { exec }) {
129
+ exec(`supervisorctl restart ${inst.slug}`);
130
+ },
131
+
132
+ removeService(inst, { exec }) {
133
+ exec(`supervisorctl stop ${inst.slug}`, { allowFail: true });
134
+ exec(`rm -f ${supervisorConfPath(inst)}`, { allowFail: true });
135
+ exec('supervisorctl reread', { allowFail: true });
136
+ exec('supervisorctl update', { allowFail: true });
137
+ },
138
+ };
139
+
140
+ const MANAGERS = { systemd, supervisord };
141
+
142
+ // Was this host booted under systemd? /run/systemd/system is the check sd_booted(3)
143
+ // makes and the one systemd documents for exactly this question — it exists only when
144
+ // systemd is PID 1, so a container answers no without us guessing at container-ness.
145
+ function systemdIsBooted(fsImpl) {
146
+ try { return fsImpl.existsSync('/run/systemd/system'); } catch { return false; }
147
+ }
148
+
149
+ // Explicit → probed → default. An unrecognised knob value throws rather than falling
150
+ // back: silently supervising an instance a different way than the operator asked is
151
+ // worse than refusing to provision.
152
+ function resolveProcessManager({ env = process.env, fsImpl = fs } = {}) {
153
+ const want = String(env.PROVISION_PROCESS_MANAGER || '').trim().toLowerCase();
154
+ if (want) {
155
+ if (MANAGERS[want]) return MANAGERS[want];
156
+ throw new Error(`PROVISION_PROCESS_MANAGER=${want} is not a known process manager (${Object.keys(MANAGERS).join(', ')})`);
157
+ }
158
+ return systemdIsBooted(fsImpl) ? systemd : supervisord;
159
+ }
160
+
161
+ module.exports = {
162
+ resolveProcessManager,
163
+ supervisorConfPath,
164
+ supervisorProgram,
165
+ supervisord,
166
+ systemd,
167
+ };
@@ -12,7 +12,7 @@
12
12
  const path = require('node:path');
13
13
  const { CONFIG, REPO_ROOT } = require('./provision-config.js');
14
14
  const { dbName, deployKeyPath, deployKeyTitle, parseTargetRef, resolveOwnerGithubToken } = require('./provision-repo.js');
15
- const { backupServicePath, backupTimerPath, backupUnitName, serviceUnitPath } = require('./provision-units.js');
15
+ const { resolveProcessManager } = require('./provision-process.js');
16
16
  const { caddySnippetPath, dnsReleaseEnv } = require('./provision-net.js');
17
17
  const apexMod = require('./instance-apex.js');
18
18
  const { isReservedHostname } = require('../../modules/provisioning/provisioning.js');
@@ -29,13 +29,8 @@ async function teardownInstance(inst, deps) {
29
29
  log(`teardown ${inst.slug} (status=${inst.status})`);
30
30
  if (apply) await provisioning.setInstanceStatus(db, inst.id, 'tearing_down');
31
31
  if (inst.hosting_shape !== 'dedicated') {
32
- boxExec(`${sudoP}systemctl disable --now ${inst.slug}`, { allowFail: true });
33
- boxExec(`${sudoP}rm -f ${serviceUnitPath(inst)}`, { allowFail: true });
34
- // Stop + remove the nightly backup units too (task 2213; the dir + dumps stay — precious data, like the DB below), then reset-failed (task 1003940): a removed unit's FAILED state outlives its file, so `systemctl --failed` listed torn-down ghosts for weeks.
35
- boxExec(`${sudoP}systemctl disable --now ${backupUnitName(inst)}.timer`, { allowFail: true });
36
- boxExec(`${sudoP}rm -f ${backupServicePath(inst)} ${backupTimerPath(inst)}`, { allowFail: true });
37
- boxExec(`${sudoP}systemctl daemon-reload`, { allowFail: true });
38
- boxExec(`${sudoP}systemctl reset-failed ${inst.slug}.service ${backupUnitName(inst)}.service`, { allowFail: true });
32
+ // Stop + remove the service AND the nightly backup units (task 2213; the dir + dumps stay — precious data, like the DB below). Whoever supervises the instance owns its removal (ADR 0306) — the systemd manager keeps the reset-failed step task 1003940 added, since a removed unit's FAILED state outlives its file and `systemctl --failed` listed torn-down ghosts for weeks.
33
+ (deps.processManager || resolveProcessManager()).removeService(inst, { exec: boxExec, sudoP });
39
34
  // The site snippet standup step 6 wrote: while it stays, Caddy proxies the hostname to
40
35
  // a port nothing listens on and every visitor gets a 502 (task 1002897). Not precious.
41
36
  boxExec(`${sudoP}rm -f ${caddySnippetPath(inst)}`, { allowFail: true });
@@ -53,12 +53,17 @@ const { CONFIG, MANIFEST_UA, MANIFEST_VERIFY_INTERVAL_MS, MANIFEST_VERIFY_TRIES,
53
53
  const { APP_USER, alreadyScaffolded, buildCorePinRefreshCommit, coreCheckoutHasGit, dbCreateCmd, dbName, dbRoleCmd, dbRoleCmdShown, dbRoleGrantsCmd, ensureInstanceUserCmd, generateDbPassword, grantInstanceRepoReadCmd, instanceDbRole, instanceStateDir, instanceUser, deployKeyPath, deployKeyTitle, ensurePrivateRepoAccess, installedCorePackDir, installedCoreTarball, instanceInitSpec, instanceRepoRemote, migrateCmd, onboardMode, ownerLoginOf, parseTargetRef, planCorePinRefresh, refreshStandaloneCorePin, resolveOwnerGithubToken, resolveVendorableCoreTarball, safeVersionLabel, scaffoldStandaloneRepo, seedFirstVersionCmd, standaloneInstallCmd, standaloneMigrateCmd, standalonePullCmd, standaloneRegenDocsCmd, standaloneRoot } = require('./provision-repo.js');
54
54
  const { backupScriptPath, backupService, backupServicePath, backupTimer, backupTimerPath, backupUnitName, instanceManifestCmd, originEnvVarsFor, serviceUnit, serviceUnitPath, settingsConsumed, settingsEnvVarsFor, upsertEnvVars, webEnvBody, webEnvPath } = require('./provision-units.js');
55
55
  const { caddyBlock, caddySnippetPath, checkCaddyWiring, checkDnsTokenScope, classifyZoneScope, dnsReleaseEnv, dnsUpsertEnv, federateInstance, federationHubOrigin, healthzCmd, identityCmd, identityVerdict, shouldFederate, zoneForDomain } = require('./provision-net.js');
56
+ const { resolveProcessManager } = require('./provision-process.js');
56
57
  const { capacityPreflight, statusCapacityLine } = require('./provision-capacity.js');
57
58
  const { installedCoreVersion, runnerBanner } = require('./runner-drift.js');
58
59
  const { teardownInstance } = require('./provision-teardown.js');
59
60
  const { disconnectInstance } = require('./provision-disconnect.js');
60
61
  const { coreUpgradeInstance, snapshotAllCoreVersions } = require('./provision-core-upgrade.js'); // the owner's deploy door, ADR 0293
61
62
 
63
+ // WHAT supervises an instance's service (ADR 0306). systemd on every box we run — the
64
+ // seam exists so a plane without systemd is not a dead end. Injectable for tests.
65
+ const pmFor = (deps) => (deps && deps.processManager) || resolveProcessManager();
66
+
62
67
  function makeExec({ apply, log = console.log, remoteHost = null, remoteCwd = null, sshOpts = '' }) {
63
68
  // `shown` (task 1003140): what to LOG in place of the command text — for a command
64
69
  // that carries a secret (a remote file placement embeds the base64 body), so the
@@ -435,12 +440,11 @@ async function provisionInstance(inst, deps) {
435
440
  if (local) boxWriteFile(webEnvPath(inst), webEnvBody(inst, { federation, ownerLogin: await ownerLoginOf(inst, deps.db), dbPassword }), { mode: 0o600, sudo: true });
436
441
  else log(' (baked into cloud-init)');
437
442
 
438
- // ── step 4: systemd unit + enable ─────────────────────────────────────────
439
- log(' [service] install + enable systemd unit');
443
+ // ── step 4: install + enable the instance's service ───────────────────────
444
+ const pm = pmFor(deps);
445
+ log(` [service] install + enable the ${pm.key} service`);
440
446
  if (local) {
441
- boxWriteFile(serviceUnitPath(inst), serviceUnit(inst), { mode: 0o644, sudo: true });
442
- boxExec(`${sudoP}systemctl daemon-reload`);
443
- boxExec(`${sudoP}systemctl enable --now ${inst.slug}`);
447
+ pm.installService(inst, { exec: boxExec, writeFile: boxWriteFile, sudoP, log });
444
448
  } else { log(' (unit baked into cloud-init)'); }
445
449
 
446
450
  // ── step 4b: nightly DB backup — dir + consumer-layout timer/service (task 2213) ──
@@ -451,11 +455,7 @@ async function provisionInstance(inst, deps) {
451
455
  // overwrites, `enable --now` is idempotent. Dedicated bakes the same into cloud-init.
452
456
  log(' [backup] create backup dir + install + enable nightly DB backup timer');
453
457
  if (local) {
454
- boxExec(`${sudoP}install -d -o ${APP_USER} -g ${APP_USER} -m 0750 ${CONFIG.backupDir}`);
455
- boxWriteFile(backupServicePath(inst), backupService(inst), { mode: 0o644, sudo: true });
456
- boxWriteFile(backupTimerPath(inst), backupTimer(inst), { mode: 0o644, sudo: true });
457
- boxExec(`${sudoP}systemctl daemon-reload`);
458
- boxExec(`${sudoP}systemctl enable --now ${backupUnitName(inst)}.timer`);
458
+ pm.installBackup(inst, { exec: boxExec, writeFile: boxWriteFile, sudoP, log });
459
459
  } else { log(' (backup dir + units baked into cloud-init)'); }
460
460
 
461
461
  // ── step 5: DNS — Cloudflare A-record UPSERT for the instance's domain (P4) ──
@@ -778,7 +778,7 @@ async function domainAttachInstance(inst, deps) {
778
778
  boxWriteFile(webEnvPath(inst), upsertEnvVars(currentEnv, origins), { mode: 0o600, sudo: true });
779
779
  }
780
780
  log(' [service] restart to pick up the new origins');
781
- boxExec(`${sudoP}systemctl restart ${inst.slug}`);
781
+ pmFor(deps).restartService(inst, { exec: boxExec, sudoP });
782
782
 
783
783
  // DNS A-record UPSERT — mirrors provisionInstance step 5 (control-plane exec only:
784
784
  // the hook needs the Cloudflare token, which never leaves the control plane).
@@ -889,7 +889,7 @@ async function settingsApplyInstance(inst, deps) {
889
889
  const current = String((read && read.stdout) || '');
890
890
  if (!apply) {
891
891
  log(` [config] would patch ${Object.keys(vars).join(', ')} in ${webEnvPath(inst)} in place (every other line untouched)`);
892
- boxExec(`${sudoP}systemctl restart ${inst.slug}`);
892
+ pmFor(deps).restartService(inst, { exec: boxExec, sudoP });
893
893
  boxExec(healthzCmd(inst), { allowFail: true });
894
894
  log(` ✓ would restart ${inst.slug} with the new settings (dry-run — nothing ran)`);
895
895
  return { ok: true, healthy: null, consumed: null };
@@ -901,7 +901,7 @@ async function settingsApplyInstance(inst, deps) {
901
901
  log(' [config] patch the settings lines of web.env in place (creds + origins untouched)');
902
902
  boxWriteFile(webEnvPath(inst), upsertEnvVars(current, vars), { mode: 0o600, sudo: true });
903
903
  log(' [service] restart so the memoized branding pack re-reads them');
904
- boxExec(`${sudoP}systemctl restart ${inst.slug}`);
904
+ pmFor(deps).restartService(inst, { exec: boxExec, sudoP });
905
905
  } catch (e) {
906
906
  // ok:false, never a throw: the retry re-reads the file (the upsert is
907
907
  // idempotent) and restarts again; the instance's status is left alone.
@@ -991,14 +991,14 @@ async function restartInstance(inst, deps) {
991
991
  return { ok: false, error: `restart is not automated for a dedicated droplet — run 'systemctl restart ${inst.slug}' on that machine` };
992
992
  }
993
993
  if (!apply) {
994
- boxExec(`${sudoP}systemctl restart ${inst.slug}`);
994
+ pmFor(deps).restartService(inst, { exec: boxExec, sudoP });
995
995
  boxExec(healthzCmd(inst), { allowFail: true });
996
996
  log(` ✓ would restart ${inst.slug} and wait for it to answer (dry-run — nothing ran)`);
997
997
  return { ok: true, healthy: null };
998
998
  }
999
999
  try {
1000
1000
  log(' [service] restart');
1001
- boxExec(`${sudoP}systemctl restart ${inst.slug}`);
1001
+ pmFor(deps).restartService(inst, { exec: boxExec, sudoP });
1002
1002
  } catch (e) {
1003
1003
  // ok:false, never a throw — the retry simply restarts again (idempotent).
1004
1004
  return { ok: false, error: `could not restart ${inst.slug}: ${(e && e.message) || e}` };
@@ -1142,7 +1142,7 @@ async function placeManifestCreds(inst, mrow, deps) {
1142
1142
  // it is harmless and heals once the owner grants the permissions + installs.
1143
1143
  appId: creds.appId, appPem: creds.appPem,
1144
1144
  }), { mode: 0o600, sudo: true });
1145
- boxExec(`${sudoP}systemctl restart ${inst.slug}`);
1145
+ pmFor(deps).restartService(inst, { exec: boxExec, sudoP });
1146
1146
 
1147
1147
  // 4) verify /healthz reports auth_configured=true (only meaningful under --apply).
1148
1148
  // PATIENT (F23): the child must boot before it can answer; losing this race
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.854'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.855'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -34,6 +34,12 @@ const reEsc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
34
34
  // (the resolution rule itself is covered directly, with an injected probe).
35
35
  process.env.PROVISION_GIT_SSH_ALIAS = 'github-deploy';
36
36
 
37
+ // Same reasoning for supervision (task 1003997, ADR 0306): the manager is probed from
38
+ // /run/systemd/system when nothing pins it, so pin systemd here — the composition
39
+ // assertions below are about the runbook, not about whether the test box boots systemd.
40
+ // The probe itself is covered directly, with an injected fs.
41
+ process.env.PROVISION_PROCESS_MANAGER = 'systemd';
42
+
37
43
  let passed = 0, failed = 0;
38
44
  function t(name, fn) {
39
45
  try { fn(); passed++; console.log(` PASS ${name}`); }
@@ -576,6 +582,113 @@ t('instanceRepoRemotes: the accept-set a stored remote is validated against, pro
576
582
  } finally { process.env.PROVISION_GIT_SSH_ALIAS = saved; }
577
583
  });
578
584
 
585
+ // ── the process-manager seam (task 1003997, ADR 0306) ───────────────────────
586
+ const PM = createRequire(import.meta.url)('../scripts/gds/provision-process.js');
587
+
588
+ // A recording io: the same { exec, writeFile, sudoP, log } shape the runbook hands over.
589
+ function recordIo({ sudoP = '' } = {}) {
590
+ const cmds = [], files = [], logs = [];
591
+ return {
592
+ cmds, files, logs, sudoP,
593
+ exec: (cmd, opts) => cmds.push(opts && opts.allowFail ? `${cmd} [allowFail]` : cmd),
594
+ writeFile: (p, body, opts) => files.push({ path: p, body, opts }),
595
+ log: (m) => logs.push(m),
596
+ };
597
+ }
598
+ const INST = { slug: 'demo', hosting_shape: 'standalone', port: 3003, db_name: 'demo' };
599
+
600
+ t('resolveProcessManager: explicit knob wins, then the /run/systemd/system probe', () => {
601
+ const booted = { existsSync: (p) => p === '/run/systemd/system' };
602
+ const bare = { existsSync: () => false };
603
+ assert.equal(PM.resolveProcessManager({ env: { PROVISION_PROCESS_MANAGER: 'supervisord' }, fsImpl: booted }).key,
604
+ 'supervisord', 'the knob overrides even a systemd host');
605
+ assert.equal(PM.resolveProcessManager({ env: {}, fsImpl: booted }).key, 'systemd',
606
+ 'the live box boots systemd, so it keeps systemd');
607
+ assert.equal(PM.resolveProcessManager({ env: {}, fsImpl: bare }).key, 'supervisord',
608
+ 'a container boots no systemd');
609
+ assert.throws(() => PM.resolveProcessManager({ env: { PROVISION_PROCESS_MANAGER: 'runit' }, fsImpl: booted }),
610
+ /PROVISION_PROCESS_MANAGER/, 'an unknown manager is LOUD, never a silent fallback');
611
+ });
612
+
613
+ t('systemd manager: emits byte-identical commands + unit paths to the inline strings it replaced', () => {
614
+ const io = recordIo({ sudoP: 'sudo ' });
615
+ PM.systemd.installService(INST, io);
616
+ assert.deepEqual(io.cmds, ['sudo systemctl daemon-reload', 'sudo systemctl enable --now demo']);
617
+ assert.equal(io.files[0].path, '/etc/systemd/system/demo.service');
618
+ assert.deepEqual(io.files[0].opts, { mode: 0o644, sudo: true });
619
+
620
+ const io2 = recordIo({ sudoP: 'sudo ' });
621
+ PM.systemd.installBackup(INST, io2);
622
+ // The backup DIR moved in here with the schedule it serves: a manager that installs no
623
+ // schedule must not create it either. Order is unchanged — dir, units, reload, enable.
624
+ assert.match(io2.cmds[0], /^sudo install -d -o \S+ -g \S+ -m 0750 /);
625
+ assert.deepEqual(io2.cmds.slice(1), ['sudo systemctl daemon-reload', 'sudo systemctl enable --now demo-db-backup.timer']);
626
+ assert.deepEqual(io2.files.map((f) => f.path),
627
+ ['/etc/systemd/system/demo-db-backup.service', '/etc/systemd/system/demo-db-backup.timer']);
628
+
629
+ const io3 = recordIo({ sudoP: 'sudo ' });
630
+ PM.systemd.restartService(INST, io3);
631
+ assert.deepEqual(io3.cmds, ['sudo systemctl restart demo']);
632
+
633
+ const io4 = recordIo({ sudoP: 'sudo ' });
634
+ PM.systemd.removeService(INST, io4);
635
+ assert.deepEqual(io4.cmds, [
636
+ 'sudo systemctl disable --now demo [allowFail]',
637
+ 'sudo rm -f /etc/systemd/system/demo.service [allowFail]',
638
+ 'sudo systemctl disable --now demo-db-backup.timer [allowFail]',
639
+ 'sudo rm -f /etc/systemd/system/demo-db-backup.service /etc/systemd/system/demo-db-backup.timer [allowFail]',
640
+ 'sudo systemctl daemon-reload [allowFail]',
641
+ 'sudo systemctl reset-failed demo.service demo-db-backup.service [allowFail]',
642
+ ], 'including reset-failed — a removed unit\'s FAILED state outlives its file (task 1003940)');
643
+ });
644
+
645
+ t('systemd manager: an UNPRIVILEGED run drops the sudo prefix and nothing else', () => {
646
+ const io = recordIo({ sudoP: '' });
647
+ PM.systemd.installService(INST, io);
648
+ assert.deepEqual(io.cmds, ['systemctl daemon-reload', 'systemctl enable --now demo']);
649
+ });
650
+
651
+ t('supervisord manager: a program config, no systemd, and the ADR 0281 user= carried over', () => {
652
+ const io = recordIo();
653
+ PM.supervisord.installService(INST, io);
654
+ const conf = io.files[0];
655
+ assert.equal(conf.path, '/etc/supervisor/conf.d/demo.conf');
656
+ assert.match(conf.body, /^\[program:demo\]$/m);
657
+ assert.match(conf.body, /^user=/m, 'isolation is restated per manager, never dropped (ADR 0306)');
658
+ assert.match(conf.body, /platform-server\.js/, 'it runs the same entrypoint the unit did');
659
+ assert.deepEqual(io.cmds, ['supervisorctl reread', 'supervisorctl update', 'supervisorctl start demo']);
660
+ assert.ok(!io.cmds.some((c) => /systemctl/.test(c)), 'nothing reaches for systemd');
661
+
662
+ const io2 = recordIo();
663
+ PM.supervisord.restartService(INST, io2);
664
+ assert.deepEqual(io2.cmds, ['supervisorctl restart demo']);
665
+
666
+ const io3 = recordIo();
667
+ PM.supervisord.removeService(INST, io3);
668
+ assert.deepEqual(io3.cmds, [
669
+ 'supervisorctl stop demo [allowFail]',
670
+ 'rm -f /etc/supervisor/conf.d/demo.conf [allowFail]',
671
+ 'supervisorctl reread [allowFail]',
672
+ 'supervisorctl update [allowFail]',
673
+ ]);
674
+ });
675
+
676
+ t('supervisord manager: installs NO backup schedule and says so with the crontab line', () => {
677
+ const io = recordIo();
678
+ const r = PM.supervisord.installBackup(INST, io);
679
+ assert.equal(r.installed, false, 'supervisord supervises processes, not schedules');
680
+ assert.deepEqual(io.files, [], 'no unit files invented');
681
+ assert.deepEqual(io.cmds, [], 'and no scheduler commands either');
682
+ assert.ok(io.logs.some((l) => /crontab|cron/i.test(l)),
683
+ 'the skip is LOUD and hands the operator the line to add (ADR 0306)');
684
+ assert.ok(io.logs.some((l) => /db-backup-nightly\.sh/.test(l)), 'naming the actual script');
685
+ // The env is lifted from the systemd unit, so the line the operator pastes carries the
686
+ // same DB and dir the timer would have used — and cannot drift from it.
687
+ const cronLine = io.logs.find((l) => /db-backup-nightly\.sh/.test(l));
688
+ assert.ok(/_BACKUP_DB=demo\b/.test(cronLine) && /_BACKUP_DIR=\S+/.test(cronLine),
689
+ 'the dump env rides along, taken from the unit rather than retyped');
690
+ });
691
+
579
692
  t('instanceInitSpec: builds a VALID greenfield init spec from the row + owner login', () => {
580
693
  const spec = P.instanceInitSpec(
581
694
  { slug: 'demo', target_ref: 'example-owner/demo', domain: 'demo.cloudbongos.com', port: 3003 },