@bongos/core 1.19.954 → 1.19.956

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.
@@ -2382,5 +2382,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
2382
2382
  landed since 1.19.952 with no explicit bump. run 35687192774. (task 1002620)
2383
2383
  1.19.954 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2384
2384
  landed since 1.19.953 with no explicit bump. run 35687914716. (task 1002620)
2385
+ 1.19.955 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2386
+ landed since 1.19.954 with no explicit bump. run 35690089430. (task 1002620)
2387
+ 1.19.956 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2388
+ landed since 1.19.955 with no explicit bump. run 35691908420. (task 1002620)
2385
2389
  ---------------------------------------------------------------------------
2386
2390
  ```
@@ -0,0 +1,215 @@
1
+ // modules/provisioning/env-manifest.js — the project ENV VAR MANIFEST: the NAMES a
2
+ // project's environment variables have and what each one is for (task 1004062,
3
+ // [ADR 0323](../../docs/adr/0323-hosting-is-three-shapes-and-we-are-not-the-landlord.md)
4
+ // decision 6).
5
+ //
6
+ // **THE ONE RULE: THIS FILE NEVER HANDLES A VALUE.** Not stored, not accepted, not
7
+ // logged, not returned, not encrypted-at-rest — there is nothing to encrypt, because a
8
+ // variable NAME is not a secret (it appears in the source that reads it). The values live
9
+ // on the deployment platform the running application reads them from, which already
10
+ // encrypts, audits and rotates them; Bongos keeping a second copy would make this
11
+ // platform the custodian of other companies' credentials in exchange for nothing the
12
+ // manifest does not already deliver.
13
+ //
14
+ // The rule is enforced rather than stated. `rejectsValueField` is the boundary's answer
15
+ // to a caller that sends one — a LOUD 400, never a silent drop — and
16
+ // `../../tests/provisioning_env_manifest.mjs` asserts both that no row shape carries a
17
+ // value field and that sending one is refused. A silent drop would be worse than storing
18
+ // it: the caller would believe the platform had taken custody.
19
+ //
20
+ // What a manifest buys, and why the owner asked for it: "no project or code base works
21
+ // without this". A builder — human or agent — writing code has to know which variables
22
+ // exist and what each is for, or it invents a name the platform has never heard of and
23
+ // the setup document is incomplete in a way nothing detects until deploy.
24
+ //
25
+ // PURE except the four db functions at the bottom, which take the pool as an argument
26
+ // (the module's own convention — see provisioning.js). Reaches core only through
27
+ // src/module-api.js, and requires this module's own siblings, never another module's.
28
+
29
+ 'use strict';
30
+
31
+ // Where a variable can apply. Fixed and small on purpose: these are the three the
32
+ // deployment platforms in ADR 0323 decision 3 actually name, and an open-ended list
33
+ // would make "which environments does this project have" a second question nobody asked.
34
+ // Mirrors the CHECK in provisioning_023_env_manifest.sql.
35
+ const ENVIRONMENTS = Object.freeze(['production', 'preview', 'development']);
36
+
37
+ // The conventional POSIX-ish environment variable name: a letter or underscore, then
38
+ // letters, digits and underscores. UPPERCASE is not enforced — `npm_config_*` and
39
+ // `PORT` coexist in real projects, and refusing a lowercase name a project genuinely
40
+ // reads would make the manifest wrong rather than tidy. PURE.
41
+ const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
42
+ const ENV_NAME_MAX = 128;
43
+
44
+ function isValidEnvName(name) {
45
+ const s = typeof name === 'string' ? name.trim() : '';
46
+ if (!s || s.length > ENV_NAME_MAX) return false;
47
+ return ENV_NAME_RE.test(s);
48
+ }
49
+
50
+ // Why a name is being refused, in the words a caller can act on. Returns null when the
51
+ // name is fine. Separate from isValidEnvName so the route's message and the predicate
52
+ // cannot drift. PURE.
53
+ function envNameRefusal(name) {
54
+ if (typeof name !== 'string' || !name.trim()) return 'A variable needs a name.';
55
+ const s = name.trim();
56
+ if (s.length > ENV_NAME_MAX) return `That name is ${s.length} characters; the limit is ${ENV_NAME_MAX}.`;
57
+ if (/\s/.test(s)) return `"${s}" contains a space — an environment variable name cannot.`;
58
+ if (/^[0-9]/.test(s)) return `"${s}" starts with a digit — an environment variable name cannot.`;
59
+ if (!ENV_NAME_RE.test(s)) return `"${s}" is not a valid environment variable name: letters, digits and underscores only, not starting with a digit.`;
60
+ return null;
61
+ }
62
+
63
+ // Coerce the caller's environment list to the known set. An ABSENT or empty list means
64
+ // "everywhere", which is stored as all three rather than as an empty array: a variable
65
+ // the code reads is read in every environment unless somebody says otherwise, and
66
+ // storing the explicit set means a later reader never has to know that an empty array
67
+ // was a special case. An unknown entry is dropped here and refused by the route — the
68
+ // drop is the backstop, not the answer. PURE.
69
+ function normalizeEnvironments(list) {
70
+ if (list === undefined || list === null) return [...ENVIRONMENTS];
71
+ const arr = Array.isArray(list) ? list : [list];
72
+ const kept = [];
73
+ for (const raw of arr) {
74
+ const s = typeof raw === 'string' ? raw.trim().toLowerCase() : '';
75
+ if (ENVIRONMENTS.includes(s) && !kept.includes(s)) kept.push(s);
76
+ }
77
+ if (kept.length === 0) return [...ENVIRONMENTS];
78
+ // Stored in the canonical order, so two callers naming the same set produce equal rows
79
+ // and a diff of two manifests is about content rather than about ordering.
80
+ return ENVIRONMENTS.filter((e) => kept.includes(e));
81
+ }
82
+
83
+ // The environments a caller named that are not environments. The route refuses on this
84
+ // rather than silently keeping the remainder: dropping 'staging' would tell the owner
85
+ // their variable applies somewhere it does not. PURE.
86
+ function unknownEnvironments(list) {
87
+ if (list === undefined || list === null) return [];
88
+ const arr = Array.isArray(list) ? list : [list];
89
+ const bad = [];
90
+ for (const raw of arr) {
91
+ const s = typeof raw === 'string' ? raw.trim().toLowerCase() : String(raw);
92
+ if (!ENVIRONMENTS.includes(s) && !bad.includes(s)) bad.push(s);
93
+ }
94
+ return bad;
95
+ }
96
+
97
+ // The field names that would be a VALUE, in every spelling a caller might reach for.
98
+ // Checked as a group so the refusal can name what was sent.
99
+ const VALUE_FIELDS = Object.freeze(['value', 'values', 'secret', 'secret_value', 'val', 'default_value', 'example_value']);
100
+
101
+ // Refuse a body that carries a value, LOUDLY. Returns null when clean, else a
102
+ // { code, message } the route fails with.
103
+ //
104
+ // Loud and not a silent strip, and the distinction is the whole point: a caller who
105
+ // POSTs a secret and gets a 201 back has every reason to believe the platform now holds
106
+ // it — they may then delete it from the place that actually stores it. A 400 that says
107
+ // "this platform does not take values" leaves them in no doubt, and nothing is written.
108
+ // PURE.
109
+ function rejectsValueField(body) {
110
+ if (!body || typeof body !== 'object') return null;
111
+ const sent = VALUE_FIELDS.filter((f) => Object.prototype.hasOwnProperty.call(body, f));
112
+ if (sent.length === 0) return null;
113
+ return {
114
+ code: 'value_not_accepted',
115
+ message: `This platform stores variable NAMES, never values — ${sent.join(', ')} ${sent.length === 1 ? 'was' : 'were'} not saved and nothing was recorded. Keep the value where the running application reads it (your deployment platform), and declare the name here so builders know it exists.`,
116
+ };
117
+ }
118
+
119
+ // The row shape every reader gets. Built by NAMING each column rather than spreading the
120
+ // row, so a value column that somehow appeared in the table could still never leave
121
+ // through this function. The test asserts the absence on this projection.
122
+ function envVarRow(row) {
123
+ return {
124
+ id: String(row.id),
125
+ instance_id: String(row.instance_id),
126
+ name: row.name,
127
+ description: row.description ?? null,
128
+ environments: Array.isArray(row.environments) ? row.environments : [],
129
+ required: row.required !== false,
130
+ created_at: row.created_at,
131
+ updated_at: row.updated_at,
132
+ };
133
+ }
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // Storage (the pool is an argument — this module's convention)
137
+ // ---------------------------------------------------------------------------
138
+
139
+ // The declared surface for one project, name-ordered so two reads of an unchanged
140
+ // manifest are byte-identical (a builder session diffs this).
141
+ async function listEnvVars(db, instanceId) {
142
+ const { rows } = await db.query(
143
+ `SELECT * FROM provisioning_env_vars WHERE instance_id = $1 ORDER BY name ASC`,
144
+ [instanceId]
145
+ );
146
+ return rows.map(envVarRow);
147
+ }
148
+
149
+ // Declare a variable, or EDIT the declaration that name already has. The UNIQUE index on
150
+ // (instance_id, name) makes a second POST an update rather than a duplicate: two rows
151
+ // disagreeing about DATABASE_URL is a manifest that cannot be read, and the reader would
152
+ // have no rule for choosing. Returns { row, created }.
153
+ async function declareEnvVar(db, { instanceId, name, description = null, environments, required = true }) {
154
+ const { rows } = await db.query(
155
+ `INSERT INTO provisioning_env_vars (instance_id, name, description, environments, required)
156
+ VALUES ($1, $2, $3, $4, $5)
157
+ ON CONFLICT (instance_id, name) DO UPDATE
158
+ SET description = EXCLUDED.description,
159
+ environments = EXCLUDED.environments,
160
+ required = EXCLUDED.required,
161
+ updated_at = now()
162
+ RETURNING *, (xmax = 0) AS inserted`,
163
+ [instanceId, String(name).trim(), description, normalizeEnvironments(environments), required !== false]
164
+ );
165
+ return { row: envVarRow(rows[0]), created: rows[0].inserted === true };
166
+ }
167
+
168
+ // Edit one declaration by id, scoped to its project so a guessed id cannot reach another
169
+ // owner's manifest. MERGE-patch: an omitted field is left alone, which is what lets the
170
+ // hall edit a description without restating the environments. Returns the row, or null
171
+ // when the id does not belong to that project.
172
+ async function updateEnvVar(db, instanceId, id, patch = {}) {
173
+ const sets = ['updated_at = now()'];
174
+ const params = [instanceId, id];
175
+ if (Object.prototype.hasOwnProperty.call(patch, 'description')) {
176
+ params.push(patch.description === null ? null : String(patch.description));
177
+ sets.push(`description = $${params.length}`);
178
+ }
179
+ if (Object.prototype.hasOwnProperty.call(patch, 'environments')) {
180
+ params.push(normalizeEnvironments(patch.environments));
181
+ sets.push(`environments = $${params.length}`);
182
+ }
183
+ if (Object.prototype.hasOwnProperty.call(patch, 'required')) {
184
+ params.push(patch.required !== false);
185
+ sets.push(`required = $${params.length}`);
186
+ }
187
+ if (Object.prototype.hasOwnProperty.call(patch, 'name')) {
188
+ params.push(String(patch.name).trim());
189
+ sets.push(`name = $${params.length}`);
190
+ }
191
+ const { rows } = await db.query(
192
+ `UPDATE provisioning_env_vars SET ${sets.join(', ')}
193
+ WHERE instance_id = $1 AND id = $2 RETURNING *`,
194
+ params
195
+ );
196
+ return rows[0] ? envVarRow(rows[0]) : null;
197
+ }
198
+
199
+ // Remove one declaration, scoped to its project for the same reason. Returns true when a
200
+ // row went, false when the id was not that project's — the route turns both into the
201
+ // same 404, so a guessed id never confirms another project's contents.
202
+ async function removeEnvVar(db, instanceId, id) {
203
+ const { rowCount } = await db.query(
204
+ `DELETE FROM provisioning_env_vars WHERE instance_id = $1 AND id = $2`,
205
+ [instanceId, id]
206
+ );
207
+ return rowCount > 0;
208
+ }
209
+
210
+ module.exports = {
211
+ ENVIRONMENTS, ENV_NAME_MAX, VALUE_FIELDS,
212
+ isValidEnvName, envNameRefusal, normalizeEnvironments, unknownEnvironments,
213
+ rejectsValueField, envVarRow,
214
+ listEnvVars, declareEnvVar, updateEnvVar, removeEnvVar,
215
+ };
@@ -0,0 +1,67 @@
1
+ -- provisioning_023 — the project ENV VAR MANIFEST (task 1004062, ADR 0323 decision 6).
2
+ --
3
+ -- **THERE IS NO VALUE COLUMN, AND ADDING ONE IS THE THING THIS TABLE EXISTS TO PREVENT.**
4
+ --
5
+ -- A project's environment variables have two halves. The VALUES live on the deployment
6
+ -- platform the running application reads them from — Vercel, Netlify, Render or the
7
+ -- owner's own machine — which already encrypts, audits and rotates them. Bongos storing
8
+ -- them too would make this platform the custodian of other companies' credentials for no
9
+ -- gain, which is the responsibility ADR 0323 spends its entire budget avoiding.
10
+ --
11
+ -- The half Bongos genuinely needs is the SURFACE: which variables exist and what each one
12
+ -- is for, so a builder — human or agent — writing code references the right names and a
13
+ -- setup document is complete. That is a manifest, not a vault, and a manifest is exactly
14
+ -- this: a name, a sentence, where it applies, and whether it is required.
15
+ --
16
+ -- Not encrypted-at-rest either, because there is nothing here to encrypt. A variable NAME
17
+ -- is not a secret: it appears in the source that reads it. Anyone who can read this table
18
+ -- can already read the repository.
19
+ --
20
+ -- For a `byo-host` project there is no platform to ask, so the manifest is simply
21
+ -- unverifiable — which keeps "Bongos holds no values" true everywhere, with no exception
22
+ -- to reason about later (ADR 0323 decision 6's own wording).
23
+ --
24
+ -- Additive + `provisioning_`-namespaced per ADR 0083 §5: CREATE TABLE IF NOT EXISTS only,
25
+ -- no core table touched, so an instance with this module disabled is unaffected.
26
+
27
+ CREATE TABLE IF NOT EXISTS provisioning_env_vars (
28
+ id bigserial PRIMARY KEY,
29
+
30
+ -- The project this declaration belongs to. CASCADE because a manifest is meaningless
31
+ -- without its project, and ADR 0290's disconnect already relies on the row going with
32
+ -- it (provisioning_instances cascades its intents, events and manifests the same way).
33
+ instance_id bigint NOT NULL REFERENCES provisioning_instances(id) ON DELETE CASCADE,
34
+
35
+ -- The variable's NAME, as the application reads it (e.g. 'DATABASE_URL'). Shape is
36
+ -- enforced in code (env-manifest.js's isValidEnvName) so the refusal can name the rule;
37
+ -- the length cap here is the backstop a bypassed caller still meets.
38
+ name text NOT NULL CHECK (length(name) BETWEEN 1 AND 128),
39
+
40
+ -- What the variable is FOR, in a sentence. The whole point of the manifest: a name with
41
+ -- no purpose tells a builder the surface exists but not what to put behind it.
42
+ description text,
43
+
44
+ -- Where it applies. An array because one variable is usually read in several
45
+ -- environments, and a row per environment would make "rename this variable" a
46
+ -- multi-row edit. The CHECK is a containment test (<@), so it admits any subset —
47
+ -- including the empty array, which env-manifest.js reads as "everywhere" and never
48
+ -- writes.
49
+ environments text[] NOT NULL DEFAULT ARRAY['production','preview','development']::text[]
50
+ CHECK (environments <@ ARRAY['production','preview','development']::text[]),
51
+
52
+ -- Required vs optional. Defaults to required: a variable somebody bothered to declare
53
+ -- is more often one the application cannot start without, and the safe default for a
54
+ -- setup checklist is the one that makes an omission visible.
55
+ required boolean NOT NULL DEFAULT true,
56
+
57
+ created_at timestamptz NOT NULL DEFAULT now(),
58
+ updated_at timestamptz NOT NULL DEFAULT now()
59
+ );
60
+
61
+ -- One declaration per name per project. A second POST for the same name is an EDIT of
62
+ -- the same declaration, not a duplicate row: two rows saying different things about
63
+ -- DATABASE_URL is a manifest that cannot be read, and the reader would have no rule for
64
+ -- picking. Case-sensitive on purpose — environment variables are, and folding the case
65
+ -- here would silently merge `Path` and `PATH`.
66
+ CREATE UNIQUE INDEX IF NOT EXISTS ux_provisioning_env_vars_instance_name
67
+ ON provisioning_env_vars (instance_id, name);
@@ -1,19 +1,34 @@
1
1
  {
2
2
  "key": "provisioning",
3
3
  "title": "Instance provisioning",
4
- "description": "Stand up + operate a Cloud Bongos instance's own infrastructure (server, database, domain, DNS, TLS) via the API, so an owner does not manage infra by hand (ADR 0111). Owns the web-tier REQUEST + STATE surface only: it enqueues provisioning intents + reads instance state — it holds NO cloud-provider tokens and calls NO cloud API. A separate control-plane runner (scripts/gds/provision.js, task P3) is the sole token-holder that drains the intents. Distinct from the `hosting` module (ADR 0092), which brokers compute WORKLOADS.",
4
+ "description": "Stand up + operate a Cloud Bongos instance's own infrastructure (server, database, domain, DNS, TLS) via the API, so an owner does not manage infra by hand (ADR 0111). Owns the web-tier REQUEST + STATE surface only: it enqueues provisioning intents + reads instance state \u2014 it holds NO cloud-provider tokens and calls NO cloud API. A separate control-plane runner (scripts/gds/provision.js, task P3) is the sole token-holder that drains the intents. Distinct from the `hosting` module (ADR 0092), which brokers compute WORKLOADS.",
5
5
  "coreVersion": "^1.14.0",
6
6
  "default": false,
7
7
  "author": "Cloud Bongos",
8
8
  "origin": "Cloud Bongos core",
9
9
  "license": "AGPL-3.0",
10
10
  "maintainer": "Cloud Bongos maintainers",
11
- "maintenance": { "status": "core-maintained" },
11
+ "maintenance": {
12
+ "status": "core-maintained"
13
+ },
12
14
  "contributes": {
13
- "routes": ["provisioning", "disconnect", "core-upgrade"],
15
+ "routes": [
16
+ "provisioning",
17
+ "disconnect",
18
+ "core-upgrade",
19
+ "env-manifest"
20
+ ],
14
21
  "migrations": true,
15
- "pollers": ["liveness-sweep", "catalog-backfill"]
22
+ "pollers": [
23
+ "liveness-sweep",
24
+ "catalog-backfill"
25
+ ]
16
26
  },
17
- "provides": ["provisioning.instanceState", "provisioning.projectPlanets", "provisioning.projectOwnedBy", "provisioning.projectPublishIdentity"],
27
+ "provides": [
28
+ "provisioning.instanceState",
29
+ "provisioning.projectPlanets",
30
+ "provisioning.projectOwnedBy",
31
+ "provisioning.projectPublishIdentity"
32
+ ],
18
33
  "consumes": []
19
34
  }
@@ -28,12 +28,24 @@ const { Agent: UndiciAgent } = require('undici');
28
28
  const api = require('../../../src/module-api');
29
29
  const provisioning = require('../provisioning');
30
30
  const { planNotices, postNotice } = require('./liveness-notify');
31
+ // task 1004000: the runner-drift check rides this tick rather than installing a timer of
32
+ // its own — the sweep already runs every ~5 min on the one host that can see the systemd
33
+ // units. It reads the checker as a SUBPROCESS, not a require: `scripts/gds/runner-drift.js`
34
+ // is core-package territory (ADR 0083) and its systemctl reads are blocking, so requiring
35
+ // it would have put N×15s of event-loop work in the process serving live traffic. See
36
+ // ./runner-drift-notify.js's header.
37
+ const { planDriftNotices, formatDriftLine, readRunnerDrift } = require('./runner-drift-notify');
31
38
 
32
39
  // Notice state between sweeps (task 1002894): id → { streak, notified }. In memory
33
40
  // on purpose — it is seeded only by a fall this process observed, which is exactly
34
41
  // what stops a restarted control plane re-announcing an outage it arrived to find.
35
42
  const noticeState = new Map();
36
- function resetNoticeState() { noticeState.clear(); }
43
+ // Unit names already announced as drifting (task 1004000). In memory like its neighbour,
44
+ // but for the opposite reason: this one exists only to stop a stale runner posting every
45
+ // five minutes forever, NOT to suppress a first sighting — a drift that predates this
46
+ // process is exactly the thing nobody noticed, so a restart re-announcing it is correct.
47
+ const driftState = new Set();
48
+ function resetNoticeState() { noticeState.clear(); driftState.clear(); }
37
49
 
38
50
  // Name a project in an owner-facing message only when it is public on the platform's
39
51
  // map; a private or stealth one is described, never identified.
@@ -241,6 +253,34 @@ async function sweepOnce(deps = {}) {
241
253
  for (const notice of plan.notices) {
242
254
  try { notify(notice); } catch (e) { log(`[liveness] notice failed (non-blocking): ${e && e.message ? e.message : e}`); }
243
255
  }
256
+
257
+ // ── runner drift (task 1004000) ───────────────────────────────────────────
258
+ // Placed at the TAIL, after the account is built and the liveness notices are out, so
259
+ // that a systemctl hiccup can never cost this tick its liveness verdict — the reason
260
+ // the whole block is wrapped. `collect` is injected so the composition stays hermetic
261
+ // in tests; production uses readRunnerDrift, which spawns the checker and answers null
262
+ // when there is no systemd to ask. AWAITED, and that is the point: the read is a
263
+ // subprocess rather than a blocking in-process require, so a hung systemctl costs this
264
+ // tick its drift check and never the event loop serving live requests. Not gated on
265
+ // `rows.length`: the runners are worth checking on a control plane with no tenant
266
+ // projects at all.
267
+ try {
268
+ const collect = deps.collectRunnerDrift || readRunnerDrift;
269
+ const instanceRoot = deps.instanceRoot || process.env.BONGOS_INSTANCE_ROOT || process.cwd();
270
+ const drift = planDriftNotices(await collect({ instanceRoot }), driftState);
271
+ const line = formatDriftLine(drift);
272
+ if (line) log(line);
273
+ for (const notice of drift.notices) {
274
+ try { notify(notice); } catch (e) { log(`[liveness] drift notice failed (non-blocking): ${e && e.message ? e.message : e}`); }
275
+ }
276
+ out.runnerDrift = drift.drifting;
277
+ out.runnerUnresolved = drift.unresolved;
278
+ } catch (e) {
279
+ // A drift check that throws must not take the sweep with it. Logged rather than
280
+ // swallowed: silence here would be indistinguishable from a healthy host, which is
281
+ // the exact failure this task was filed about.
282
+ log(`[drift] check failed (non-blocking): ${e && e.message ? e.message : e}`);
283
+ }
244
284
  return out;
245
285
  }
246
286
 
@@ -0,0 +1,203 @@
1
+ 'use strict';
2
+
3
+ // modules/provisioning/pollers/runner-drift-notify.js — say it out loud when an
4
+ // intent-runner unit is pointed at a different core than the instance installed
5
+ // (task 1004000, the follow-up the task 1002722 grade asked for).
6
+ //
7
+ // WHY. Three drift signals already existed and every one of them needed a human to go
8
+ // and look: `scripts/gds/runner-drift.js` exits 1, `bongos doctor` has an "Intent
9
+ // runners" section, and each run-intents tick writes a `⚠ DRIFT:` banner into the
10
+ // journal. A stale runner is exactly the condition nobody is looking for — it drains
11
+ // intents perfectly happily using last month's code — so a signal that waits to be read
12
+ // is a signal that is not doing its job.
13
+ //
14
+ // WHERE IT RUNS. Inside the liveness sweep, not on a timer of its own. The sweep already
15
+ // ticks every ~5 minutes on the control plane, which is the one host that can see the
16
+ // systemd units (CI cannot), and `collectRunnerDrift` is two `systemctl` reads. A second
17
+ // timer would be a second thing to install, a second thing to forget to install, and a
18
+ // second thing whose silence means nothing.
19
+ //
20
+ // HOW IT DIFFERS FROM ITS SIBLING, AND WHY. `./liveness-notify.js` is the pattern here,
21
+ // but two of its three rules are deliberately NOT copied:
22
+ //
23
+ // * IT ANNOUNCES ON FIRST SIGHT. Liveness stays silent about a project that was already
24
+ // down before this process started, so that a restarted control plane does not
25
+ // re-announce an outage it merely arrived to find. Drift is the opposite case: a
26
+ // runner that has been stale for a fortnight is precisely the thing nobody noticed,
27
+ // and this task exists because those three signals went unread. Waiting for an edge
28
+ // would mean the only drift ever announced is drift that begins while the process
29
+ // happens to be watching.
30
+ // * THERE IS NO CONFIRM STREAK. Liveness waits out a second sweep because one refused
31
+ // connection is a blip. Drift is a deterministic read of two files on local disk —
32
+ // there is no blip to wait out, and a delay would buy nothing but a later notice.
33
+ //
34
+ // The third rule IS copied, and matters more here than there:
35
+ //
36
+ // * COLLAPSE A FAN-OUT. Every unit drifting at once is one cause — an upgrade moved the
37
+ // installed core and the units still run the old checkout — so it is ONE message. A
38
+ // host with several runners would otherwise emit a burst that reads like several
39
+ // independent faults. Unconditional here rather than threshold-gated; see the note
40
+ // where liveness's FLEET_GUARD would have been.
41
+ //
42
+ // And one rule of its own:
43
+ //
44
+ // * `unresolved` IS NOT DRIFT. It means the checker could not tell (an ExecStart that
45
+ // runs no .js directly, no core version above the script, no installed core). That is
46
+ // the absence of a verdict, not a bad one, so announcing it would be a siren over
47
+ // nothing. It is counted into the sweep's log line instead, where it is visible
48
+ // without being urgent — and a host that is ALL unresolved says so in that line
49
+ // rather than reading as healthy.
50
+ //
51
+ // HOW IT READS SYSTEMD, AND WHY NOT BY require(). The checker itself is
52
+ // `scripts/gds/runner-drift.js`, which is CORE-package territory: a module may require
53
+ // only the published doorway `src/module-api.js` (ADR 0083, `docs/modules-contract.md`,
54
+ // and this module's own CLAUDE.md header). So this reads it the way its sibling reads
55
+ // `scripts/discord/post.js` — as a SUBPROCESS, `node scripts/gds/runner-drift.js --json`
56
+ // — which crosses no require boundary and needs no new doorway for a fact only the
57
+ // control plane's own host can answer.
58
+ //
59
+ // The subprocess is ALSO the reason this is safe inside the web process. `collectRunnerDrift`
60
+ // uses `spawnSync` with a 15-second timeout per `systemctl` call; requiring it would have
61
+ // put N×15s of blocking work on the event loop that serves live HTTP traffic, once every
62
+ // five minutes. `execFile` is async, so a hung systemctl costs this tick its drift check
63
+ // and nothing else.
64
+ //
65
+ // **A NON-ZERO EXIT IS NOT AN ERROR HERE.** `runner-drift.js` exits 1 precisely WHEN it
66
+ // finds drift — that is its contract as a CI-style gate — so treating a failed exit as a
67
+ // failed read would discard the only result worth reporting. The JSON on stdout is the
68
+ // answer; the exit code is ignored when it parses, and only an unparseable stdout counts
69
+ // as "could not look".
70
+ //
71
+ // PURE except `readRunnerDrift`, which spawns that subprocess, and `postNotice`, which
72
+ // ./liveness-sweep.js imports straight from ./liveness-notify.js — one Discord path, one
73
+ // dry-run switch, one place a webhook change lands.
74
+
75
+ const { execFile } = require('node:child_process');
76
+ const path = require('node:path');
77
+
78
+ // scripts/gds/runner-drift.js is invoked from the repo root regardless of cwd, the same
79
+ // way ./liveness-notify.js invokes scripts/discord/post.js.
80
+ const REPO_ROOT = path.resolve(__dirname, '..', '..', '..');
81
+ // Generous next to the checker's own per-call 15s, and it exists so a hung systemctl
82
+ // costs this tick its drift check rather than the tick.
83
+ const READ_TIMEOUT_MS = Number(process.env.PROVISIONING_DRIFT_TIMEOUT_MS) || 30_000;
84
+
85
+ // Ask the checker what the host's runner units look like. Resolves to the units array, or
86
+ // NULL for "could not look" — no systemd, an unparseable answer, a timeout, a missing
87
+ // script. Null is deliberately not an empty array: "no runner units" and "could not ask"
88
+ // must not read the same, and planDriftNotices treats them differently.
89
+ function readRunnerDrift({ instanceRoot, execFileImpl = execFile, timeoutMs = READ_TIMEOUT_MS } = {}) {
90
+ return new Promise((resolve) => {
91
+ let settled = false;
92
+ const done = (v) => { if (!settled) { settled = true; resolve(v); } };
93
+ try {
94
+ execFileImpl('node',
95
+ ['scripts/gds/runner-drift.js', '--instance-root', String(instanceRoot || ''), '--json'],
96
+ { cwd: REPO_ROOT, timeout: timeoutMs, maxBuffer: 4 * 1024 * 1024 },
97
+ (_err, stdout) => {
98
+ // _err is IGNORED on purpose: exit 1 IS the drift signal (see the header).
99
+ let parsed = null;
100
+ try { parsed = JSON.parse(String(stdout || '')); } catch (_) { parsed = null; }
101
+ if (!parsed || parsed.systemd !== true) return done(null);
102
+ done(Array.isArray(parsed.units) ? parsed.units : null);
103
+ });
104
+ } catch (_) {
105
+ done(null);
106
+ }
107
+ });
108
+ }
109
+
110
+ // NO FLEET-GUARD KNOB, and the absence is deliberate. ./liveness-notify.js needs one
111
+ // because it posts per PROJECT and a fleet can be large, so "how many at once means look
112
+ // at the platform" is a real threshold. Here the collapse is unconditional: a host runs
113
+ // two or three runner units, every drifting one has the same cause (an upgrade moved the
114
+ // installed core), and the notice is one message whose wording already branches on the
115
+ // count. A tunable that can only ever be 1 or 2 on a population of 2 is a knob that does
116
+ // nothing, and a knob that does nothing still has to be read and reasoned about.
117
+
118
+ // What the owner reads. Owner-audience and plain: the reader is being told their
119
+ // automation is quietly running old code, and what that means for them. A unit name is
120
+ // operator vocabulary, so it appears once, at the end, for whoever goes to fix it. PURE.
121
+ function driftNoticeText({ kind, units = [], installed = null } = {}) {
122
+ const n = units.length;
123
+ if (kind === 'clear') {
124
+ return n > 1
125
+ ? `✅ All ${n} background runners are back on the current version.`
126
+ : `✅ The background runner is back on the current version.`;
127
+ }
128
+ const version = installed ? ` The project installed **${installed}**.` : '';
129
+ const which = units.length ? ` (${units.join(', ')})` : '';
130
+ if (n > 1) {
131
+ return `⚠️ ${n} background runners are running an older version of the platform than this project has installed.${version} When that many disagree at once it is almost always one upgrade that moved the code without restarting them, so they are still doing their work with last month's rules — worth pointing them at the new version${which}.`;
132
+ }
133
+ return `⚠️ A background runner is running an older version of the platform than this project has installed.${version} It will keep doing its work, but with the older rules, which is the kind of thing that goes unnoticed for weeks — worth pointing it at the new version${which}.`;
134
+ }
135
+
136
+ // The decision, given this tick's assessment (the rows `collectRunnerDrift` returns) and
137
+ // the notice state carried between ticks. MUTATES `state` (a Set of unit names already
138
+ // announced) and returns what to post; everything else is pure, so the rules above are
139
+ // unit-tested with no systemd, no sweep and no network.
140
+ //
141
+ // `units === null` means there is no systemd to ask — not a healthy host, and not a
142
+ // drifted one. It returns nothing and touches no state, so a checker that cannot run
143
+ // never clears an announcement it also cannot re-make.
144
+ function planDriftNotices(units, state) {
145
+ if (units === null || units === undefined) {
146
+ return { notices: [], drifting: 0, unresolved: 0, checked: 0, systemd: false };
147
+ }
148
+ const drifting = units.filter((u) => u && u.status === 'drift');
149
+ const unresolved = units.filter((u) => u && u.status === 'unresolved');
150
+ const names = drifting.map((u) => u.name);
151
+
152
+ // NEW drift only: a unit already announced stays silent until it recovers, so a stale
153
+ // runner does not post every five minutes for a fortnight.
154
+ const fresh = names.filter((name) => !state.has(name));
155
+ // Recovery: a unit we announced that is no longer drifting. Read from the units this
156
+ // tick actually assessed — a unit that has been REMOVED from the host is not a
157
+ // recovery and is dropped from the state without a notice, because "the unit is gone"
158
+ // and "the unit is fixed" are different facts and only one of them is good news.
159
+ const assessed = new Set(units.map((u) => u && u.name).filter(Boolean));
160
+ const stillDrifting = new Set(names);
161
+ const recovered = [...state].filter((name) => assessed.has(name) && !stillDrifting.has(name));
162
+ const vanished = [...state].filter((name) => !assessed.has(name));
163
+
164
+ const notices = [];
165
+ if (fresh.length) {
166
+ // The installed version is the same for every unit on the host, so the first one
167
+ // carries it for the message.
168
+ const installed = (drifting.find((u) => u.instanceVersion) || {}).instanceVersion || null;
169
+ notices.push({ kind: 'drift', units: fresh, text: driftNoticeText({ kind: 'drift', units: fresh, installed }) });
170
+ }
171
+ if (recovered.length) {
172
+ notices.push({ kind: 'clear', units: recovered, text: driftNoticeText({ kind: 'clear', units: recovered }) });
173
+ }
174
+
175
+ for (const name of fresh) state.add(name);
176
+ for (const name of recovered) state.delete(name);
177
+ for (const name of vanished) state.delete(name);
178
+
179
+ return {
180
+ notices,
181
+ drifting: drifting.length,
182
+ unresolved: unresolved.length,
183
+ checked: units.length,
184
+ systemd: true,
185
+ };
186
+ }
187
+
188
+ // The sweep's one-line account of what the drift check saw. Returns null when there is
189
+ // nothing worth a line — no systemd, or no runner units at all — so a vanilla control
190
+ // plane does not narrate an empty check every five minutes. A host whose every unit is
191
+ // `unresolved` DOES get a line: that is not health, and it must not read as health. PURE.
192
+ function formatDriftLine({ systemd, checked, drifting, unresolved }) {
193
+ if (!systemd || !checked) return null;
194
+ if (!drifting && !unresolved) return null;
195
+ const parts = [`runners=${checked}`];
196
+ if (drifting) parts.push(`drift=${drifting}`);
197
+ if (unresolved) parts.push(`unresolved=${unresolved} (cannot tell — not a verdict)`);
198
+ return `[drift] ${parts.join(' · ')}`;
199
+ }
200
+
201
+ // postNotice is NOT re-exported: ./liveness-sweep.js imports it straight from
202
+ // ./liveness-notify.js, so a re-export here would be API surface with no caller.
203
+ module.exports = { planDriftNotices, driftNoticeText, formatDriftLine, readRunnerDrift };