@bongos/core 1.20.31 → 1.20.32
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 +31 -16
- package/docs/module-api-changelog.md +2 -0
- package/docs/recipes/upgrading-the-core.md +1 -0
- package/modules/provisioning/migrations/provisioning_032_intent_remedied_reason.sql +19 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +6 -0
- package/scripts/gds/provision-core-upgrade.js +10 -0
- package/scripts/gds/provision.js +3 -0
- package/scripts/gds/upgrade-outcome.js +5 -1
- package/scripts/gds/wedge-remedy.js +157 -0
- package/src/module-api.js +1 -1
- package/tests/wedge_remedy.mjs +268 -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.20.
|
|
6
|
-
"core_contract": "1.20.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.20.32",
|
|
6
|
+
"core_contract": "1.20.32",
|
|
7
|
+
"source_commit": "22743b01c499a1cc3bd285e9b25d6efe937aa8a7",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-30T21:
|
|
9
|
+
"built_at": "2026-09-30T21:45:12.402Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 562,
|
|
13
13
|
"agent_docs_stubbed": 25,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 2669,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 3257,
|
|
20
|
+
"tree_sha256": "7de0ec4831b06324127256eef3791a481ef07b33654cd0ca8b2cc058ec0e14ef",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/ask-for-help/SKILL.md",
|
|
@@ -2802,7 +2802,7 @@
|
|
|
2802
2802
|
{
|
|
2803
2803
|
"path": "docs/module-api-changelog.md",
|
|
2804
2804
|
"mode": "0000644",
|
|
2805
|
-
"sha256": "
|
|
2805
|
+
"sha256": "bd97221b57256af19ec7b09b897cce082e54fca07e69500ccf485feba106317f"
|
|
2806
2806
|
},
|
|
2807
2807
|
{
|
|
2808
2808
|
"path": "docs/modules-contract.md",
|
|
@@ -3057,7 +3057,7 @@
|
|
|
3057
3057
|
{
|
|
3058
3058
|
"path": "docs/recipes/upgrading-the-core.md",
|
|
3059
3059
|
"mode": "0000644",
|
|
3060
|
-
"sha256": "
|
|
3060
|
+
"sha256": "6d44cfdcd7fa92d3b6c6514af69356d6e986d5443219251cd091685918e4e1f2"
|
|
3061
3061
|
},
|
|
3062
3062
|
{
|
|
3063
3063
|
"path": "docs/recipes/windows-builders.md",
|
|
@@ -7469,6 +7469,11 @@
|
|
|
7469
7469
|
"mode": "0000644",
|
|
7470
7470
|
"sha256": "a93d8946cbe4631b2af64b2e3638dca188981032a08f5d29d63e4d0adb22d235"
|
|
7471
7471
|
},
|
|
7472
|
+
{
|
|
7473
|
+
"path": "modules/provisioning/migrations/provisioning_032_intent_remedied_reason.sql",
|
|
7474
|
+
"mode": "0000644",
|
|
7475
|
+
"sha256": "73794821e3beca0129a435747e89dd5755b26f054dca0d8ca0b421392abf7127"
|
|
7476
|
+
},
|
|
7472
7477
|
{
|
|
7473
7478
|
"path": "modules/provisioning/module.json",
|
|
7474
7479
|
"mode": "0000644",
|
|
@@ -8987,12 +8992,12 @@
|
|
|
8987
8992
|
{
|
|
8988
8993
|
"path": "package-lock.json",
|
|
8989
8994
|
"mode": "0000644",
|
|
8990
|
-
"sha256": "
|
|
8995
|
+
"sha256": "cd508994cbd9d44ef721787e0eb7c803bfeda98459587d53ba726a36cff2581e"
|
|
8991
8996
|
},
|
|
8992
8997
|
{
|
|
8993
8998
|
"path": "package.json",
|
|
8994
8999
|
"mode": "0000644",
|
|
8995
|
-
"sha256": "
|
|
9000
|
+
"sha256": "5c53762b7665282aeca491b037cb2c02ee8c6def6c00018262596235d02bbb64"
|
|
8996
9001
|
},
|
|
8997
9002
|
{
|
|
8998
9003
|
"path": "public-docs/index.html",
|
|
@@ -9012,7 +9017,7 @@
|
|
|
9012
9017
|
{
|
|
9013
9018
|
"path": "release-notes.json",
|
|
9014
9019
|
"mode": "0000644",
|
|
9015
|
-
"sha256": "
|
|
9020
|
+
"sha256": "1f7d261d95f2fbaf1d0e438e9c3ad7f13773c5b56d349263815d5be03ebab401"
|
|
9016
9021
|
},
|
|
9017
9022
|
{
|
|
9018
9023
|
"path": "scripts/bongos-mcp.js",
|
|
@@ -9977,7 +9982,7 @@
|
|
|
9977
9982
|
{
|
|
9978
9983
|
"path": "scripts/gds/provision-core-upgrade.js",
|
|
9979
9984
|
"mode": "0000644",
|
|
9980
|
-
"sha256": "
|
|
9985
|
+
"sha256": "53904eb7a91f7f0c7c80c3caaceed29d6317652d6830e1f575198b0ba9f5158f"
|
|
9981
9986
|
},
|
|
9982
9987
|
{
|
|
9983
9988
|
"path": "scripts/gds/provision-disconnect.js",
|
|
@@ -10032,7 +10037,7 @@
|
|
|
10032
10037
|
{
|
|
10033
10038
|
"path": "scripts/gds/provision.js",
|
|
10034
10039
|
"mode": "0000644",
|
|
10035
|
-
"sha256": "
|
|
10040
|
+
"sha256": "dd972cc62ed8282af5f1239a3b728761fb06a414120cac8a39b38838e8301f7d"
|
|
10036
10041
|
},
|
|
10037
10042
|
{
|
|
10038
10043
|
"path": "scripts/gds/publish-credential-check.js",
|
|
@@ -10647,7 +10652,7 @@
|
|
|
10647
10652
|
{
|
|
10648
10653
|
"path": "scripts/gds/upgrade-outcome.js",
|
|
10649
10654
|
"mode": "0000644",
|
|
10650
|
-
"sha256": "
|
|
10655
|
+
"sha256": "3bd19ba68a09a6739379bdd626bcf5b93addccdf1cd8774e20857dc9a66686fb"
|
|
10651
10656
|
},
|
|
10652
10657
|
{
|
|
10653
10658
|
"path": "scripts/gds/upgrade.js",
|
|
@@ -10679,6 +10684,11 @@
|
|
|
10679
10684
|
"mode": "0000644",
|
|
10680
10685
|
"sha256": "cf034e9c4c5887dde47951272dba1e31b9b94a212640181de1cc3086e225b357"
|
|
10681
10686
|
},
|
|
10687
|
+
{
|
|
10688
|
+
"path": "scripts/gds/wedge-remedy.js",
|
|
10689
|
+
"mode": "0000644",
|
|
10690
|
+
"sha256": "abe6ecedc808f3f57ef2dc5e9f953792ae26f39625425952a8ce529dbf6de576"
|
|
10691
|
+
},
|
|
10682
10692
|
{
|
|
10683
10693
|
"path": "scripts/gds/worktree-claim-guard.js",
|
|
10684
10694
|
"mode": "0000644",
|
|
@@ -11142,7 +11152,7 @@
|
|
|
11142
11152
|
{
|
|
11143
11153
|
"path": "src/module-api.js",
|
|
11144
11154
|
"mode": "0000644",
|
|
11145
|
-
"sha256": "
|
|
11155
|
+
"sha256": "a1f03380a933a2c9fdcd63f55f0152bf7d36101135d171c729f7cd79c9ef0ec4"
|
|
11146
11156
|
},
|
|
11147
11157
|
{
|
|
11148
11158
|
"path": "src/module-loader/catalog.js",
|
|
@@ -16229,6 +16239,11 @@
|
|
|
16229
16239
|
"mode": "0000644",
|
|
16230
16240
|
"sha256": "456b20334164a710f59116d6a6c4b85090dc0e65668284d0a736e6ee1df5dea6"
|
|
16231
16241
|
},
|
|
16242
|
+
{
|
|
16243
|
+
"path": "tests/wedge_remedy.mjs",
|
|
16244
|
+
"mode": "0000644",
|
|
16245
|
+
"sha256": "9810e6b2cf5ec77fad5af12b195a3ff4919143b146ff43f106c67c4090c61c25"
|
|
16246
|
+
},
|
|
16232
16247
|
{
|
|
16233
16248
|
"path": "tests/wizard_born_first_board.mjs",
|
|
16234
16249
|
"mode": "0000644",
|
|
@@ -2719,5 +2719,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
2719
2719
|
landed since 1.20.29 with no explicit bump. run 36777226204. (task 1002620)
|
|
2720
2720
|
1.20.31 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2721
2721
|
landed since 1.20.30 with no explicit bump. run 36779142919. (task 1002620)
|
|
2722
|
+
1.20.32 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2723
|
+
landed since 1.20.31 with no explicit bump. run 36781343271. (task 1002620)
|
|
2722
2724
|
---------------------------------------------------------------------------
|
|
2723
2725
|
```
|
|
@@ -179,6 +179,7 @@ running a go-live.
|
|
|
179
179
|
|
|
180
180
|
## Gotchas
|
|
181
181
|
|
|
182
|
+
- **The Deploy door fixes three known snags by itself, once** (task 1004448, `scripts/gds/wedge-remedy.js`). When a core move through `/deploy` fails on a **dirty tree** (it commits the tracked changes inside the project), a **full disk** (it prunes that project's own nightly dumps down to the newest 7) or a **database behind its code** (it migrates that database with its own `PGDATABASE` before the code moves), the runner applies the fix, records a `core-upgrade-remedy` event on the project, and tries the move once more on the next tick. If the retry fails for the same reason, or the fix itself fails, the move is retired as `unknown` (`remedy_did_not_hold` / `remedy_failed`) for a person — never a second fix. The manual fixes below still apply to the unattended nightly sweep, which does not go through the door.
|
|
182
183
|
- **`--registry` needs no credential at all** (task 1003878). `@bongos/core` is **public** on npm (task 1003872), so `bongos upgrade --registry` resolves it from the default registry like any other dependency — no token, no login, and no instance `.npmrc`. An install failure on this path is therefore a *real* failure (network, registry outage, or a version that was never published), never a missing credential.
|
|
183
184
|
*History, so the symptom is recognisable on an old instance:* the core used to publish private, and the instance `.npmrc` read `_authToken=<redacted> npm gives that per-project key precedence, so with the env var unset it expanded EMPTY and **masked a perfectly valid literal token in `~/.npmrc`** — npm went anonymous, the private core 404'd, and the error blamed a credential the box actually held. Task 1002754 worked around it by resolving the token in-process; task 1003878 deleted the whole mechanism along with the `.npmrc` that caused it. An instance still carrying a committed `.npmrc` from a pre-1003878 scaffold can safely delete it.
|
|
184
185
|
- **A green `publish` run is not evidence of a publish.** The publish-on-merge lane succeeds on its no-op paths too (cascade terminator, armed-gate). Read the run's **job summary**, which now states the verdict explicitly: `PUBLISHED @bongos/core@<v>` vs `NO-OP — nothing was published this run`.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
-- provisioning_032_intent_remedied_reason.sql — which known snag the runner already fixed
|
|
2
|
+
-- for a core move (task 1004448, goal 1000090 deploy self-heal).
|
|
3
|
+
--
|
|
4
|
+
-- WHY. A core move that fails on a known snag (a dirty checkout, a full disk, a database
|
|
5
|
+
-- behind its code) now has the fix applied by the runner and is tried once more
|
|
6
|
+
-- (scripts/gds/wedge-remedy.js). "Once" has to survive between runner ticks, so the reason
|
|
7
|
+
-- the fix was applied for is kept on the intent. A retry that fails on the SAME reason is
|
|
8
|
+
-- retired for a person instead of fixed again — never a loop.
|
|
9
|
+
--
|
|
10
|
+
-- remedied_reason: a reason code from scripts/gds/upgrade-outcome.js (fixed vocabulary,
|
|
11
|
+
-- never builder text). NULL = no fix has been applied for this intent.
|
|
12
|
+
--
|
|
13
|
+
-- Additive and namespaced (ADR 0083): no down-migration, idempotent — safe to re-run.
|
|
14
|
+
|
|
15
|
+
BEGIN;
|
|
16
|
+
|
|
17
|
+
ALTER TABLE provisioning_intents ADD COLUMN IF NOT EXISTS remedied_reason text;
|
|
18
|
+
|
|
19
|
+
COMMIT;
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.20.
|
|
3
|
+
"version": "1.20.32",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.20.
|
|
9
|
+
"version": "1.20.32",
|
|
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.20.
|
|
3
|
+
"version": "1.20.32",
|
|
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/release-notes.json
CHANGED
|
@@ -8258,5 +8258,11 @@
|
|
|
8258
8258
|
"id": "1004447",
|
|
8259
8259
|
"text": "A software update that fails for a temporary reason now waits and tries again later — after 15 minutes, then an hour, then six hours — instead of hammering the same failing step three times in a row. Failures that retrying can"
|
|
8260
8260
|
}
|
|
8261
|
+
],
|
|
8262
|
+
"1.20.32": [
|
|
8263
|
+
{
|
|
8264
|
+
"id": "1004448",
|
|
8265
|
+
"text": "When an update to a project gets stuck on one of three known problems (leftover file changes, a full disk, or a database that is behind), the system now fixes it by itself and tries again once. If that doesn't work, it stops a"
|
|
8266
|
+
}
|
|
8261
8267
|
]
|
|
8262
8268
|
}
|
|
@@ -241,6 +241,16 @@ async function coreUpgradeInstance(inst, deps, intent) {
|
|
|
241
241
|
log(` already serving core ${to} — nothing to move`);
|
|
242
242
|
return { ok: true, noop: true };
|
|
243
243
|
}
|
|
244
|
+
// ---- 2b. is the database current for the code it runs NOW (task 1004448) -------------
|
|
245
|
+
// A database behind its own code is already failing every page that touches the missing
|
|
246
|
+
// tables (the 2026-07-04 shape), and moving the code on top of it tangles a catch-up
|
|
247
|
+
// migration into a pin move. So it is refused as a known snag, and wedge-remedy.js
|
|
248
|
+
// migrates that database and tries the move once more. An answer it cannot read proceeds.
|
|
249
|
+
if (apply) {
|
|
250
|
+
const probe = boxExec(versionCmd(inst), { allowFail: true });
|
|
251
|
+
const pending = parseSchemaPending(probe && !probe.softFailed ? (probe.stdout || '') : null);
|
|
252
|
+
if (pending > 0) return failed('schema_pending', `the project's database is ${pending} migration(s) behind the code it is running now — it has to catch up before the core moves`);
|
|
253
|
+
}
|
|
244
254
|
|
|
245
255
|
// ---- 3. drive the proven path -----------------------------------------------------
|
|
246
256
|
// Exactly the invocation docs/recipes/core-release-pipeline.md gate 3 documents, plus
|
package/scripts/gds/provision.js
CHANGED
|
@@ -1196,6 +1196,9 @@ async function cmdRunIntents(deps) {
|
|
|
1196
1196
|
// WAIT OR RETIRE (task 1004447, intent-retry.js): a transient failure waits ~15m/1h/6h; a
|
|
1197
1197
|
// classified non-transient one is retired at once (retrying cannot change its answer);
|
|
1198
1198
|
// an unclassified one keeps its budget but never re-runs in the same tick.
|
|
1199
|
+
const fix = await require('./wedge-remedy.js').remedyAfterFailure({ intent, inst, result, deps }); // a KNOWN snag: fix it, try once more (task 1004448)
|
|
1200
|
+
if (fix.repended) { errors++; continue; }
|
|
1201
|
+
Object.assign(result, { failure: fix.failure, error: fix.error });
|
|
1199
1202
|
const next = retry.retryDecision({ attempts: intent.attempts, failure: result.failure, terminal: result.terminal, maxAttempts: MAX_INTENT_ATTEMPTS });
|
|
1200
1203
|
if (next.action === 'retire') { // terminal: a leg that must not be retried unattended (the Render leg's key is already gone — task 1004183)
|
|
1201
1204
|
await provisioning.resolveIntent(db, intent.id, 'error', result.error || 'op failed');
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
//
|
|
20
20
|
// THE FOUR CLASSES the rest of the self-heal chain keys on:
|
|
21
21
|
// transient — try again later and it may simply work (registry blip, not published yet)
|
|
22
|
-
// known_wedge — a known snag with a known fix the runner
|
|
22
|
+
// known_wedge — a known snag with a known fix the runner applies itself, once (wedge-remedy.js)
|
|
23
23
|
// needs_decision — a rule the owner set is refusing it; only the owner can change that
|
|
24
24
|
// unknown — anything else, and every rollback that failed; a person should look
|
|
25
25
|
//
|
|
@@ -42,6 +42,7 @@ const REASON_CLASS = Object.freeze({
|
|
|
42
42
|
bad_slug: 'unknown',
|
|
43
43
|
installed_unreadable: 'unknown',
|
|
44
44
|
served_mismatch: 'unknown',
|
|
45
|
+
schema_pending: 'known_wedge', // the database is behind the code it runs NOW (task 1004448)
|
|
45
46
|
// upgrade.js refusals, before the pin moves
|
|
46
47
|
dirty_tree: 'known_wedge',
|
|
47
48
|
downgrade: 'needs_decision',
|
|
@@ -59,6 +60,9 @@ const REASON_CLASS = Object.freeze({
|
|
|
59
60
|
migrate: 'unknown',
|
|
60
61
|
restart: 'unknown',
|
|
61
62
|
health: 'unknown',
|
|
63
|
+
// the runner's one automatic fix did not clear it, or itself failed (task 1004448)
|
|
64
|
+
remedy_did_not_hold: 'unknown',
|
|
65
|
+
remedy_failed: 'unknown',
|
|
62
66
|
// the one that must never be retried unattended
|
|
63
67
|
rollback_failed: 'unknown',
|
|
64
68
|
});
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
//
|
|
3
|
+
// scripts/gds/wedge-remedy.js — a core move that failed on a KNOWN snag fixes the snag and
|
|
4
|
+
// tries once more (task 1004448, goal 1000090 deploy self-heal).
|
|
5
|
+
//
|
|
6
|
+
// WHY. upgrade-outcome.js names three failures that are not a fault in the release and not a
|
|
7
|
+
// decision the owner has to make, but a mess on the box with one known fix — the fixes the
|
|
8
|
+
// owner has been doing over SSH (docs/recipes/upgrading-the-core.md, Gotchas):
|
|
9
|
+
// dirty_tree — the project's checkout has uncommitted changes to tracked files (the
|
|
10
|
+
// upgrade's own rewrites, left behind by an earlier move). Fix: commit them
|
|
11
|
+
// inside the project.
|
|
12
|
+
// disk_full — the move ran out of disk. Fix: prune this project's OWN nightly
|
|
13
|
+
// pg_dumps down to the newest few (the only thing on the box this runner
|
|
14
|
+
// knows is safe to delete — they are copies, and the newest are kept).
|
|
15
|
+
// schema_pending — the project's database is behind the code it is running NOW. Fix:
|
|
16
|
+
// migrate that database, with its own PGDATABASE, before the code moves.
|
|
17
|
+
// intent-retry.js retires every known_wedge at once, so on its own the failure only waited
|
|
18
|
+
// for a person. This file runs the fix, records it as an event on the project, and re-pends
|
|
19
|
+
// the move for ONE more try.
|
|
20
|
+
//
|
|
21
|
+
// AT MOST ONCE PER FAILURE. The reason a fix was applied for is stored on the intent
|
|
22
|
+
// (provisioning_intents.remedied_reason, migration provisioning_032). If the retry fails on
|
|
23
|
+
// the SAME reason, the fix did not hold: the move is retired as `unknown` /
|
|
24
|
+
// remedy_did_not_hold, which is the class that reaches a person — never a second fix, never
|
|
25
|
+
// a loop. A fix that itself fails is retired the same way (remedy_failed). A different
|
|
26
|
+
// known reason on the retry gets its own one fix (a disk full after a dirty tree is a new
|
|
27
|
+
// snag, not the same one twice); the attempt budget still bounds the whole run.
|
|
28
|
+
//
|
|
29
|
+
// 'tagged-not-published' needs nothing here: it is `not_published`, a transient failure that
|
|
30
|
+
// intent-retry.js already waits out.
|
|
31
|
+
//
|
|
32
|
+
// planRemedy is PURE (the command, never run). remedyAfterFailure does the I/O through the
|
|
33
|
+
// runner's own exec and db, so every fix is testable with an injected exec.
|
|
34
|
+
|
|
35
|
+
const { CONFIG } = require('./provision-config.js');
|
|
36
|
+
const { dbName, selfInstanceRoot, standaloneRoot } = require('./provision-repo.js');
|
|
37
|
+
const { migratePlan, CORE_MIGRATE_SCRIPT } = require('./upgrade-migrate.js');
|
|
38
|
+
const { isValidSlug } = require('../../modules/provisioning/provisioning.js');
|
|
39
|
+
const outcome = require('./upgrade-outcome.js');
|
|
40
|
+
const { DEFAULT_RETRY_DELAY_MS } = require('./intent-retry.js');
|
|
41
|
+
|
|
42
|
+
const REMEDY_REASONS = Object.freeze(['dirty_tree', 'disk_full', 'schema_pending']);
|
|
43
|
+
// The newest dumps a disk-full prune always leaves behind: a week of nightlies. The nightly
|
|
44
|
+
// job's own retention is 30 days; this only ever cuts below that when the disk is full.
|
|
45
|
+
const DUMP_KEEP_FLOOR = 7;
|
|
46
|
+
const REMEDY_EVENT = 'core-upgrade-remedy';
|
|
47
|
+
// A value that reaches a shell string below. Narrower than what Postgres or a path allow, on
|
|
48
|
+
// purpose: anything outside it is refused, never quoted.
|
|
49
|
+
const SHELL_SAFE_RE = /^[A-Za-z0-9._/-]{1,200}$/;
|
|
50
|
+
const DB_NAME_RE = /^[A-Za-z0-9_-]{1,63}$/;
|
|
51
|
+
const ACCOUNT_RE = /^[a-z_][a-z0-9_-]{0,31}$/;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The fix for one known snag, as a command to run on the box. PURE.
|
|
55
|
+
* @param {object} inst the provisioning_instances row
|
|
56
|
+
* @param {string} reason the failure's reason code
|
|
57
|
+
* @param {object} [o] { privileged, runAs, instanceDir, selfRoot, backupDir, migrate }
|
|
58
|
+
* @returns {{ reason, cmd, cwd, env, describe } | { refused: string } | null} null: no known fix
|
|
59
|
+
*/
|
|
60
|
+
function planRemedy(inst, reason, o = {}) {
|
|
61
|
+
if (!REMEDY_REASONS.includes(reason)) return null;
|
|
62
|
+
if (!inst || !isValidSlug(inst.slug)) return { refused: 'the project name is not a valid slug' };
|
|
63
|
+
const dir = o.instanceDir || (inst.hosting_shape === 'cloud-host' ? standaloneRoot(inst) : (o.selfRoot || selfInstanceRoot)());
|
|
64
|
+
if (!SHELL_SAFE_RE.test(dir)) return { refused: 'the project folder is not a path this runner will put in a command' };
|
|
65
|
+
const db = dbName(inst);
|
|
66
|
+
if (!DB_NAME_RE.test(db)) return { refused: 'the project database name is not one this runner will put in a command' };
|
|
67
|
+
|
|
68
|
+
if (reason === 'dirty_tree') {
|
|
69
|
+
// TRACKED files only (`add -u`): the snag is the upgrade's own rewrites of tracked files.
|
|
70
|
+
// A new untracked file is not swept into the owner's history on a guess — if that is the
|
|
71
|
+
// dirt, the retry fails the same way and a person looks. Runs as the runner, which owns
|
|
72
|
+
// the checkout (pushUpgradePin's shape), with hooks off and a runner identity so the
|
|
73
|
+
// commit says who made it.
|
|
74
|
+
const g = `git -C ${dir} -c core.hooksPath=/dev/null -c user.name=bongos-runner -c user.email=runner@bongos.invalid`;
|
|
75
|
+
return {
|
|
76
|
+
reason, cwd: null, env: null,
|
|
77
|
+
cmd: `${g} add -u && ${g} commit -m "chore: commit changes left in the working tree (automatic fix before a core move)"`,
|
|
78
|
+
describe: `committed the uncommitted changes in ${dir} so the core move could run`,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
if (reason === 'disk_full') {
|
|
82
|
+
const backupDir = o.backupDir || CONFIG.backupDir;
|
|
83
|
+
if (!SHELL_SAFE_RE.test(backupDir)) return { refused: 'the backup folder is not a path this runner will put in a command' };
|
|
84
|
+
// EXACTLY db-backup-nightly.sh's name, so this never touches a sibling project's dumps
|
|
85
|
+
// (`test` must not prune `test-nk`'s — task 1003948). The timestamp sorts by name, so
|
|
86
|
+
// `sort | head -n -K` drops all but the newest K. Nothing is deleted when K or fewer exist.
|
|
87
|
+
const D = '[0-9]';
|
|
88
|
+
const glob = `${db}-${D}${D}${D}${D}-${D}${D}-${D}${D}T${D}${D}${D}${D}${D}${D}Z.sql.gz`;
|
|
89
|
+
return {
|
|
90
|
+
reason, cwd: null, env: null,
|
|
91
|
+
cmd: `find ${backupDir} -maxdepth 1 -type f -name '${glob}' | sort | head -n -${DUMP_KEEP_FLOOR} | xargs -r rm -f --`,
|
|
92
|
+
describe: `pruned ${db}'s old database backups in ${backupDir}, keeping the newest ${DUMP_KEEP_FLOOR}`,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
// schema_pending — the project's OWN migrate command (a declared script wins, else the
|
|
96
|
+
// core's migrate.sh: upgrade-migrate.js), aimed at its OWN database. migrate.sh defaults to
|
|
97
|
+
// production when PGDATABASE is unset, so it is always set, never inherited.
|
|
98
|
+
const plan = (o.migrate || migratePlan)(dir);
|
|
99
|
+
const run = plan.via === 'npm-script' ? 'npm run migrate' : `bash ${CORE_MIGRATE_SCRIPT}`;
|
|
100
|
+
const initCwd = plan.via === 'npm-script' ? '' : `INIT_CWD=${dir} `;
|
|
101
|
+
if (o.privileged) {
|
|
102
|
+
if (!ACCOUNT_RE.test(String(o.runAs || ''))) return { refused: 'no account to run the migration as' };
|
|
103
|
+
return { reason, cwd: dir, env: null, cmd: `sudo -u ${o.runAs} env PGDATABASE=${db} ${initCwd}${run}`, describe: `migrated ${db} to match the code it is running` };
|
|
104
|
+
}
|
|
105
|
+
return { reason, cwd: dir, env: { PGDATABASE: db, ...(initCwd ? { INIT_CWD: dir } : {}) }, cmd: run, describe: `migrated ${db} to match the code it is running` };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// The account the project runs as, probed the way the move itself probes (task 1004128).
|
|
109
|
+
function runAsFor(inst, deps) {
|
|
110
|
+
try { return require('./provision-core-upgrade.js').resolveRunAs(inst, deps).runAs; } catch { return null; }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* After a core move failed: apply the known fix, record it, and re-pend the move once.
|
|
115
|
+
*
|
|
116
|
+
* Returns `{ repended: true }` when the move will be tried again, otherwise
|
|
117
|
+
* `{ repended: false, failure, error }` — the failure the drain loop should RETIRE with,
|
|
118
|
+
* which is the original one when there is no fix for it, and `unknown` when a fix was
|
|
119
|
+
* already spent on this reason or the fix itself failed.
|
|
120
|
+
*/
|
|
121
|
+
async function remedyAfterFailure({ intent, inst, result, deps }) {
|
|
122
|
+
const failure = result && result.failure;
|
|
123
|
+
const reason = failure && failure.reason;
|
|
124
|
+
const none = { repended: false, failure, error: result && result.error };
|
|
125
|
+
if (!intent || intent.action !== 'core-upgrade' || !failure || failure.class !== 'known_wedge') return none;
|
|
126
|
+
const { db, provisioning, log = () => {}, apply } = deps;
|
|
127
|
+
if (!apply) return none;
|
|
128
|
+
if (intent.remedied_reason === reason) {
|
|
129
|
+
return {
|
|
130
|
+
repended: false, failure: outcome.classifyFailure('remedy_did_not_hold'),
|
|
131
|
+
error: `${result.error || 'core move failed'} — the automatic fix for this (${reason}) was already tried once and did not clear it, so a person needs to look`,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
const boxExec = inst.hosting_shape === 'cloud-host' ? (deps.controlExec || deps.exec) : deps.exec;
|
|
135
|
+
const plan = planRemedy(inst, reason, { privileged: deps.privileged, runAs: reason === 'schema_pending' && deps.privileged ? runAsFor(inst, deps) : null, selfRoot: deps.selfInstanceRoot, migrate: deps.migratePlan });
|
|
136
|
+
if (!plan) return none;
|
|
137
|
+
const giveUp = async (why) => {
|
|
138
|
+
await provisioning.recordEvent(db, { instanceId: inst.id, ownerBuilderId: inst.owner_builder_id, event: REMEDY_EVENT, detail: `could not fix ${reason} automatically: ${why}`, actor: 'runner' }).catch(() => {});
|
|
139
|
+
return { repended: false, failure: outcome.classifyFailure('remedy_failed'), error: `${result.error || 'core move failed'} — the automatic fix for ${reason} did not work (${why}), so a person needs to look` };
|
|
140
|
+
};
|
|
141
|
+
if (plan.refused) return giveUp(plan.refused);
|
|
142
|
+
log(` [remedy] ${reason}: ${plan.cmd}`);
|
|
143
|
+
const r = boxExec(plan.cmd, { allowFail: true, ...(plan.cwd ? { cwd: plan.cwd } : {}), ...(plan.env ? { env: plan.env } : {}) });
|
|
144
|
+
if (!r || r.softFailed === true || r.ok === false) {
|
|
145
|
+
const said = String((r && (r.stderr || r.stdout || r.error)) || 'the command failed').trim().split('\n').pop().slice(0, 160);
|
|
146
|
+
return giveUp(said);
|
|
147
|
+
}
|
|
148
|
+
await provisioning.recordEvent(db, { instanceId: inst.id, ownerBuilderId: inst.owner_builder_id, event: REMEDY_EVENT, detail: `${plan.describe}; trying the move to ${intent.target_version} once more`, actor: 'runner' }).catch(() => {});
|
|
149
|
+
await db.query(
|
|
150
|
+
`UPDATE provisioning_intents SET state='pending', last_error=$2, remedied_reason=$3, failure_class=$4, failure_reason=$5,
|
|
151
|
+
not_before = now() + ($6 * interval '1 millisecond'), updated_at=now() WHERE id=$1`,
|
|
152
|
+
[intent.id, result.error || 'core move failed', reason, failure.class, failure.reason, DEFAULT_RETRY_DELAY_MS]);
|
|
153
|
+
log(` ✓ fixed ${reason} on ${inst.slug}; the move to ${intent.target_version} will be tried once more`);
|
|
154
|
+
return { repended: true };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
module.exports = { REMEDY_REASONS, DUMP_KEEP_FLOOR, REMEDY_EVENT, planRemedy, remedyAfterFailure };
|
package/src/module-api.js
CHANGED
|
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
75
75
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
76
76
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
77
77
|
|
|
78
|
-
const CORE_VERSION = '1.20.
|
|
78
|
+
const CORE_VERSION = '1.20.32'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
79
79
|
|
|
80
80
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
81
81
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
// tests/wedge_remedy.mjs — a core move that failed on a KNOWN snag fixes the snag and tries
|
|
2
|
+
// once more, and never twice (task 1004448, goal 1000090 deploy self-heal).
|
|
3
|
+
//
|
|
4
|
+
// WHAT IS AT RISK. Each fix runs a command on a live project's box: a commit in the owner's
|
|
5
|
+
// checkout, a delete among database backups, a migration against a database. The cases pin
|
|
6
|
+
// WHAT each command is (and what it refuses to build), that it runs at most once per
|
|
7
|
+
// failure, and that a fix which did not hold reaches a person instead of looping. The
|
|
8
|
+
// disk-full prune is also run for real against a temp folder, because a `find` pattern is
|
|
9
|
+
// the kind of thing that reads right and deletes a sibling project's dumps.
|
|
10
|
+
//
|
|
11
|
+
// Run: node tests/wedge_remedy.mjs
|
|
12
|
+
|
|
13
|
+
import { strict as assert } from 'node:assert';
|
|
14
|
+
import { createRequire } from 'node:module';
|
|
15
|
+
import { spawnSync } from 'node:child_process';
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import os from 'node:os';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
|
|
20
|
+
process.env.PROVISION_APP_USER = process.env.PROVISION_APP_USER || 'lars'; // appUser() refuses a Windows account name
|
|
21
|
+
const require = createRequire(import.meta.url);
|
|
22
|
+
const W = require('../scripts/gds/wedge-remedy.js');
|
|
23
|
+
const R = require('../scripts/gds/provision-core-upgrade.js');
|
|
24
|
+
const P = require('../scripts/gds/provision.js');
|
|
25
|
+
const { REASON_CLASS } = require('../scripts/gds/upgrade-outcome.js');
|
|
26
|
+
const { DEFAULT_RETRY_DELAY_MS } = require('../scripts/gds/intent-retry.js');
|
|
27
|
+
|
|
28
|
+
let passed = 0;
|
|
29
|
+
let failed = 0;
|
|
30
|
+
const t = async (name, fn) => {
|
|
31
|
+
try { await fn(); passed += 1; console.log(` PASS ${name}`); }
|
|
32
|
+
catch (e) { failed += 1; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const inst = (over = {}) => ({ id: 7, slug: 'charter', db_name: 'charter', port: 3010, status: 'active', hosting_shape: 'co-tenant', owner_builder_id: 1, ...over });
|
|
36
|
+
const intent = (over = {}) => ({ id: 99, action: 'core-upgrade', target_version: '1.20.31', attempts: 1, remedied_reason: null, ...over });
|
|
37
|
+
const wedge = (reason) => ({ ok: false, error: `core upgrade to 1.20.31 was refused (${reason})`, failure: { class: 'known_wedge', reason } });
|
|
38
|
+
const selfRoot = () => '/srv/charter';
|
|
39
|
+
|
|
40
|
+
function harness({ execFails = false } = {}) {
|
|
41
|
+
const ran = []; const events = []; const queries = [];
|
|
42
|
+
const deps = {
|
|
43
|
+
apply: true, privileged: false, log: () => {}, selfInstanceRoot: selfRoot,
|
|
44
|
+
exec: (cmd, opts) => { ran.push({ cmd, ...opts }); return execFails ? { ok: false, softFailed: true, stderr: 'fatal: nope' } : { ok: true, stdout: '' }; },
|
|
45
|
+
db: { query: async (sql, params) => { queries.push({ sql, params }); return { rows: [] }; } },
|
|
46
|
+
provisioning: { recordEvent: async (_db, e) => { events.push(e); } },
|
|
47
|
+
migratePlan: () => ({ via: 'npm-script' }),
|
|
48
|
+
};
|
|
49
|
+
return { deps, ran, events, queries };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
console.log('\nthe fix for each known snag (planRemedy):');
|
|
53
|
+
|
|
54
|
+
await t('every reason with a fix is a known_wedge in the classifier — and back', () => {
|
|
55
|
+
assert.deepEqual([...W.REMEDY_REASONS].sort(), ['dirty_tree', 'disk_full', 'schema_pending']);
|
|
56
|
+
for (const r of W.REMEDY_REASONS) assert.equal(REASON_CLASS[r], 'known_wedge', r);
|
|
57
|
+
for (const [r, c] of Object.entries(REASON_CLASS)) if (c === 'known_wedge') assert.ok(W.REMEDY_REASONS.includes(r), `${r} is known_wedge but has no fix — it would be retired with nothing done`);
|
|
58
|
+
assert.equal(REASON_CLASS.remedy_did_not_hold, 'unknown', 'a fix that did not hold reaches a person');
|
|
59
|
+
assert.equal(REASON_CLASS.remedy_failed, 'unknown');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
await t('dirty tree: commits TRACKED changes inside the project, hooks off, as the runner', () => {
|
|
63
|
+
const p = W.planRemedy(inst(), 'dirty_tree', { selfRoot });
|
|
64
|
+
assert.match(p.cmd, /^git -C \/srv\/charter -c core\.hooksPath=\/dev\/null .* add -u && git -C \/srv\/charter .* commit -m /);
|
|
65
|
+
assert.doesNotMatch(p.cmd, /add -A|add \./, 'an untracked file is never swept into the owner\'s history on a guess');
|
|
66
|
+
assert.doesNotMatch(p.cmd, /sudo/, 'the runner owns the checkout (pushUpgradePin\'s shape)');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
await t('a cloud-host project is fixed in ITS folder, not the control plane\'s', () => {
|
|
70
|
+
const p = W.planRemedy(inst({ hosting_shape: 'cloud-host' }), 'dirty_tree', { selfRoot });
|
|
71
|
+
assert.match(p.cmd, /git -C \/\S+\/charter /);
|
|
72
|
+
assert.doesNotMatch(p.cmd, /\/srv\/charter/);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
await t('disk full: prunes only THIS database\'s dumps, keeping the newest week', () => {
|
|
76
|
+
const p = W.planRemedy(inst(), 'disk_full', { selfRoot, backupDir: '/var/backups/gds' });
|
|
77
|
+
assert.match(p.cmd, /^find \/var\/backups\/gds -maxdepth 1 -type f -name 'charter-\[0-9\]/);
|
|
78
|
+
assert.match(p.cmd, new RegExp(`head -n -${W.DUMP_KEEP_FLOOR} \\| xargs -r rm -f --$`));
|
|
79
|
+
assert.equal(W.DUMP_KEEP_FLOOR, 7);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
await t('schema pending: migrates the project\'s OWN database, PGDATABASE always set', () => {
|
|
83
|
+
const plain = W.planRemedy(inst(), 'schema_pending', { selfRoot, migrate: () => ({ via: 'npm-script' }) });
|
|
84
|
+
assert.equal(plain.cmd, 'npm run migrate');
|
|
85
|
+
assert.equal(plain.cwd, '/srv/charter');
|
|
86
|
+
assert.deepEqual(plain.env, { PGDATABASE: 'charter' });
|
|
87
|
+
const adopted = W.planRemedy(inst(), 'schema_pending', { selfRoot, migrate: () => ({ via: 'core-script' }) });
|
|
88
|
+
assert.equal(adopted.cmd, 'bash node_modules/@bongos/core/scripts/migrate.sh');
|
|
89
|
+
assert.deepEqual(adopted.env, { PGDATABASE: 'charter', INIT_CWD: '/srv/charter' });
|
|
90
|
+
const priv = W.planRemedy(inst(), 'schema_pending', { selfRoot, privileged: true, runAs: 'bongos-charter', migrate: () => ({ via: 'npm-script' }) });
|
|
91
|
+
assert.equal(priv.cmd, 'sudo -u bongos-charter env PGDATABASE=charter npm run migrate', 'sudo resets env, so PGDATABASE rides the command');
|
|
92
|
+
assert.ok(W.planRemedy(inst(), 'schema_pending', { selfRoot, privileged: true, runAs: null }).refused);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
await t('no command is built from a name that is not shell-safe', () => {
|
|
96
|
+
assert.ok(W.planRemedy(inst({ slug: 'a;rm -rf' }), 'dirty_tree', { selfRoot }).refused);
|
|
97
|
+
assert.ok(W.planRemedy(inst({ db_name: 'x; drop' }), 'disk_full', { selfRoot }).refused);
|
|
98
|
+
assert.ok(W.planRemedy(inst(), 'dirty_tree', { selfRoot: () => '/srv/a b' }).refused);
|
|
99
|
+
assert.ok(W.planRemedy(inst(), 'disk_full', { selfRoot, backupDir: '/tmp/$(x)' }).refused);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
await t('a reason with no fix gets no command', () => {
|
|
103
|
+
for (const r of ['artist_gate', 'downgrade', 'health', 'not_published', 'nope']) assert.equal(W.planRemedy(inst(), r, { selfRoot }), null, r);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
console.log('\nthe prune, run for real:');
|
|
107
|
+
|
|
108
|
+
const bash = spawnSync('bash', ['-c', 'echo ok'], { encoding: 'utf8' });
|
|
109
|
+
await t('keeps the newest 7 of this project\'s dumps and never touches a sibling\'s', () => {
|
|
110
|
+
if (bash.status !== 0) { console.log(' (skipped: no bash here)'); return; }
|
|
111
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wedge-prune-'));
|
|
112
|
+
try {
|
|
113
|
+
const mine = []; for (let d = 1; d <= 10; d++) mine.push(`charter-2026-09-${String(d).padStart(2, '0')}T030000Z.sql.gz`);
|
|
114
|
+
const others = ['charter-nk-2026-09-01T030000Z.sql.gz', 'test-2026-09-01T030000Z.sql.gz', 'charter-notes.txt'];
|
|
115
|
+
for (const f of [...mine, ...others]) fs.writeFileSync(path.join(dir, f), 'x');
|
|
116
|
+
const p = W.planRemedy(inst(), 'disk_full', { selfRoot, backupDir: '.' }); // run IN the folder: a temp path can hold a `~`
|
|
117
|
+
const r = spawnSync('bash', ['-c', p.cmd], { encoding: 'utf8', cwd: dir });
|
|
118
|
+
assert.equal(r.status, 0, r.stderr);
|
|
119
|
+
const left = fs.readdirSync(dir).sort();
|
|
120
|
+
assert.deepEqual(left, [...others, ...mine.slice(3)].sort());
|
|
121
|
+
// Nothing is deleted when the floor is not exceeded.
|
|
122
|
+
const again = spawnSync('bash', ['-c', p.cmd], { encoding: 'utf8', cwd: dir });
|
|
123
|
+
assert.equal(again.status, 0);
|
|
124
|
+
assert.equal(fs.readdirSync(dir).length, left.length);
|
|
125
|
+
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
console.log('\nonce per failure (remedyAfterFailure):');
|
|
129
|
+
|
|
130
|
+
for (const reason of W.REMEDY_REASONS) {
|
|
131
|
+
await t(`${reason}: runs the fix, records an event, re-pends the move with the reason stored`, async () => {
|
|
132
|
+
const h = harness();
|
|
133
|
+
const r = await W.remedyAfterFailure({ intent: intent(), inst: inst(), result: wedge(reason), deps: h.deps });
|
|
134
|
+
assert.deepEqual(r, { repended: true });
|
|
135
|
+
assert.equal(h.ran.length, 1);
|
|
136
|
+
assert.equal(h.events.length, 1);
|
|
137
|
+
assert.equal(h.events[0].event, W.REMEDY_EVENT);
|
|
138
|
+
assert.match(h.events[0].detail, /trying the move to 1\.20\.31 once more/);
|
|
139
|
+
const up = h.queries.find((q) => /SET state='pending'/.test(q.sql));
|
|
140
|
+
assert.ok(up, 're-pended');
|
|
141
|
+
assert.match(up.sql, /remedied_reason=\$3/);
|
|
142
|
+
assert.deepEqual(up.params, [99, wedge(reason).error, reason, 'known_wedge', reason, DEFAULT_RETRY_DELAY_MS]);
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
await t('the same reason again after a fix: no second fix — retired as unknown for a person', async () => {
|
|
147
|
+
const h = harness();
|
|
148
|
+
const r = await W.remedyAfterFailure({ intent: intent({ remedied_reason: 'dirty_tree' }), inst: inst(), result: wedge('dirty_tree'), deps: h.deps });
|
|
149
|
+
assert.equal(r.repended, false);
|
|
150
|
+
assert.deepEqual(r.failure, { class: 'unknown', reason: 'remedy_did_not_hold' });
|
|
151
|
+
assert.match(r.error, /already tried once/);
|
|
152
|
+
assert.equal(h.ran.length, 0, 'nothing ran');
|
|
153
|
+
assert.equal(h.queries.length, 0);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
await t('a DIFFERENT known snag on the retry gets its own one fix', async () => {
|
|
157
|
+
const h = harness();
|
|
158
|
+
const r = await W.remedyAfterFailure({ intent: intent({ remedied_reason: 'dirty_tree' }), inst: inst(), result: wedge('disk_full'), deps: h.deps });
|
|
159
|
+
assert.equal(r.repended, true);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
await t('a fix that fails is recorded and retired as unknown — never re-pended', async () => {
|
|
163
|
+
const h = harness({ execFails: true });
|
|
164
|
+
const r = await W.remedyAfterFailure({ intent: intent(), inst: inst(), result: wedge('dirty_tree'), deps: h.deps });
|
|
165
|
+
assert.equal(r.repended, false);
|
|
166
|
+
assert.deepEqual(r.failure, { class: 'unknown', reason: 'remedy_failed' });
|
|
167
|
+
assert.match(r.error, /did not work \(fatal: nope\)/);
|
|
168
|
+
assert.equal(h.events.length, 1);
|
|
169
|
+
assert.match(h.events[0].detail, /could not fix dirty_tree/);
|
|
170
|
+
assert.equal(h.queries.length, 0);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
await t('leaves everything else alone: other classes, other actions, and a dry run', async () => {
|
|
174
|
+
for (const [i, res] of [[intent(), { ok: false, error: 'x', failure: { class: 'transient', reason: 'not_published' } }],
|
|
175
|
+
[intent(), { ok: false, error: 'x', failure: { class: 'needs_decision', reason: 'artist_gate' } }],
|
|
176
|
+
[intent(), { ok: false, error: 'x' }],
|
|
177
|
+
[intent({ action: 'restart' }), wedge('dirty_tree')]]) {
|
|
178
|
+
const h = harness();
|
|
179
|
+
const r = await W.remedyAfterFailure({ intent: i, inst: inst(), result: res, deps: h.deps });
|
|
180
|
+
assert.equal(r.repended, false);
|
|
181
|
+
assert.deepEqual(r.failure, res.failure);
|
|
182
|
+
assert.equal(h.ran.length, 0);
|
|
183
|
+
}
|
|
184
|
+
const h = harness(); h.deps.apply = false;
|
|
185
|
+
assert.equal((await W.remedyAfterFailure({ intent: intent(), inst: inst(), result: wedge('dirty_tree'), deps: h.deps })).repended, false);
|
|
186
|
+
assert.equal(h.ran.length, 0);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
console.log('\nthe move refuses on a database behind its code:');
|
|
190
|
+
|
|
191
|
+
await t('schemaPending > 0 on the box refuses as schema_pending BEFORE the upgrade runs', async () => {
|
|
192
|
+
const ran = [];
|
|
193
|
+
const deps = {
|
|
194
|
+
apply: true, privileged: false, log: () => {}, sleep: async () => {},
|
|
195
|
+
exec: (cmd) => { ran.push(cmd); return { ok: true, stdout: /\/version/.test(cmd) ? JSON.stringify({ coreVersion: '1.20.30', schemaPending: 2 }) : '' }; },
|
|
196
|
+
db: { query: async () => ({ rows: [] }) }, provisioning: { recordEvent: async () => {} },
|
|
197
|
+
readInstalledCoreVersion: () => '1.20.30', selfInstanceRoot: () => '/srv/cb',
|
|
198
|
+
updateChannel: { DEFAULT_CHANNEL: 'patch', normalizeChannel: (c) => c || 'patch', channelAllows: () => true, listAvailableVersions: () => ({ ok: true, versions: ['1.20.30', '1.20.31'] }) },
|
|
199
|
+
};
|
|
200
|
+
const r = await R.coreUpgradeInstance(inst({ hosting_shape: 'control-plane', slug: 'cloudbongos' }), deps, intent());
|
|
201
|
+
assert.equal(r.ok, false);
|
|
202
|
+
assert.deepEqual(r.failure, { class: 'known_wedge', reason: 'schema_pending' });
|
|
203
|
+
assert.match(r.error, /2 migration\(s\) behind/);
|
|
204
|
+
assert.ok(!ran.some((c) => /upgrade\.js/.test(c)), 'the pin never moved');
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
console.log('\nthe drain loop, end to end:');
|
|
208
|
+
|
|
209
|
+
// A stateful fake queue, the shape tests/provision.mjs uses: claimNextIntent honours
|
|
210
|
+
// not_before, so "fixed, then tried once more on a LATER tick" is measured.
|
|
211
|
+
async function drain({ row, reply }) {
|
|
212
|
+
const ran = []; const events = []; const resolved = []; let runs = 0;
|
|
213
|
+
const deps = {
|
|
214
|
+
apply: true, privileged: false, log: () => {}, sleep: async () => {},
|
|
215
|
+
db: { query: async (sql, params) => {
|
|
216
|
+
if (/SET state='pending'/.test(sql)) { row.state = 'pending'; row.not_before = new Date(Date.now() + params[params.length - 1]); if (/remedied_reason/.test(sql)) row.remedied_reason = params[2]; }
|
|
217
|
+
if (/SET failure_class/.test(sql)) { row.failure_class = params[1]; row.failure_reason = params[2]; }
|
|
218
|
+
return { rows: [] };
|
|
219
|
+
} },
|
|
220
|
+
provisioning: {
|
|
221
|
+
claimNextIntent: async () => {
|
|
222
|
+
if (row.state !== 'pending' || (row.not_before && row.not_before > new Date())) return null;
|
|
223
|
+
row.state = 'running'; row.attempts += 1; runs += 1; return { ...row };
|
|
224
|
+
},
|
|
225
|
+
getInstanceById: async () => inst({ hosting_shape: 'control-plane', slug: 'cloudbongos', db_name: 'cloudbongos' }),
|
|
226
|
+
resolveIntent: async (_db, id, state, note) => { resolved.push({ id, state, note }); row.state = state; },
|
|
227
|
+
setInstanceStatus: async () => {}, recordEvent: async (_db, e) => { events.push(e); },
|
|
228
|
+
claimNextOAuthManifest: async () => null, resolveOAuthManifest: async () => {},
|
|
229
|
+
},
|
|
230
|
+
exec: (cmd, opts) => { ran.push(cmd); return reply(cmd, opts); },
|
|
231
|
+
writeFile: () => ({ ok: true }),
|
|
232
|
+
readInstalledCoreVersion: () => '1.20.30', selfInstanceRoot: () => '/srv/cb',
|
|
233
|
+
updateChannel: { DEFAULT_CHANNEL: 'patch', normalizeChannel: (c) => c || 'patch', channelAllows: () => true, listAvailableVersions: () => ({ ok: true, versions: ['1.20.30', '1.20.31'] }) },
|
|
234
|
+
checkDnsTokenScope: async () => ({ reachable: true, zone: 'z' }), checkCaddyWiring: () => null,
|
|
235
|
+
};
|
|
236
|
+
await P.cmdRunIntents(deps);
|
|
237
|
+
return { ran, events, resolved, runs, row };
|
|
238
|
+
}
|
|
239
|
+
const dirtyUpgrade = (cmd) => {
|
|
240
|
+
if (/upgrade\.js/.test(cmd)) { const e = new Error('Command failed'); e.stderr = '✖ working tree not clean — commit/stash first\nBONGOS_UPGRADE_OUTCOME {"stage":"refused","reason":"dirty_tree"}\n'; throw e; }
|
|
241
|
+
return { ok: true, stdout: /\/version/.test(cmd) ? JSON.stringify({ coreVersion: '1.20.30', schemaPending: 0 }) : '' };
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
await t('a dirty tree is committed, the move waits for the next tick, and is NOT retired', async () => {
|
|
245
|
+
const row = { id: 11, instance_id: 7, action: 'core-upgrade', target_version: '1.20.31', attempts: 0, state: 'pending', not_before: null, remedied_reason: null };
|
|
246
|
+
const d = await drain({ row, reply: dirtyUpgrade });
|
|
247
|
+
assert.equal(d.runs, 1, 'the retry is not run in the same tick');
|
|
248
|
+
assert.equal(d.resolved.length, 0, 'not retired');
|
|
249
|
+
assert.equal(d.row.state, 'pending');
|
|
250
|
+
assert.equal(d.row.remedied_reason, 'dirty_tree');
|
|
251
|
+
assert.ok(d.ran.some((c) => /add -u && .* commit/.test(c)), 'the commit ran');
|
|
252
|
+
assert.ok(d.events.some((e) => e.event === W.REMEDY_EVENT));
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
await t('the retry fails dirty AGAIN: retired as unknown/remedy_did_not_hold, no second commit', async () => {
|
|
256
|
+
const row = { id: 11, instance_id: 7, action: 'core-upgrade', target_version: '1.20.31', attempts: 1, state: 'pending', not_before: null, remedied_reason: 'dirty_tree' };
|
|
257
|
+
const d = await drain({ row, reply: dirtyUpgrade });
|
|
258
|
+
assert.equal(d.runs, 1);
|
|
259
|
+
assert.equal(d.resolved.length, 1);
|
|
260
|
+
assert.equal(d.resolved[0].state, 'error');
|
|
261
|
+
assert.match(d.resolved[0].note, /already tried once/);
|
|
262
|
+
assert.equal(d.row.failure_class, 'unknown');
|
|
263
|
+
assert.equal(d.row.failure_reason, 'remedy_did_not_hold');
|
|
264
|
+
assert.ok(!d.ran.some((c) => /commit -m/.test(c)), 'no second fix');
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
console.log(`\nwedge_remedy: ${passed} passed, ${failed} failed`);
|
|
268
|
+
if (failed) process.exit(1);
|