@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.
- package/.bongos-core.json +53 -23
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +8 -0
- package/clients/bongos-client/index.cjs +8 -0
- package/clients/bongos-client/index.d.ts +13 -0
- package/clients/bongos-client/index.mjs +8 -0
- package/docs/api/openapi.json +334 -3
- package/docs/api-reference.md +6 -2
- package/docs/copy-inventory.md +156 -151
- package/docs/copy-registry.json +238 -192
- package/docs/module-api-changelog.md +4 -0
- package/modules/provisioning/env-manifest.js +215 -0
- package/modules/provisioning/migrations/provisioning_023_env_manifest.sql +67 -0
- package/modules/provisioning/module.json +20 -5
- package/modules/provisioning/pollers/liveness-sweep.js +41 -1
- package/modules/provisioning/pollers/runner-drift-notify.js +203 -0
- package/modules/provisioning/routes/env-manifest.js +201 -0
- package/modules/public-landing/public/projects.html +157 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/provisioning_env_manifest.mjs +435 -0
- package/tests/provisioning_runner_drift_notice.mjs +292 -0
|
@@ -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
|
|
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": {
|
|
11
|
+
"maintenance": {
|
|
12
|
+
"status": "core-maintained"
|
|
13
|
+
},
|
|
12
14
|
"contributes": {
|
|
13
|
-
"routes": [
|
|
15
|
+
"routes": [
|
|
16
|
+
"provisioning",
|
|
17
|
+
"disconnect",
|
|
18
|
+
"core-upgrade",
|
|
19
|
+
"env-manifest"
|
|
20
|
+
],
|
|
14
21
|
"migrations": true,
|
|
15
|
-
"pollers": [
|
|
22
|
+
"pollers": [
|
|
23
|
+
"liveness-sweep",
|
|
24
|
+
"catalog-backfill"
|
|
25
|
+
]
|
|
16
26
|
},
|
|
17
|
-
"provides": [
|
|
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
|
-
|
|
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 };
|