@bongos/core 1.19.772 → 1.19.774
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 +204 -79
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +5 -1
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0083-modular-architecture-strangler-build-plan.md +1 -0
- package/docs/adr/0177-goal-membership-kind.md +26 -2
- package/docs/adr/0181-teardown-state-gate-and-address-retention.md +2 -0
- package/docs/adr/0285-a-shared-box-holds-about-twelve-projects-per-gb-and-memory-is-the-wall.md +1 -1
- package/docs/adr/0288-the-goal-wall-has-a-shared-floor-for-paths-no-module-owns.md +33 -0
- package/docs/adr/0289-an-unconsumed-contribution-is-a-ci-failure-not-a-boot-refusal.md +33 -0
- package/docs/adr/0290-disconnect-is-the-owner-initiated-exception-to-address-retention.md +40 -0
- package/docs/adr/README.md +3 -0
- package/docs/api/openapi.json +102 -11
- package/docs/api-reference.md +5 -4
- package/docs/copy-inventory.md +205 -190
- package/docs/copy-registry.json +435 -269
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +4 -0
- package/docs/modules-contract.md +1 -1
- package/modules/builder-settings/render-prefs.js +281 -33
- package/modules/builder-settings/routes/settings.js +20 -12
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
- package/modules/dev-box/module.json +4 -0
- package/modules/hall-ui/public/settings-render.js +138 -0
- package/modules/hall-ui/public/settings.html +16 -9
- package/modules/hall-ui/public/settings.js +3 -70
- package/modules/lifecycle/db-tasks.js +7 -1
- package/modules/lifecycle/dead-deps.js +10 -2
- package/modules/lifecycle/ship-card.js +49 -13
- package/modules/lifecycle/task-list-limits.js +44 -0
- package/modules/platform-identity/migrations/platform_identity_021_drop_legacy_role.sql +94 -0
- package/modules/platform-identity/platform-identity.js +8 -19
- package/modules/platform-identity/project-catalog.js +24 -0
- package/modules/platform-identity/routes/sso.js +11 -14
- package/modules/platform-identity/tests/platform-identity.mjs +7 -9
- package/modules/provisioning/capacity-gate.js +54 -0
- package/modules/provisioning/catalog-bridge.js +2 -0
- package/modules/provisioning/disconnect.js +35 -0
- package/modules/provisioning/migrations/provisioning_016_disconnect.sql +21 -0
- package/modules/provisioning/module.json +1 -1
- package/modules/provisioning/provisioning.js +3 -3
- package/modules/provisioning/routes/disconnect.js +63 -0
- package/modules/provisioning/routes/provisioning.js +6 -6
- package/modules/public-landing/public/projects.html +116 -11
- package/modules/public-landing/public/projects.probes.json +3 -0
- package/modules/ui-design/kit/serve.js +3 -2
- package/modules/ui-design/module.json +1 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/address-park-sweep.sh +78 -0
- package/scripts/gds/audit-deps.js +38 -4
- package/scripts/gds/cli-lib.js +37 -0
- package/scripts/gds/fitness-migration-contraction.js +50 -0
- package/scripts/gds/fitness.js +18 -0
- package/scripts/gds/land-watch.js +8 -2
- package/scripts/gds/provision-config.js +4 -4
- package/scripts/gds/provision-disconnect.js +90 -0
- package/scripts/gds/provision-teardown.js +108 -0
- package/scripts/gds/provision.js +6 -65
- package/scripts/gds/seam-wiring-guard.js +273 -0
- package/scripts/gds/ship-finish.js +17 -10
- package/scripts/gds/ship-flow.js +28 -6
- package/scripts/gds/ship-resume.js +32 -4
- package/scripts/gds/start.js +13 -7
- package/src/bongos/auth-admission.js +7 -8
- package/src/bongos/auth-config.js +3 -2
- package/src/bongos/goal-scope-check.js +23 -8
- package/src/bongos/module-scope-map.js +40 -0
- package/src/module-api.js +1 -1
- package/src/module-loader/manifest-schema.js +79 -1
- package/tests/catalog_only_client.mjs +8 -5
- package/tests/fitness.mjs +22 -0
- package/tests/goal_scope_check.mjs +63 -1
- package/tests/hall_settings_world.mjs +57 -1
- package/tests/hub_disconnect_ui.mjs +105 -0
- package/tests/land_watch.mjs +44 -0
- package/tests/membership_kind_lockstep.mjs +86 -27
- package/tests/module-scope-map.mjs +39 -0
- package/tests/module_exports_unique_keys.mjs +70 -0
- package/tests/module_manifest.mjs +85 -0
- package/tests/project_catalog_forget.mjs +83 -0
- package/tests/projects_hub.mjs +10 -4
- package/tests/projects_hub_pre_uat.mjs +5 -3
- package/tests/provision_disconnect.mjs +231 -0
- package/tests/provision_teardown_address.mjs +230 -0
- package/tests/provisioning_capacity_gate.mjs +192 -0
- package/tests/provisioning_disconnect.mjs +231 -0
- package/tests/provisioning_restart.mjs +5 -5
- package/tests/public_landing_projects.mjs +4 -3
- package/tests/render_prefs.mjs +152 -0
- package/tests/seam_wiring_guard.mjs +234 -0
- package/tests/ship_card.mjs +67 -1
- package/tests/ship_resume.mjs +86 -12
- package/tests/sso_checkin_legacy_field_gone.mjs +246 -0
- package/tests/task_list_paging.mjs +178 -0
package/scripts/gds/cli-lib.js
CHANGED
|
@@ -1166,6 +1166,42 @@ function detectWorktreeName() {
|
|
|
1166
1166
|
}
|
|
1167
1167
|
}
|
|
1168
1168
|
|
|
1169
|
+
// ---- card richness (task 1002518) ------------------------------------------
|
|
1170
|
+
//
|
|
1171
|
+
// The one place a CLI turns a /me payload into "what should this card look
|
|
1172
|
+
// like": three booleans — print the card at all, write the widget, open its
|
|
1173
|
+
// summary — that emitCard consumes directly.
|
|
1174
|
+
//
|
|
1175
|
+
// WHY THIS READS THE PAYLOAD AND NEVER require()s render-prefs.js. The ladder
|
|
1176
|
+
// lives in modules/builder-settings/render-prefs.js, and requiring it from here
|
|
1177
|
+
// would work on a full checkout and BREAK on a task-scoped dev box, which
|
|
1178
|
+
// carries scripts/ and src/bongos/ but not every modules/ directory (task
|
|
1179
|
+
// 1003098 — tree-preflight exists precisely to fail an entry point that reaches
|
|
1180
|
+
// for module code, and start.js is one). So the server resolves the rung and
|
|
1181
|
+
// ships the answers on `render.flags`; this function only picks them up.
|
|
1182
|
+
//
|
|
1183
|
+
// TWO SERVER SHAPES, deliberately. `render.flags` is the ladder (task 1002518);
|
|
1184
|
+
// `render.widgets_enabled` is the boolean that predates it. A CLI newer than the
|
|
1185
|
+
// instance it is pointed at sees only the boolean — the /me payload is not
|
|
1186
|
+
// version-negotiated — so the fallback is not defensive padding, it is the
|
|
1187
|
+
// supported path against a pinned instance, and it reconstructs exactly what
|
|
1188
|
+
// that boolean has always meant: card always, widget iff true, never expanded.
|
|
1189
|
+
// An absent or failed /me lands on the same default.
|
|
1190
|
+
function cardFlagsFromMe(me) {
|
|
1191
|
+
const render = me && me.data && me.data.render;
|
|
1192
|
+
const f = render && render.flags;
|
|
1193
|
+
if (f && typeof f === 'object') {
|
|
1194
|
+
return {
|
|
1195
|
+
level: typeof f.level === 'string' ? f.level : null,
|
|
1196
|
+
showCard: f.showCard !== false,
|
|
1197
|
+
showWidget: f.showWidget === true,
|
|
1198
|
+
expanded: f.expanded === true,
|
|
1199
|
+
};
|
|
1200
|
+
}
|
|
1201
|
+
const enabled = !render || render.widgets_enabled !== false;
|
|
1202
|
+
return { level: null, showCard: true, showWidget: enabled, expanded: false };
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1169
1205
|
module.exports = {
|
|
1170
1206
|
API_BASE,
|
|
1171
1207
|
detectWorktreeName,
|
|
@@ -1220,4 +1256,5 @@ module.exports = {
|
|
|
1220
1256
|
printOnboardingNudge,
|
|
1221
1257
|
printCostWarning,
|
|
1222
1258
|
printNeedsNudge,
|
|
1259
|
+
cardFlagsFromMe,
|
|
1223
1260
|
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// scripts/gds/fitness-migration-contraction.js — the ONE shape of destructive DDL a
|
|
2
|
+
// module migration may carry: an ACKNOWLEDGED contract-phase DROP COLUMN on its own
|
|
3
|
+
// namespaced table (task 1003092; ADR 0083 §Module-owned migrations, amended; the
|
|
4
|
+
// expand-then-contract pattern of ADR 0177).
|
|
5
|
+
//
|
|
6
|
+
// WHY. Check 11 in fitness.js keeps module migrations ADDITIVE: there is no down
|
|
7
|
+
// path, "disable a module" means "stop applying", so a DROP can only be an accident.
|
|
8
|
+
// That rule met its first legitimate exception the moment a module ran an
|
|
9
|
+
// expand-then-contract rename (ADR 0177 §"the fifth use"): `platform_identity_010`
|
|
10
|
+
// ADDed `membership_kind` beside the legacy `role` column and kept the pair in
|
|
11
|
+
// lockstep; the CONTRACT phase has to DROP the legacy column, or the rename never
|
|
12
|
+
// finishes and the shim outlives its release (ADR 0174's Lineage lesson). A drop
|
|
13
|
+
// that a previous migration wrote down in advance is not an accident.
|
|
14
|
+
//
|
|
15
|
+
// THE SHAPE, AND WHY IT IS THIS NARROW. A statement is let through only when ALL of:
|
|
16
|
+
// 1. the FILE carries the `-- forward-unsafe: <reason>` pragma — the same
|
|
17
|
+
// acknowledgement scripts/gds/lint-migrations.js requires of a core migration,
|
|
18
|
+
// so one vocabulary says "this destruction is deliberate" everywhere;
|
|
19
|
+
// 2. the statement is EXACTLY `ALTER TABLE <t> DROP COLUMN [IF EXISTS] <c>` and
|
|
20
|
+
// nothing else — no second clause, no DROP TABLE, no TRUNCATE, no bare DROP
|
|
21
|
+
// CONSTRAINT (which stays a violation even with the pragma);
|
|
22
|
+
// 3. <t> is inside the module's own namespace (or its grandfathered legacy set),
|
|
23
|
+
// the same test the namespace check applies to every ALTER.
|
|
24
|
+
// The pragma does not turn the check off for the file: every OTHER destructive
|
|
25
|
+
// statement in an acknowledged file is still flagged. PURE — no fs, no process.
|
|
26
|
+
'use strict';
|
|
27
|
+
|
|
28
|
+
// Same single-line acknowledgement lint-migrations.js captures (ACK_RE there).
|
|
29
|
+
const ACK_RE = /^\s*--\s*forward-unsafe\s*:\s*(.+)$/im;
|
|
30
|
+
|
|
31
|
+
// Exactly one column drop, on one table, and nothing else in the statement.
|
|
32
|
+
const COLUMN_DROP_RE = /^\s*alter\s+table\s+(?:if\s+exists\s+)?(?:only\s+)?("?[\w.]+"?)\s+drop\s+column\s+(?:if\s+exists\s+)?"?\w+"?\s*$/i;
|
|
33
|
+
|
|
34
|
+
// Is the migration FILE acknowledged as forward-unsafe? Read on the RAW sql — the
|
|
35
|
+
// pragma is a comment, and the destructive-DDL scan strips comments first.
|
|
36
|
+
function acknowledgedContraction(rawSql) {
|
|
37
|
+
const m = ACK_RE.exec(String(rawSql == null ? '' : rawSql));
|
|
38
|
+
return m ? m[1].trim() : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Is THIS statement the one allowed shape? `inNamespace(table)` is the caller's
|
|
42
|
+
// namespace predicate (fitness.js owns the <key>_ / legacy-set rule).
|
|
43
|
+
function isAcknowledgedColumnContraction(stmt, { acknowledged, inNamespace }) {
|
|
44
|
+
if (!acknowledged) return false;
|
|
45
|
+
const m = COLUMN_DROP_RE.exec(stmt);
|
|
46
|
+
if (!m) return false;
|
|
47
|
+
return inNamespace(m[1]);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = { acknowledgedContraction, isAcknowledgedColumnContraction };
|
package/scripts/gds/fitness.js
CHANGED
|
@@ -827,6 +827,12 @@ function isConstraintWidening(stmt, reAdded) {
|
|
|
827
827
|
return reAdded.has(m[1].replace(/"/g, '')); // re-added same-named constraint → widening
|
|
828
828
|
}
|
|
829
829
|
|
|
830
|
+
// The ONE destructive shape a module may carry: an ACKNOWLEDGED contract-phase
|
|
831
|
+
// `ALTER TABLE <own table> DROP COLUMN <c>` (task 1003092 — the expand-then-contract
|
|
832
|
+
// rename of ADR 0177 has to finish; ADR 0083 §Module-owned migrations, amended). The
|
|
833
|
+
// pragma is read off the RAW sql (it is a comment); everything else here still runs.
|
|
834
|
+
const { acknowledgedContraction, isAcknowledgedColumnContraction } = require('./fitness-migration-contraction.js');
|
|
835
|
+
|
|
830
836
|
// Pure: violations for ONE module's migration SQL. Exported for the negative test.
|
|
831
837
|
function moduleMigrationViolations(key, sql) {
|
|
832
838
|
const ns = moduleNamespace(key);
|
|
@@ -835,8 +841,11 @@ function moduleMigrationViolations(key, sql) {
|
|
|
835
841
|
const violations = [];
|
|
836
842
|
// additive — scan per statement so an ALTER…DROP never spans a `;`
|
|
837
843
|
const reAdded = reAddedConstraintNames(clean);
|
|
844
|
+
const acknowledged = acknowledgedContraction(sql);
|
|
845
|
+
const ownTable = (t) => inNamespace(t, ns, legacy);
|
|
838
846
|
for (const stmt of clean.split(';')) {
|
|
839
847
|
if (isConstraintWidening(stmt, reAdded)) continue; // additive CHECK-enum widening (task 1978)
|
|
848
|
+
if (isAcknowledgedColumnContraction(stmt, { acknowledged, inNamespace: ownTable })) continue; // contract phase (task 1003092)
|
|
840
849
|
for (const d of DESTRUCTIVE_DDL) {
|
|
841
850
|
if (d.re.test(stmt)) {
|
|
842
851
|
violations.push(`module "${key}" migration uses destructive DDL (${d.label}) — module migrations must be ADDITIVE; disabling a module stops applying, it never drops (ADR 0083 §Decision #5).`);
|
|
@@ -1205,6 +1214,15 @@ const CHECKS = [
|
|
|
1205
1214
|
// migrations: same shared, monotonic, human-allocated number line, and until this
|
|
1206
1215
|
// landed only one of the two was enforced. An ADR number is a citation target, so a
|
|
1207
1216
|
// duplicate is wrong forever (ADR 0195).
|
|
1217
|
+
// Check 33 — task 1003819: a module declares what it contributes, and nothing
|
|
1218
|
+
// checked that a declared seam is actually WIRED. verifyPortsSatisfied asks the
|
|
1219
|
+
// opposite question (every `consumes` has a provider) and validateManifest checks
|
|
1220
|
+
// only the SHAPE, so a contributes entry whose artifact was never shipped — or was
|
|
1221
|
+
// renamed out from under the manifest — boots silent: every consumer skips it the
|
|
1222
|
+
// same way it correctly skips a real declarative-only entry. CI, not boot: nothing
|
|
1223
|
+
// crashes later, so a start-up refusal would trade an outage risk for a cosmetic
|
|
1224
|
+
// one (ADR 0289). Rationale + the seam→consumer table live in that file.
|
|
1225
|
+
require('./seam-wiring-guard.js').checkSeamWiring,
|
|
1208
1226
|
require('./adr-namespace.js').checkAdrNumbering, require('./exec-path-guard.js').checkExecPathsStatic, // Check 32 — task 1003371 / B50: what the server EXECUTES, out of whose tree — the blind spot all three 2026-08-29 CRITICALs shared. Rationale + reviewed exemptions live in that file. Sharing Check 31's line (the 19c/19d + 23/24 precedent above): fitness.js is AT the 1,500 ratchet and a new check cannot afford a line until task 1003825 buys it back.
|
|
1209
1227
|
];
|
|
1210
1228
|
|
|
@@ -122,9 +122,15 @@ function buildShippedEnvelope(crumb, task) {
|
|
|
122
122
|
title: inputs.title || (task && task.title) || `Task #${crumb.taskId}`,
|
|
123
123
|
valueSummary: inputs.valueSummary || (task && task.value_summary) || '',
|
|
124
124
|
});
|
|
125
|
+
// task 1002518: prefer the rung the shipping session actually resolved, so the
|
|
126
|
+
// later-rendered shipped card matches the one it printed. A breadcrumb written
|
|
127
|
+
// by an older CLI carries only `widgetsEnabled`, which still reads correctly.
|
|
128
|
+
const flags = (crumb.cardFlags && typeof crumb.cardFlags === 'object')
|
|
129
|
+
? crumb.cardFlags
|
|
130
|
+
: { showWidget: crumb.widgetsEnabled !== false, expanded: false };
|
|
125
131
|
return {
|
|
126
|
-
widgets_enabled:
|
|
127
|
-
html: shipCard.renderCardHtml(model),
|
|
132
|
+
widgets_enabled: flags.showWidget === true,
|
|
133
|
+
html: shipCard.renderCardHtml(model, { expanded: flags.expanded === true }),
|
|
128
134
|
markdown: shipCard.renderMonospaceCard(model),
|
|
129
135
|
warnings: [],
|
|
130
136
|
title: `task_${crumb.taskId}_card`,
|
|
@@ -177,16 +177,16 @@ function loadDeps() {
|
|
|
177
177
|
// (fitness Check 16b / task 2135). A vanilla core that neither ships nor enables the
|
|
178
178
|
// module never resolves it → no MODULE_NOT_FOUND on a fresh boot. Null here → federation
|
|
179
179
|
// dormant (shouldFederate skips) → the instance owns its auth, byte-identical to today.
|
|
180
|
-
let platformIdentity = null, hubKeys = null;
|
|
180
|
+
let platformIdentity = null, hubKeys = null, projectCatalog = null;
|
|
181
181
|
const PI_KEY = 'platform-identity';
|
|
182
182
|
let piEnabled = false;
|
|
183
183
|
try { piEnabled = require(path.join(REPO_ROOT, 'src/modules')).isModuleEnabled(PI_KEY); } catch { piEnabled = false; }
|
|
184
184
|
if (piEnabled) {
|
|
185
185
|
const piBase = path.join(REPO_ROOT, 'modules', PI_KEY);
|
|
186
|
-
try { platformIdentity = require(path.join(piBase, 'platform-identity')); hubKeys = require(path.join(piBase, 'hub-keys')); }
|
|
187
|
-
catch { platformIdentity = null; hubKeys = null; }
|
|
186
|
+
try { platformIdentity = require(path.join(piBase, 'platform-identity')); hubKeys = require(path.join(piBase, 'hub-keys')); projectCatalog = require(path.join(piBase, 'project-catalog')); }
|
|
187
|
+
catch { platformIdentity = null; hubKeys = null; projectCatalog = null; }
|
|
188
188
|
}
|
|
189
|
-
_deps = { pool, provisioning, DoApi, platformIdentity, hubKeys };
|
|
189
|
+
_deps = { pool, provisioning, DoApi, platformIdentity, hubKeys, projectCatalog };
|
|
190
190
|
return _deps;
|
|
191
191
|
}
|
|
192
192
|
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// provision-disconnect.js — "Disconnect from Bongos entirely": the runner leg (task 1003249,
|
|
3
|
+
// ADR 0290).
|
|
4
|
+
//
|
|
5
|
+
// WHY. A teardown deliberately KEEPS a project's database, dumps and address (ADR 0181), so
|
|
6
|
+
// taking a project offline is always reversible. The owner asked for a separate, truer delete.
|
|
7
|
+
// This is it, and it is the exception ADR 0181 names: owner-initiated only, behind a typed-name
|
|
8
|
+
// confirm the route checks server-side (modules/provisioning/routes/disconnect.js).
|
|
9
|
+
//
|
|
10
|
+
// ORDER IS THE SAFETY PROPERTY. The teardown runs first (units stopped, Caddy block gone, DNS
|
|
11
|
+
// parked, deploy key revoked). Then the erase. Then the catalog, membership and client rows
|
|
12
|
+
// leave the map. The provisioning row is deleted LAST, and only when everything before it
|
|
13
|
+
// succeeded: the row is what lets the runner retry, so a failure anywhere leaves the row intact
|
|
14
|
+
// (an erase or forget failure returns a retryable { ok:false }; a teardown throw propagates).
|
|
15
|
+
// Deleting the row is what releases the name (slug UNIQUE), the port (allocatePort reads every
|
|
16
|
+
// row) and the address (the next standup UPSERTs it).
|
|
17
|
+
//
|
|
18
|
+
// The GitHub repository is never touched, and neither is the GitHub App the owner registered for
|
|
19
|
+
// sign-in — GitHub offers no API to delete an App; the manage page tells the owner where it is.
|
|
20
|
+
|
|
21
|
+
const path = require('node:path');
|
|
22
|
+
const { CONFIG } = require('./provision-config.js');
|
|
23
|
+
const { dbName, instanceDbRole, instanceStateDir, instanceUser, standaloneRoot } = require('./provision-repo.js');
|
|
24
|
+
const { webEnvPath } = require('./provision-units.js');
|
|
25
|
+
const { teardownInstance } = require('./provision-teardown.js');
|
|
26
|
+
const { isValidSlug } = require('../../modules/provisioning/provisioning.js');
|
|
27
|
+
const { catalogOriginForInstance, ownerGithubIdForInstance } = require('../../modules/provisioning/catalog-bridge.js');
|
|
28
|
+
const { deleteInstance } = require('../../modules/provisioning/disconnect.js');
|
|
29
|
+
|
|
30
|
+
// The dump filename db-backup-nightly.sh writes — <db>-YYYY-MM-DDTHHMMSSZ.sql.gz — matched
|
|
31
|
+
// EXACTLY. The nightly prune's own `<db>-*.sql.gz` would let a project named `test` erase
|
|
32
|
+
// `test-nk`'s dumps, which is harmless for a 30-day prune and unacceptable for an erase. PURE.
|
|
33
|
+
function dumpGlob(inst) {
|
|
34
|
+
const d = '[0-9]';
|
|
35
|
+
return `${dbName(inst)}-${d.repeat(4)}-${d.repeat(2)}-${d.repeat(2)}T${d.repeat(6)}Z.sql.gz`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// The erase, in order: the database before the role that owns it; the dumps; the env dir and
|
|
39
|
+
// state dir; the per-project checkout (standalone only — a co-tenant runs from the shared one);
|
|
40
|
+
// the unix account last, idempotently. Every path derives from a slug the caller has already
|
|
41
|
+
// validated. PURE.
|
|
42
|
+
function eraseCommands(inst, { privileged = false } = {}) {
|
|
43
|
+
const sudo = privileged ? 'sudo ' : '';
|
|
44
|
+
const asPg = (cmd) => (privileged ? `sudo -u postgres ${cmd}` : cmd);
|
|
45
|
+
const user = instanceUser(inst);
|
|
46
|
+
const cmds = [
|
|
47
|
+
asPg(`dropdb --if-exists --force ${dbName(inst)}`),
|
|
48
|
+
asPg(`psql -v ON_ERROR_STOP=1 -d postgres -c 'DROP ROLE IF EXISTS "${instanceDbRole(inst)}"'`),
|
|
49
|
+
`${sudo}find ${CONFIG.backupDir} -maxdepth 1 -type f -name '${dumpGlob(inst)}' -delete`,
|
|
50
|
+
`${sudo}rm -rf ${path.posix.dirname(webEnvPath(inst))} ${instanceStateDir(inst)}`,
|
|
51
|
+
];
|
|
52
|
+
if (inst.hosting_shape === 'standalone') cmds.push(`${sudo}rm -rf ${standaloneRoot(inst)}`);
|
|
53
|
+
cmds.push(`if id -u ${user} >/dev/null 2>&1; then ${sudo}userdel ${user}; fi`);
|
|
54
|
+
return cmds;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function disconnectInstance(inst, deps) {
|
|
58
|
+
const { db, exec, log, apply, privileged } = deps;
|
|
59
|
+
// Every rm -rf below is built from the slug, so it is re-validated here rather than trusted.
|
|
60
|
+
if (!inst || !isValidSlug(inst.slug)) return { ok: false, error: `refusing to disconnect: invalid slug ${JSON.stringify(inst && inst.slug)}` };
|
|
61
|
+
log(`disconnect ${inst.slug} (status=${inst.status})`);
|
|
62
|
+
// A teardown returns ok or throws; a throw ends the run here, before anything is erased.
|
|
63
|
+
await teardownInstance(inst, deps);
|
|
64
|
+
|
|
65
|
+
if (inst.hosting_shape !== 'dedicated') {
|
|
66
|
+
const boxExec = inst.hosting_shape === 'standalone' ? (deps.controlExec || exec) : exec;
|
|
67
|
+
for (const cmd of eraseCommands(inst, { privileged })) {
|
|
68
|
+
try { boxExec(cmd); }
|
|
69
|
+
catch (e) { return { ok: false, error: `erase step failed (${cmd.split(' ').slice(0, 3).join(' ')}): ${(e && e.message) || e}` }; }
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const origin = catalogOriginForInstance(inst);
|
|
74
|
+
if (origin && deps.projectCatalog) {
|
|
75
|
+
if (apply) {
|
|
76
|
+
try {
|
|
77
|
+
const ownerGithubId = await ownerGithubIdForInstance(db, inst);
|
|
78
|
+
const gone = await deps.projectCatalog.forgetOwnedProject({ clientId: inst.slug, origin, ownerGithubId }, { pool: db });
|
|
79
|
+
log(` catalog: removed ${gone.project} project row, ${gone.client} client, ${gone.memberships} membership(s)`);
|
|
80
|
+
} catch (e) { return { ok: false, error: `catalog forget failed: ${(e && e.message) || e}` }; }
|
|
81
|
+
} else log(` would remove ${origin} from the catalog`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!apply) { log(` would delete the provisioning row for ${inst.slug}`); return { ok: true }; }
|
|
85
|
+
await deleteInstance(db, inst.id);
|
|
86
|
+
log(` ✓ ${inst.slug} disconnected — row deleted; name, port and address released`);
|
|
87
|
+
return { ok: true };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
module.exports = { disconnectInstance, eraseCommands, dumpGlob };
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// provision-teardown.js — reverse the provisioning runbook for one instance
|
|
3
|
+
// (idempotent). Split out of provision.js (task 1002897), which sat at the size
|
|
4
|
+
// ratchet; provision.js re-exports teardownInstance, so its callers are unchanged.
|
|
5
|
+
//
|
|
6
|
+
// What a teardown takes down: the service + backup units, the per-instance Caddy
|
|
7
|
+
// snippet, the DNS record (parked at the blackhole), and the deploy key. What it
|
|
8
|
+
// deliberately LEAVES (ADR 0181): the database, its dumps and the checkout (data is
|
|
9
|
+
// precious), and the row's `domain` + `port` — a revive re-uses exactly that address,
|
|
10
|
+
// and step 5 of the next standup UPSERTs it straight back.
|
|
11
|
+
|
|
12
|
+
const path = require('node:path');
|
|
13
|
+
const { CONFIG, REPO_ROOT } = require('./provision-config.js');
|
|
14
|
+
const { dbName, deployKeyPath, deployKeyTitle, parseTargetRef, resolveOwnerGithubToken } = require('./provision-repo.js');
|
|
15
|
+
const { backupServicePath, backupTimerPath, backupUnitName, serviceUnitPath } = require('./provision-units.js');
|
|
16
|
+
const { caddySnippetPath, dnsReleaseEnv } = require('./provision-net.js');
|
|
17
|
+
const apexMod = require('./instance-apex.js');
|
|
18
|
+
const { isReservedHostname } = require('../../modules/provisioning/provisioning.js');
|
|
19
|
+
|
|
20
|
+
async function teardownInstance(inst, deps) {
|
|
21
|
+
const { db, provisioning, exec, log, apply, privileged } = deps;
|
|
22
|
+
const controlExec = deps.controlExec || exec;
|
|
23
|
+
// Tear down on the box the instance actually RAN on: a standalone ran control-plane-
|
|
24
|
+
// local (task 2074), so disable/remove its unit HERE — not on the remote co-hosting
|
|
25
|
+
// box, where it was never installed (that would allowFail-noop and leave the real
|
|
26
|
+
// control-plane unit running). Co-tenant + dedicated are unchanged.
|
|
27
|
+
const boxExec = inst.hosting_shape === 'standalone' ? controlExec : exec;
|
|
28
|
+
const sudoP = privileged ? 'sudo ' : ''; // systemctl / rm-under-/etc escalation (task 2066)
|
|
29
|
+
log(`teardown ${inst.slug} (status=${inst.status})`);
|
|
30
|
+
if (apply) await provisioning.setInstanceStatus(db, inst.id, 'tearing_down');
|
|
31
|
+
if (inst.hosting_shape !== 'dedicated') {
|
|
32
|
+
boxExec(`${sudoP}systemctl disable --now ${inst.slug}`, { allowFail: true });
|
|
33
|
+
boxExec(`${sudoP}rm -f ${serviceUnitPath(inst)}`, { allowFail: true });
|
|
34
|
+
// Stop + remove the nightly backup units too (task 2213; the dir + dumps stay — precious data, like the DB below), then reset-failed (task 1003940): a removed unit's FAILED state outlives its file, so `systemctl --failed` listed torn-down ghosts for weeks.
|
|
35
|
+
boxExec(`${sudoP}systemctl disable --now ${backupUnitName(inst)}.timer`, { allowFail: true });
|
|
36
|
+
boxExec(`${sudoP}rm -f ${backupServicePath(inst)} ${backupTimerPath(inst)}`, { allowFail: true });
|
|
37
|
+
boxExec(`${sudoP}systemctl daemon-reload`, { allowFail: true });
|
|
38
|
+
boxExec(`${sudoP}systemctl reset-failed ${inst.slug}.service ${backupUnitName(inst)}.service`, { allowFail: true });
|
|
39
|
+
// The site snippet standup step 6 wrote: while it stays, Caddy proxies the hostname to
|
|
40
|
+
// a port nothing listens on and every visitor gets a 502 (task 1002897). Not precious.
|
|
41
|
+
boxExec(`${sudoP}rm -f ${caddySnippetPath(inst)}`, { allowFail: true });
|
|
42
|
+
boxExec(`${sudoP}systemctl reload caddy`, { allowFail: true });
|
|
43
|
+
// Left for an operator (data is precious), naming ONLY what this row created —
|
|
44
|
+
// teardown now takes 'requested'/'error'. `port` + `domain` stay held: ADR 0181.
|
|
45
|
+
if (inst.db_name) log(` (left for the operator: dropdb ${dbName(inst)}, prune ${CONFIG.backupDir}/${dbName(inst)}-*.sql.gz)`);
|
|
46
|
+
} else if (apply && deps.doApi && inst.droplet_id) {
|
|
47
|
+
await deps.doApi.deleteDroplet(inst.droplet_id);
|
|
48
|
+
}
|
|
49
|
+
parkAddress(inst, { controlExec, log, apply });
|
|
50
|
+
// Revoke the instance's deploy key (ADR 0176) and remove its local halves. The
|
|
51
|
+
// checkout itself is deliberately left in place (data is precious), so the key
|
|
52
|
+
// files must NOT ride along — and a revoke skip must be LOUD: a torn-down project
|
|
53
|
+
// silently keeping durable read access to the owner's private repo is the exact
|
|
54
|
+
// shape a teardown exists to end. A missing token / API hiccup never blocks it.
|
|
55
|
+
if (inst.hosting_shape === 'standalone' && inst.target_ref) {
|
|
56
|
+
const repo = parseTargetRef(inst.target_ref);
|
|
57
|
+
if (repo) {
|
|
58
|
+
try {
|
|
59
|
+
const token = await resolveOwnerGithubToken(inst, deps, { tag: '[teardown]' });
|
|
60
|
+
if (token) {
|
|
61
|
+
const ghRepos = deps.githubRepos || require(path.join(REPO_ROOT, 'src/bongos/github-repos'));
|
|
62
|
+
if (apply) {
|
|
63
|
+
const keys = await ghRepos.listDeployKeys(token, { owner: repo.owner, name: repo.name, fetchImpl: deps.fetchImpl });
|
|
64
|
+
const ours = keys.find((k) => k.title === deployKeyTitle(inst));
|
|
65
|
+
if (ours) {
|
|
66
|
+
await ghRepos.deleteDeployKey(token, { owner: repo.owner, name: repo.name, keyId: ours.id, fetchImpl: deps.fetchImpl });
|
|
67
|
+
log(` revoked the "${deployKeyTitle(inst)}" deploy key on ${repo.owner}/${repo.name}`);
|
|
68
|
+
}
|
|
69
|
+
} else log(` would revoke the "${deployKeyTitle(inst)}" deploy key on ${repo.owner}/${repo.name} if present`);
|
|
70
|
+
} else {
|
|
71
|
+
log(` (deploy-key revoke skipped: no usable owner credential — if ${repo.owner}/${repo.name} carries a "${deployKeyTitle(inst)}" deploy key, remove it in the repo's settings)`);
|
|
72
|
+
}
|
|
73
|
+
} catch (e) { log(` (deploy-key revoke skipped: ${e && e.message})`); }
|
|
74
|
+
boxExec(`rm -f ${deployKeyPath(inst)} ${deployKeyPath(inst)}.pub`, { allowFail: true });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (apply) {
|
|
78
|
+
await provisioning.setInstanceStatus(db, inst.id, 'torn_down', { torn_down_at: new Date().toISOString() });
|
|
79
|
+
await provisioning.recordEvent(db, { instanceId: inst.id, ownerBuilderId: inst.owner_builder_id, event: 'torn_down', detail: 'torn down by runner', actor: 'runner' });
|
|
80
|
+
}
|
|
81
|
+
log(` ✓ ${inst.slug} torn down`);
|
|
82
|
+
return { ok: true };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Park the row's hostname at the RFC 5737 blackhole — the release a re-point already
|
|
86
|
+
// uses (ADR 0200 §2: parked, not deleted, so no NXDOMAIN window gets negative-cached).
|
|
87
|
+
// Runs on the CONTROL PLANE for every shape: the hook needs the Cloudflare token, which
|
|
88
|
+
// never leaves it. BEST-EFFORT: a failure is logged and never strands the row. A
|
|
89
|
+
// platform-reserved hostname is refused outright — standup would never have been given
|
|
90
|
+
// one, and blackholing it would take the platform's own record down.
|
|
91
|
+
function parkAddress(inst, { controlExec, log, apply }) {
|
|
92
|
+
if (!inst.domain) return;
|
|
93
|
+
if (isReservedHostname(inst.domain, apexMod.instanceApex())) {
|
|
94
|
+
log(` [dns] NOT parking ${inst.domain}: a platform-reserved hostname is never blackholed by a teardown`);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (!CONFIG.dnsHook) {
|
|
98
|
+
log(` [dns] cannot park ${inst.domain} (BOX_DNS_HOOK unset) — point its A record at 192.0.2.1 by hand`);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const env = dnsReleaseEnv(inst.domain);
|
|
102
|
+
log(` [dns] park ${inst.domain} -> ${env.BOX_IP} (blackhole, zone ${env.CLOUDFLARE_ZONE}) via ${CONFIG.dnsHook}`);
|
|
103
|
+
if (!apply) return;
|
|
104
|
+
try { controlExec(CONFIG.dnsHook, { env }); }
|
|
105
|
+
catch (e) { log(` ⚠ could not park ${inst.domain}: ${(e && e.message) || e} — its A record still points at the platform`); }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
module.exports = { teardownInstance };
|
package/scripts/gds/provision.js
CHANGED
|
@@ -54,6 +54,8 @@ const { APP_USER, alreadyScaffolded, buildCorePinRefreshCommit, dbCreateCmd, dbN
|
|
|
54
54
|
const { backupScriptPath, backupService, backupServicePath, backupTimer, backupTimerPath, backupUnitName, instanceManifestCmd, originEnvVarsFor, serviceUnit, serviceUnitPath, settingsConsumed, settingsEnvVarsFor, upsertEnvVars, webEnvBody, webEnvPath } = require('./provision-units.js');
|
|
55
55
|
const { caddyBlock, caddySnippetPath, checkDnsTokenScope, classifyZoneScope, dnsReleaseEnv, dnsUpsertEnv, federateInstance, federationHubOrigin, healthzCmd, identityCmd, identityVerdict, shouldFederate, zoneForDomain } = require('./provision-net.js');
|
|
56
56
|
const { capacityPreflight, statusCapacityLine } = require('./provision-capacity.js');
|
|
57
|
+
const { teardownInstance } = require('./provision-teardown.js');
|
|
58
|
+
const { disconnectInstance } = require('./provision-disconnect.js');
|
|
57
59
|
|
|
58
60
|
function makeExec({ apply, log = console.log, remoteHost = null, remoteCwd = null, sshOpts = '' }) {
|
|
59
61
|
// `shown` (task 1003140): what to LOG in place of the command text — for a command
|
|
@@ -1017,69 +1019,6 @@ async function restartInstance(inst, deps) {
|
|
|
1017
1019
|
return { ok: true, healthy };
|
|
1018
1020
|
}
|
|
1019
1021
|
|
|
1020
|
-
// ---------------------------------------------------------------------------
|
|
1021
|
-
// Teardown one instance (reverse the runbook; idempotent).
|
|
1022
|
-
// ---------------------------------------------------------------------------
|
|
1023
|
-
async function teardownInstance(inst, deps) {
|
|
1024
|
-
const { db, provisioning, exec, log, apply, privileged } = deps;
|
|
1025
|
-
// Tear down on the box the instance actually RAN on: a standalone ran control-plane-
|
|
1026
|
-
// local (task 2074), so disable/remove its unit HERE — not on the remote co-hosting
|
|
1027
|
-
// box, where it was never installed (that would allowFail-noop and leave the real
|
|
1028
|
-
// control-plane unit running). Co-tenant + dedicated are unchanged.
|
|
1029
|
-
const boxExec = inst.hosting_shape === 'standalone' ? (deps.controlExec || exec) : exec;
|
|
1030
|
-
const sudoP = privileged ? 'sudo ' : ''; // systemctl / rm-under-/etc escalation (task 2066)
|
|
1031
|
-
log(`teardown ${inst.slug} (status=${inst.status})`);
|
|
1032
|
-
if (apply) await provisioning.setInstanceStatus(db, inst.id, 'tearing_down');
|
|
1033
|
-
if (inst.hosting_shape !== 'dedicated') {
|
|
1034
|
-
boxExec(`${sudoP}systemctl disable --now ${inst.slug}`, { allowFail: true });
|
|
1035
|
-
boxExec(`${sudoP}rm -f ${serviceUnitPath(inst)}`, { allowFail: true });
|
|
1036
|
-
// Stop + remove the nightly backup units too (task 2213; the dir + dumps stay — precious data, like the DB below), then reset-failed (task 1003940): a removed unit's FAILED state outlives its file, so `systemctl --failed` listed torn-down ghosts for weeks.
|
|
1037
|
-
boxExec(`${sudoP}systemctl disable --now ${backupUnitName(inst)}.timer`, { allowFail: true });
|
|
1038
|
-
boxExec(`${sudoP}rm -f ${backupServicePath(inst)} ${backupTimerPath(inst)}`, { allowFail: true });
|
|
1039
|
-
boxExec(`${sudoP}systemctl daemon-reload`, { allowFail: true });
|
|
1040
|
-
boxExec(`${sudoP}systemctl reset-failed ${inst.slug}.service ${backupUnitName(inst)}.service`, { allowFail: true });
|
|
1041
|
-
// Left for an operator (data is precious), naming ONLY what this row created —
|
|
1042
|
-
// teardown now takes 'requested'/'error'. `port` + `domain` stay held: ADR 0181.
|
|
1043
|
-
const dumps = inst.db_name ? [`dropdb ${dbName(inst)}`, `prune ${CONFIG.backupDir}/${dbName(inst)}-*.sql.gz`] : [];
|
|
1044
|
-
log(` (left for the operator: ${dumps.concat([`remove the Caddy block for ${inst.slug} if one was written`]).join(', ')})`);
|
|
1045
|
-
} else if (apply && deps.doApi && inst.droplet_id) {
|
|
1046
|
-
await deps.doApi.deleteDroplet(inst.droplet_id);
|
|
1047
|
-
}
|
|
1048
|
-
// Revoke the instance's deploy key (ADR 0176) and remove its local halves. The
|
|
1049
|
-
// checkout itself is deliberately left in place (data is precious), so the key
|
|
1050
|
-
// files must NOT ride along — and a revoke skip must be LOUD: a torn-down project
|
|
1051
|
-
// silently keeping durable read access to the owner's private repo is the exact
|
|
1052
|
-
// shape a teardown exists to end. A missing token / API hiccup never blocks it.
|
|
1053
|
-
if (inst.hosting_shape === 'standalone' && inst.target_ref) {
|
|
1054
|
-
const repo = parseTargetRef(inst.target_ref);
|
|
1055
|
-
if (repo) {
|
|
1056
|
-
try {
|
|
1057
|
-
const token = await resolveOwnerGithubToken(inst, deps, { tag: '[teardown]' });
|
|
1058
|
-
if (token) {
|
|
1059
|
-
const ghRepos = deps.githubRepos || require(path.join(REPO_ROOT, 'src/bongos/github-repos'));
|
|
1060
|
-
if (apply) {
|
|
1061
|
-
const keys = await ghRepos.listDeployKeys(token, { owner: repo.owner, name: repo.name, fetchImpl: deps.fetchImpl });
|
|
1062
|
-
const ours = keys.find((k) => k.title === deployKeyTitle(inst));
|
|
1063
|
-
if (ours) {
|
|
1064
|
-
await ghRepos.deleteDeployKey(token, { owner: repo.owner, name: repo.name, keyId: ours.id, fetchImpl: deps.fetchImpl });
|
|
1065
|
-
log(` revoked the "${deployKeyTitle(inst)}" deploy key on ${repo.owner}/${repo.name}`);
|
|
1066
|
-
}
|
|
1067
|
-
} else log(` would revoke the "${deployKeyTitle(inst)}" deploy key on ${repo.owner}/${repo.name} if present`);
|
|
1068
|
-
} else {
|
|
1069
|
-
log(` (deploy-key revoke skipped: no usable owner credential — if ${repo.owner}/${repo.name} carries a "${deployKeyTitle(inst)}" deploy key, remove it in the repo's settings)`);
|
|
1070
|
-
}
|
|
1071
|
-
} catch (e) { log(` (deploy-key revoke skipped: ${e && e.message})`); }
|
|
1072
|
-
boxExec(`rm -f ${deployKeyPath(inst)} ${deployKeyPath(inst)}.pub`, { allowFail: true });
|
|
1073
|
-
}
|
|
1074
|
-
}
|
|
1075
|
-
if (apply) {
|
|
1076
|
-
await provisioning.setInstanceStatus(db, inst.id, 'torn_down', { torn_down_at: new Date().toISOString() });
|
|
1077
|
-
await provisioning.recordEvent(db, { instanceId: inst.id, ownerBuilderId: inst.owner_builder_id, event: 'torn_down', detail: 'torn down by runner', actor: 'runner' });
|
|
1078
|
-
}
|
|
1079
|
-
log(` ✓ ${inst.slug} torn down`);
|
|
1080
|
-
return { ok: true };
|
|
1081
|
-
}
|
|
1082
|
-
|
|
1083
1022
|
// ---------------------------------------------------------------------------
|
|
1084
1023
|
// GitHub App Manifest conversion + cred placement (task 2080, ADR 0133).
|
|
1085
1024
|
// The web tier stored GitHub's temporary `code` on a provisioning_oauth_manifests
|
|
@@ -1298,6 +1237,7 @@ async function cmdRunIntents(deps) {
|
|
|
1298
1237
|
else if (intent.action === 'domain-attach') result = await domainAttachInstance(inst, deps);
|
|
1299
1238
|
else if (intent.action === 'settings-apply') result = await settingsApplyInstance(inst, deps);
|
|
1300
1239
|
else if (intent.action === 'restart') result = await restartInstance(inst, deps);
|
|
1240
|
+
else if (intent.action === 'disconnect') result = await disconnectInstance(inst, deps);
|
|
1301
1241
|
else result = { ok: false, error: `unknown intent action '${intent.action}' — refusing to guess` };
|
|
1302
1242
|
if (result && result.ok === false) {
|
|
1303
1243
|
if (intent.attempts >= MAX_INTENT_ATTEMPTS) {
|
|
@@ -1425,8 +1365,8 @@ async function main() {
|
|
|
1425
1365
|
const slug = args[1] && !args[1].startsWith('--') ? args[1] : null;
|
|
1426
1366
|
if ((cmd === 'provision' || cmd === 'teardown') && !slug) { console.error(`${cmd} needs a <slug>`); process.exit(2); }
|
|
1427
1367
|
|
|
1428
|
-
let pool, provisioning, DoApi, platformIdentity, hubKeys;
|
|
1429
|
-
try { ({ pool, provisioning, DoApi, platformIdentity, hubKeys } = loadDeps()); }
|
|
1368
|
+
let pool, provisioning, DoApi, platformIdentity, hubKeys, projectCatalog;
|
|
1369
|
+
try { ({ pool, provisioning, DoApi, platformIdentity, hubKeys, projectCatalog } = loadDeps()); }
|
|
1430
1370
|
catch (e) { console.error(`cannot load DB layer (run this on the control plane): ${e.message}`); process.exit(1); }
|
|
1431
1371
|
|
|
1432
1372
|
// Box-target executor (remote over SSH when PROVISION_REMOTE_HOST is set, else
|
|
@@ -1446,6 +1386,7 @@ async function main() {
|
|
|
1446
1386
|
// Cross-site identity (ADR 0141 §4). Null on a non-hub control plane → federation
|
|
1447
1387
|
// dormant; shouldFederate() then skips and the instance owns its auth (as today).
|
|
1448
1388
|
platformIdentity, hubKeys,
|
|
1389
|
+
projectCatalog, // the disconnect leg's catalog forget (task 1003249); null off the hub
|
|
1449
1390
|
};
|
|
1450
1391
|
|
|
1451
1392
|
let result;
|