@bongos/core 1.19.937 → 1.19.939
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 +36 -31
- package/docs/adr/0281-an-instance-identity-is-its-own-unix-account-and-pg-role.md +2 -0
- package/docs/module-api-changelog.md +4 -0
- package/docs/recipes/self-host.md +1 -1
- package/modules/hall-ui/public/deploy.js +1 -1
- package/modules/provisioning/core-upgrade.js +1 -1
- package/modules/provisioning/routes/core-upgrade.js +1 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/address-park-sweep.sh +16 -5
- package/scripts/gds/audit-authorship.sh +28 -0
- package/scripts/gds/db-backup-nightly.sh +3 -3
- package/scripts/gds/db-backup-offsite.sh +2 -2
- package/scripts/gds/instance-apex.js +11 -1
- package/scripts/gds/provision-config.js +3 -2
- package/scripts/gds/provision-core-upgrade.js +7 -7
- package/scripts/gds/provision-process.js +2 -2
- package/scripts/gds/provision-repo.js +35 -10
- package/scripts/gds/provision-units.js +3 -3
- package/scripts/gds/provision.js +6 -3
- package/scripts/gds/stray-unit-sweep.sh +16 -3
- package/src/module-api.js +1 -1
- package/tests/core_upgrade_runner.mjs +10 -7
- package/tests/provision.mjs +44 -7
- package/tests/provision_account_identity.mjs +16 -0
- package/tests/sweep_app_account.mjs +112 -0
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.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.939",
|
|
6
|
+
"core_contract": "1.19.939",
|
|
7
|
+
"source_commit": "5a36a537ba12fc2d9dc3ce29ec6e84fb102238e8",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-
|
|
9
|
+
"built_at": "2026-09-21T18:56:49.358Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 511,
|
|
13
13
|
"agent_docs_stubbed": 26,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 2335,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2872,
|
|
20
|
+
"tree_sha256": "718dc449d9f22dd45347cce2f17dc24d6ca3d8e0cce9c9b1e7aad67a8683df47",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/ask-for-help/SKILL.md",
|
|
@@ -1897,7 +1897,7 @@
|
|
|
1897
1897
|
{
|
|
1898
1898
|
"path": "docs/adr/0281-an-instance-identity-is-its-own-unix-account-and-pg-role.md",
|
|
1899
1899
|
"mode": "0000644",
|
|
1900
|
-
"sha256": "
|
|
1900
|
+
"sha256": "d36d2333c07107c09d7431b4f49ceaafe51231dd4646dbb13bb081d0b2b63a33"
|
|
1901
1901
|
},
|
|
1902
1902
|
{
|
|
1903
1903
|
"path": "docs/adr/0282-a-module-that-spends-for-its-callers-declares-a-payer-and-the-core-refuses-it-without-one.md",
|
|
@@ -2677,7 +2677,7 @@
|
|
|
2677
2677
|
{
|
|
2678
2678
|
"path": "docs/module-api-changelog.md",
|
|
2679
2679
|
"mode": "0000644",
|
|
2680
|
-
"sha256": "
|
|
2680
|
+
"sha256": "4e9e33798c5671990f7d9d45e0695f43b8de1621cdaa316dd30d4e014f93df90"
|
|
2681
2681
|
},
|
|
2682
2682
|
{
|
|
2683
2683
|
"path": "docs/modules-contract.md",
|
|
@@ -2897,7 +2897,7 @@
|
|
|
2897
2897
|
{
|
|
2898
2898
|
"path": "docs/recipes/self-host.md",
|
|
2899
2899
|
"mode": "0000644",
|
|
2900
|
-
"sha256": "
|
|
2900
|
+
"sha256": "48dbce2966702987cbb9de6639ceb1e63ab3c7166e759bf93df06bd9f5875a88"
|
|
2901
2901
|
},
|
|
2902
2902
|
{
|
|
2903
2903
|
"path": "docs/recipes/server-bring-up.md",
|
|
@@ -4807,7 +4807,7 @@
|
|
|
4807
4807
|
{
|
|
4808
4808
|
"path": "modules/hall-ui/public/deploy.js",
|
|
4809
4809
|
"mode": "0000644",
|
|
4810
|
-
"sha256": "
|
|
4810
|
+
"sha256": "de280baf7432be56c67a65681a6e5d5af7146464271dec547d5bd6d45be2f37a"
|
|
4811
4811
|
},
|
|
4812
4812
|
{
|
|
4813
4813
|
"path": "modules/hall-ui/public/diagram-viewer.css",
|
|
@@ -6497,7 +6497,7 @@
|
|
|
6497
6497
|
{
|
|
6498
6498
|
"path": "modules/provisioning/core-upgrade.js",
|
|
6499
6499
|
"mode": "0000644",
|
|
6500
|
-
"sha256": "
|
|
6500
|
+
"sha256": "f5556a75b34977d0c46d4c9cf95383e4f57088c137e1c29c5b87211a5f159451"
|
|
6501
6501
|
},
|
|
6502
6502
|
{
|
|
6503
6503
|
"path": "modules/provisioning/credential-preflight.js",
|
|
@@ -6667,7 +6667,7 @@
|
|
|
6667
6667
|
{
|
|
6668
6668
|
"path": "modules/provisioning/routes/core-upgrade.js",
|
|
6669
6669
|
"mode": "0000644",
|
|
6670
|
-
"sha256": "
|
|
6670
|
+
"sha256": "8ceb0707b3bfe16e308cdf3034b034bcea8b5e266bcfa1d0e47bd53a61392990"
|
|
6671
6671
|
},
|
|
6672
6672
|
{
|
|
6673
6673
|
"path": "modules/provisioning/routes/disconnect.js",
|
|
@@ -8002,12 +8002,12 @@
|
|
|
8002
8002
|
{
|
|
8003
8003
|
"path": "package-lock.json",
|
|
8004
8004
|
"mode": "0000644",
|
|
8005
|
-
"sha256": "
|
|
8005
|
+
"sha256": "f9412625c08d05fd8718e0590544e5b4ac8d61ce85168c01c98973b0b0afbafd"
|
|
8006
8006
|
},
|
|
8007
8007
|
{
|
|
8008
8008
|
"path": "package.json",
|
|
8009
8009
|
"mode": "0000644",
|
|
8010
|
-
"sha256": "
|
|
8010
|
+
"sha256": "fcdd31f9e93e415ebe9384cfa896520ea80733044e833513f914c0f873180774"
|
|
8011
8011
|
},
|
|
8012
8012
|
{
|
|
8013
8013
|
"path": "public-docs/index.html",
|
|
@@ -8067,7 +8067,7 @@
|
|
|
8067
8067
|
{
|
|
8068
8068
|
"path": "scripts/gds/address-park-sweep.sh",
|
|
8069
8069
|
"mode": "0000644",
|
|
8070
|
-
"sha256": "
|
|
8070
|
+
"sha256": "b08a77da7a834dbe30fa9bd041ce05289f8abf48b3c44764ecbe2238cbc88e9c"
|
|
8071
8071
|
},
|
|
8072
8072
|
{
|
|
8073
8073
|
"path": "scripts/gds/adopt-detect.js",
|
|
@@ -8137,7 +8137,7 @@
|
|
|
8137
8137
|
{
|
|
8138
8138
|
"path": "scripts/gds/audit-authorship.sh",
|
|
8139
8139
|
"mode": "0000644",
|
|
8140
|
-
"sha256": "
|
|
8140
|
+
"sha256": "95607e7f1383148ecb21d448196872f752988d5c70169f5470a12eafc7e2643e"
|
|
8141
8141
|
},
|
|
8142
8142
|
{
|
|
8143
8143
|
"path": "scripts/gds/audit-deps.js",
|
|
@@ -8427,12 +8427,12 @@
|
|
|
8427
8427
|
{
|
|
8428
8428
|
"path": "scripts/gds/db-backup-nightly.sh",
|
|
8429
8429
|
"mode": "0000644",
|
|
8430
|
-
"sha256": "
|
|
8430
|
+
"sha256": "6123be86860d43d95d5d44966eb3e49caf9c5dc05a2a9ec02c67f53f527919dd"
|
|
8431
8431
|
},
|
|
8432
8432
|
{
|
|
8433
8433
|
"path": "scripts/gds/db-backup-offsite.sh",
|
|
8434
8434
|
"mode": "0000644",
|
|
8435
|
-
"sha256": "
|
|
8435
|
+
"sha256": "f530f46117bb052f8294e29c95970f6325e1e39f7ee27c96b92c2d672537b37d"
|
|
8436
8436
|
},
|
|
8437
8437
|
{
|
|
8438
8438
|
"path": "scripts/gds/demote.js",
|
|
@@ -8742,7 +8742,7 @@
|
|
|
8742
8742
|
{
|
|
8743
8743
|
"path": "scripts/gds/instance-apex.js",
|
|
8744
8744
|
"mode": "0000644",
|
|
8745
|
-
"sha256": "
|
|
8745
|
+
"sha256": "7b2402543c3b1eadbafc14907e6dc6689b434ac65fb9c709115dd81a92eb13eb"
|
|
8746
8746
|
},
|
|
8747
8747
|
{
|
|
8748
8748
|
"path": "scripts/gds/key-card.js",
|
|
@@ -8947,12 +8947,12 @@
|
|
|
8947
8947
|
{
|
|
8948
8948
|
"path": "scripts/gds/provision-config.js",
|
|
8949
8949
|
"mode": "0000644",
|
|
8950
|
-
"sha256": "
|
|
8950
|
+
"sha256": "6905238b164d1fbd47abbc1f495e6f1bf2de58ed26545853f8aa92f199dc78e5"
|
|
8951
8951
|
},
|
|
8952
8952
|
{
|
|
8953
8953
|
"path": "scripts/gds/provision-core-upgrade.js",
|
|
8954
8954
|
"mode": "0000644",
|
|
8955
|
-
"sha256": "
|
|
8955
|
+
"sha256": "4a84f48518a6427e95bf23e273865b6b2753489b40737b9256191fd2a65562e1"
|
|
8956
8956
|
},
|
|
8957
8957
|
{
|
|
8958
8958
|
"path": "scripts/gds/provision-disconnect.js",
|
|
@@ -8967,7 +8967,7 @@
|
|
|
8967
8967
|
{
|
|
8968
8968
|
"path": "scripts/gds/provision-process.js",
|
|
8969
8969
|
"mode": "0000644",
|
|
8970
|
-
"sha256": "
|
|
8970
|
+
"sha256": "34f24f674b7be4607f598d6f968e3e3c1f15cb9175361956b0808a1df1ac723c"
|
|
8971
8971
|
},
|
|
8972
8972
|
{
|
|
8973
8973
|
"path": "scripts/gds/provision-repo-auth.js",
|
|
@@ -8977,7 +8977,7 @@
|
|
|
8977
8977
|
{
|
|
8978
8978
|
"path": "scripts/gds/provision-repo.js",
|
|
8979
8979
|
"mode": "0000644",
|
|
8980
|
-
"sha256": "
|
|
8980
|
+
"sha256": "ab6b240d93920bb81bf096aff9553c478a2c3e8c0c4926b81c468e99aee89d67"
|
|
8981
8981
|
},
|
|
8982
8982
|
{
|
|
8983
8983
|
"path": "scripts/gds/provision-teardown.js",
|
|
@@ -8987,12 +8987,12 @@
|
|
|
8987
8987
|
{
|
|
8988
8988
|
"path": "scripts/gds/provision-units.js",
|
|
8989
8989
|
"mode": "0000644",
|
|
8990
|
-
"sha256": "
|
|
8990
|
+
"sha256": "f8c642838cbae5dc096f35870bda10f3da342e1cd93b0713e4528bfefe1819a8"
|
|
8991
8991
|
},
|
|
8992
8992
|
{
|
|
8993
8993
|
"path": "scripts/gds/provision.js",
|
|
8994
8994
|
"mode": "0000644",
|
|
8995
|
-
"sha256": "
|
|
8995
|
+
"sha256": "e879686d5fcadfd32eb1dfc4e1640fc229cc4f0afc78615681964484e8457c1f"
|
|
8996
8996
|
},
|
|
8997
8997
|
{
|
|
8998
8998
|
"path": "scripts/gds/publish-credential-check.js",
|
|
@@ -9512,7 +9512,7 @@
|
|
|
9512
9512
|
{
|
|
9513
9513
|
"path": "scripts/gds/stray-unit-sweep.sh",
|
|
9514
9514
|
"mode": "0000644",
|
|
9515
|
-
"sha256": "
|
|
9515
|
+
"sha256": "f90a8ae14f3715f1454431d49be858b605bb3030a2ff3359f5ba2d41ebfd7a3f"
|
|
9516
9516
|
},
|
|
9517
9517
|
{
|
|
9518
9518
|
"path": "scripts/gds/surface.js",
|
|
@@ -10007,7 +10007,7 @@
|
|
|
10007
10007
|
{
|
|
10008
10008
|
"path": "src/module-api.js",
|
|
10009
10009
|
"mode": "0000644",
|
|
10010
|
-
"sha256": "
|
|
10010
|
+
"sha256": "7e4584b3285ed7261fa69727d9100dc10856000a7d8f75c8e9f1f47c26eb2f93"
|
|
10011
10011
|
},
|
|
10012
10012
|
{
|
|
10013
10013
|
"path": "src/module-loader/catalog.js",
|
|
@@ -10922,7 +10922,7 @@
|
|
|
10922
10922
|
{
|
|
10923
10923
|
"path": "tests/core_upgrade_runner.mjs",
|
|
10924
10924
|
"mode": "0000644",
|
|
10925
|
-
"sha256": "
|
|
10925
|
+
"sha256": "f8bc5b5329c4de27b41a9172e6b5023c4854d864f532aac3ed062e0d35633f00"
|
|
10926
10926
|
},
|
|
10927
10927
|
{
|
|
10928
10928
|
"path": "tests/cosmos_token_contrast.mjs",
|
|
@@ -13037,12 +13037,12 @@
|
|
|
13037
13037
|
{
|
|
13038
13038
|
"path": "tests/provision.mjs",
|
|
13039
13039
|
"mode": "0000644",
|
|
13040
|
-
"sha256": "
|
|
13040
|
+
"sha256": "b6558cb094a2d38c837d3e5028573c14e5c55c832555b382c1d1135a55b5c6b5"
|
|
13041
13041
|
},
|
|
13042
13042
|
{
|
|
13043
13043
|
"path": "tests/provision_account_identity.mjs",
|
|
13044
13044
|
"mode": "0000644",
|
|
13045
|
-
"sha256": "
|
|
13045
|
+
"sha256": "9f62eea1b43a478334a83df52d0e81a3b3b67f4c4eea54680d2d8db3b25ee513"
|
|
13046
13046
|
},
|
|
13047
13047
|
{
|
|
13048
13048
|
"path": "tests/provision_caddy_wiring.mjs",
|
|
@@ -14009,6 +14009,11 @@
|
|
|
14009
14009
|
"mode": "0000644",
|
|
14010
14010
|
"sha256": "24257ca4ad1a7bcefb735ee1b02609469035730e0723645f71e10eb71525bb72"
|
|
14011
14011
|
},
|
|
14012
|
+
{
|
|
14013
|
+
"path": "tests/sweep_app_account.mjs",
|
|
14014
|
+
"mode": "0000644",
|
|
14015
|
+
"sha256": "46fef77aa82d7f5295ed119b2d5ca77aeb29f5f267a3b82235260a94ee5bd41f"
|
|
14016
|
+
},
|
|
14012
14017
|
{
|
|
14013
14018
|
"path": "tests/task_classifier_vocab.mjs",
|
|
14014
14019
|
"mode": "0000644",
|
|
@@ -46,6 +46,8 @@ ADR 0128 anticipated the DB half in its own alternatives: *"Do createdb/migrate
|
|
|
46
46
|
- **`provision.js` sits two lines under the 1,500-line ratchet.** The generators and their full rationale live in `provision-repo.js`; the runner carries the wiring and a pointer. The next change there will have to move something out.
|
|
47
47
|
- **The non-privileged path is unchanged in kind.** The DDL escalates through the same `asPostgres()` helper ADR 0128 introduced, so a runner without `PROVISION_SUDO` renders the same commands without `sudo` — as broken, and no more broken, than it already was for `createdb`.
|
|
48
48
|
|
|
49
|
+
> **Amended by task 1002719 — the app user is resolved, not named.** The account whose group every instance joins (and which owns the backup dir and runs the backup timers) was the founder's own account as a literal, so a standup on any other host died `invalid user` at the backup step. `appUser()` in `provision-repo.js` now answers it: `PROVISION_APP_USER`, else the account the runner runs as — which on a plane *is* the app user, so the founder box resolves exactly what the literal said. **root is refused, not resolved**: instance accounts join this account's group, and group root is not a read grant to hand a tenant. The name is validated before it reaches a shell command or a unit file, and the refusal is a preflight: it fires before the status flip, never midway through a standup. Its **group** is the account's primary group looked up on the box (`$(id -gn …)`; the backup unit carries no `Group=`), never the account name spelled twice — an admin-made service account need not have a group of its own name. `audit-authorship.sh` now flags the founder's unix account in account position (`-o`/`-u`, `User=`, `*user = '…'`) in runtime code, because the GitHub-login grep never could.
|
|
50
|
+
|
|
49
51
|
## Alternatives considered
|
|
50
52
|
|
|
51
53
|
- **A `DATABASE_URL` in `web.env` instead of discrete `PG*` vars.** `pool.js` already prefers it, so it would have needed no pool change at all. Rejected: the socket path has to ride as `?host=…` or a percent-encoded authority, and the exact parse is `pg-connection-string`'s business rather than something the test could pin — trading a stated, assertable identity for a URL whose behavior we would be trusting.
|
|
@@ -2348,5 +2348,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
2348
2348
|
landed since 1.19.935 with no explicit bump. run 35561285958. (task 1002620)
|
|
2349
2349
|
1.19.937 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2350
2350
|
landed since 1.19.936 with no explicit bump. run 35566446478. (task 1002620)
|
|
2351
|
+
1.19.938 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2352
|
+
landed since 1.19.937 with no explicit bump. run 35638291152. (task 1002620)
|
|
2353
|
+
1.19.939 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2354
|
+
landed since 1.19.938 with no explicit bump. run 35641614084. (task 1002620)
|
|
2351
2355
|
---------------------------------------------------------------------------
|
|
2352
2356
|
```
|
|
@@ -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) — 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
|
|
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 assumed a systemd box, and on a compose self-host a new project still cannot stand up: the container has no reverse proxy for a child instance's front door, cannot create the per-instance unix account that isolates one instance from another, and installed its backup dir as a hardcoded founder user. **That third leg is fixed** (task 1002719): the backup dir's owner is `PROVISION_APP_USER`, else the account the runner runs as (root is refused), so the standup no longer dies `invalid user` on a host without the founder's account. **The other two now refuse instead of failing opaquely, each for its own reason** — so fixing one cannot silently re-open the other. **The ingress leg now refuses instead of failing opaquely** (task 1004017, [ADR 0315](../adr/0315-a-plane-with-no-proxy-is-refused-not-given-a-no-op-ingress.md)): provisioning probes the plane with `caddy version` and returns `Ingress preflight failed: …` naming the missing proxy, before it writes a Caddy snippet or reloads anything. Previously a domained child was told to edit an `/etc/caddy/Caddyfile` the container does not have, and a domainless one crashed on the snippet write. Giving the container a real front door is still unbuilt — the refusal closes the failure mode, not the capability. **The identity leg refuses too** (task 1004018, [ADR 0317](../adr/0317-a-plane-that-cannot-give-an-instance-its-own-account-is-refused.md)): provisioning probes whether the runner can reach root on this plane and returns `Account preflight failed: …` before any mutation, because `useradd` cannot run under `USER node` and every child there would otherwise share one uid — the sibling-secret vector [ADR 0281](../adr/0281-an-instance-identity-is-its-own-unix-account-and-pg-role.md) exists to close. It is a capability question, not a knob: there is no setting that accepts a shared uid. 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
|
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// pipeline and the ONLY act that makes shipped work visible to users — and it was
|
|
7
7
|
// reachable from exactly one place, a shell on the control-plane box holding an ssh key.
|
|
8
8
|
// An agent could not close the loop either: the permission classifier blocks a mutating
|
|
9
|
-
// `sudo -u
|
|
9
|
+
// `sudo -u <app user>` over ssh by design. So the one gate that is the owner's to hold was the
|
|
10
10
|
// one gate with no instrument. This page is the instrument.
|
|
11
11
|
//
|
|
12
12
|
// WHY THE PROJECTS ARE HERE AND NOT IN THEIR OWN HALLS. The obvious shape — each owner
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// that makes shipped work visible to users. It was reachable from exactly one
|
|
8
8
|
// place — a shell on the control-plane box with an ssh key — so the owner could not
|
|
9
9
|
// take it, and neither could an agent (the permission classifier blocks a mutating
|
|
10
|
-
// `sudo -u
|
|
10
|
+
// `sudo -u <app user>` over ssh by design).
|
|
11
11
|
//
|
|
12
12
|
// THIS FILE ONLY ASKS. It validates and queues an intent; the control-plane runner
|
|
13
13
|
// drains it and drives the proven `bongos upgrade` path with its health gate and
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// reachable from exactly one place — a shell on the control-plane box holding an
|
|
9
9
|
// ssh key — which is why the owner said "I don't think I can make anything live",
|
|
10
10
|
// and why an agent could not close the loop for them either: the permission
|
|
11
|
-
// classifier blocks a mutating `sudo -u
|
|
11
|
+
// classifier blocks a mutating `sudo -u <app user>` over ssh by design.
|
|
12
12
|
//
|
|
13
13
|
// THE WEB TIER ONLY ASKS. This queues an intent; the control-plane runner drains it
|
|
14
14
|
// and drives the proven `bongos upgrade` path with its health gate and auto-rollback
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.939",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.939",
|
|
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.
|
|
3
|
+
"version": "1.19.939",
|
|
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",
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
# Run on the control plane as a sudoer:
|
|
17
17
|
# bash scripts/gds/address-park-sweep.sh # survey only — read-only
|
|
18
18
|
# bash scripts/gds/address-park-sweep.sh --apply # remove the snippets, reload Caddy, park the hostnames
|
|
19
|
+
# Run from anywhere but an instance's own vendored core, set INSTANCE_DIR to that instance.
|
|
19
20
|
#
|
|
20
21
|
# Left in place on purpose, even with --apply: each project's database, dumps and checkout,
|
|
21
22
|
# and the row's domain + port (ADR 0181 — a revive re-uses that address).
|
|
@@ -23,15 +24,25 @@ set -uo pipefail
|
|
|
23
24
|
|
|
24
25
|
APPLY=0; [ "${1:-}" = "--apply" ] && APPLY=1
|
|
25
26
|
DB="${PGDATABASE:-cloudbongos}"
|
|
26
|
-
|
|
27
|
+
# The instance checkout and the account that owns it (task 1004135) — never a named person.
|
|
28
|
+
# Run from an instance's vendored core, the checkout is three levels up (the rule
|
|
29
|
+
# selfInstanceRoot() in provision-repo.js uses); from anywhere else it must be named. The
|
|
30
|
+
# account is that checkout's owner unless PROVISION_APP_USER pins it; root holds no role here.
|
|
31
|
+
CORE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
|
32
|
+
case "$CORE_DIR" in */node_modules/@bongos/core) INSTANCE_DIR="${INSTANCE_DIR:-${CORE_DIR%/node_modules/@bongos/core}}" ;; esac
|
|
33
|
+
[ -n "${INSTANCE_DIR:-}" ] || { echo "set INSTANCE_DIR to the control plane's instance checkout — this sweep is not running from one"; exit 1; }
|
|
34
|
+
APP_USER="${PROVISION_APP_USER:-$(stat -c %U "$INSTANCE_DIR" 2>/dev/null)}"
|
|
35
|
+
if [ "$APP_USER" = root ] || ! [[ "$APP_USER" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]]; then
|
|
36
|
+
echo "cannot use '$APP_USER' as the app account — set PROVISION_APP_USER to the unprivileged account that owns $INSTANCE_DIR"; exit 1
|
|
37
|
+
fi
|
|
27
38
|
SITES="${PROVISION_CADDY_SNIPPET_DIR:-/etc/caddy/sites}"
|
|
28
39
|
BOX_ENV="${BOX_ENV:-/etc/cloudbongos/box.env}"
|
|
29
40
|
|
|
30
|
-
rows=$(sudo -n -u
|
|
31
|
-
|| { echo "cannot read provisioning_instances from $DB as
|
|
41
|
+
rows=$(sudo -n -u "$APP_USER" psql -d "$DB" -tAF '|' -c "select slug, coalesce(domain, '') from provisioning_instances where status='torn_down' order by slug" 2>/dev/null) \
|
|
42
|
+
|| { echo "cannot read provisioning_instances from $DB as $APP_USER"; exit 1; }
|
|
32
43
|
|
|
33
44
|
# slug|domain|zone|reserved|blackhole per row, plus an apex line — from the installed core.
|
|
34
|
-
plan=$(sudo -n -u
|
|
45
|
+
plan=$(sudo -n -u "$APP_USER" env ROWS="$rows" bash -c 'cd "$1" && exec node -' _ "$INSTANCE_DIR" <<'JS'
|
|
35
46
|
const core = './node_modules/@bongos/core';
|
|
36
47
|
const { isReservedHostname } = require(`${core}/modules/provisioning/provisioning.js`);
|
|
37
48
|
const { dnsReleaseEnv } = require(`${core}/scripts/gds/provision-net.js`);
|
|
@@ -66,7 +77,7 @@ while IFS='|' read -r kind slug domain zone reserved blackhole; do
|
|
|
66
77
|
printf ' %-22s %s resolves to %s — park at %s (zone %s)\n' "$slug" "$domain" "$now" "$blackhole" "$zone"
|
|
67
78
|
left+=("$slug:dns")
|
|
68
79
|
[ "$APPLY" = 1 ] || continue
|
|
69
|
-
sudo -n -u
|
|
80
|
+
sudo -n -u "$APP_USER" bash -c 'set -a; . "$1"; set +a; BOX_HOSTNAME="$2" BOX_IP="$3" CLOUDFLARE_ZONE="$4" BOX_PROXIED=0 exec "$BOX_DNS_HOOK"' \
|
|
70
81
|
_ "$BOX_ENV" "$domain" "$blackhole" "$zone" </dev/null || echo " ⚠ could not park $domain — re-run to retry"
|
|
71
82
|
done <<< "$plan"
|
|
72
83
|
|
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
# during a builder's session. (Use `req.builder.id` / the active claim.)
|
|
14
14
|
# * `lars589` / `larskristensen` literals in setup-path code or test data
|
|
15
15
|
# that would mis-attribute a second builder's work.
|
|
16
|
+
# * The founder's unix account in account position (`-o`/`-u <it>`,
|
|
17
|
+
# `User=<it>`, `APP_USER = '<it>'`) in runtime code — section 6.
|
|
16
18
|
#
|
|
17
19
|
# What is INTENTIONAL and allow-listed (NOT a violation):
|
|
18
20
|
# * Archived shipping docs in `limitations/*-shipped.md` — historical record.
|
|
@@ -280,6 +282,31 @@ fi
|
|
|
280
282
|
grep -vE "$OPS_ALLOWLIST_PATTERN" "$TMP_HITS" \
|
|
281
283
|
| grep -vE "$SPEC_ALLOWLIST_PATTERN" > "$TMP_FILTERED" || true
|
|
282
284
|
|
|
285
|
+
# ----------------------------------------------------------------------------
|
|
286
|
+
# 6. The founder's UNIX ACCOUNT in account position (task 1002719). Family (c)
|
|
287
|
+
# greps the GitHub login, and a unix name is not a GitHub login — which is
|
|
288
|
+
# how the standup's `install -d -o <founder> …` reached every operator's box
|
|
289
|
+
# unseen. Runtime code only (tests/ pin fixtures by design), and the ops
|
|
290
|
+
# allowlist above does NOT apply: "that is the droplet's unix user" is the
|
|
291
|
+
# very assumption this family refuses in code that runs on other planes.
|
|
292
|
+
# Derive the account instead (provision-repo.js appUser()).
|
|
293
|
+
# ----------------------------------------------------------------------------
|
|
294
|
+
UNIX_USER_PATTERN='((^|[^A-Za-z0-9_.-])(-o|-g|-u)[ =]lars\b)|(\b(User|Group)=lars\b)|(\bchown( -R)? lars\b)|([A-Za-z_]*[Uu][Ss][Ee][Rr][A-Za-z_]*\s*[:=]\s*["'"'"']lars["'"'"'])'
|
|
295
|
+
UNIX_ALLOWLIST=(
|
|
296
|
+
# This script's own pattern.
|
|
297
|
+
'scripts/gds/audit-authorship.sh'
|
|
298
|
+
)
|
|
299
|
+
UNIX_ALLOWLIST_RE=''
|
|
300
|
+
for e in "${UNIX_ALLOWLIST[@]}"; do
|
|
301
|
+
UNIX_ALLOWLIST_RE+="^${e//./\\.}:|"
|
|
302
|
+
done
|
|
303
|
+
UNIX_ALLOWLIST_RE="${UNIX_ALLOWLIST_RE%|}"
|
|
304
|
+
|
|
305
|
+
grep -RIn -E "${EXCLUDE_DIRS[@]}" --exclude-dir=tests --exclude-dir=test "${EXCLUDE_FILES[@]}" -e "$UNIX_USER_PATTERN" . 2>/dev/null \
|
|
306
|
+
| sed 's|^\./||' \
|
|
307
|
+
| sort -u \
|
|
308
|
+
| grep -vE "$UNIX_ALLOWLIST_RE" >> "$TMP_FILTERED" || true
|
|
309
|
+
|
|
283
310
|
HIT_COUNT=$(wc -l < "$TMP_FILTERED" | tr -d ' ')
|
|
284
311
|
|
|
285
312
|
if [ "$VERBOSE" = "1" ]; then
|
|
@@ -306,6 +333,7 @@ echo "Remediation:"
|
|
|
306
333
|
echo " * Paths like /Users/larskristensen/... -> use \$HOME or os.path.expanduser('~')"
|
|
307
334
|
echo " * builder_id = 1 / shipped_by = 1 -> use req.builder.id / the active claim"
|
|
308
335
|
echo " * lars589 / larskristensen literals -> use the current builder's github_login"
|
|
336
|
+
echo " * -o/-u lars, User=lars, *user = 'lars' -> derive the account (appUser() in provision-repo.js)"
|
|
309
337
|
echo ""
|
|
310
338
|
echo "If a hit IS intentional (historical doc, droplet operational identity,"
|
|
311
339
|
echo "or a frozen seed/import script), add it to the ALLOWLIST or"
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
2
|
# Nightly pg_dump of the GDS Postgres DB.
|
|
3
3
|
#
|
|
4
|
-
# Runs as
|
|
4
|
+
# Runs as the unit's User= (the plane's app account) via the gds-db-backup.timer systemd unit (see
|
|
5
5
|
# scripts/gds/gds-db-backup.timer + .service in this repo). Output lands
|
|
6
6
|
# in /var/backups/gds/ as <db>-YYYY-MM-DDTHHMMSSZ.sql.gz (UTC timestamp), and
|
|
7
7
|
# files older than 30 days are pruned.
|
|
@@ -33,7 +33,7 @@ set -euo pipefail
|
|
|
33
33
|
|
|
34
34
|
# ---- configuration ----------------------------------------------------------
|
|
35
35
|
|
|
36
|
-
# Destination directory for dumps. Owned by
|
|
36
|
+
# Destination directory for dumps. Owned by that account and its primary group, mode 0750.
|
|
37
37
|
BACKUP_DIR="${GDS_BACKUP_DIR:-/var/backups/gds}"
|
|
38
38
|
|
|
39
39
|
# Database to dump: explicit GDS_BACKUP_DB wins, else the instance's own branding
|
|
@@ -69,7 +69,7 @@ DUMP_GLOB="${DB_NAME}-${D}${D}${D}${D}-${D}${D}-${D}${D}T${D}${D}${D}${D}${D}${D
|
|
|
69
69
|
if [ ! -d "$BACKUP_DIR" ]; then
|
|
70
70
|
echo "ERROR: backup dir $BACKUP_DIR does not exist." >&2
|
|
71
71
|
echo " Create it with:" >&2
|
|
72
|
-
echo " sudo install -d -o
|
|
72
|
+
echo " sudo install -d -o $(id -un) -g $(id -gn) -m 0750 $BACKUP_DIR" >&2
|
|
73
73
|
exit 2
|
|
74
74
|
fi
|
|
75
75
|
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
#
|
|
10
10
|
# Vendor decision: DigitalOcean Spaces over Backblaze B2 — see ADR 0025.
|
|
11
11
|
#
|
|
12
|
-
# Runs as
|
|
12
|
+
# Runs as the unit's User= (the plane's app account) via the gds-db-offsite.timer systemd unit (see
|
|
13
13
|
# scripts/gds/gds-db-offsite.service + .timer). Weekly cadence.
|
|
14
14
|
#
|
|
15
15
|
# Authentication: S3-style credentials for the Space live OUTSIDE the repo
|
|
@@ -121,7 +121,7 @@ for v in SPACES_KEY SPACES_SECRET SPACES_BUCKET SPACES_REGION SPACES_ENDPOINT; d
|
|
|
121
121
|
done
|
|
122
122
|
if [ "${#missing[@]}" -gt 0 ]; then
|
|
123
123
|
echo "ERROR: missing Spaces credentials: ${missing[*]}" >&2
|
|
124
|
-
echo " Set them in $ENV_FILE (chmod 600, owned by
|
|
124
|
+
echo " Set them in $ENV_FILE (chmod 600, owned by $(id -un)). See ADR 0022 + 0025." >&2
|
|
125
125
|
exit 2
|
|
126
126
|
fi
|
|
127
127
|
|
|
@@ -25,4 +25,14 @@ function provisionerBotEmail(env = process.env) {
|
|
|
25
25
|
return apex ? `bot@${apex}` : 'bot@cloud-bongos.invalid';
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
// The name that identity commits under: the pack's product name, else the platform's
|
|
29
|
+
// (task 1002719). Every caller builds `git config user.name "<name>"`, so a name the
|
|
30
|
+
// shell cannot carry inside double quotes falls back instead of being interpolated.
|
|
31
|
+
function brandProductName() {
|
|
32
|
+
try { return String(require('../../src/branding').branding().identity.productName || '').trim(); } catch { return ''; }
|
|
33
|
+
}
|
|
34
|
+
function provisionerBotName(name = brandProductName()) {
|
|
35
|
+
return name && !/["`$\\\r\n]/.test(name) ? name : 'Cloud Bongos';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
module.exports = { instanceApex, provisionerBotEmail, provisionerBotName };
|
|
@@ -108,7 +108,7 @@ const CONFIG = {
|
|
|
108
108
|
standaloneBase: process.env.PROVISION_STANDALONE_BASE || '/srv/cloudbongos',
|
|
109
109
|
// ── Privilege escalation (task 2066, ADR 0128) ───────────────────────────
|
|
110
110
|
// The intent runner runs as a NON-root user (provision-intent-runner.service:
|
|
111
|
-
// User=
|
|
111
|
+
// User= the app user) that holds passwordless sudo. That user cannot itself write under
|
|
112
112
|
// /etc, reload systemd/Caddy, or peer-auth to Postgres as a superuser — so the
|
|
113
113
|
// runbook steps below FAIL as bare commands (the goal-35 blocker). With
|
|
114
114
|
// PROVISION_SUDO=1 (alias PROVISION_PRIVILEGED=1) the privileged steps escalate:
|
|
@@ -163,7 +163,7 @@ const CONFIG = {
|
|
|
163
163
|
|
|
164
164
|
// task 1002757 — the provisioner's git identity resolves from the instance's own
|
|
165
165
|
// apex (env → bot@<apex> → neutral), never the hub's address hardcoded.
|
|
166
|
-
const { provisionerBotEmail } = require('./instance-apex');
|
|
166
|
+
const { provisionerBotEmail, provisionerBotName } = require('./instance-apex');
|
|
167
167
|
|
|
168
168
|
const MAX_INTENT_ATTEMPTS = numEnv('PROVISION_INTENT_MAX_ATTEMPTS', 3);
|
|
169
169
|
|
|
@@ -229,5 +229,6 @@ module.exports = {
|
|
|
229
229
|
numEnv,
|
|
230
230
|
oauthSecret,
|
|
231
231
|
provisionerBotEmail,
|
|
232
|
+
provisionerBotName,
|
|
232
233
|
softFailResult,
|
|
233
234
|
};
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
|
|
31
31
|
const path = require('path');
|
|
32
32
|
const { CONFIG, MANIFEST_VERIFY_INTERVAL_MS, MANIFEST_VERIFY_TRIES, REPO_ROOT } = require('./provision-config.js');
|
|
33
|
-
const {
|
|
33
|
+
const { appUser, dbName, instanceUser, selfInstanceRoot, standaloneRoot } = require('./provision-repo.js');
|
|
34
34
|
const { isValidSlug } = require('../../modules/provisioning/provisioning.js');
|
|
35
35
|
|
|
36
36
|
// The shapes whose box this runner can actually reach. Mirrors UPGRADE_SHAPES in the
|
|
@@ -277,19 +277,19 @@ function parsePreflight(out) {
|
|
|
277
277
|
* Which unix account should this instance's upgrade run as? Asks the box (task 1004128).
|
|
278
278
|
*
|
|
279
279
|
* The fleet is heterogeneous: instances provisioned before task 1003369 run as the
|
|
280
|
-
* shared
|
|
280
|
+
* shared app user, instances provisioned after run as their own `bongos-<slug>`. Neither
|
|
281
281
|
* is guessable from the row, and getting it wrong is not a soft failure — `sudo` refuses
|
|
282
282
|
* an unknown user and the upgrade dies before pre-flight, which is exactly how the
|
|
283
283
|
* tenant leg has been broken.
|
|
284
284
|
*
|
|
285
285
|
* Falling back is NOT a privilege escalation: an instance with no account of its own is
|
|
286
|
-
* already running as
|
|
286
|
+
* already running as the app user (its unit says so), so this runs the upgrade as precisely
|
|
287
287
|
* the account that owns the files and the database it is about to touch. It IS reported,
|
|
288
288
|
* because a missing per-instance account is a provisioning gap someone should close, and
|
|
289
289
|
* a silent fallback would hide it forever.
|
|
290
290
|
*/
|
|
291
291
|
function resolveRunAs(inst, deps, { probe: allowProbe = true } = {}) {
|
|
292
|
-
if (inst.hosting_shape === 'control-plane') return { runAs:
|
|
292
|
+
if (inst.hosting_shape === 'control-plane') return { runAs: appUser(), fellBack: false };
|
|
293
293
|
// Re-checked HERE rather than left to instanceUser()'s own throw, for the reason
|
|
294
294
|
// upgradeInvocation states about itself: a call site whose safety depends on a
|
|
295
295
|
// helper's internals is one refactor away from a shell-interpolation path.
|
|
@@ -317,7 +317,7 @@ function resolveRunAs(inst, deps, { probe: allowProbe = true } = {}) {
|
|
|
317
317
|
if (!/no such user|unknown user/i.test(said)) {
|
|
318
318
|
return { runAs: wanted, fellBack: false, probeInconclusive: true };
|
|
319
319
|
}
|
|
320
|
-
return { runAs:
|
|
320
|
+
return { runAs: appUser(), fellBack: true, wanted };
|
|
321
321
|
}
|
|
322
322
|
|
|
323
323
|
// ONE BUILDER FOR BOTH LEGS (task 1004121).
|
|
@@ -357,7 +357,7 @@ function upgradeInvocation(inst, to, { privileged = false, dryRun = false, reque
|
|
|
357
357
|
// half right. The control-plane leg is correct and verified live. The tenant leg
|
|
358
358
|
// assumed the per-instance account model of task 1003369 is provisioned everywhere —
|
|
359
359
|
// and on the live box NO `bongos-*` account exists, no such Postgres role exists, and
|
|
360
|
-
// every instance unit still
|
|
360
|
+
// every instance unit still names the shared app user. So the fix for "Postgres has no role
|
|
361
361
|
// root" swapped one absent account for another, and every tenant upgrade died with
|
|
362
362
|
// `sudo: unknown user bongos-test4`.
|
|
363
363
|
//
|
|
@@ -368,7 +368,7 @@ function upgradeInvocation(inst, to, { privileged = false, dryRun = false, reque
|
|
|
368
368
|
// (resolveRunAs below) instead of either side assuming. Passing it in keeps this
|
|
369
369
|
// builder pure; the default preserves the old derivation for callers that have not
|
|
370
370
|
// probed.
|
|
371
|
-
const account = runAs || (inst.hosting_shape === 'control-plane' ?
|
|
371
|
+
const account = runAs || (inst.hosting_shape === 'control-plane' ? appUser() : instanceUser(inst));
|
|
372
372
|
const sudoP = privileged ? `sudo -u ${account} env PGDATABASE=${dbName(inst)} ` : '';
|
|
373
373
|
const cli = path.posix.join('scripts', 'gds', 'upgrade.js');
|
|
374
374
|
const shared = [`${sudoP}node ${cli}`, '--registry', `--to ${to}`,
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
const fs = require('node:fs');
|
|
21
21
|
const path = require('node:path');
|
|
22
22
|
const { CONFIG, REPO_ROOT } = require('./provision-config.js');
|
|
23
|
-
const {
|
|
23
|
+
const { CORE_PKG_DIR, appGroupExpr, appUser, instanceUser, standaloneRoot } = require('./provision-repo.js');
|
|
24
24
|
const {
|
|
25
25
|
backupScriptPath, backupService, backupServicePath, backupTimer, backupTimerPath,
|
|
26
26
|
backupUnitName, serviceUnit, serviceUnitPath,
|
|
@@ -39,7 +39,7 @@ const systemd = {
|
|
|
39
39
|
// The backup DIR belongs to this step, not to the runbook: it exists only to serve the
|
|
40
40
|
// schedule, so a manager that installs no schedule must not create it either.
|
|
41
41
|
installBackup(inst, { exec, writeFile, sudoP }) {
|
|
42
|
-
exec(`${sudoP}install -d -o ${
|
|
42
|
+
exec(`${sudoP}install -d -o ${appUser()} -g ${appGroupExpr()} -m 0750 ${CONFIG.backupDir}`);
|
|
43
43
|
writeFile(backupServicePath(inst), backupService(inst), { mode: 0o644, sudo: true });
|
|
44
44
|
writeFile(backupTimerPath(inst), backupTimer(inst), { mode: 0o644, sudo: true });
|
|
45
45
|
exec(`${sudoP}systemctl daemon-reload`);
|
|
@@ -14,7 +14,7 @@ const crypto = require('node:crypto');
|
|
|
14
14
|
const fs = require('node:fs');
|
|
15
15
|
const os = require('node:os');
|
|
16
16
|
const path = require('node:path');
|
|
17
|
-
const { CONFIG, REPO_ROOT, coreVersionSafe, provisionerBotEmail } = require('./provision-config.js');
|
|
17
|
+
const { CONFIG, REPO_ROOT, coreVersionSafe, provisionerBotEmail, provisionerBotName } = require('./provision-config.js');
|
|
18
18
|
// The repo-identity + owner-credential leaf (task 1003081). Carved OUT of this file so
|
|
19
19
|
// upgrade.js can reach these three without pulling provision*.js into the merge
|
|
20
20
|
// resolver's executed closure; re-exported below so every existing caller is unchanged.
|
|
@@ -86,10 +86,34 @@ function seedFirstVersionCmd(inst, { privileged = false } = {}) {
|
|
|
86
86
|
// It is the ADR 0108 DESTINATION model; these generators make provision.js drive the
|
|
87
87
|
// scripts/deploy/deploy-instance.sh runbook API-driven instead of by hand.
|
|
88
88
|
const CORE_PKG_DIR = 'node_modules/@bongos/core';
|
|
89
|
-
// The
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
|
|
89
|
+
// The plane's app user: the unix account that owns the control-plane checkout, owns the
|
|
90
|
+
// backup dir, runs the backup timers, and whose GROUP every instance account joins to read
|
|
91
|
+
// the checkout. One resolver, so those four can't drift apart.
|
|
92
|
+
//
|
|
93
|
+
// Derived, never named (task 1002719). It used to be the founder's own account as a
|
|
94
|
+
// literal, and a standup on any host that account did not exist on died `invalid user` at
|
|
95
|
+
// the backup step. PROVISION_APP_USER pins it; otherwise it is the account this runner
|
|
96
|
+
// executes as, which on a plane IS the app user (the runner unit runs as it), so the live
|
|
97
|
+
// box resolves exactly what the literal said. A runner driving a different box over SSH
|
|
98
|
+
// (remoteHost) should pin it. root is refused, not resolved: the instance
|
|
99
|
+
// accounts join this account's group, and group root is not a read grant to hand a tenant.
|
|
100
|
+
// Validated because it is interpolated into shell commands and unit files.
|
|
101
|
+
const UNIX_ACCOUNT_RE = /^[a-z_][a-z0-9_-]{0,31}$/;
|
|
102
|
+
function appUser({ env = process.env, userInfo = os.userInfo } = {}) {
|
|
103
|
+
let name = String(env.PROVISION_APP_USER || '').trim();
|
|
104
|
+
if (!name) {
|
|
105
|
+
try { name = userInfo().username; } catch { name = ''; } // a uid with no passwd entry
|
|
106
|
+
}
|
|
107
|
+
if (name === 'root' || !UNIX_ACCOUNT_RE.test(name)) {
|
|
108
|
+
const why = name === 'root' ? 'is root' : 'is not a usable unix account name';
|
|
109
|
+
throw new Error(`provisioning: the app user ${JSON.stringify(name)} ${why} — set PROVISION_APP_USER to the unprivileged account that owns this checkout`);
|
|
110
|
+
}
|
|
111
|
+
return name;
|
|
112
|
+
}
|
|
113
|
+
// The app user's PRIMARY group, looked up by the shell on the box the command runs on. It
|
|
114
|
+
// need not share the account's name (a service account created into `users`), so it is
|
|
115
|
+
// never spelled as the account name.
|
|
116
|
+
function appGroupExpr() { return `$(id -gn ${appUser()})`; }
|
|
93
117
|
function standaloneRoot(inst, { base = CONFIG.standaloneBase } = {}) { return path.posix.join(base, inst.slug); }
|
|
94
118
|
// WHERE THE INSTANCE THIS CORE SERVES ACTUALLY LIVES (task 1004123).
|
|
95
119
|
//
|
|
@@ -145,7 +169,7 @@ function standaloneRegenDocsCmd() { return `node ${CORE_PKG_DIR}/scripts/gds/reg
|
|
|
145
169
|
// ── Per-instance identity: its OWN unix account + its OWN password-authed PG role ──
|
|
146
170
|
// (task 1003369, audit ref B3 of the 2026-08-29 security audit.)
|
|
147
171
|
//
|
|
148
|
-
// THE HOLE THIS CLOSES. Every instance service used to run
|
|
172
|
+
// THE HOLE THIS CLOSES. Every instance service used to run as the one shared app user, and
|
|
149
173
|
// co-tenant is the DEFAULT shape — so on a shared box every instance ran as the SAME
|
|
150
174
|
// unix user. A unix user may read /proc/<pid>/environ of its own processes, which means
|
|
151
175
|
// any instance could read every SIBLING's environment: GITHUB_APP_PRIVATE_KEY, the
|
|
@@ -223,7 +247,7 @@ function ensureInstanceUserCmd(inst, { privileged = false } = {}) {
|
|
|
223
247
|
// NOT re-open the two vectors this task closes: /proc/<pid>/environ stays readable only
|
|
224
248
|
// by the process's own uid, and the DB now demands a password no sibling holds.
|
|
225
249
|
function grantInstanceRepoReadCmd(inst, { privileged = false } = {}) {
|
|
226
|
-
return `${privileged ? 'sudo ' : ''}usermod -aG ${
|
|
250
|
+
return `${privileged ? 'sudo ' : ''}usermod -aG ${appGroupExpr()} ${instanceUser(inst)}`;
|
|
227
251
|
}
|
|
228
252
|
|
|
229
253
|
// Can this plane give a child instance its OWN unix account AT ALL? (task 1004018, ADR 0317)
|
|
@@ -758,7 +782,7 @@ async function scaffoldStandaloneRepo(inst, deps) {
|
|
|
758
782
|
} else {
|
|
759
783
|
writeFile(giPath, INSTANCE_GITIGNORE);
|
|
760
784
|
}
|
|
761
|
-
exec(`git -C ${root} config user.name "
|
|
785
|
+
exec(`git -C ${root} config user.name "${provisionerBotName()}"`);
|
|
762
786
|
exec(`git -C ${root} config user.email "${provisionerBotEmail()}"`);
|
|
763
787
|
exec(`git -C ${root} add -A`);
|
|
764
788
|
const commitMsg = mode === 'adopt'
|
|
@@ -1012,7 +1036,7 @@ function buildCorePinRefreshCommit({ root, fromVersion, toVersion, repo, ownerTo
|
|
|
1012
1036
|
: `git -C ${root} push origin main`;
|
|
1013
1037
|
return {
|
|
1014
1038
|
config: [
|
|
1015
|
-
`git -C ${root} config user.name "
|
|
1039
|
+
`git -C ${root} config user.name "${provisionerBotName()}"`,
|
|
1016
1040
|
`git -C ${root} config user.email "${provisionerBotEmail()}"`,
|
|
1017
1041
|
],
|
|
1018
1042
|
add: `git -C ${root} add -A`,
|
|
@@ -1094,7 +1118,8 @@ async function refreshStandaloneCorePin(inst, deps) {
|
|
|
1094
1118
|
// cwd, deploy-instance.sh §4); co-tenant/dedicated run this control-plane checkout's src/.
|
|
1095
1119
|
|
|
1096
1120
|
module.exports = {
|
|
1097
|
-
|
|
1121
|
+
appGroupExpr,
|
|
1122
|
+
appUser,
|
|
1098
1123
|
accountPlaneGap,
|
|
1099
1124
|
accountPreflight,
|
|
1100
1125
|
CORE_PKG_DIR,
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
const fs = require('node:fs');
|
|
12
12
|
const path = require('node:path');
|
|
13
13
|
const { CONFIG, REPO_ROOT } = require('./provision-config.js');
|
|
14
|
-
const {
|
|
14
|
+
const { CORE_PKG_DIR, appUser, dbName, instanceDbRole, instanceStateDir, instanceUser, parseTargetRef, standaloneRoot } = require('./provision-repo.js');
|
|
15
15
|
|
|
16
16
|
function serviceUnit(inst, { etcBase = CONFIG.etcBase } = {}) {
|
|
17
17
|
const envFile = path.posix.join(etcBase, inst.slug, 'web.env');
|
|
@@ -103,8 +103,8 @@ Requires=postgresql.service
|
|
|
103
103
|
|
|
104
104
|
[Service]
|
|
105
105
|
Type=oneshot
|
|
106
|
-
User=${
|
|
107
|
-
Group
|
|
106
|
+
User=${appUser()}
|
|
107
|
+
# No Group=: systemd then uses the app user's primary group, whatever it is named.
|
|
108
108
|
# The DB to dump + where dumps land — set on the unit (not via the instance web.env)
|
|
109
109
|
# so the dump targets the right database with no fallback to a stale default (root
|
|
110
110
|
# cause of the task-2210 breakage). db-backup-nightly.sh reads both as env.
|
package/scripts/gds/provision.js
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
// WOULD run and mutates nothing (no DB writes, no shell, no cloud call). The
|
|
27
27
|
// LIVE leg is an explicit operator/timer `--apply`.
|
|
28
28
|
// • PRIVILEGE model (task 2066, ADR 0128). The intent runner runs as a NON-root
|
|
29
|
-
// user (
|
|
29
|
+
// user (the app user) with passwordless sudo, which cannot itself write under /etc,
|
|
30
30
|
// reload systemd/Caddy, or peer-auth to Postgres as a superuser. PROVISION_SUDO=1
|
|
31
31
|
// escalates exactly those steps (sudo install / sudo systemctl / sudo -u postgres);
|
|
32
32
|
// default OFF = bare commands (root / test / co-tenant-legacy contexts unchanged).
|
|
@@ -50,7 +50,7 @@ const cp = require('node:child_process');
|
|
|
50
50
|
const crypto = require('node:crypto');
|
|
51
51
|
|
|
52
52
|
const { CONFIG, MANIFEST_UA, softFailResult, MANIFEST_VERIFY_INTERVAL_MS, MANIFEST_VERIFY_TRIES, MAX_INTENT_ATTEMPTS, REPO_ROOT, coreVersionSafe, hasFlag, loadDeps, oauthSecret, provisionerBotEmail } = require('./provision-config.js');
|
|
53
|
-
const {
|
|
53
|
+
const { accountPreflight, appGroupExpr, appUser, 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, ingressPlaneGap, ingressPreflight, shouldFederate, zoneForDomain } = require('./provision-net.js');
|
|
56
56
|
const { resolveProcessManager } = require('./provision-process.js');
|
|
@@ -282,6 +282,9 @@ async function provisionInstance(inst, deps) {
|
|
|
282
282
|
// useradd would crash mid-provision instead of refusing.
|
|
283
283
|
const accountGap = accountPreflight({ exec: boxExec, apply, log, shape: inst.hosting_shape, privileged, probe: deps.accountPlaneGap });
|
|
284
284
|
if (accountGap) return { ok: false, error: accountGap };
|
|
285
|
+
// App-user preflight (task 1002719): steps below name the app user, and appUser() refuses
|
|
286
|
+
// root or an unusable name. Refuse HERE, before the status flip, not midway through.
|
|
287
|
+
try { appUser(); } catch (e) { return { ok: false, error: e.message }; }
|
|
285
288
|
// Caddy preflight (task 1002860, ADR 0303): a box whose base Caddyfile has no tls-check ask
|
|
286
289
|
// can never certify this domain, so refuse before touching it. Injectable like the DNS check.
|
|
287
290
|
const caddyCheck = deps.checkCaddyWiring || checkCaddyWiring;
|
|
@@ -640,7 +643,7 @@ runcmd:
|
|
|
640
643
|
- [ bash, -lc, "${dbCreateCmd(i)} || true" ]
|
|
641
644
|
- [ bash, -lc, "cd ${REPO_ROOT} && ${migrateCmd(i)}" ]
|
|
642
645
|
- [ bash, -lc, "${seedFirstVersionCmd(i)}" ]
|
|
643
|
-
${roleLines} - [ bash, -lc, "install -d -o ${
|
|
646
|
+
${roleLines} - [ bash, -lc, "install -d -o ${appUser()} -g ${appGroupExpr()} -m 0750 ${CONFIG.backupDir}" ]
|
|
644
647
|
- [ bash, -lc, "systemctl daemon-reload && systemctl enable --now ${i.slug} ${backupUnitName(i)}.timer" ]
|
|
645
648
|
`;
|
|
646
649
|
}
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
# Run on the control plane as a sudoer:
|
|
14
14
|
# bash scripts/gds/stray-unit-sweep.sh # survey only — read-only
|
|
15
15
|
# bash scripts/gds/stray-unit-sweep.sh --apply # disable --now, remove the unit files, reload, reset-failed
|
|
16
|
+
# Run from anywhere but an instance's own vendored core, set INSTANCE_DIR to that instance.
|
|
16
17
|
#
|
|
17
18
|
# Left in place on purpose, even with --apply: the project's database, its dumps under
|
|
18
19
|
# /var/backups/gds, any checkout, and its Caddy block — data is precious, and the address is
|
|
@@ -23,10 +24,22 @@ APPLY=0; [ "${1:-}" = "--apply" ] && APPLY=1
|
|
|
23
24
|
DB="${PGDATABASE:-cloudbongos}"
|
|
24
25
|
ETC=/etc/systemd/system
|
|
25
26
|
|
|
26
|
-
# The
|
|
27
|
+
# The app account (task 1004135) — never a named person: PROVISION_APP_USER, else the owner
|
|
28
|
+
# of the instance checkout, found the way address-park-sweep.sh finds it (three levels up
|
|
29
|
+
# from an instance's vendored core; from anywhere else INSTANCE_DIR names it). A clone in an
|
|
30
|
+
# operator's own home must not decide it. root is refused: it holds no Postgres role here.
|
|
31
|
+
CORE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
|
32
|
+
case "$CORE_DIR" in */node_modules/@bongos/core) INSTANCE_DIR="${INSTANCE_DIR:-${CORE_DIR%/node_modules/@bongos/core}}" ;; esac
|
|
33
|
+
[ -n "${PROVISION_APP_USER:-}${INSTANCE_DIR:-}" ] || { echo "set INSTANCE_DIR to the control plane's instance checkout (or PROVISION_APP_USER) — this sweep is not running from one"; exit 1; }
|
|
34
|
+
APP_USER="${PROVISION_APP_USER:-$(stat -c %U "$INSTANCE_DIR" 2>/dev/null)}"
|
|
35
|
+
if [ "$APP_USER" = root ] || ! [[ "$APP_USER" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]]; then
|
|
36
|
+
echo "cannot use '$APP_USER' as the app account — set PROVISION_APP_USER to the unprivileged account that owns ${INSTANCE_DIR:-the instance checkout}"; exit 1
|
|
37
|
+
fi
|
|
38
|
+
|
|
39
|
+
# The torn-down slugs, from the ledger, read as the app account (peer auth — the runner's
|
|
27
40
|
# own identity; nothing here needs a superuser).
|
|
28
|
-
slugs=$(sudo -n -u
|
|
29
|
-
|| { echo "cannot read provisioning_instances from $DB as
|
|
41
|
+
slugs=$(sudo -n -u "$APP_USER" psql -d "$DB" -tAc "select slug from provisioning_instances where status='torn_down' order by slug" 2>/dev/null) \
|
|
42
|
+
|| { echo "cannot read provisioning_instances from $DB as $APP_USER"; exit 1; }
|
|
30
43
|
|
|
31
44
|
found=(); cleared=()
|
|
32
45
|
echo "== torn-down rows that still have systemd units =="
|
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.
|
|
74
|
+
const CORE_VERSION = '1.19.939'; // 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');
|
|
@@ -38,6 +38,9 @@ const P = require('../modules/provisioning/provisioning.js');
|
|
|
38
38
|
const G = require('../modules/provisioning/control-plane-guard.js');
|
|
39
39
|
const RP = require('../scripts/gds/provision-repo.js');
|
|
40
40
|
|
|
41
|
+
// task 1002719: the app user is derived, so pin one that is nobody's real account.
|
|
42
|
+
process.env.PROVISION_APP_USER = 'appsvc';
|
|
43
|
+
|
|
41
44
|
let passed = 0;
|
|
42
45
|
let failed = 0;
|
|
43
46
|
const t = async (name, fn) => {
|
|
@@ -433,7 +436,7 @@ await t('the control plane upgrades as the operator account, never as root', asy
|
|
|
433
436
|
const d = { ...makeDeps({ served: '1.19.762' }), privileged: true };
|
|
434
437
|
await R.coreUpgradeInstance(inst({ hosting_shape: 'control-plane', slug: 'cloudbongos', port: 3002 }), d, intent());
|
|
435
438
|
const cmd = upgradeCall(d);
|
|
436
|
-
assert.match(cmd, /sudo -u
|
|
439
|
+
assert.match(cmd, /sudo -u appsvc /, 'runs as the operator account');
|
|
437
440
|
assert.doesNotMatch(cmd, /sudo node /, 'a bare `sudo node` is the bug — it lands on root');
|
|
438
441
|
});
|
|
439
442
|
|
|
@@ -442,7 +445,7 @@ await t('a tenant upgrades as its OWN account, which is also its Postgres login
|
|
|
442
445
|
await R.coreUpgradeInstance(inst({ hosting_shape: 'standalone', slug: 'test4', port: 3006 }), d, intent());
|
|
443
446
|
const cmd = upgradeCall(d);
|
|
444
447
|
assert.match(cmd, /sudo -u bongos-test4 /, 'the instance account, not the operator and not root');
|
|
445
|
-
assert.doesNotMatch(cmd, /sudo -u
|
|
448
|
+
assert.doesNotMatch(cmd, /sudo -u appsvc /, 'one tenant must never be upgraded under another account');
|
|
446
449
|
});
|
|
447
450
|
|
|
448
451
|
await t('the database is NAMED, not discovered — an adopted repo has no core at its root to ask', async () => {
|
|
@@ -691,7 +694,7 @@ await t('a tenant WITHOUT one falls back to the app user, and says so', () => {
|
|
|
691
694
|
// is reported rather than silently papered over.
|
|
692
695
|
const d = acctDeps({ accountExists: false });
|
|
693
696
|
const r = R.resolveRunAs(inst({ hosting_shape: 'standalone', slug: 'test4' }), d);
|
|
694
|
-
assert.equal(r.runAs, '
|
|
697
|
+
assert.equal(r.runAs, 'appsvc');
|
|
695
698
|
assert.equal(r.fellBack, true);
|
|
696
699
|
assert.equal(r.wanted, 'bongos-test4', 'and names the account that is missing');
|
|
697
700
|
});
|
|
@@ -699,7 +702,7 @@ await t('a tenant WITHOUT one falls back to the app user, and says so', () => {
|
|
|
699
702
|
await t('the control plane is never probed — it has no instance account by design', () => {
|
|
700
703
|
const d = acctDeps({ accountExists: false });
|
|
701
704
|
const r = R.resolveRunAs(inst({ hosting_shape: 'control-plane', slug: 'cloudbongos' }), d);
|
|
702
|
-
assert.deepEqual({ runAs: r.runAs, fellBack: r.fellBack }, { runAs: '
|
|
705
|
+
assert.deepEqual({ runAs: r.runAs, fellBack: r.fellBack }, { runAs: 'appsvc', fellBack: false });
|
|
703
706
|
assert.equal(d.ranWith.filter((c) => /^id -u /.test(c.cmd)).length, 0, 'no probe was spent');
|
|
704
707
|
});
|
|
705
708
|
|
|
@@ -707,7 +710,7 @@ await t('the dry run is invoked as the account that actually exists', async () =
|
|
|
707
710
|
const d = acctDeps({ accountExists: false, dryRunStdout: ' ✓ pre-flight: ok\n' });
|
|
708
711
|
await R.preflightUpgrade(inst({ hosting_shape: 'standalone', slug: 'test4', port: 3006 }), '1.19.762', d);
|
|
709
712
|
const cmd = (d.ranWith.find((c) => /upgrade\.js/.test(c.cmd)) || {}).cmd || '';
|
|
710
|
-
assert.match(cmd, /sudo -u
|
|
713
|
+
assert.match(cmd, /sudo -u appsvc /, `must not sudo to an account that does not exist — got: ${cmd}`);
|
|
711
714
|
assert.doesNotMatch(cmd, /bongos-test4/);
|
|
712
715
|
});
|
|
713
716
|
|
|
@@ -715,7 +718,7 @@ await t('the MOVE probes identically, so preview and move cannot diverge on the
|
|
|
715
718
|
const d = acctDeps({ accountExists: false });
|
|
716
719
|
await R.coreUpgradeInstance(inst({ hosting_shape: 'standalone', slug: 'test4', port: 3006 }), d, intent());
|
|
717
720
|
const cmd = (d.ranWith.find((c) => /upgrade\.js/.test(c.cmd)) || {}).cmd || '';
|
|
718
|
-
assert.match(cmd, /sudo -u
|
|
721
|
+
assert.match(cmd, /sudo -u appsvc /, 'the move follows the same probe as the preview');
|
|
719
722
|
});
|
|
720
723
|
|
|
721
724
|
await t('an INCONCLUSIVE probe keeps the isolated account rather than quietly widening it', () => {
|
|
@@ -789,7 +792,7 @@ await t('a ✖ line still wins over the raw tail', async () => {
|
|
|
789
792
|
await t('a fallback is recorded on the verdict the owner reads', async () => {
|
|
790
793
|
const d = acctDeps({ accountExists: false, dryRunStdout: ' ✓ pre-flight: ok\n' });
|
|
791
794
|
const v = await R.preflightUpgrade(inst({ hosting_shape: 'standalone', slug: 'test4', port: 3006 }), '1.19.762', d);
|
|
792
|
-
assert.equal(v.ran_as, '
|
|
795
|
+
assert.equal(v.ran_as, 'appsvc');
|
|
793
796
|
assert.match(v.account_note, /no bongos-test4 account yet/);
|
|
794
797
|
});
|
|
795
798
|
|
package/tests/provision.mjs
CHANGED
|
@@ -18,6 +18,8 @@ import { join } from 'node:path';
|
|
|
18
18
|
|
|
19
19
|
const require = createRequire(import.meta.url);
|
|
20
20
|
const P = require('../scripts/gds/provision.js');
|
|
21
|
+
const AP = require('../scripts/gds/instance-apex.js');
|
|
22
|
+
const RP = require('../scripts/gds/provision-repo.js');
|
|
21
23
|
|
|
22
24
|
// The env-var prefix a provisioned instance resolves = the envPrefix an instance INHERITS from
|
|
23
25
|
// the neutral pack (task 1003740: webEnvBody no longer pins a branding pack anywhere — the
|
|
@@ -40,6 +42,11 @@ process.env.PROVISION_GIT_SSH_ALIAS = 'github-deploy';
|
|
|
40
42
|
// The probe itself is covered directly, with an injected fs.
|
|
41
43
|
process.env.PROVISION_PROCESS_MANAGER = 'systemd';
|
|
42
44
|
|
|
45
|
+
// And the app user (task 1002719): unpinned it is whoever runs the suite. Pinned to a name
|
|
46
|
+
// that is nobody's real account, so an assertion passes only if the value came through the
|
|
47
|
+
// resolver — a leftover literal cannot produce it.
|
|
48
|
+
process.env.PROVISION_APP_USER = 'appsvc';
|
|
49
|
+
|
|
43
50
|
let passed = 0, failed = 0;
|
|
44
51
|
function t(name, fn) {
|
|
45
52
|
try { fn(); passed++; console.log(` PASS ${name}`); }
|
|
@@ -167,11 +174,16 @@ t('buildCorePinRefreshCommit (the commit step): git sequence, token-URL push, an
|
|
|
167
174
|
// must not attribute its commits to the hub. Assert against the resolver so
|
|
168
175
|
// this pins the behaviour, not one instance's literal.
|
|
169
176
|
assert.deepEqual(withTok.config, [
|
|
170
|
-
|
|
177
|
+
`git -C /srv/bongos/spike-harbor config user.name "${AP.provisionerBotName()}"`,
|
|
171
178
|
`git -C /srv/bongos/spike-harbor config user.email "${P.provisionerBotEmail()}"`,
|
|
172
179
|
]);
|
|
173
180
|
assert.equal(P.provisionerBotEmail({ PROVISION_BOT_EMAIL: 'ops@acme.test' }), 'ops@acme.test', 'env override wins');
|
|
174
181
|
assert.doesNotMatch(P.provisionerBotEmail({}), /example/i, 'never a founder-host address');
|
|
182
|
+
// task 1002719: the NAME comes from the pack too, and one the shell cannot quote falls back.
|
|
183
|
+
assert.equal(AP.provisionerBotName('Acme Forge'), 'Acme Forge', 'the pack\'s product name');
|
|
184
|
+
for (const unsafe of ['Acme "Forge"', 'a$(id)', 'a`id`', 'a\\b', 'a\nb', '']) {
|
|
185
|
+
assert.equal(AP.provisionerBotName(unsafe), 'Cloud Bongos', `falls back on ${JSON.stringify(unsafe)}`);
|
|
186
|
+
}
|
|
175
187
|
assert.equal(withTok.add, 'git -C /srv/bongos/spike-harbor add -A');
|
|
176
188
|
assert.match(withTok.commit, /@bongos\/core 1\.19\.2 -> 1\.19\.196 \(task 1003050\)/);
|
|
177
189
|
assert.match(withTok.push, /store --file=\/tmp\/cred.*x-access-token@github\.com\/jaxri\/spike-harbor\.git HEAD:main/);
|
|
@@ -199,7 +211,7 @@ t('backup units: per-instance names + the DB set on the unit (fixes the task-221
|
|
|
199
211
|
// the DB is pinned on the unit → no fallback to the nonexistent example default
|
|
200
212
|
assert.match(svc, /Environment=GDS_BACKUP_DB=mercury/);
|
|
201
213
|
assert.match(svc, /Environment=GDS_BACKUP_DIR=\/var\/backups\/gds/);
|
|
202
|
-
assert.match(svc, new RegExp(`User=${reEsc('
|
|
214
|
+
assert.match(svc, new RegExp(`User=${reEsc('appsvc')}`), 'runs as the app user (mirrors serviceUnit)');
|
|
203
215
|
assert.match(svc, /ReadWritePaths=\/var\/backups\/gds/, 'the only writable path is the backup dir');
|
|
204
216
|
const timer = P.backupTimer(coTenant);
|
|
205
217
|
assert.match(timer, /OnCalendar=\*-\*-\* 04:00:00 UTC/);
|
|
@@ -466,7 +478,7 @@ await ta('provisionInstance(co-tenant) composes db → migrate → service → h
|
|
|
466
478
|
assert.ok(writes.some((w) => /\/mercury\.service$/.test(w)), 'wrote systemd unit');
|
|
467
479
|
assert.ok(writes.some((w) => /\/mercury\.caddy$/.test(w)), 'wrote per-instance Caddy snippet');
|
|
468
480
|
// task 2213: EVERY instance is born with backups — dir + per-instance timer/service
|
|
469
|
-
assert.match(joined, /install -d -o
|
|
481
|
+
assert.match(joined, /install -d -o appsvc -g \$\(id -gn appsvc\) -m 0750 \/var\/backups\/gds/, 'creates the backup dir');
|
|
470
482
|
assert.match(joined, /systemctl enable --now mercury-db-backup\.timer/, 'enables the per-instance backup timer');
|
|
471
483
|
assert.ok(writes.some((w) => /\/mercury-db-backup\.service$/.test(w)), 'wrote backup service unit');
|
|
472
484
|
assert.ok(writes.some((w) => /\/mercury-db-backup\.timer$/.test(w)), 'wrote backup timer unit');
|
|
@@ -626,7 +638,8 @@ t('systemd manager: emits byte-identical commands + unit paths to the inline str
|
|
|
626
638
|
PM.systemd.installBackup(INST, io2);
|
|
627
639
|
// The backup DIR moved in here with the schedule it serves: a manager that installs no
|
|
628
640
|
// schedule must not create it either. Order is unchanged — dir, units, reload, enable.
|
|
629
|
-
|
|
641
|
+
// The group is the app user's PRIMARY group, looked up on the box (task 1002719).
|
|
642
|
+
assert.match(io2.cmds[0], /^sudo install -d -o appsvc -g \$\(id -gn appsvc\) -m 0750 /);
|
|
630
643
|
assert.deepEqual(io2.cmds.slice(1), ['sudo systemctl daemon-reload', 'sudo systemctl enable --now demo-db-backup.timer']);
|
|
631
644
|
assert.deepEqual(io2.files.map((f) => f.path),
|
|
632
645
|
['/etc/systemd/system/demo-db-backup.service', '/etc/systemd/system/demo-db-backup.timer']);
|
|
@@ -1329,7 +1342,7 @@ await ta('dedicatedUserData bakes the nightly backup: unit files (b64) + dir + t
|
|
|
1329
1342
|
const b64 = cloudInit.match(/content: ([A-Za-z0-9+/=]+)/)[1];
|
|
1330
1343
|
assert.match(Buffer.from(b64, 'base64').toString('utf8'), /Environment=GDS_BACKUP_DB=mercury/);
|
|
1331
1344
|
// and the dir is created + the timer enabled on first boot
|
|
1332
|
-
assert.match(cloudInit, /install -d -o
|
|
1345
|
+
assert.match(cloudInit, /install -d -o appsvc -g \$\(id -gn appsvc\) -m 0750 \/var\/backups\/gds/);
|
|
1333
1346
|
assert.match(cloudInit, /enable --now mercury mercury-db-backup\.timer/);
|
|
1334
1347
|
});
|
|
1335
1348
|
|
|
@@ -2932,7 +2945,31 @@ t('ensureInstanceUserCmd: idempotent SYSTEM account, nologin, state dir re-asser
|
|
|
2932
2945
|
});
|
|
2933
2946
|
|
|
2934
2947
|
t('grantInstanceRepoReadCmd: adds the instance account to the app-user group so the unit can exec its WorkingDirectory', () => {
|
|
2935
|
-
assert.equal(P.grantInstanceRepoReadCmd(coTenant, { privileged: true }), 'sudo usermod -aG
|
|
2948
|
+
assert.equal(P.grantInstanceRepoReadCmd(coTenant, { privileged: true }), 'sudo usermod -aG $(id -gn appsvc) bongos-mercury');
|
|
2949
|
+
});
|
|
2950
|
+
|
|
2951
|
+
t('appUser: PROVISION_APP_USER, else the account the runner runs as — never a named founder (task 1002719)', () => {
|
|
2952
|
+
const runner = (username) => () => ({ username });
|
|
2953
|
+
assert.equal(RP.appUser({ env: { PROVISION_APP_USER: 'ops' }, userInfo: runner('deploy') }), 'ops', 'the pin wins');
|
|
2954
|
+
assert.equal(RP.appUser({ env: {}, userInfo: runner('deploy') }), 'deploy', "unpinned: the runner's own account");
|
|
2955
|
+
assert.equal(RP.appUser(), 'appsvc', 'the suite pin is what every generator above rendered');
|
|
2956
|
+
});
|
|
2957
|
+
|
|
2958
|
+
t('appUser: refuses root and any name a shell command or unit file could not carry', () => {
|
|
2959
|
+
const runner = (username) => () => ({ username });
|
|
2960
|
+
assert.throws(() => RP.appUser({ env: {}, userInfo: runner('root') }), /is root — set PROVISION_APP_USER/);
|
|
2961
|
+
assert.throws(() => RP.appUser({ env: {}, userInfo: () => { throw new Error('ENOENT'); } }),
|
|
2962
|
+
/"" is not a usable unix account name/, 'a uid with no passwd entry names the knob');
|
|
2963
|
+
for (const bad of ['a b', 'x;rm -rf /', '$(id)', 'Admin', '-o', 'a'.repeat(33)]) {
|
|
2964
|
+
assert.throws(() => RP.appUser({ env: { PROVISION_APP_USER: bad }, userInfo: runner('deploy') }),
|
|
2965
|
+
/not a usable unix account name/, `refuses ${JSON.stringify(bad)}`);
|
|
2966
|
+
}
|
|
2967
|
+
// …and the generators go through it rather than rendering the bad value.
|
|
2968
|
+
process.env.PROVISION_APP_USER = 'root';
|
|
2969
|
+
try {
|
|
2970
|
+
assert.throws(() => P.backupService(coTenant), /is root/);
|
|
2971
|
+
assert.throws(() => P.grantInstanceRepoReadCmd(coTenant), /is root/);
|
|
2972
|
+
} finally { process.env.PROVISION_APP_USER = 'appsvc'; }
|
|
2936
2973
|
});
|
|
2937
2974
|
|
|
2938
2975
|
t('dbRoleCmd: LOGIN + password, upserted — CREATE on a fresh box, ALTER on a re-provision (the rotation path)', () => {
|
|
@@ -3052,7 +3089,7 @@ t('dedicatedUserData: the droplet runs the SAME account + role steps, in the sam
|
|
|
3052
3089
|
const ud = P.dedicatedUserData(coTenant);
|
|
3053
3090
|
const at = (needle) => ud.indexOf(needle);
|
|
3054
3091
|
assert.ok(at('useradd --system') > -1, 'the droplet creates the account too');
|
|
3055
|
-
assert.ok(at('usermod -aG
|
|
3092
|
+
assert.ok(at('usermod -aG $(id -gn appsvc)') > -1);
|
|
3056
3093
|
assert.ok(at('useradd --system') < at('createdb mercury'), 'account before the DB');
|
|
3057
3094
|
assert.ok(at('migrate.sh') < at('CREATE ROLE %I'), 'the role grants must follow the migrate that created the tables');
|
|
3058
3095
|
assert.ok(at('CREATE ROLE %I') < at('GRANT ALL ON ALL TABLES'), 'role before its grants');
|
|
@@ -12,6 +12,9 @@ const require = createRequire(import.meta.url);
|
|
|
12
12
|
const P = require('../scripts/gds/provision.js');
|
|
13
13
|
const { accountPlaneGap, accountPreflight } = require('../scripts/gds/provision-repo.js');
|
|
14
14
|
|
|
15
|
+
// The app user is derived from whoever runs the suite unless pinned (task 1002719).
|
|
16
|
+
process.env.PROVISION_APP_USER = 'appsvc';
|
|
17
|
+
|
|
15
18
|
const PROBE = `sh -c 'test "$(id -u)" = 0'`;
|
|
16
19
|
const isRoot = () => ({ ok: true, stdout: '' });
|
|
17
20
|
// What a shell that RAN reports: exit 1 from `test`, or 127 when sudo is not installed.
|
|
@@ -133,3 +136,16 @@ test('provision: a box where the runner reaches root is unchanged — the live s
|
|
|
133
136
|
const idx = (re) => h.cmds.findIndex((c) => re.test(c));
|
|
134
137
|
assert.ok(idx(/id -u.*= 0/) < idx(/useradd --system/), 'the probe precedes the account it guards');
|
|
135
138
|
});
|
|
139
|
+
|
|
140
|
+
test('provision: an app user that resolves to root is refused before a single write (task 1002719)', async () => {
|
|
141
|
+
const h = harness();
|
|
142
|
+
process.env.PROVISION_APP_USER = 'root'; // what an unpinned runner running as root resolves
|
|
143
|
+
try {
|
|
144
|
+
const r = await P.provisionInstance(coTenant, h.deps);
|
|
145
|
+
assert.equal(r.ok, false);
|
|
146
|
+
assert.match(r.error, /is root — set PROVISION_APP_USER/);
|
|
147
|
+
assert.ok(!h.ran(/useradd/), 'refused before step 1c makes an account the run could not finish');
|
|
148
|
+
assert.equal(h.writes.length, 0);
|
|
149
|
+
assert.deepEqual(h.statuses, [], 'the instance never flips to provisioning');
|
|
150
|
+
} finally { process.env.PROVISION_APP_USER = 'appsvc'; }
|
|
151
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// tests/sweep_app_account.mjs — the two backfill sweeps act as the plane's app account,
|
|
2
|
+
// never a named person (task 1004135). Both used to `sudo -u <founder>`, so a second
|
|
3
|
+
// operator who ran them got `sudo: unknown user`.
|
|
4
|
+
//
|
|
5
|
+
// Behavioural: each sweep runs under bash with stub `sudo` / `stat` / `systemctl` first on
|
|
6
|
+
// PATH. The sudo stub records its argv and answers nothing (an empty ledger), so the
|
|
7
|
+
// assertion is on the account the script actually asked for. Nothing touches a box.
|
|
8
|
+
import assert from 'node:assert/strict';
|
|
9
|
+
import { test } from 'node:test';
|
|
10
|
+
import { spawnSync } from 'node:child_process';
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import os from 'node:os';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
|
|
16
|
+
const REPO = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
17
|
+
const skip = spawnSync('bash', ['-c', 'true']).error ? 'bash not on PATH' : false;
|
|
18
|
+
const slash = (p) => p.replace(/\\/g, '/');
|
|
19
|
+
|
|
20
|
+
function stubs() {
|
|
21
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sweep-stubs-'));
|
|
22
|
+
const sudoLog = slash(path.join(dir, 'sudo.log'));
|
|
23
|
+
const statLog = slash(path.join(dir, 'stat.log'));
|
|
24
|
+
const bin = (name, body) => fs.writeFileSync(path.join(dir, name), `#!/usr/bin/env bash\n${body}\n`, { mode: 0o755 });
|
|
25
|
+
bin('sudo', `echo "$*" >> "${sudoLog}"; exit 0`);
|
|
26
|
+
bin('stat', `echo "$*" >> "${statLog}"; echo checkout-owner`);
|
|
27
|
+
bin('systemctl', 'exit 0');
|
|
28
|
+
const lines = (f) => (fs.existsSync(f) ? fs.readFileSync(f, 'utf8').split('\n').filter(Boolean) : []);
|
|
29
|
+
return { dir, sudo: () => lines(sudoLog), stat: () => lines(statLog) };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function run(scriptPath, s, env = {}) {
|
|
33
|
+
const base = { ...process.env };
|
|
34
|
+
delete base.PROVISION_APP_USER;
|
|
35
|
+
delete base.INSTANCE_DIR;
|
|
36
|
+
return spawnSync('bash', [slash(scriptPath)], {
|
|
37
|
+
cwd: REPO, encoding: 'utf8',
|
|
38
|
+
env: { ...base, ...env, PATH: `${s.dir}${path.delimiter}${process.env.PATH}` },
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const STRAY = path.join(REPO, 'scripts', 'gds', 'stray-unit-sweep.sh');
|
|
43
|
+
const PARK = path.join(REPO, 'scripts', 'gds', 'address-park-sweep.sh');
|
|
44
|
+
|
|
45
|
+
test('stray-unit-sweep: PROVISION_APP_USER is the account the ledger is read as', { skip }, () => {
|
|
46
|
+
const s = stubs();
|
|
47
|
+
run(STRAY, s, { PROVISION_APP_USER: 'appsvc' });
|
|
48
|
+
assert.match(s.sudo()[0] || '', /^-n -u appsvc psql /);
|
|
49
|
+
assert.equal(s.stat().length, 0, 'a pin is not second-guessed');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('stray-unit-sweep: unpinned outside a vendored core, it refuses rather than trust whoever owns this clone', { skip }, () => {
|
|
53
|
+
const s = stubs();
|
|
54
|
+
const r = run(STRAY, s);
|
|
55
|
+
assert.equal(r.status, 1);
|
|
56
|
+
assert.match(r.stdout, /set INSTANCE_DIR/);
|
|
57
|
+
assert.deepEqual(s.stat(), [], 'the clone\'s owner was never asked');
|
|
58
|
+
assert.deepEqual(s.sudo(), []);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('stray-unit-sweep: INSTANCE_DIR names the checkout whose owner acts', { skip }, () => {
|
|
62
|
+
const s = stubs();
|
|
63
|
+
const inst = fs.mkdtempSync(path.join(os.tmpdir(), 'sweep-inst-'));
|
|
64
|
+
run(STRAY, s, { INSTANCE_DIR: slash(inst) });
|
|
65
|
+
assert.match(s.stat()[0] || '', new RegExp(`^-c %U .*${path.basename(inst)}$`));
|
|
66
|
+
assert.match(s.sudo()[0] || '', /^-n -u checkout-owner psql /);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('stray-unit-sweep: root is refused before anything runs', { skip }, () => {
|
|
70
|
+
const s = stubs();
|
|
71
|
+
const r = run(STRAY, s, { PROVISION_APP_USER: 'root' });
|
|
72
|
+
assert.equal(r.status, 1);
|
|
73
|
+
assert.match(r.stdout, /cannot use 'root' as the app account — set PROVISION_APP_USER/);
|
|
74
|
+
assert.deepEqual(s.sudo(), [], 'sudo never asked');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('address-park-sweep: outside a vendored core, INSTANCE_DIR must be named — no founder-home default', { skip }, () => {
|
|
78
|
+
const s = stubs();
|
|
79
|
+
const r = run(PARK, s);
|
|
80
|
+
assert.equal(r.status, 1);
|
|
81
|
+
assert.match(r.stdout, /set INSTANCE_DIR/);
|
|
82
|
+
assert.deepEqual(s.sudo(), []);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('address-park-sweep: root is refused before anything runs', { skip }, () => {
|
|
86
|
+
const s = stubs();
|
|
87
|
+
const r = run(PARK, s, { INSTANCE_DIR: slash(os.tmpdir()), PROVISION_APP_USER: 'root' });
|
|
88
|
+
assert.equal(r.status, 1);
|
|
89
|
+
assert.match(r.stdout, /cannot use 'root' as the app account — set PROVISION_APP_USER/);
|
|
90
|
+
assert.deepEqual(s.sudo(), [], 'sudo never asked');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('address-park-sweep: every sudo hop uses the pinned account', { skip }, () => {
|
|
94
|
+
const s = stubs();
|
|
95
|
+
run(PARK, s, { INSTANCE_DIR: slash(os.tmpdir()), PROVISION_APP_USER: 'appsvc' });
|
|
96
|
+
const hops = s.sudo().filter((l) => / -u /.test(l));
|
|
97
|
+
assert.ok(hops.length >= 2, `the ledger read and the core load both ran — got ${JSON.stringify(s.sudo())}`);
|
|
98
|
+
for (const h of hops) assert.match(h, /^-n -u appsvc /);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
for (const [name, src] of [['address-park-sweep', PARK], ['stray-unit-sweep', STRAY]]) {
|
|
102
|
+
test(`${name}: run from an instance's vendored core, the instance is three levels up and its owner acts`, { skip }, () => {
|
|
103
|
+
const s = stubs();
|
|
104
|
+
const inst = fs.mkdtempSync(path.join(os.tmpdir(), 'sweep-inst-'));
|
|
105
|
+
const gds = path.join(inst, 'node_modules', '@bongos', 'core', 'scripts', 'gds');
|
|
106
|
+
fs.mkdirSync(gds, { recursive: true });
|
|
107
|
+
fs.copyFileSync(src, path.join(gds, `${name}.sh`));
|
|
108
|
+
run(path.join(gds, `${name}.sh`), s);
|
|
109
|
+
assert.match(s.stat()[0] || '', new RegExp(`^-c %U .*${path.basename(inst)}$`), 'the instance root, not the package');
|
|
110
|
+
assert.match(s.sudo()[0] || '', /^-n -u checkout-owner psql /);
|
|
111
|
+
});
|
|
112
|
+
}
|