@bongos/core 1.19.773 → 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 +189 -74
- 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 +2 -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/ship-card.js +49 -13
- 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/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 +5 -5
- 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/sso_checkin_legacy_field_gone.mjs +246 -0
|
@@ -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;
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
// scripts/gds/seam-wiring-guard.js — Check 33: a declared contributes.<seam> that
|
|
2
|
+
// nothing consumes (task 1003819; successor to abandoned task 1002464).
|
|
3
|
+
//
|
|
4
|
+
// THE GAP THIS CLOSES. A module declares what it contributes in module.json.
|
|
5
|
+
// src/module-seams.js `verifyPortsSatisfied` checks the OPPOSITE direction — every
|
|
6
|
+
// `consumes` port has exactly one provider among the enabled modules — and
|
|
7
|
+
// src/module-loader/manifest-schema.js `validateManifest` checks the SHAPE of
|
|
8
|
+
// `contributes` (a closed key set, so a typo'd KEY errors). Neither asks whether a
|
|
9
|
+
// declared entry is actually WIRED. So a module can declare `contributes.pollers:
|
|
10
|
+
// ["ghost"]` with no modules/<key>/pollers/ghost.js and boot stays silent: the
|
|
11
|
+
// loader's startModulePollers treats a missing poller file as declarative-only and
|
|
12
|
+
// skips it, hallWidgetScripts skips a uiSection with no hall-widget.js, and
|
|
13
|
+
// moduleWebSurfaces skips a surface whose dir was never shipped. Every one of those
|
|
14
|
+
// skips is correct behaviour for a REAL declarative-only entry, and indistinguishable
|
|
15
|
+
// from a typo or a rename that left the manifest behind.
|
|
16
|
+
//
|
|
17
|
+
// WHAT "CONSUMED" MEANS. Each seam has one consumer in the tree, and each consumer
|
|
18
|
+
// looks for one artifact at a fixed path. SEAM_CONSUMERS below records that pairing —
|
|
19
|
+
// seam → the artifact its consumer resolves — so this guard asks the same question
|
|
20
|
+
// the consumer asks rather than inventing a second convention that could drift from
|
|
21
|
+
// it. A seam with NO consumer anywhere carries `consumer: null`, and every
|
|
22
|
+
// declaration of it is reported: that is the strongest form of the defect.
|
|
23
|
+
//
|
|
24
|
+
// WHY A CI GATE AND NOT A BOOT REFUSAL (ADR 0289). `verifyEnabledPorts` throws at
|
|
25
|
+
// boot because an unprovided port otherwise becomes a runtime throw on a request
|
|
26
|
+
// path, in a process that reported healthy — refusing early is strictly safer. For
|
|
27
|
+
// every seam BUT `routes`, an unconsumed contribution is not that shape: the consumer
|
|
28
|
+
// skips it gracefully, nothing crashes later, and the instance runs correctly minus
|
|
29
|
+
// one feature. Adding a production start-up failure for a manifest-hygiene defect
|
|
30
|
+
// would trade a real outage risk for a cosmetic one.
|
|
31
|
+
//
|
|
32
|
+
// `routes` is the exception and it argues the SAME way: mountModuleRoutes `require`s
|
|
33
|
+
// the factory with no try/catch, so a missing route file already takes the boot down
|
|
34
|
+
// today. There the guard is not a new gate at all — it moves an existing crash from
|
|
35
|
+
// production start-up to the build, which is strictly earlier and strictly cheaper.
|
|
36
|
+
// So CI covers both cases: the seams that fail silently get a voice, and the one that
|
|
37
|
+
// fails loudly gets to fail before it reaches a server.
|
|
38
|
+
//
|
|
39
|
+
// Exported for scripts/gds/fitness.js (checkSeamWiring) and for the unit suite, which
|
|
40
|
+
// drives scanSeamWiring() over synthetic manifests with an injected `exists` so every
|
|
41
|
+
// case is hermetic — the scanScopeMapSource / scanDeterministicBody precedent, where
|
|
42
|
+
// the pure scanner is the tested surface.
|
|
43
|
+
|
|
44
|
+
'use strict';
|
|
45
|
+
|
|
46
|
+
const fs = require('node:fs');
|
|
47
|
+
const path = require('node:path');
|
|
48
|
+
// The SAME enumerator + labeller validateManifest uses for the exemption cross-check,
|
|
49
|
+
// imported rather than re-declared: two copies would drift, and the drift would land
|
|
50
|
+
// on whether an exemption is honoured (task 1003819).
|
|
51
|
+
const { declaredEntries, entryLabel } = require('../../src/module-loader/manifest-schema.js');
|
|
52
|
+
|
|
53
|
+
const REPO_ROOT = path.resolve(__dirname, '..', '..');
|
|
54
|
+
|
|
55
|
+
// Is `child` inside `parent`? Both are repo-relative-or-absolute; normalised before
|
|
56
|
+
// comparing so a '..' segment cannot pass by spelling.
|
|
57
|
+
function within(parent, child) {
|
|
58
|
+
const a = path.resolve(REPO_ROOT, parent);
|
|
59
|
+
const b = path.resolve(REPO_ROOT, child);
|
|
60
|
+
const r = path.relative(a, b);
|
|
61
|
+
return r !== '' && !r.startsWith('..') && !path.isAbsolute(r);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// A repo-relative, forward-slashed path for an error message.
|
|
65
|
+
function rel(p) {
|
|
66
|
+
if (!p) return '(no artifact path)';
|
|
67
|
+
const r = path.relative(REPO_ROOT, path.isAbsolute(p) ? p : path.join(REPO_ROOT, p));
|
|
68
|
+
return r.split(path.sep).join('/');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// seam → how its consumer resolves a declared entry to an on-disk artifact.
|
|
72
|
+
// consumer the function that actually reads it, named so a reader can go check
|
|
73
|
+
// kind 'file' | 'dir' | 'registry' — what must exist
|
|
74
|
+
// artifact (moduleDir, entry) → path, or null when the seam is per-MODULE rather
|
|
75
|
+
// than per-entry (uiSections ships ONE widget however many it names)
|
|
76
|
+
// alternates optional extra paths that also count as wired
|
|
77
|
+
const SEAM_CONSUMERS = {
|
|
78
|
+
routes: {
|
|
79
|
+
consumer: 'src/module-loader/loader.js mountModuleRoutes',
|
|
80
|
+
kind: 'file',
|
|
81
|
+
artifact: (dir, entry) => path.join(dir, 'routes', `${entry}.js`),
|
|
82
|
+
},
|
|
83
|
+
pollers: {
|
|
84
|
+
consumer: 'src/module-loader/loader.js startModulePollers',
|
|
85
|
+
kind: 'file',
|
|
86
|
+
artifact: (dir, entry) => path.join(dir, 'pollers', `${entry}.js`),
|
|
87
|
+
},
|
|
88
|
+
rooms: {
|
|
89
|
+
// Allowed by the manifest schema since the doorway, but nothing in core reads
|
|
90
|
+
// contributes.rooms — the realtime seam was never wired to the loader. No module
|
|
91
|
+
// declares it today; if one ever does, this guard says so rather than letting the
|
|
92
|
+
// declaration read as though it did something.
|
|
93
|
+
consumer: null,
|
|
94
|
+
kind: 'file',
|
|
95
|
+
artifact: (dir, entry) => path.join(dir, 'rooms', `${entry}.js`),
|
|
96
|
+
},
|
|
97
|
+
uiSections: {
|
|
98
|
+
// Per-MODULE, not per-entry: hallWidgetScripts looks for one hall-widget.js
|
|
99
|
+
// however many sections the module names.
|
|
100
|
+
consumer: 'src/module-loader/loader.js hallWidgetScripts',
|
|
101
|
+
kind: 'file',
|
|
102
|
+
artifact: (dir) => path.join(dir, 'public', 'hall-widget.js'),
|
|
103
|
+
},
|
|
104
|
+
skills: {
|
|
105
|
+
// moduleSkillSources resolves a declared name against the module's OWN tree or
|
|
106
|
+
// its vendor/ subdir. A name that exists only in the core .claude/skills/ is
|
|
107
|
+
// skipped by it (`if (!src) continue`), so it is unconsumed BY THE MANIFEST —
|
|
108
|
+
// a legitimate declarative-only case, and what ui-design's exemption records.
|
|
109
|
+
//
|
|
110
|
+
// The artifact is the SKILL.md, not the directory: moduleSkillSources tests
|
|
111
|
+
// `fs.existsSync(path.join(p, 'SKILL.md'))`, so a skills/<name>/ dir without one
|
|
112
|
+
// is NOT a skill to it. Probing the bare directory would call such an entry wired
|
|
113
|
+
// while the real consumer skipped it — this guard's own defect, on the seam with
|
|
114
|
+
// the most declared entries.
|
|
115
|
+
consumer: 'scripts/gds/claude-materialize.js moduleSkillSources',
|
|
116
|
+
kind: 'file',
|
|
117
|
+
artifact: (dir, entry) => path.join(dir, 'skills', entry, 'SKILL.md'),
|
|
118
|
+
alternates: (dir, entry) => [path.join(dir, 'skills', 'vendor', entry, 'SKILL.md')],
|
|
119
|
+
},
|
|
120
|
+
disciplines: {
|
|
121
|
+
consumer: 'scripts/gds/discipline-modes.json (read by src/modules.js + claim.js)',
|
|
122
|
+
kind: 'registry',
|
|
123
|
+
artifact: () => null,
|
|
124
|
+
},
|
|
125
|
+
webSurfaces: {
|
|
126
|
+
consumer: 'src/module-loader/loader.js moduleWebSurfaces',
|
|
127
|
+
kind: 'dir',
|
|
128
|
+
// `entry` is the surface OBJECT here, not a string.
|
|
129
|
+
artifact: (dir, entry) => (entry && typeof entry.dir === 'string' ? path.join(dir, entry.dir) : null),
|
|
130
|
+
},
|
|
131
|
+
migrations: {
|
|
132
|
+
consumer: 'scripts/migrate.sh (the module-migration runner)',
|
|
133
|
+
kind: 'dir',
|
|
134
|
+
artifact: (dir) => path.join(dir, 'migrations'),
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
// Default existence probe — `kind` decides file-vs-directory. A relative path is
|
|
139
|
+
// resolved against the repo root so a manifest's `dir` may be either.
|
|
140
|
+
function fsExists(p, kind) {
|
|
141
|
+
try {
|
|
142
|
+
const st = fs.statSync(path.isAbsolute(p) ? p : path.join(REPO_ROOT, p));
|
|
143
|
+
return kind === 'dir' ? st.isDirectory() : st.isFile();
|
|
144
|
+
} catch {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// THE SCANNER. Pure over its inputs (the only I/O is the injected `exists`), so the
|
|
150
|
+
// unit suite drives every case with synthetic manifests and no tmp dirs.
|
|
151
|
+
//
|
|
152
|
+
// modules [{ key, dir, manifest }] — the loader's `discovered().loaded` shape
|
|
153
|
+
// exists (path, kind) => boolean — defaults to the real filesystem
|
|
154
|
+
// registryKeys Set of valid `disciplines` entries, or null to skip that seam
|
|
155
|
+
//
|
|
156
|
+
// Returns { ok, findings[], wired[], declarative[] }. `wired` and `declarative` are
|
|
157
|
+
// populated on the SUCCESS path too, deliberately: a guard that reports nothing when
|
|
158
|
+
// it passes leaves no evidence it ran, so the caller prints the counts either way.
|
|
159
|
+
function scanSeamWiring(modules = [], { exists = fsExists, registryKeys = null } = {}) {
|
|
160
|
+
const findings = [];
|
|
161
|
+
const wired = [];
|
|
162
|
+
const declarative = [];
|
|
163
|
+
|
|
164
|
+
for (const { key, dir, manifest } of modules) {
|
|
165
|
+
const contributes = (manifest && manifest.contributes) || {};
|
|
166
|
+
const exempt = (manifest && manifest.declarativeSeams) || {};
|
|
167
|
+
const isExempt = (seam, label) => Array.isArray(exempt[seam]) && exempt[seam].includes(label);
|
|
168
|
+
|
|
169
|
+
for (const [seam, value] of Object.entries(contributes)) {
|
|
170
|
+
const spec = SEAM_CONSUMERS[seam];
|
|
171
|
+
if (!spec) continue; // an unknown seam key is validateManifest's error, not ours
|
|
172
|
+
|
|
173
|
+
for (const entry of declaredEntries(seam, value)) {
|
|
174
|
+
const label = entryLabel(seam, entry);
|
|
175
|
+
|
|
176
|
+
if (spec.consumer === null) {
|
|
177
|
+
findings.push(`module "${key}" declares contributes.${seam}: "${label}", but NOTHING in the tree consumes the ${seam} seam at all — the declaration does nothing`);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// A 'registry' seam is satisfied by membership in a roster, not by a file.
|
|
182
|
+
if (spec.kind === 'registry') {
|
|
183
|
+
if (registryKeys && !registryKeys.has(label)) {
|
|
184
|
+
findings.push(`module "${key}" declares contributes.${seam}: "${label}", but ${spec.consumer} has no such entry`);
|
|
185
|
+
} else {
|
|
186
|
+
wired.push({ key, seam, entry: label, via: spec.consumer });
|
|
187
|
+
}
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const primary = spec.artifact(dir, entry);
|
|
192
|
+
const candidates = [primary, ...(spec.alternates ? spec.alternates(dir, entry) : [])].filter(Boolean);
|
|
193
|
+
// An entry carrying path separators or '..' would aim the probe outside
|
|
194
|
+
// modules/<key>/. It is only an existence test over repo-committed content, so
|
|
195
|
+
// the blast radius is an on/off answer — but a declaration that reaches out of
|
|
196
|
+
// its own module is a finding in its own right, not something to resolve.
|
|
197
|
+
const escapes = candidates.find((p) => !within(dir, p));
|
|
198
|
+
if (escapes) {
|
|
199
|
+
findings.push(`module "${key}" declares contributes.${seam}: "${label}", which resolves to ${rel(escapes)} — outside the module's own directory. A contributes entry names something the module ships, so it may not contain a path separator or ".."`);
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
const found = candidates.some((p) => exists(p, spec.kind));
|
|
203
|
+
|
|
204
|
+
if (found) {
|
|
205
|
+
// A STALE exemption is the same rot in the other direction: the artifact
|
|
206
|
+
// exists now, so the entry IS wired and the exemption reads as though a
|
|
207
|
+
// real gap were sanctioned. Fail it rather than let the list quietly lie.
|
|
208
|
+
if (isExempt(seam, label)) {
|
|
209
|
+
findings.push(`module "${key}" exempts ${seam} "${label}" as declarative-only, but ${rel(primary)} exists and ${spec.consumer} wires it — drop the stale declarativeSeams.${seam} entry`);
|
|
210
|
+
} else {
|
|
211
|
+
wired.push({ key, seam, entry: label, via: spec.consumer });
|
|
212
|
+
}
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (isExempt(seam, label)) {
|
|
217
|
+
declarative.push({ key, seam, entry: label });
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
findings.push(`module "${key}" declares contributes.${seam}: "${label}", but ${rel(primary)} does not exist — ${spec.consumer} silently skips it. Ship the artifact, drop the declaration, or record it in declarativeSeams.${seam} if it is declarative-only on purpose`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return { ok: findings.length === 0, findings, wired, declarative };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// The roster `contributes.disciplines` is checked against. Returns null when the file
|
|
230
|
+
// is unreadable, which makes the disciplines seam UNCHECKED rather than failed — a
|
|
231
|
+
// missing registry is its own check's problem (fitness.js role packs), not this one's.
|
|
232
|
+
function disciplineKeys() {
|
|
233
|
+
try {
|
|
234
|
+
const raw = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, 'scripts', 'gds', 'discipline-modes.json'), 'utf8'));
|
|
235
|
+
return new Set(Object.keys(raw.modes || {}));
|
|
236
|
+
} catch {
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// The fitness-check wrapper. Discovers every module.json through the loader — the same
|
|
242
|
+
// discovery the running server uses, so the guard cannot check a different module set
|
|
243
|
+
// than the one that boots.
|
|
244
|
+
function checkSeamWiring() {
|
|
245
|
+
const name = 'declared module seams are consumed (task 1003819)';
|
|
246
|
+
let modules = [];
|
|
247
|
+
try {
|
|
248
|
+
modules = require('../../src/module-loader/loader.js').discoverModules().loaded;
|
|
249
|
+
} catch (e) {
|
|
250
|
+
return {
|
|
251
|
+
name,
|
|
252
|
+
ok: true,
|
|
253
|
+
hardFail: false,
|
|
254
|
+
violations: [],
|
|
255
|
+
warnings: [`module discovery failed: ${e && e.message ? e.message : e}`],
|
|
256
|
+
note: 'module discovery unavailable — skipped.',
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const { ok, findings, wired, declarative } = scanSeamWiring(modules, { registryKeys: disciplineKeys() });
|
|
261
|
+
const seams = [...new Set(wired.map((w) => w.seam))].sort();
|
|
262
|
+
return {
|
|
263
|
+
name,
|
|
264
|
+
ok,
|
|
265
|
+
hardFail: !ok,
|
|
266
|
+
violations: findings,
|
|
267
|
+
warnings: [],
|
|
268
|
+
// Printed on the PASS path too — the wired set is the evidence the guard ran.
|
|
269
|
+
note: `${modules.length} module(s): ${wired.length} declared seam entr(ies) WIRED across ${seams.length} seam(s) (${seams.join(', ') || 'none'}), ${declarative.length} recorded declarative-only. A declared contributes.<seam> whose consumer's artifact is absent is a silent no-op (ADR 0289).`,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
module.exports = { checkSeamWiring, scanSeamWiring, SEAM_CONSUMERS, declaredEntries, entryLabel, disciplineKeys };
|