@bongos/core 1.19.675 → 1.19.677
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 +18 -18
- package/docs/api/openapi.json +1 -1
- package/docs/module-api-changelog.md +4 -0
- package/docs/recipes/core-release-pipeline.md +12 -0
- package/modules/provisioning/provisioning.js +52 -0
- package/modules/provisioning/routes/provisioning.js +22 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/go-live.js +118 -25
- package/scripts/gds/upgrade.js +28 -4
- package/src/module-api.js +1 -1
- package/tests/go_live.mjs +174 -2
- package/tests/provisioning_settings_apply.mjs +129 -0
- package/tests/upgrade_persist_pin.mjs +61 -0
package/.bongos-core.json
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
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.677",
|
|
6
|
+
"core_contract": "1.19.677",
|
|
7
|
+
"source_commit": "edcffc22cdab15c1f61e5cd2cb2b9d6a13872150",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-
|
|
9
|
+
"built_at": "2026-09-11T21:42:20.049Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 474,
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
19
|
"file_count": 2621,
|
|
20
|
-
"tree_sha256": "
|
|
20
|
+
"tree_sha256": "2afeb3b9fd9e3cf3de8feb833f7710642830cc081102cfd1a58d9e7ea6057332",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/ask-for-help/SKILL.md",
|
|
@@ -1917,7 +1917,7 @@
|
|
|
1917
1917
|
{
|
|
1918
1918
|
"path": "docs/api/openapi.json",
|
|
1919
1919
|
"mode": "0000644",
|
|
1920
|
-
"sha256": "
|
|
1920
|
+
"sha256": "45efd65847e2b9e5ade3d21eef0bc3ec3562184b109927f93425887ca99b02ba"
|
|
1921
1921
|
},
|
|
1922
1922
|
{
|
|
1923
1923
|
"path": "docs/architecture.md",
|
|
@@ -2792,7 +2792,7 @@
|
|
|
2792
2792
|
{
|
|
2793
2793
|
"path": "docs/module-api-changelog.md",
|
|
2794
2794
|
"mode": "0000644",
|
|
2795
|
-
"sha256": "
|
|
2795
|
+
"sha256": "2fbf6cfad023d90c93ffcc7cadce65e73e578cead78069011d2b47b23a0de8e5"
|
|
2796
2796
|
},
|
|
2797
2797
|
{
|
|
2798
2798
|
"path": "docs/modules-contract.md",
|
|
@@ -2912,7 +2912,7 @@
|
|
|
2912
2912
|
{
|
|
2913
2913
|
"path": "docs/recipes/core-release-pipeline.md",
|
|
2914
2914
|
"mode": "0000644",
|
|
2915
|
-
"sha256": "
|
|
2915
|
+
"sha256": "86d266be2c1833484225886c1ed961b8f19d511e84226032dc65c9ccf18f5550"
|
|
2916
2916
|
},
|
|
2917
2917
|
{
|
|
2918
2918
|
"path": "docs/recipes/devbox-app-release.md",
|
|
@@ -6482,7 +6482,7 @@
|
|
|
6482
6482
|
{
|
|
6483
6483
|
"path": "modules/provisioning/provisioning.js",
|
|
6484
6484
|
"mode": "0000644",
|
|
6485
|
-
"sha256": "
|
|
6485
|
+
"sha256": "0f82fbdf4838a7e72d4128c76b4da7f05ed4578b8a20c167c7fb2e95cb1e9237"
|
|
6486
6486
|
},
|
|
6487
6487
|
{
|
|
6488
6488
|
"path": "modules/provisioning/rate-limit.js",
|
|
@@ -6507,7 +6507,7 @@
|
|
|
6507
6507
|
{
|
|
6508
6508
|
"path": "modules/provisioning/routes/provisioning.js",
|
|
6509
6509
|
"mode": "0000644",
|
|
6510
|
-
"sha256": "
|
|
6510
|
+
"sha256": "c83cec200215e9cb04446592e1aed32daf8e083142416bdc1f25d261f460d1cf"
|
|
6511
6511
|
},
|
|
6512
6512
|
{
|
|
6513
6513
|
"path": "modules/provisioning/starter-bundles.js",
|
|
@@ -7767,12 +7767,12 @@
|
|
|
7767
7767
|
{
|
|
7768
7768
|
"path": "package-lock.json",
|
|
7769
7769
|
"mode": "0000644",
|
|
7770
|
-
"sha256": "
|
|
7770
|
+
"sha256": "f9c8562e66253cdfd8487dfb052d7a3443c477d4bb1abfef8b53946cd5104691"
|
|
7771
7771
|
},
|
|
7772
7772
|
{
|
|
7773
7773
|
"path": "package.json",
|
|
7774
7774
|
"mode": "0000644",
|
|
7775
|
-
"sha256": "
|
|
7775
|
+
"sha256": "7be7c3c8b5ca557dbbf7f5a38453fedb85bd004e53b170e1746a03a02ab75ae9"
|
|
7776
7776
|
},
|
|
7777
7777
|
{
|
|
7778
7778
|
"path": "public-docs/index.html",
|
|
@@ -8327,7 +8327,7 @@
|
|
|
8327
8327
|
{
|
|
8328
8328
|
"path": "scripts/gds/go-live.js",
|
|
8329
8329
|
"mode": "0000644",
|
|
8330
|
-
"sha256": "
|
|
8330
|
+
"sha256": "d1383b714ba0d8e4d0f5ceb097e45c1ed1af09caf648f7a43a6c6431c38fb422"
|
|
8331
8331
|
},
|
|
8332
8332
|
{
|
|
8333
8333
|
"path": "scripts/gds/goal-names.js",
|
|
@@ -9102,7 +9102,7 @@
|
|
|
9102
9102
|
{
|
|
9103
9103
|
"path": "scripts/gds/upgrade.js",
|
|
9104
9104
|
"mode": "0000644",
|
|
9105
|
-
"sha256": "
|
|
9105
|
+
"sha256": "7a173ecf47b25007bba28f68787893352f429a52b34b7f7b4eae0d3791a5cdcf"
|
|
9106
9106
|
},
|
|
9107
9107
|
{
|
|
9108
9108
|
"path": "scripts/gds/validate-design.js",
|
|
@@ -9532,7 +9532,7 @@
|
|
|
9532
9532
|
{
|
|
9533
9533
|
"path": "src/module-api.js",
|
|
9534
9534
|
"mode": "0000644",
|
|
9535
|
-
"sha256": "
|
|
9535
|
+
"sha256": "025ba17e4ac4f614e81bde6009db0ed9ba04ccfb3d226abcc92e9e51d24b97b9"
|
|
9536
9536
|
},
|
|
9537
9537
|
{
|
|
9538
9538
|
"path": "src/module-loader/catalog.js",
|
|
@@ -10667,7 +10667,7 @@
|
|
|
10667
10667
|
{
|
|
10668
10668
|
"path": "tests/go_live.mjs",
|
|
10669
10669
|
"mode": "0000644",
|
|
10670
|
-
"sha256": "
|
|
10670
|
+
"sha256": "7528da9c6cd81d9876f0ffae0bf836844cb726a9bbb24cda44f0675d6f6ac0ea"
|
|
10671
10671
|
},
|
|
10672
10672
|
{
|
|
10673
10673
|
"path": "tests/goal_abuse_matrix.mjs",
|
|
@@ -12132,7 +12132,7 @@
|
|
|
12132
12132
|
{
|
|
12133
12133
|
"path": "tests/provisioning_settings_apply.mjs",
|
|
12134
12134
|
"mode": "0000644",
|
|
12135
|
-
"sha256": "
|
|
12135
|
+
"sha256": "a55a1c88958710a8b84048b88ba5d68b05e4489242a777852af8a2c9991462fb"
|
|
12136
12136
|
},
|
|
12137
12137
|
{
|
|
12138
12138
|
"path": "tests/provisioning_settings_env.mjs",
|
|
@@ -12982,7 +12982,7 @@
|
|
|
12982
12982
|
{
|
|
12983
12983
|
"path": "tests/upgrade_persist_pin.mjs",
|
|
12984
12984
|
"mode": "0000644",
|
|
12985
|
-
"sha256": "
|
|
12985
|
+
"sha256": "c0a96fbdc6a9f851925babfb0857c9a168374c4165c84884f5c4c56c7177dd58"
|
|
12986
12986
|
},
|
|
12987
12987
|
{
|
|
12988
12988
|
"path": "tests/verify_task_guard.mjs",
|
package/docs/api/openapi.json
CHANGED
|
@@ -12149,7 +12149,7 @@
|
|
|
12149
12149
|
"provisioning"
|
|
12150
12150
|
],
|
|
12151
12151
|
"summary": "PATCH /provisioning/instances/:id/settings",
|
|
12152
|
-
"description": "PATCH /provisioning/instances/:id/settings — change a project's settings. Body: any subset of the known keys; strict validation refuses unknown keys and off-vocabulary values. rank: any authenticated builder (own resource; archon may change any — the override is audited via the event's actor). The platform's row is updated first; then, for an ACTIVE instance on a shape the runner can reach, a 'settings-apply' intent is enqueued (task 1003140): the control-plane runner patches the instance's web.env in place and RESTARTS it, because the branding pack is memoized for the process lifetime. A save that changes nothing neither writes nor restarts. The response is a request receipt — `saved` is what the platform now stores, and `push` { queued, restart, reachable, message } says whether a restart was queued and when the value takes effect — never a claim the instance already runs it; a client reads that back from the instance's own public GET /instance manifest (brand.project). 409 intent_conflict (details.recorded: true) when the open slot is held by other work — the change IS saved.\n\n**Rank:** `any-builder` — Any authenticated builder (row-level ownership enforced in-handler).",
|
|
12152
|
+
"description": "PATCH /provisioning/instances/:id/settings — change a project's settings. Body: any subset of the known keys; strict validation refuses unknown keys and off-vocabulary values. rank: any authenticated builder (own resource; archon may change any — the override is audited via the event's actor). The platform's row is updated first; then, for an ACTIVE instance on a shape the runner can reach, a 'settings-apply' intent is enqueued (task 1003140): the control-plane runner patches the instance's web.env in place and RESTARTS it, because the branding pack is memoized for the process lifetime. A save that changes nothing neither writes nor restarts. The response is a request receipt — `saved` is what the platform now stores, and `push` { queued, restart, reachable, message } says whether a restart was queued and when the value takes effect — never a claim the instance already runs it; a client reads that back from the instance's own public GET /instance manifest (brand.project). 409 intent_conflict (details.recorded: true) when the open slot is held by other work — the change IS saved. A save whose push did not get carried (that 409, or an enqueue that threw) marks the push OWED, which is what makes the \"send it again\" both of those name actually re-queue instead of hitting the no-op \"No change\" branch (task 1003524).\n\n**Rank:** `any-builder` — Any authenticated builder (row-level ownership enforced in-handler).",
|
|
12153
12153
|
"x-rank": "any-builder",
|
|
12154
12154
|
"x-source": "modules/provisioning/routes/provisioning.js",
|
|
12155
12155
|
"parameters": [
|
|
@@ -1809,5 +1809,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1809
1809
|
landed since 1.19.673 with no explicit bump. run 34634515595. (task 1002620)
|
|
1810
1810
|
1.19.675 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1811
1811
|
landed since 1.19.674 with no explicit bump. run 34639371461. (task 1002620)
|
|
1812
|
+
1.19.676 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1813
|
+
landed since 1.19.675 with no explicit bump. run 34645751352. (task 1002620)
|
|
1814
|
+
1.19.677 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1815
|
+
landed since 1.19.676 with no explicit bump. run 34650610723. (task 1002620)
|
|
1812
1816
|
---------------------------------------------------------------------------
|
|
1813
1817
|
```
|
|
@@ -168,6 +168,18 @@ Host topology (ssh alias, box paths, service names, health URL) lives in a host-
|
|
|
168
168
|
`config/go-live.json`, never in the core — run `node scripts/gds/go-live.js --help` for the
|
|
169
169
|
schema. A core that named one instance's box would not be a core.
|
|
170
170
|
|
|
171
|
+
**Upgrading a provisioned co-tenant** (task 1003521). A co-tenant under `/srv/<host>/<slug>` is
|
|
172
|
+
the same engine with two fields different, because two of this instance's assumptions do not hold
|
|
173
|
+
there: it has no pull-deploy timer to stop (only its `<slug>-db-backup.timer`), and its repo of
|
|
174
|
+
record is the **customer's** own GitHub repo, so pushing the pin there is both wrong and
|
|
175
|
+
uncredentialed. So omit `deployTimer` — the stop and the restart are skipped, and there is no
|
|
176
|
+
`reset --hard` to race — and set `"pinMode": "local-commit"`, which commits the pin without
|
|
177
|
+
pushing it. (`"leave-dirty"` leaves the bump in the working tree; `"push"` is the default and is
|
|
178
|
+
what the platform instance needs.) Everything else — the registry proof, `--verify-marker`, the
|
|
179
|
+
`pg_dump`, the health-gated upgrade with auto-rollback, the box-side version read-back — is
|
|
180
|
+
unchanged. The co-tenant's own scaffold gap is separate and still open:
|
|
181
|
+
[#1003520](https://cloudbongos.com/builders#/task/1003520).
|
|
182
|
+
|
|
171
183
|
### Step 1 — pick the version (your machine, main checkout)
|
|
172
184
|
|
|
173
185
|
```bash
|
|
@@ -441,6 +441,12 @@ ${lines}
|
|
|
441
441
|
`;
|
|
442
442
|
}
|
|
443
443
|
|
|
444
|
+
// The reserved settings-jsonb key settingsPushOwed/recordSettingsPushOwed use
|
|
445
|
+
// (task 1003524). Deliberately NOT in SETTINGS_VOCAB or PLANET_VOCAB — it is the
|
|
446
|
+
// platform's own bookkeeping, not a setting an owner declares, and the test
|
|
447
|
+
// asserts it stays out of both so it can never start projecting as one.
|
|
448
|
+
const SETTINGS_PUSH_OWED_KEY = 'push_owed';
|
|
449
|
+
|
|
444
450
|
// The instance's EFFECTIVE settings: the defaults overlaid with the stored,
|
|
445
451
|
// on-vocabulary keys. An off-vocabulary or unknown stored value falls back to
|
|
446
452
|
// its default rather than leaking into the read surface. PURE.
|
|
@@ -466,6 +472,30 @@ function planetChoice(row) {
|
|
|
466
472
|
return out;
|
|
467
473
|
}
|
|
468
474
|
|
|
475
|
+
// Is a POLICY settings push OWED to this instance (task 1003524)? Set when the
|
|
476
|
+
// row was written but its push demonstrably did not get carried: the open slot
|
|
477
|
+
// was held by other work (409 intent_conflict), or the enqueue itself threw.
|
|
478
|
+
// Both of those answer the owner "send it again" — and without this marker that
|
|
479
|
+
// remedy is a NO-OP, because the re-send is by definition unchanged and takes
|
|
480
|
+
// the "No change" branch, leaving the saved value stranded on the platform. (The
|
|
481
|
+
// only way out was toggling to a third value and back.)
|
|
482
|
+
//
|
|
483
|
+
// It lives as a RESERVED key in the settings jsonb rather than a column so it
|
|
484
|
+
// costs no migration — the provisioning_009 rationale, which is why that column
|
|
485
|
+
// is jsonb in the first place. It can neither leak nor be client-written:
|
|
486
|
+
// effectiveSettings / planetChoice / settingsEnvVars project ONLY vocabulary
|
|
487
|
+
// keys, and the route builds its patch only from settingKeys.
|
|
488
|
+
//
|
|
489
|
+
// error_note is the sibling marker and the two do not overlap: that one latches
|
|
490
|
+
// a push that WAS queued and then failed (F19), this one a push that was never
|
|
491
|
+
// queued at all. Together they make "send it again" true on every branch that
|
|
492
|
+
// says it. PURE.
|
|
493
|
+
function settingsPushOwed(row) {
|
|
494
|
+
const stored = row && row.settings && typeof row.settings === 'object' && !Array.isArray(row.settings)
|
|
495
|
+
? row.settings : {};
|
|
496
|
+
return stored[SETTINGS_PUSH_OWED_KEY] === true;
|
|
497
|
+
}
|
|
498
|
+
|
|
469
499
|
// A description as it may be STORED: trimmed, collapsed of runaway whitespace,
|
|
470
500
|
// capped, and empty-means-absent. Returns null for anything that isn't real prose,
|
|
471
501
|
// because null is what leaves the project dark-matter — an empty string stored in a
|
|
@@ -669,6 +699,25 @@ async function updateInstanceSettings(db, id, patch) {
|
|
|
669
699
|
return rows[0] || null;
|
|
670
700
|
}
|
|
671
701
|
|
|
702
|
+
// Mark (or clear) the owed-push marker settingsPushOwed reads (task 1003524).
|
|
703
|
+
// Same narrowness rule as recordDnsNote / clearErrorNote: no `status`, and no
|
|
704
|
+
// `updated_at` — this is the platform's own bookkeeping, and bumping updated_at
|
|
705
|
+
// would reshuffle the owner's card ordering (listInstancesForOwner orders by it)
|
|
706
|
+
// for a write the owner did not make. Clearing DELETES the key rather than
|
|
707
|
+
// storing false, so a row that never owed a push is byte-identical to one whose
|
|
708
|
+
// debt was settled.
|
|
709
|
+
async function recordSettingsPushOwed(db, id, owed) {
|
|
710
|
+
const { rows } = await db.query(
|
|
711
|
+
`UPDATE provisioning_instances
|
|
712
|
+
SET settings = CASE WHEN $2::boolean
|
|
713
|
+
THEN settings || $3::jsonb
|
|
714
|
+
ELSE settings - $4::text END
|
|
715
|
+
WHERE id = $1 RETURNING id, settings`,
|
|
716
|
+
[id, !!owed, JSON.stringify({ [SETTINGS_PUSH_OWED_KEY]: true }), SETTINGS_PUSH_OWED_KEY]
|
|
717
|
+
);
|
|
718
|
+
return rows[0] || null;
|
|
719
|
+
}
|
|
720
|
+
|
|
672
721
|
// Fill in (or change) the project-detail answers AFTER creation — the other half
|
|
673
722
|
// of "skippable" (task 1002334). Skipping the step at creation is a supported
|
|
674
723
|
// default, but it is only a default and not a dead end if there is a way back; this
|
|
@@ -1290,6 +1339,7 @@ module.exports = {
|
|
|
1290
1339
|
// pure
|
|
1291
1340
|
isValidSlug, costEstimateUsd, publicInstance, normalizeHostname,
|
|
1292
1341
|
defaultDomainForSlug, isReservedHostname, effectiveSettings, planetChoice,
|
|
1342
|
+
settingsPushOwed,
|
|
1293
1343
|
normalizeDescription, normalizeProjectDetail, projectDetailPatch, projectDetail,
|
|
1294
1344
|
// pure — GitHub App Manifest flow (task 2080)
|
|
1295
1345
|
githubAppName, buildGithubAppManifest, manifestPostUrl, publicGithubApp,
|
|
@@ -1298,6 +1348,8 @@ module.exports = {
|
|
|
1298
1348
|
getInstanceById, getInstanceBySlug, getInstanceByDomain, listInstancesForOwner, listAllInstances,
|
|
1299
1349
|
// data access — verification overlay (task 1002696)
|
|
1300
1350
|
listActiveInstancesForLiveness, recordLivenessProbe, recordDnsNote, clearErrorNote,
|
|
1351
|
+
// data access — the owed-push marker (task 1003524)
|
|
1352
|
+
recordSettingsPushOwed,
|
|
1301
1353
|
createInstance, setInstanceStatus, updateInstanceDomain, updateInstanceSettings, updateInstanceDetail,
|
|
1302
1354
|
createInstance, setInstanceStatus, updateInstanceDomain, clearReleasedDomains, updateInstanceSettings,
|
|
1303
1355
|
enqueueIntent, enqueueTeardown, enqueueSettingsApply, enqueueRestart, getOpenIntent, claimNextIntent, resolveIntent,
|
|
@@ -853,7 +853,10 @@ module.exports = function provisioningRoutes() {
|
|
|
853
853
|
// and when the value takes effect — never a claim the instance already runs
|
|
854
854
|
// it; a client reads that back from the instance's own public GET /instance
|
|
855
855
|
// manifest (brand.project). 409 intent_conflict (details.recorded: true) when
|
|
856
|
-
// the open slot is held by other work — the change IS saved.
|
|
856
|
+
// the open slot is held by other work — the change IS saved. A save whose push
|
|
857
|
+
// did not get carried (that 409, or an enqueue that threw) marks the push OWED,
|
|
858
|
+
// which is what makes the "send it again" both of those name actually re-queue
|
|
859
|
+
// instead of hitting the no-op "No change" branch (task 1003524).
|
|
857
860
|
router.patch('/provisioning/instances/:id/settings', auth.requireBuilder, async (req, res) => {
|
|
858
861
|
const id = parseId(req, res); // (req, res) — parseId sends 400 bad_id + returns null on failure
|
|
859
862
|
if (!id) return;
|
|
@@ -898,7 +901,13 @@ module.exports = function provisioningRoutes() {
|
|
|
898
901
|
const policyKeys = Object.keys(provisioning.SETTINGS_VOCAB);
|
|
899
902
|
const policyTouched = policyKeys.some((k) => patch[k] !== undefined);
|
|
900
903
|
const policyChanged = policyKeys.some((k) => patch[k] !== undefined && before[k] !== patch[k]);
|
|
901
|
-
|
|
904
|
+
// A push is OWED when the last save's push did not get carried — a queued
|
|
905
|
+
// push that RAN and failed (error_note, the F19 re-kick), or one never queued
|
|
906
|
+
// at all (the 409 below, or an enqueue that threw; task 1003524). Both answer
|
|
907
|
+
// "send it again", and folding the second in is what makes that sentence
|
|
908
|
+
// true: the re-send is unchanged, so it would otherwise hit "No change".
|
|
909
|
+
const pushOwed = provisioning.settingsPushOwed(inst);
|
|
910
|
+
const policyResent = (!!inst.error_note || pushOwed) && policyTouched;
|
|
902
911
|
const updated = changed ? await provisioning.updateInstanceSettings(pool, id, patch) : inst;
|
|
903
912
|
if (changed) {
|
|
904
913
|
await provisioning.recordEvent(pool, {
|
|
@@ -947,14 +956,25 @@ module.exports = function provisioningRoutes() {
|
|
|
947
956
|
catch (err) {
|
|
948
957
|
// The value IS saved — say so; a 500 here would deny a durable write.
|
|
949
958
|
log.error('[provisioning] PATCH /provisioning/instances/:id/settings enqueue', err);
|
|
959
|
+
// The remedy this sentence names has to WORK (task 1003524): nothing is
|
|
960
|
+
// going to carry the saved value, so record the debt. Best-effort — a failed
|
|
961
|
+
// marker write must never turn a durable settings write into a 500.
|
|
962
|
+
await provisioning.recordSettingsPushOwed(pool, id, true).catch(() => {});
|
|
950
963
|
push = receipt({ queued: false, restart: true, message: 'Saved, but the restart could not be queued just now — send it again to apply it.' });
|
|
951
964
|
}
|
|
952
965
|
if (r && r.conflict) {
|
|
966
|
+
// Saved, but the slot is held by work that will not carry this change, so
|
|
967
|
+
// "send it again once that settles" has to re-queue when they do (task 1003524).
|
|
968
|
+
await provisioning.recordSettingsPushOwed(pool, id, true).catch(() => {});
|
|
953
969
|
return res.fail('intent_conflict', { status: 409,
|
|
954
970
|
message: `The change was saved, but a '${r.intent.action}' run is currently ${r.intent.state} for this project — send it again once that settles to apply it.`,
|
|
955
971
|
details: { action: r.intent.action, state: r.intent.state, recorded: true } });
|
|
956
972
|
}
|
|
957
973
|
if (r) {
|
|
974
|
+
// A push is now in hand — fresh, or a PENDING one that has not read the row
|
|
975
|
+
// yet — so either way it carries the current settings. Only written when
|
|
976
|
+
// there IS a debt, so the hot path stays a single UPDATE.
|
|
977
|
+
if (pushOwed) await provisioning.recordSettingsPushOwed(pool, id, false).catch(() => {});
|
|
958
978
|
push = receipt({ queued: r.created, restart: true, message: r.created
|
|
959
979
|
? 'Saved. Your project will restart to pick this up — until then it keeps its current setting.'
|
|
960
980
|
: 'Saved. A restart is already queued for this project and will carry this change.' });
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.677",
|
|
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.677",
|
|
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.677",
|
|
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",
|
package/scripts/gds/go-live.js
CHANGED
|
@@ -77,8 +77,32 @@ function isSafeToken(v) {
|
|
|
77
77
|
// rather than ignored: a typo'd `healthURL` that silently disables the health gate
|
|
78
78
|
// is precisely the class of quiet failure this pipeline keeps being bitten by.
|
|
79
79
|
// `ssh` is NOT required: a target with no ssh host is LOCAL (the box driving itself).
|
|
80
|
-
|
|
81
|
-
|
|
80
|
+
//
|
|
81
|
+
// `deployTimer` is NOT required either — that is CO-TENANT MODE (task 1003521). A
|
|
82
|
+
// provisioned instance under /srv/<host>/<slug> has no pull-deploy timer at all (only
|
|
83
|
+
// its <slug>-db-backup.timer), so requiring the field forced an operator to invent a
|
|
84
|
+
// unit name that the script would then try to `systemctl stop`. Absent, the stop and
|
|
85
|
+
// the restart are simply skipped: there is no `reset --hard` to race.
|
|
86
|
+
const REQUIRED_FIELDS = ['instanceDir', 'service', 'healthUrl', 'versionUrl'];
|
|
87
|
+
const OPTIONAL_FIELDS = ['ssh', 'local', 'deployTimer', 'deployService', 'npmEnvFile', 'runAs', 'backupDb', 'backupDir', 'env', 'registryPackage', 'pinMode'];
|
|
88
|
+
|
|
89
|
+
// How the bump is made durable — the other half of co-tenant mode (task 1003521).
|
|
90
|
+
//
|
|
91
|
+
// On the PLATFORM instance the repo of record is ours, and a pull-deploy
|
|
92
|
+
// `git reset --hard origin/main` reverts any pin that was not PUSHED (task 1002712 —
|
|
93
|
+
// thirteen releases were lost exactly that way). So the default is `push`.
|
|
94
|
+
//
|
|
95
|
+
// On a provisioned co-tenant the repo of record is the CUSTOMER's own GitHub repo, so
|
|
96
|
+
// that push is both wrong and, in practice, uncredentialed. With no pull-deploy timer
|
|
97
|
+
// nothing will revert the pin, so a local commit is enough to leave the tree clean:
|
|
98
|
+
// local-commit — commit the pin, do not push it
|
|
99
|
+
// leave-dirty — do not commit at all; the bump lives in the working tree by design
|
|
100
|
+
const PIN_MODES = ['push', 'local-commit', 'leave-dirty'];
|
|
101
|
+
const DEFAULT_PIN_MODE = 'push';
|
|
102
|
+
|
|
103
|
+
function pinModeOf(target) {
|
|
104
|
+
return (target && target.pinMode) || DEFAULT_PIN_MODE;
|
|
105
|
+
}
|
|
82
106
|
|
|
83
107
|
function validateTarget(name, t) {
|
|
84
108
|
const errors = [];
|
|
@@ -105,6 +129,10 @@ function validateTarget(name, t) {
|
|
|
105
129
|
if (typeof v !== 'boolean') errors.push(`target "${name}".local must be true or false`);
|
|
106
130
|
continue;
|
|
107
131
|
}
|
|
132
|
+
if (k === 'pinMode') { // an enum, never interpolated into a command
|
|
133
|
+
if (!PIN_MODES.includes(v)) errors.push(`target "${name}".pinMode must be one of ${PIN_MODES.join(', ')} (got ${JSON.stringify(v)})`);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
108
136
|
if (!isSafeToken(v)) errors.push(`target "${name}".${k} contains characters that are unsafe in a shell (got ${JSON.stringify(v)})`);
|
|
109
137
|
}
|
|
110
138
|
return errors;
|
|
@@ -123,14 +151,27 @@ const CONFIG_TEMPLATE = `{
|
|
|
123
151
|
"service": "my-instance.service", // systemd unit to restart
|
|
124
152
|
"healthUrl": "http://127.0.0.1:3002/healthz", // box-side, NOT the apex
|
|
125
153
|
"versionUrl": "http://127.0.0.1:3002/version", // box-side truth read back after
|
|
126
|
-
"deployTimer": "my-deploy.timer", // pull-deploy timer (its reset --hard is the hazard)
|
|
154
|
+
"deployTimer": "my-deploy.timer", // optional: pull-deploy timer (its reset --hard is the hazard)
|
|
127
155
|
"deployService": "my-deploy.service", // optional: forced tick proves the pin stuck
|
|
128
156
|
"npmEnvFile": "/etc/my/npm.env", // optional: root-only NPM_TOKEN for a PRIVATE package
|
|
129
157
|
"runAs": "me", // optional: unprivileged owner of the instance
|
|
130
158
|
"backupDb": "mydb", // optional: pg_dump before the bump
|
|
131
159
|
"backupDir": "~/backups",
|
|
132
160
|
"env": { "PGDATABASE": "mydb" }, // optional: load-bearing for migrate
|
|
133
|
-
"registryPackage": "@bongos/core"
|
|
161
|
+
"registryPackage": "@bongos/core",
|
|
162
|
+
"pinMode": "push" // optional: push (default) | local-commit | leave-dirty
|
|
163
|
+
},
|
|
164
|
+
"a-co-tenant": {
|
|
165
|
+
// A PROVISIONED instance: no pull-deploy timer to stop, and the repo of record
|
|
166
|
+
// is the customer's own GitHub repo — so the pin is committed, never pushed.
|
|
167
|
+
"ssh": "my-box",
|
|
168
|
+
"instanceDir": "/srv/my-host/their-slug",
|
|
169
|
+
"service": "their-slug.service",
|
|
170
|
+
"healthUrl": "http://127.0.0.1:3101/healthz",
|
|
171
|
+
"versionUrl": "http://127.0.0.1:3101/version",
|
|
172
|
+
"runAs": "their-slug",
|
|
173
|
+
"env": { "PGDATABASE": "their_slug" },
|
|
174
|
+
"pinMode": "local-commit"
|
|
134
175
|
}
|
|
135
176
|
}
|
|
136
177
|
}`;
|
|
@@ -455,7 +496,10 @@ function renderReport(r) {
|
|
|
455
496
|
|
|
456
497
|
L.push('');
|
|
457
498
|
L.push(` live reads back ${r.liveVersion || '(unread)'}`);
|
|
458
|
-
|
|
499
|
+
const mode = r.pinMode || DEFAULT_PIN_MODE;
|
|
500
|
+
if (mode === 'leave-dirty') L.push(' pin left in the working tree (pinMode leave-dirty)');
|
|
501
|
+
else if (r.pinCommitted) L.push(` pin committed yes${mode === 'local-commit' ? ' (local commit — this target\'s remote is not ours to push)' : ''}`);
|
|
502
|
+
else L.push(' pin committed NO — the next pull-deploy reset will revert this bump');
|
|
459
503
|
L.push('');
|
|
460
504
|
return L.join('\n');
|
|
461
505
|
}
|
|
@@ -506,36 +550,78 @@ echo "##END##"
|
|
|
506
550
|
};
|
|
507
551
|
}
|
|
508
552
|
|
|
553
|
+
// The `bongos upgrade` invocation, as its own function so the pin-mode wiring is
|
|
554
|
+
// assertable without a box.
|
|
555
|
+
//
|
|
556
|
+
// Both non-default modes pass --pin-no-push, which is what tells upgrade.js the repo of
|
|
557
|
+
// record is not ours: `local-commit` pairs it with --commit-pin (commit, no push), and
|
|
558
|
+
// `leave-dirty` sends it alone, which downgrades upgrade.js's "PIN NOT COMMITTED" alarm
|
|
559
|
+
// to a note. That alarm is correct on a pull-deploy instance and misleading here, and an
|
|
560
|
+
// alarm an operator learns to ignore is worse than no alarm.
|
|
561
|
+
function upgradeCommand(target, targetVersion) {
|
|
562
|
+
const mode = pinModeOf(target);
|
|
563
|
+
return [
|
|
564
|
+
`./node_modules/.bin/bongos upgrade`,
|
|
565
|
+
`--to ${targetVersion}`, '--registry',
|
|
566
|
+
`--instance ${target.instanceDir}`,
|
|
567
|
+
`--service ${target.service}`,
|
|
568
|
+
`--health-url ${target.healthUrl}`,
|
|
569
|
+
...(mode === 'leave-dirty' ? [] : ['--commit-pin']),
|
|
570
|
+
...(mode === 'push' ? [] : ['--pin-no-push']),
|
|
571
|
+
'--force',
|
|
572
|
+
].join(' ');
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// Whether the post-bump pin state is what this target ASKED for. Under `leave-dirty` a
|
|
576
|
+
// modified working tree is the configured outcome, so reporting it as the durability
|
|
577
|
+
// failure it is on a pull-deploy instance would make a correct co-tenant run read as
|
|
578
|
+
// broken — the one place the three modes must not share a verdict.
|
|
579
|
+
function pinStep(mode, pinDirty, head) {
|
|
580
|
+
if (mode === 'leave-dirty') {
|
|
581
|
+
return {
|
|
582
|
+
name: 'pin left in the working tree (pinMode leave-dirty)',
|
|
583
|
+
ok: true,
|
|
584
|
+
detail: pinDirty ? 'package.json/-lock MODIFIED by design — this target has no pull-deploy to revert it' : (head || null),
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
return {
|
|
588
|
+
name: mode === 'local-commit' ? 'pin committed locally (not pushed)' : 'pin committed + pushed',
|
|
589
|
+
ok: !pinDirty,
|
|
590
|
+
detail: pinDirty ? 'package.json/-lock still MODIFIED — the next reset --hard reverts this bump' : (head || null),
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
|
|
509
594
|
function applyGoLive(target, targetName, targetVersion, from, opts = {}) {
|
|
510
595
|
const pkg = target.registryPackage || '@bongos/core';
|
|
596
|
+
const pinMode = pinModeOf(target);
|
|
511
597
|
const steps = [];
|
|
512
598
|
const src = target.npmEnvFile ? `set -a; . ${target.npmEnvFile}; set +a` : 'true';
|
|
513
599
|
|
|
514
600
|
// Phase A — stop the pull-deploy timer, then back up. The timer's `reset --hard`
|
|
515
|
-
// is the hazard: it would revert an uncommitted pin mid-flight.
|
|
601
|
+
// is the hazard: it would revert an uncommitted pin mid-flight. A co-tenant has no
|
|
602
|
+
// such timer (see REQUIRED_FIELDS), so there is nothing to stop and no hazard.
|
|
516
603
|
const backup = target.backupDb
|
|
517
604
|
? asOwner(target, `bash -c "mkdir -p ${target.backupDir || '~/backups'} && pg_dump ${target.backupDb} | gzip > ${target.backupDir || '~/backups'}/pre-${targetVersion}-$(date -u +%Y%m%dT%H%M%SZ).sql.gz"`)
|
|
518
605
|
: 'echo "(no backupDb configured — skipping)"';
|
|
519
606
|
const phaseA = remote(target, `
|
|
520
607
|
set -euo pipefail
|
|
521
608
|
${SUDO_PREAMBLE}
|
|
522
|
-
echo "##TIMER##"; $
|
|
609
|
+
echo "##TIMER##"; ${target.deployTimer
|
|
610
|
+
? `$SUDO systemctl stop ${target.deployTimer} && $SUDO systemctl is-active ${target.deployTimer} || true`
|
|
611
|
+
: 'echo "(no deployTimer configured — no pull-deploy to stop)"'}
|
|
523
612
|
echo "##BACKUP##"; ${backup}
|
|
524
613
|
echo "##END##"
|
|
525
614
|
`, opts);
|
|
526
|
-
steps.push({
|
|
527
|
-
|
|
615
|
+
steps.push({
|
|
616
|
+
name: target.deployTimer ? `stop ${target.deployTimer} + backup` : 'backup (no pull-deploy timer)',
|
|
617
|
+
ok: phaseA.ok,
|
|
618
|
+
detail: phaseA.ok ? null : (phaseA.stderr || '').trim().split('\n').pop(),
|
|
619
|
+
});
|
|
620
|
+
if (!phaseA.ok) return { ok: false, steps, from, to: targetVersion, targetName, pinMode, failedAt: 'PREPARE', raw: phaseA };
|
|
528
621
|
|
|
529
622
|
// Phase B — the upgrade itself. --commit-pin makes the bump durable against the
|
|
530
623
|
// pull-deploy reset; auto-rollback stays ON (do NOT pass --no-rollback-on-failure).
|
|
531
|
-
const upgrade =
|
|
532
|
-
`./node_modules/.bin/bongos upgrade`,
|
|
533
|
-
`--to ${targetVersion}`, '--registry',
|
|
534
|
-
`--instance ${target.instanceDir}`,
|
|
535
|
-
`--service ${target.service}`,
|
|
536
|
-
`--health-url ${target.healthUrl}`,
|
|
537
|
-
'--commit-pin', '--force',
|
|
538
|
-
].join(' ');
|
|
624
|
+
const upgrade = upgradeCommand(target, targetVersion);
|
|
539
625
|
const phaseB = remote(target, `
|
|
540
626
|
set -uo pipefail
|
|
541
627
|
${src}
|
|
@@ -549,9 +635,10 @@ ${asOwner(target, `bash -c "cd ${target.instanceDir} && ${upgrade}"`, { needsNpm
|
|
|
549
635
|
});
|
|
550
636
|
if (!phaseB.ok || rolledBack) {
|
|
551
637
|
// Restart the timer even on failure — leaving it stopped silently disables
|
|
552
|
-
// the instance's pull-deploy until someone notices.
|
|
553
|
-
|
|
554
|
-
|
|
638
|
+
// the instance's pull-deploy until someone notices. Nothing was stopped on a
|
|
639
|
+
// co-tenant, so there is nothing to put back.
|
|
640
|
+
if (target.deployTimer) remote(target, `${SUDO_PREAMBLE}\n$SUDO systemctl start ${target.deployTimer} || true`, opts);
|
|
641
|
+
return { ok: false, steps, from, to: targetVersion, targetName, pinMode, failedAt: 'UPGRADE', rolledBack, raw: phaseB };
|
|
555
642
|
}
|
|
556
643
|
|
|
557
644
|
// Phase C — restart the timer, force one tick, and read back the BOX-SIDE truth.
|
|
@@ -560,7 +647,7 @@ ${asOwner(target, `bash -c "cd ${target.instanceDir} && ${upgrade}"`, { needsNpm
|
|
|
560
647
|
const phaseC = remote(target, `
|
|
561
648
|
set -uo pipefail
|
|
562
649
|
${SUDO_PREAMBLE}
|
|
563
|
-
echo "##TIMERSTART##"; $SUDO systemctl start ${target.deployTimer}; $SUDO systemctl is-active ${target.deployTimer}
|
|
650
|
+
${target.deployTimer ? `echo "##TIMERSTART##"; $SUDO systemctl start ${target.deployTimer}; $SUDO systemctl is-active ${target.deployTimer}` : ''}
|
|
564
651
|
echo "##PIN##"; ( cd ${target.instanceDir} && ${target.runAs ? `sudo -u ${target.runAs} -H ` : ''}git status --porcelain -- package.json package-lock.json )
|
|
565
652
|
echo "##HEAD##"; ( cd ${target.instanceDir} && ${target.runAs ? `sudo -u ${target.runAs} -H ` : ''}git log -1 --format='%h %s' )
|
|
566
653
|
${target.deployService ? `echo "##TICK##"; $SUDO systemctl start ${target.deployService} || true; $SUDO journalctl -u ${target.deployService} -n 20 --no-pager | tail -8` : ''}
|
|
@@ -578,13 +665,13 @@ echo "##END##"
|
|
|
578
665
|
let liveVersion = null;
|
|
579
666
|
try { liveVersion = JSON.parse(section('VERSION') || '{}').coreVersion || null; } catch { liveVersion = null; }
|
|
580
667
|
|
|
581
|
-
steps.push({ name: `restart ${target.deployTimer}`, ok: (section('TIMERSTART') || '').includes('active') });
|
|
582
|
-
steps.push(
|
|
668
|
+
if (target.deployTimer) steps.push({ name: `restart ${target.deployTimer}`, ok: (section('TIMERSTART') || '').includes('active') });
|
|
669
|
+
steps.push(pinStep(pinMode, pinDirty, section('HEAD')));
|
|
583
670
|
steps.push({ name: 'installed package version', ok: installed === targetVersion, detail: installed === targetVersion ? installed : `reports ${installed}, expected ${targetVersion}` });
|
|
584
671
|
steps.push({ name: 'live reads back (box-side)', ok: liveVersion === targetVersion, detail: liveVersion === targetVersion ? liveVersion : `reports ${liveVersion}, expected ${targetVersion}` });
|
|
585
672
|
|
|
586
673
|
const ok = steps.every((s) => s.ok);
|
|
587
|
-
return { ok, steps, from, to: targetVersion, targetName, liveVersion, installed, pinCommitted: !pinDirty, raw: phaseC };
|
|
674
|
+
return { ok, steps, from, to: targetVersion, targetName, pinMode, liveVersion, installed, pinCommitted: !pinDirty, raw: phaseC };
|
|
588
675
|
}
|
|
589
676
|
|
|
590
677
|
// ---------------------------------------------------------------------------
|
|
@@ -608,6 +695,11 @@ go-live — move an enrolled instance's core pin, as one command.
|
|
|
608
695
|
node scripts/gds/go-live.js --no-divergence skip the git-derived checks (needs --to)
|
|
609
696
|
node scripts/gds/go-live.js --json machine-readable
|
|
610
697
|
|
|
698
|
+
CO-TENANT TARGETS. A provisioned instance has no pull-deploy timer and its repo of
|
|
699
|
+
record is the customer's own, so omit "deployTimer" (the stop/restart is skipped) and
|
|
700
|
+
set "pinMode": "local-commit" (the pin is committed, never pushed). "leave-dirty"
|
|
701
|
+
leaves the bump in the working tree; "push" (the default) is the platform instance.
|
|
702
|
+
|
|
611
703
|
Host topology is NOT baked into the core. ${DEFAULT_CONFIG} (host-owned, not published):
|
|
612
704
|
|
|
613
705
|
${CONFIG_TEMPLATE}
|
|
@@ -770,5 +862,6 @@ if (require.main === module) {
|
|
|
770
862
|
module.exports = {
|
|
771
863
|
loadConfig, selectTarget, validateTarget, planGoLive, parseMarker, parseMarkerHits,
|
|
772
864
|
renderPlan, renderReport, failureExcerpt, asOwner, isSafeToken, NPM_AUTH_PREAMBLE, refreshMain, isLocalTarget, gitRefExists, SUDO_PREAMBLE,
|
|
773
|
-
|
|
865
|
+
applyGoLive, upgradeCommand, pinStep, pinModeOf,
|
|
866
|
+
CONFIG_TEMPLATE, HALTS, SEMVER_RE, PIN_MODES, DEFAULT_PIN_MODE,
|
|
774
867
|
};
|
package/scripts/gds/upgrade.js
CHANGED
|
@@ -160,7 +160,12 @@ function dirtyPinFiles(instanceDir, run = spawnSync) {
|
|
|
160
160
|
return (r.stdout || '').split('\n').map((l) => l.slice(3).trim()).filter(Boolean);
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
-
|
|
163
|
+
// `push: false` is CO-TENANT MODE (task 1003521). The reasoning above assumes the repo
|
|
164
|
+
// of record is OURS; on a provisioned instance it is the customer's own GitHub repo, so
|
|
165
|
+
// pushing there is both wrong and uncredentialed. Such an instance also has no
|
|
166
|
+
// pull-deploy timer, so nothing will `reset --hard` the pin away — the commit alone
|
|
167
|
+
// makes it durable, and leaves the tree clean for the customer's own pulls.
|
|
168
|
+
function persistPin({ instanceDir, fromVersion, toVersion, commitPin, push = true, remote = 'origin', dryRun = false },
|
|
164
169
|
run = spawnSync, log = console.log, err = console.error) {
|
|
165
170
|
const dirty = dirtyPinFiles(instanceDir, run);
|
|
166
171
|
if (dirty === null) return { applicable: false, reason: 'not a git repo' };
|
|
@@ -169,6 +174,15 @@ function persistPin({ instanceDir, fromVersion, toVersion, commitPin, remote = '
|
|
|
169
174
|
const branch = gitCurrentBranch(instanceDir, run);
|
|
170
175
|
|
|
171
176
|
if (!commitPin) {
|
|
177
|
+
if (!push) {
|
|
178
|
+
// No pull-deploy of ours reaches this instance, so an uncommitted pin is untidy
|
|
179
|
+
// rather than doomed. Crying wolf here would teach an operator to ignore the
|
|
180
|
+
// warning that DOES matter on the platform instance.
|
|
181
|
+
err(` ! pin not committed — ${dirty.join(', ')} changed in the working tree only.`);
|
|
182
|
+
err(' This target pulls from a repo that is not ours, so nothing here will revert it —');
|
|
183
|
+
err(' but the tree stays dirty until it is committed. Re-run with --commit-pin to commit it.');
|
|
184
|
+
return { applicable: true, committed: false, pushed: false, dirty, warned: true };
|
|
185
|
+
}
|
|
172
186
|
// LOUD — this is the whole point. A silent uncommitted pin is the bug.
|
|
173
187
|
err(` ! PIN NOT COMMITTED — ${dirty.join(', ')} changed in the working tree only.`);
|
|
174
188
|
err(' On a pull-deploy instance the next push to the repo of record makes deploy.sh');
|
|
@@ -184,7 +198,9 @@ function persistPin({ instanceDir, fromVersion, toVersion, commitPin, remote = '
|
|
|
184
198
|
}
|
|
185
199
|
|
|
186
200
|
if (dryRun) {
|
|
187
|
-
log(
|
|
201
|
+
log(push
|
|
202
|
+
? ` [dry-run] would commit + push ${dirty.join(', ')} to ${remote}/${branch || 'main'}`
|
|
203
|
+
: ` [dry-run] would commit ${dirty.join(', ')} locally (no push — this remote is not ours)`);
|
|
188
204
|
return { applicable: true, committed: false, pushed: false, dirty, dryRun: true };
|
|
189
205
|
}
|
|
190
206
|
|
|
@@ -201,8 +217,13 @@ function persistPin({ instanceDir, fromVersion, toVersion, commitPin, remote = '
|
|
|
201
217
|
}
|
|
202
218
|
log(` ✓ committed the pin (${dirty.join(', ')})`);
|
|
203
219
|
|
|
204
|
-
|
|
205
|
-
|
|
220
|
+
if (!push) {
|
|
221
|
+
log(' ✓ not pushed, by request — this instance\'s repo of record is not ours, and no pull-deploy of ours can revert the commit');
|
|
222
|
+
return { applicable: true, committed: true, pushed: false, dirty, pushSkipped: true };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const pushed = run('git', ['push', remote, `HEAD:${branch || 'main'}`], { cwd: instanceDir, encoding: 'utf8' });
|
|
226
|
+
if (!pushed || pushed.error || pushed.status !== 0) {
|
|
206
227
|
// Committed but not pushed is still doomed: deploy.sh resets to the REMOTE.
|
|
207
228
|
err(` ! commit landed locally but the push to ${remote} FAILED — this bump is still not durable.`);
|
|
208
229
|
err(` Push it: git -C ${instanceDir} push ${remote} HEAD:${branch || 'main'}`);
|
|
@@ -984,6 +1005,7 @@ async function runUpgrade(opts, deps = {}) {
|
|
|
984
1005
|
fromVersion,
|
|
985
1006
|
toVersion: targetVersion,
|
|
986
1007
|
commitPin: opts.commitPin,
|
|
1008
|
+
push: !opts.pinNoPush,
|
|
987
1009
|
remote: opts.gitRemote || 'origin',
|
|
988
1010
|
dryRun: opts.dryRun,
|
|
989
1011
|
}, run, log, err);
|
|
@@ -1006,6 +1028,7 @@ function parseArgs(argv) {
|
|
|
1006
1028
|
pinPath: arg('--pin-path', argv),
|
|
1007
1029
|
registry: hasFlag('--registry', argv), // ADR 0134: pull @bongos/core@<--to> from the npm registry instead of vendoring
|
|
1008
1030
|
commitPin: hasFlag('--commit-pin', argv), // task 1002712: commit + push package.json/-lock so a pull-deploy reset cannot revert the bump
|
|
1031
|
+
pinNoPush: hasFlag('--pin-no-push', argv), // task 1003521 co-tenant mode: commit the pin but leave the customer's own remote alone
|
|
1009
1032
|
gitRemote: arg('--git-remote', argv),
|
|
1010
1033
|
dryRun: hasFlag('--dry-run', argv),
|
|
1011
1034
|
force: hasFlag('--force', argv),
|
|
@@ -1042,6 +1065,7 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
1042
1065
|
' --skip-pin-verify do not verify the integrity pin (tree_sha256 + tarball.sha256) after install',
|
|
1043
1066
|
' --require-pin treat a MISSING reference manifest as fatal (default: warn — a MISMATCH is always fatal)',
|
|
1044
1067
|
' --no-rollback-on-failure do NOT auto-revert on a failed install/pin-verify/migrate/health check (default: auto-rollback ON)',
|
|
1068
|
+
' --pin-no-push with --commit-pin: commit the pin but do NOT push it — for an instance whose repo of record is not ours (a provisioned co-tenant), where the push is wrong and no pull-deploy can revert the commit',
|
|
1045
1069
|
' --dry-run show the plan; change nothing',
|
|
1046
1070
|
' --force skip the clean-tree + same-version guards',
|
|
1047
1071
|
'',
|
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.677'; // 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');
|
package/tests/go_live.mjs
CHANGED
|
@@ -144,9 +144,11 @@ test('a typo’d field is reported, never silently ignored', () => {
|
|
|
144
144
|
|
|
145
145
|
test('missing required fields are named individually', () => {
|
|
146
146
|
const errs = G.validateTarget('t', { ssh: 'box' });
|
|
147
|
-
for (const f of ['instanceDir', 'service', 'healthUrl', 'versionUrl'
|
|
147
|
+
for (const f of ['instanceDir', 'service', 'healthUrl', 'versionUrl']) {
|
|
148
148
|
assert.match(errs.join(' '), new RegExp(`missing required field "${f}"`));
|
|
149
149
|
}
|
|
150
|
+
// deployTimer is co-tenant-optional (task 1003521) and must NOT be demanded.
|
|
151
|
+
assert.ok(!/missing required field "deployTimer"/.test(errs.join(' ')));
|
|
150
152
|
});
|
|
151
153
|
|
|
152
154
|
test('a valid target passes clean', () => {
|
|
@@ -393,7 +395,7 @@ test('ssh is OPTIONAL — a local target validates clean', () => {
|
|
|
393
395
|
|
|
394
396
|
test('the remaining required fields are still enforced for a local target', () => {
|
|
395
397
|
const errs = G.validateTarget('local', { instanceDir: '/i' });
|
|
396
|
-
for (const f of ['service', 'healthUrl', 'versionUrl'
|
|
398
|
+
for (const f of ['service', 'healthUrl', 'versionUrl']) {
|
|
397
399
|
assert.match(errs.join(' '), new RegExp(`missing required field "${f}"`));
|
|
398
400
|
}
|
|
399
401
|
// ssh must NOT be demanded any more.
|
|
@@ -411,3 +413,173 @@ test('gitRefExists reports rather than throwing', () => {
|
|
|
411
413
|
assert.equal(G.gitRefExists('x', { run: () => ({ status: 1 }) }), false);
|
|
412
414
|
assert.equal(G.gitRefExists('x', { run: () => ({ error: new Error('ENOENT') }) }), false);
|
|
413
415
|
});
|
|
416
|
+
|
|
417
|
+
// ---------------------------------------------------------------------------
|
|
418
|
+
// Co-tenant mode (task 1003521)
|
|
419
|
+
//
|
|
420
|
+
// go-live drove the PLATFORM instance and could not drive a provisioned co-tenant,
|
|
421
|
+
// for two structural reasons, so mercury had to be upgraded by hand from a box-side
|
|
422
|
+
// script — the exact one-command path this file exists to provide:
|
|
423
|
+
//
|
|
424
|
+
// 1. deployTimer was REQUIRED. A co-tenant has no pull-deploy timer at all (only
|
|
425
|
+
// its <slug>-db-backup.timer), so the config could not even be AUTHORED without
|
|
426
|
+
// inventing a unit name that the script would then try to `systemctl stop`.
|
|
427
|
+
// 2. The pin was always PUSHED. On a co-tenant the repo of record is the customer's
|
|
428
|
+
// own GitHub repo, so that push is both wrong and, in practice, uncredentialed.
|
|
429
|
+
//
|
|
430
|
+
// The assertions below are on the scripts applyGoLive COMPOSES and the verdicts it
|
|
431
|
+
// reaches — `run` is injected, so nothing here touches a box.
|
|
432
|
+
// ---------------------------------------------------------------------------
|
|
433
|
+
|
|
434
|
+
const coTenant = {
|
|
435
|
+
ssh: 'my-box',
|
|
436
|
+
instanceDir: '/srv/my-host/their-slug',
|
|
437
|
+
service: 'their-slug.service',
|
|
438
|
+
healthUrl: 'http://127.0.0.1:3101/healthz',
|
|
439
|
+
versionUrl: 'http://127.0.0.1:3101/version',
|
|
440
|
+
pinMode: 'local-commit',
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
// Canned phase-C output. The sections are what applyGoLive parses its verdicts from.
|
|
444
|
+
const phaseC = ({ timer = true, pin = '', version = '1.19.306', installed = '1.19.306' } = {}) => [
|
|
445
|
+
timer ? '##TIMERSTART##\nactive' : '',
|
|
446
|
+
`##PIN##\n${pin}`,
|
|
447
|
+
'##HEAD##\ndeadbee pin @bongos/core 1.19.306',
|
|
448
|
+
`##VERSION##\n{"coreVersion":"${version}"}`,
|
|
449
|
+
`##INSTALLED##\n${installed}`,
|
|
450
|
+
'##END##',
|
|
451
|
+
].filter(Boolean).join('\n');
|
|
452
|
+
|
|
453
|
+
// Records every script fed to a shell. `upgradeFails` drives the rollback path.
|
|
454
|
+
function fakeShell({ upgradeFails = false, phaseCOut = phaseC() } = {}) {
|
|
455
|
+
const scripts = [];
|
|
456
|
+
const fn = (cmd, args, opts = {}) => {
|
|
457
|
+
const input = opts.input || '';
|
|
458
|
+
scripts.push(input);
|
|
459
|
+
if (input.includes('bongos upgrade')) return { status: upgradeFails ? 1 : 0, stdout: '', stderr: upgradeFails ? 'boom' : '' };
|
|
460
|
+
if (input.includes('##PIN##')) return { status: 0, stdout: phaseCOut, stderr: '' };
|
|
461
|
+
return { status: 0, stdout: '##TIMER##\n##BACKUP##\n##END##', stderr: '' };
|
|
462
|
+
};
|
|
463
|
+
fn.scripts = scripts;
|
|
464
|
+
fn.all = () => scripts.join('\n');
|
|
465
|
+
return fn;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const stepNames = (r) => (r.steps || []).map((s) => s.name);
|
|
469
|
+
|
|
470
|
+
test('pinMode defaults to push, so an existing config keeps its behaviour', () => {
|
|
471
|
+
assert.equal(G.pinModeOf(okTarget), 'push');
|
|
472
|
+
assert.equal(G.pinModeOf({}), 'push');
|
|
473
|
+
assert.equal(G.pinModeOf(undefined), 'push');
|
|
474
|
+
assert.equal(G.pinModeOf({ pinMode: 'leave-dirty' }), 'leave-dirty');
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
test('a bogus pinMode is REFUSED, and each documented one validates', () => {
|
|
478
|
+
assert.match(G.validateTarget('t', { ...okTarget, pinMode: 'shove-it' }).join(' '), /pinMode must be one of/);
|
|
479
|
+
for (const m of G.PIN_MODES) assert.deepEqual(G.validateTarget('t', { ...okTarget, pinMode: m }), []);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
test('push mode pushes the pin; the two co-tenant modes never do', () => {
|
|
483
|
+
const cmd = (t) => G.upgradeCommand(t, '1.19.306');
|
|
484
|
+
|
|
485
|
+
// The platform instance: --commit-pin ALONE, which pushes (task 1002712 — thirteen
|
|
486
|
+
// releases were lost to an unpushed pin, so this is the regression that matters).
|
|
487
|
+
const push = cmd({ ...okTarget, pinMode: 'push' });
|
|
488
|
+
assert.ok(push.includes('--commit-pin'), 'push mode must still commit the pin');
|
|
489
|
+
assert.ok(!push.includes('--pin-no-push'), 'push mode must NOT suppress the push');
|
|
490
|
+
|
|
491
|
+
// local-commit: commit it, leave the customer's remote alone.
|
|
492
|
+
const local = cmd({ ...okTarget, pinMode: 'local-commit' });
|
|
493
|
+
assert.ok(local.includes('--commit-pin') && local.includes('--pin-no-push'));
|
|
494
|
+
|
|
495
|
+
// leave-dirty: no commit at all, and --pin-no-push so upgrade.js does not raise the
|
|
496
|
+
// pull-deploy alarm at an instance that has no pull-deploy.
|
|
497
|
+
const dirty = cmd({ ...okTarget, pinMode: 'leave-dirty' });
|
|
498
|
+
assert.ok(!dirty.includes('--commit-pin'), 'leave-dirty must not commit');
|
|
499
|
+
assert.ok(dirty.includes('--pin-no-push'));
|
|
500
|
+
|
|
501
|
+
// Unchanged for every mode: auto-rollback stays ON.
|
|
502
|
+
for (const c of [push, local, dirty]) assert.ok(!c.includes('--no-rollback-on-failure'));
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
test('a co-tenant target validates with NO deployTimer — the field that blocked the config', () => {
|
|
506
|
+
assert.deepEqual(G.validateTarget('a-co-tenant', coTenant), []);
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
test('with no deployTimer, nothing is stopped, started, or reported as restarted', () => {
|
|
510
|
+
const run = fakeShell({ phaseCOut: phaseC({ timer: false }) });
|
|
511
|
+
const r = G.applyGoLive(coTenant, 'a-co-tenant', '1.19.306', '1.19.300', { run });
|
|
512
|
+
|
|
513
|
+
assert.equal(r.ok, true, `expected a clean run, got ${JSON.stringify(stepNames(r))}`);
|
|
514
|
+
// The bug shape: an invented unit name reaching systemctl, or a literal `undefined`.
|
|
515
|
+
assert.ok(!/systemctl (stop|start)/.test(run.all()), 'must not touch a timer it has not got');
|
|
516
|
+
assert.ok(!run.all().includes('undefined'), 'no field may interpolate as undefined');
|
|
517
|
+
assert.ok(!stepNames(r).some((n) => /restart/.test(n)), 'no restart step for a timer that does not exist');
|
|
518
|
+
assert.match(stepNames(r)[0], /^backup \(no pull-deploy timer\)$/);
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
test('the platform instance still stops the timer and puts it back', () => {
|
|
522
|
+
const run = fakeShell();
|
|
523
|
+
const r = G.applyGoLive({ ...okTarget, deployTimer: 'inst-deploy.timer' }, 'web', '1.19.306', '1.19.300', { run });
|
|
524
|
+
|
|
525
|
+
assert.equal(r.ok, true);
|
|
526
|
+
assert.match(run.all(), /systemctl stop inst-deploy\.timer/);
|
|
527
|
+
assert.match(run.all(), /systemctl start inst-deploy\.timer/);
|
|
528
|
+
assert.ok(stepNames(r).includes('restart inst-deploy.timer'));
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
test('a failed upgrade restarts the timer it stopped — and asks for none when it stopped none', () => {
|
|
532
|
+
const withTimer = fakeShell({ upgradeFails: true });
|
|
533
|
+
const a = G.applyGoLive(okTarget, 'web', '1.19.306', '1.19.300', { run: withTimer });
|
|
534
|
+
assert.equal(a.ok, false);
|
|
535
|
+
assert.equal(a.failedAt, 'UPGRADE');
|
|
536
|
+
assert.match(withTimer.all(), /systemctl start inst-deploy\.timer/);
|
|
537
|
+
|
|
538
|
+
const coT = fakeShell({ upgradeFails: true });
|
|
539
|
+
const b = G.applyGoLive(coTenant, 'a-co-tenant', '1.19.306', '1.19.300', { run: coT });
|
|
540
|
+
assert.equal(b.ok, false);
|
|
541
|
+
assert.ok(!/systemctl start/.test(coT.all()), 'nothing was stopped, so nothing is restarted');
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
test('a dirty pin is the durability failure it is under push and local-commit', () => {
|
|
545
|
+
for (const pinMode of ['push', 'local-commit']) {
|
|
546
|
+
const run = fakeShell({ phaseCOut: phaseC({ pin: ' M package.json' }) });
|
|
547
|
+
const r = G.applyGoLive({ ...okTarget, pinMode }, 'web', '1.19.306', '1.19.300', { run });
|
|
548
|
+
assert.equal(r.ok, false, `${pinMode} must fail on an uncommitted pin`);
|
|
549
|
+
assert.equal(r.pinCommitted, false);
|
|
550
|
+
}
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
test('under leave-dirty the SAME dirty pin is the configured outcome, not a failure', () => {
|
|
554
|
+
const run = fakeShell({ phaseCOut: phaseC({ pin: ' M package.json' }) });
|
|
555
|
+
const r = G.applyGoLive({ ...okTarget, pinMode: 'leave-dirty' }, 'web', '1.19.306', '1.19.300', { run });
|
|
556
|
+
assert.equal(r.ok, true, `a correct leave-dirty run must not read as broken: ${JSON.stringify(stepNames(r))}`);
|
|
557
|
+
assert.match(stepNames(r).join(' '), /leave-dirty/);
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
test('the health and version gates are untouched by pin mode', () => {
|
|
561
|
+
// A co-tenant serving the WRONG version must still fail, or co-tenant mode would be
|
|
562
|
+
// a way to get a green report out of a bump that did not take.
|
|
563
|
+
const run = fakeShell({ phaseCOut: phaseC({ timer: false, version: '1.19.300' }) });
|
|
564
|
+
const r = G.applyGoLive(coTenant, 'a-co-tenant', '1.19.306', '1.19.300', { run });
|
|
565
|
+
assert.equal(r.ok, false);
|
|
566
|
+
assert.match(stepNames(r).join(' '), /live reads back/);
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
test('the report does not promise a push it did not make', () => {
|
|
570
|
+
const base = { targetName: 'x', from: '1.19.300', to: '1.19.306', steps: [], liveVersion: '1.19.306', ok: true };
|
|
571
|
+
assert.match(G.renderReport({ ...base, pinMode: 'push', pinCommitted: true }), /pin committed {5}yes$/m);
|
|
572
|
+
assert.match(G.renderReport({ ...base, pinMode: 'local-commit', pinCommitted: true }), /local commit/);
|
|
573
|
+
assert.match(G.renderReport({ ...base, pinMode: 'leave-dirty', pinCommitted: false }), /left in the working tree/);
|
|
574
|
+
// An absent pinMode (a failure return from an older path) still reads as push.
|
|
575
|
+
assert.match(G.renderReport({ ...base, pinCommitted: false }), /next pull-deploy reset will revert/);
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
test('the shipped co-tenant template is itself valid config', () => {
|
|
579
|
+
const r = G.loadConfig({ configPath: 'x.json', exists: () => true, readFile: () => G.CONFIG_TEMPLATE });
|
|
580
|
+
assert.ok(r.ok, `template did not parse: ${r.detail || ''}`);
|
|
581
|
+
const sel = G.selectTarget(r.config, 'a-co-tenant');
|
|
582
|
+
assert.ok(sel.ok, `co-tenant template target invalid: ${sel.detail || ''}`);
|
|
583
|
+
assert.equal(sel.target.pinMode, 'local-commit');
|
|
584
|
+
assert.equal(sel.target.deployTimer, undefined);
|
|
585
|
+
});
|
|
@@ -132,7 +132,19 @@ let enqueueCalls = [];
|
|
|
132
132
|
let enqueueResult = null; // what enqueueSettingsApply reports (null → created:true)
|
|
133
133
|
let enqueueThrows = null; // an Error to throw from enqueueSettingsApply
|
|
134
134
|
let events = [];
|
|
135
|
+
let owedCalls = []; // every recordSettingsPushOwed(id, owed) the route makes
|
|
135
136
|
provisioning.getInstanceById = async (_db, id) => (instance ? { ...instance, id } : null);
|
|
137
|
+
// The marker is a reserved key in the SAME settings jsonb, so the stub mutates the
|
|
138
|
+
// fixture the way the real UPDATE does — that is what lets a test re-send against
|
|
139
|
+
// the row its own previous save left behind.
|
|
140
|
+
const realRecordSettingsPushOwed = provisioning.recordSettingsPushOwed;
|
|
141
|
+
provisioning.recordSettingsPushOwed = async (_db, id, owed) => {
|
|
142
|
+
owedCalls.push({ id, owed });
|
|
143
|
+
const next = { ...(instance.settings || {}) };
|
|
144
|
+
if (owed) next.push_owed = true; else delete next.push_owed;
|
|
145
|
+
instance = { ...instance, settings: next };
|
|
146
|
+
return { id, settings: next };
|
|
147
|
+
};
|
|
136
148
|
provisioning.updateInstanceSettings = async (_db, id, patch) => {
|
|
137
149
|
updateCalls.push({ id, patch, enqueuedSoFar: enqueueCalls.length });
|
|
138
150
|
instance = { ...instance, settings: { ...(instance.settings || {}), ...patch } };
|
|
@@ -196,6 +208,7 @@ function reset({ inst, as } = {}) {
|
|
|
196
208
|
enqueueResult = null;
|
|
197
209
|
enqueueThrows = null;
|
|
198
210
|
events = [];
|
|
211
|
+
owedCalls = [];
|
|
199
212
|
}
|
|
200
213
|
// every receipt carries the same four keys, and never a duration promise
|
|
201
214
|
function receiptShape(push, { queued, restart, reachable }) {
|
|
@@ -354,6 +367,122 @@ await ta('ACTIVE dedicated: stored, NO intent, reachable:false, restart:true (a
|
|
|
354
367
|
assert.doesNotMatch(r.body.push.message, /web\.env/, 'no file the owner has never seen');
|
|
355
368
|
});
|
|
356
369
|
|
|
370
|
+
console.log("\nthe owed push — making \"send it again\" true (task 1003524):");
|
|
371
|
+
|
|
372
|
+
// THE HEADLINE, end to end, exactly as the bug was reported: the owner saves
|
|
373
|
+
// visibility (queued), saves joinability one card below while the first push is
|
|
374
|
+
// RUNNING (409), that run settles, and they do what the 409 told them to — re-send
|
|
375
|
+
// the SAME joinability. Before this fix the re-send was unchanged, so it took the
|
|
376
|
+
// "No change" branch and queued nothing: the value sat on the platform forever and
|
|
377
|
+
// the only way out was toggling to a third value and back.
|
|
378
|
+
await ta('save A queues → save B 409s while A RUNS → A settles → the UNCHANGED re-send of B queues a NEW push', async () => {
|
|
379
|
+
reset();
|
|
380
|
+
|
|
381
|
+
// 1. visibility: a clean queue.
|
|
382
|
+
let r = await patch('/provisioning/instances/18/settings', { platform_visibility: 'gated' });
|
|
383
|
+
assert.equal(r.status, 200, r.raw);
|
|
384
|
+
assert.equal(r.body.push.queued, true);
|
|
385
|
+
assert.equal(enqueueCalls.length, 1);
|
|
386
|
+
|
|
387
|
+
// 2. joinability, while that push is RUNNING — saved, but nothing carries it.
|
|
388
|
+
enqueueResult = { intent: { id: 5, action: 'settings-apply', state: 'running' }, created: false, conflict: 'open_intent' };
|
|
389
|
+
r = await patch('/provisioning/instances/18/settings', { joinability: 'open' });
|
|
390
|
+
assert.equal(r.status, 409, r.raw);
|
|
391
|
+
assert.equal(r.body.error.details.recorded, true, 'the value IS stored');
|
|
392
|
+
assert.match(r.body.error.message, /send it again once that settles/i, 'the remedy this test exists to make true');
|
|
393
|
+
assert.deepEqual(owedCalls, [{ id: 18, owed: true }], 'the debt is recorded at the moment the push is refused');
|
|
394
|
+
|
|
395
|
+
// 3. that run settles; the owner re-sends the SAME joinability.
|
|
396
|
+
enqueueResult = null; // the slot is free again
|
|
397
|
+
const enqueuedBefore = enqueueCalls.length;
|
|
398
|
+
const writesBefore = updateCalls.length;
|
|
399
|
+
r = await patch('/provisioning/instances/18/settings', { joinability: 'open' });
|
|
400
|
+
|
|
401
|
+
assert.equal(r.status, 200, r.raw);
|
|
402
|
+
assert.doesNotMatch(r.body.push.message, /No change/, 'the whole bug: the remedy used to be a no-op');
|
|
403
|
+
assert.equal(r.body.push.queued, true, 'a NEW push is queued');
|
|
404
|
+
assert.equal(enqueueCalls.length, enqueuedBefore + 1, 'exactly one new intent');
|
|
405
|
+
assert.equal(updateCalls.length, writesBefore, 'nothing re-written — the value was already stored in step 2');
|
|
406
|
+
assert.deepEqual(owedCalls[owedCalls.length - 1], { id: 18, owed: false }, 'and the debt is settled');
|
|
407
|
+
assert.deepEqual(r.body.saved, { platform_visibility: 'gated', joinability: 'open', visibility: 'public', join_grant: 'full' },
|
|
408
|
+
'the value that finally ships is the one the owner saved');
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
await ta('an enqueue that THROWS records the debt too — its "send it again" is the same sentence', async () => {
|
|
412
|
+
reset();
|
|
413
|
+
enqueueThrows = new Error('db down');
|
|
414
|
+
const r = await patch('/provisioning/instances/18/settings', { joinability: 'open' });
|
|
415
|
+
assert.equal(r.status, 200, r.raw);
|
|
416
|
+
assert.match(r.body.push.message, /send it again/i);
|
|
417
|
+
assert.deepEqual(owedCalls, [{ id: 18, owed: true }]);
|
|
418
|
+
|
|
419
|
+
// and the re-send it names now works
|
|
420
|
+
enqueueThrows = null;
|
|
421
|
+
const r2 = await patch('/provisioning/instances/18/settings', { joinability: 'open' });
|
|
422
|
+
assert.equal(r2.body.push.queued, true, 'the unchanged re-send queues');
|
|
423
|
+
assert.equal(updateCalls.length, 1, 'still only the original write');
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
await ta('a push already in hand settles the debt; with no debt outstanding nothing is written (the hot path stays one UPDATE)', async () => {
|
|
427
|
+
// an identical PENDING push (created:false, no conflict) has not read the row yet,
|
|
428
|
+
// so it carries this change — the debt is settled just as for a fresh intent.
|
|
429
|
+
reset({ inst: { ...ACTIVE, settings: { platform_visibility: 'gated', push_owed: true } } });
|
|
430
|
+
enqueueResult = { intent: { id: 5, action: 'settings-apply', state: 'pending' }, created: false };
|
|
431
|
+
const r = await patch('/provisioning/instances/18/settings', { platform_visibility: 'gated' });
|
|
432
|
+
assert.equal(r.status, 200, r.raw);
|
|
433
|
+
assert.deepEqual(owedCalls, [{ id: 18, owed: false }], 'a pending push carries it — debt settled');
|
|
434
|
+
|
|
435
|
+
reset();
|
|
436
|
+
await patch('/provisioning/instances/18/settings', { platform_visibility: 'gated' });
|
|
437
|
+
assert.deepEqual(owedCalls, [], 'no debt, no bookkeeping write');
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
await ta('an owed push is a POLICY debt: a planet-only re-send neither re-kicks it nor clears it', async () => {
|
|
441
|
+
reset({ inst: { ...ACTIVE, settings: { planet_template: 'bead-obsidian-tint', push_owed: true } } });
|
|
442
|
+
const r = await patch('/provisioning/instances/18/settings', { planet_template: 'bead-obsidian-tint' });
|
|
443
|
+
assert.equal(r.status, 200, r.raw);
|
|
444
|
+
assert.equal(enqueueCalls.length, 0, 'the planet reaches no instance, so it re-kicks nothing');
|
|
445
|
+
assert.deepEqual(owedCalls, [], 'and the policy debt survives, still owed');
|
|
446
|
+
assert.equal(provisioning.settingsPushOwed(instance), true);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
await ta('the marker never leaks: not a vocabulary key, not in any projection, and a client cannot write it', async () => {
|
|
450
|
+
const row = { settings: { push_owed: true, joinability: 'open', planet_template: 'bead-obsidian-tint' } };
|
|
451
|
+
assert.equal(provisioning.settingsPushOwed(row), true);
|
|
452
|
+
assert.ok(!('push_owed' in provisioning.SETTINGS_VOCAB), 'not a policy setting');
|
|
453
|
+
assert.ok(!('push_owed' in provisioning.PLANET_VOCAB), 'not a planet setting');
|
|
454
|
+
assert.ok(!('push_owed' in provisioning.effectiveSettings(row)), 'never in the saved projection');
|
|
455
|
+
assert.ok(!('push_owed' in provisioning.planetChoice(row)), 'never in the planet projection');
|
|
456
|
+
const env = provisioning.settingsEnvVars(row, 'BONGOS');
|
|
457
|
+
for (const k of Object.keys(env)) assert.doesNotMatch(k, /PUSH_OWED/, 'never reaches the instance web.env');
|
|
458
|
+
assert.equal(provisioning.settingsPushOwed({ settings: {} }), false);
|
|
459
|
+
assert.equal(provisioning.settingsPushOwed({ settings: { push_owed: 'yes' } }), false, 'strictly true, never truthy');
|
|
460
|
+
assert.equal(provisioning.settingsPushOwed(null), false);
|
|
461
|
+
|
|
462
|
+
// strict validation refuses it on the way in, so it is server-only by construction
|
|
463
|
+
reset();
|
|
464
|
+
const r = await patch('/provisioning/instances/18/settings', { push_owed: true });
|
|
465
|
+
assert.equal(r.status, 400, r.raw);
|
|
466
|
+
assert.equal(updateCalls.length, 0);
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
await ta('recordSettingsPushOwed writes NOTHING but the marker — no status, no updated_at, and clearing deletes the key', async () => {
|
|
470
|
+
const seen = [];
|
|
471
|
+
const db = { query: async (sql, params) => { seen.push({ sql, params }); return { rows: [{ id: 18, settings: {} }] }; } };
|
|
472
|
+
await realRecordSettingsPushOwed(db, 18, true);
|
|
473
|
+
await realRecordSettingsPushOwed(db, 18, false);
|
|
474
|
+
for (const c of seen) {
|
|
475
|
+
assert.match(c.sql, /UPDATE provisioning_instances/);
|
|
476
|
+
assert.doesNotMatch(c.sql, /updated_at/, 'bookkeeping must not reshuffle the owner\'s card order');
|
|
477
|
+
assert.doesNotMatch(c.sql, /status/, 'and must not touch the lifecycle');
|
|
478
|
+
assert.match(c.sql, /settings - \$4::text/, 'clearing DELETES the key rather than storing false');
|
|
479
|
+
assert.equal(c.params[0], 18);
|
|
480
|
+
}
|
|
481
|
+
assert.equal(seen[0].params[1], true);
|
|
482
|
+
assert.equal(seen[1].params[1], false);
|
|
483
|
+
assert.equal(seen[0].params[3], 'push_owed', 'the reserved key, passed as a parameter not interpolated');
|
|
484
|
+
});
|
|
485
|
+
|
|
357
486
|
console.log('\nthe walls that did not move:');
|
|
358
487
|
|
|
359
488
|
await ta("another builder's PATCH → 404; nothing stored, nothing queued", async () => {
|
|
@@ -146,6 +146,67 @@ test('the copy-pasteable fix line is shell-quoted', () => {
|
|
|
146
146
|
assert.equal((cmd.match(/'/g) || []).length % 2, 0, 'every quote is closed');
|
|
147
147
|
});
|
|
148
148
|
|
|
149
|
+
// ── push: false — co-tenant mode (task 1003521) ─────────────────────────────
|
|
150
|
+
//
|
|
151
|
+
// The reasoning above assumes the repo of record is OURS. On a PROVISIONED instance it
|
|
152
|
+
// is the customer's own GitHub repo, so the push is both wrong and, in practice,
|
|
153
|
+
// uncredentialed — and with no pull-deploy timer nothing will reset the pin away, so
|
|
154
|
+
// the commit alone is durable. go-live.js passes --pin-no-push for such a target.
|
|
155
|
+
|
|
156
|
+
test('push: false commits the pin and stops there', () => {
|
|
157
|
+
const run = fakeRun({ status: DIRTY, 'rev-parse': ON_MAIN });
|
|
158
|
+
const r = persistPin({ ...base, commitPin: true, push: false }, run, quiet, quiet);
|
|
159
|
+
|
|
160
|
+
assert.equal(r.committed, true);
|
|
161
|
+
assert.equal(r.pushed, false);
|
|
162
|
+
assert.equal(r.pushSkipped, true);
|
|
163
|
+
assert.ok(!r.error, 'a skipped push is not a failure');
|
|
164
|
+
assert.ok(run.calls.some((c) => c === `git add ${PIN_FILES.join(' ')}`), 'still stages the pin files');
|
|
165
|
+
assert.ok(run.calls.some((c) => c.startsWith('git commit -m pin @bongos/core')), 'still commits');
|
|
166
|
+
assert.ok(!run.calls.some((c) => c.startsWith('git push')), 'must NOT touch a remote that is not ours');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('push defaults to true, so every existing caller still pushes', () => {
|
|
170
|
+
// The absent field must not silently become co-tenant mode: that is how thirteen
|
|
171
|
+
// releases were lost the first time.
|
|
172
|
+
const run = fakeRun({ status: DIRTY, 'rev-parse': ON_MAIN });
|
|
173
|
+
const r = persistPin({ ...base, commitPin: true }, run, quiet, quiet);
|
|
174
|
+
assert.equal(r.pushed, true);
|
|
175
|
+
assert.ok(run.calls.some((c) => c === 'git push origin HEAD:main'));
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('a co-tenant with no --commit-pin gets a NOTE, not the pull-deploy alarm', () => {
|
|
179
|
+
// The loud warning is correct on a pull-deploy instance and false here. An alarm an
|
|
180
|
+
// operator learns to ignore is worse than no alarm.
|
|
181
|
+
const warnings = [];
|
|
182
|
+
const run = fakeRun({ status: DIRTY, 'rev-parse': ON_MAIN });
|
|
183
|
+
const r = persistPin({ ...base, commitPin: false, push: false }, run, quiet, (m) => warnings.push(m));
|
|
184
|
+
|
|
185
|
+
assert.equal(r.warned, true);
|
|
186
|
+
assert.equal(r.committed, false);
|
|
187
|
+
const text = warnings.join('\n');
|
|
188
|
+
assert.ok(!/PIN NOT COMMITTED/.test(text), 'no shouting about a revert that cannot happen');
|
|
189
|
+
assert.ok(!/reset --hard/.test(text), 'and no claim that a pull-deploy will revert it');
|
|
190
|
+
assert.match(text, /not ours/, 'says why the push is off');
|
|
191
|
+
assert.match(text, /--commit-pin/, 'still names the fix for the dirty tree');
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('--dry-run under push: false does not promise a push', () => {
|
|
195
|
+
const logs = [];
|
|
196
|
+
const run = fakeRun({ status: DIRTY, 'rev-parse': ON_MAIN });
|
|
197
|
+
const r = persistPin({ ...base, commitPin: true, push: false, dryRun: true }, run, (m) => logs.push(m), quiet);
|
|
198
|
+
assert.equal(r.dryRun, true);
|
|
199
|
+
assert.ok(!/push/.test(logs.join('\n').replace(/no push/g, '')), `dry-run must not claim a push: ${logs.join('\n')}`);
|
|
200
|
+
assert.ok(!run.calls.some((c) => c.includes('git commit') || c.includes('git push')));
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test('a failed commit under push: false still reports the failure', () => {
|
|
204
|
+
const run = fakeRun({ status: DIRTY, 'rev-parse': ON_MAIN, commit: { status: 1, stdout: '' } });
|
|
205
|
+
const r = persistPin({ ...base, commitPin: true, push: false }, run, quiet, quiet);
|
|
206
|
+
assert.equal(r.committed, false);
|
|
207
|
+
assert.ok(!r.pushSkipped, 'nothing was skipped — the commit never happened');
|
|
208
|
+
});
|
|
209
|
+
|
|
149
210
|
// ── preflightDbIdentity (task 1002712) ──────────────────────────────────────
|
|
150
211
|
// migrate.sh uses peer auth, so `sudo bongos upgrade` connects as root, which is
|
|
151
212
|
// almost never a Postgres role. That surfaced as a raw FATAL mid-run, AFTER the
|