mandrel-platform 0.17.2 → 0.18.0
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/README.md +220 -14
- package/config/commitlint.base.mjs +36 -0
- package/config/repo-settings.schema.json +78 -0
- package/package.json +2 -1
- package/scripts/apply-uptime-monitors.mjs +378 -0
- package/scripts/apply-uptime-monitors.test.mjs +372 -0
- package/scripts/check-repo-settings.mjs +363 -0
- package/scripts/check-repo-settings.test.mjs +320 -0
- package/scripts/check-required-contexts.mjs +247 -129
- package/scripts/check-required-contexts.test.mjs +137 -0
- package/scripts/check-ruleset.mjs +435 -0
- package/scripts/check-ruleset.test.mjs +439 -0
- package/scripts/check-wrangler-baseline.mjs +514 -0
- package/scripts/check-wrangler-baseline.test.mjs +454 -0
- package/scripts/platform-sync.mjs +533 -5
- package/scripts/platform-sync.test.mjs +477 -0
- package/templates/workflows/deploy-staging.yml +86 -0
- package/templates/workflows/uptime-apply.yml +54 -0
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* apply-uptime-monitors.mjs
|
|
4
|
+
*
|
|
5
|
+
* Shared Better Stack uptime-monitor schema + apply unit (Story #180).
|
|
6
|
+
*
|
|
7
|
+
* Post-convergence triplication (2026-07-01 audit): domio, athportal, and
|
|
8
|
+
* swarm-os each carried their own `uptime-apply.yml` + Better Stack IaC
|
|
9
|
+
* (`infra/uptime/`). swarm-os's implementation (Story #163) was the
|
|
10
|
+
* newest/cleanest and is the seed donor here per standing decision #4
|
|
11
|
+
* (best-of-breed seeding) — generalized into a platform-owned shared unit so
|
|
12
|
+
* `.github/workflows/uptime-apply.yml` (and any future caller) has one
|
|
13
|
+
* script to invoke instead of re-deriving the Better Stack monitor-CRUD calls
|
|
14
|
+
* per repo.
|
|
15
|
+
*
|
|
16
|
+
* What it does: reads a small JSON monitor-config file (one entry per HTTP
|
|
17
|
+
* probe: url + optional alert email + optional check interval), diffs it
|
|
18
|
+
* against Better Stack's live monitor list (GET /api/v2/monitors), and
|
|
19
|
+
* creates/updates monitors to converge live state to the desired config.
|
|
20
|
+
* Never deletes a monitor that isn't in the config — this is an additive
|
|
21
|
+
* apply, mirroring the "preserve graceful degradation" acceptance criterion:
|
|
22
|
+
* a monitor an operator created by hand in the Better Stack UI is left alone.
|
|
23
|
+
*
|
|
24
|
+
* Graceful degradation: with no `BETTERSTACK_API_TOKEN` (env or --token),
|
|
25
|
+
* the script prints a skip notice and exits 0 — never fails a caller that
|
|
26
|
+
* hasn't provisioned Better Stack yet. This mirrors the frozen-secret
|
|
27
|
+
* skip-with-notice posture the other reusable workflows already use for
|
|
28
|
+
* their optional secret sub-steps.
|
|
29
|
+
*
|
|
30
|
+
* --------------------------------------------------------------------------
|
|
31
|
+
* Usage (CLI):
|
|
32
|
+
* node scripts/apply-uptime-monitors.mjs --config <path> [--dry-run] [--apply]
|
|
33
|
+
* [--token <token>] [--alert-email <email>]
|
|
34
|
+
*
|
|
35
|
+
* • --config Path to a JSON monitor-config file. See
|
|
36
|
+
* `MONITOR_CONFIG_SCHEMA` below for the shape.
|
|
37
|
+
* • --dry-run Compute and print the plan (create/update/unchanged);
|
|
38
|
+
* issue no writes. Default when neither --dry-run nor
|
|
39
|
+
* --apply is passed.
|
|
40
|
+
* • --apply Issue the create/update calls against the Better Stack
|
|
41
|
+
* API. Mutually exclusive with --dry-run (last one wins
|
|
42
|
+
* if both are passed).
|
|
43
|
+
* • --token Better Stack API token. Defaults to
|
|
44
|
+
* $BETTERSTACK_API_TOKEN. Missing token → skip-with-
|
|
45
|
+
* notice, exit 0.
|
|
46
|
+
* • --alert-email Default alert-contact email applied to any monitor
|
|
47
|
+
* entry that does not set its own `alertEmail`. Defaults
|
|
48
|
+
* to $UPTIME_ALERT_EMAIL.
|
|
49
|
+
*
|
|
50
|
+
* Exit codes:
|
|
51
|
+
* 0 — plan computed / applied successfully, OR skip-with-notice (no token).
|
|
52
|
+
* 1 — a usage or API error (bad config, Better Stack request failed).
|
|
53
|
+
*
|
|
54
|
+
* The config schema and API surface are the documented contract — see
|
|
55
|
+
* docs/reusable-workflows.md (`uptime-apply.yml`).
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
import { readFileSync } from "node:fs";
|
|
59
|
+
import { resolve } from "node:path";
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Monitor config schema (pure validation — no I/O)
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A monitor config file is either a bare array of monitor entries, or an
|
|
67
|
+
* object with a `monitors` array (mirrors the OSV allow-list file's
|
|
68
|
+
* "bare array or wrapped object" tolerance in pr-quality.yml's contract).
|
|
69
|
+
*
|
|
70
|
+
* Each entry:
|
|
71
|
+
* {
|
|
72
|
+
* "url": "https://api.example.com/health", // required, http(s) URL
|
|
73
|
+
* "name": "api", // optional, defaults to url's host
|
|
74
|
+
* "alertEmail": "oncall@example.com", // optional, falls back to --alert-email
|
|
75
|
+
* "checkFrequency": 30 // optional, seconds, default 30
|
|
76
|
+
* }
|
|
77
|
+
*
|
|
78
|
+
* @param {unknown} raw Parsed JSON.
|
|
79
|
+
* @returns {{ monitors: Array<{url:string, name:string, alertEmail:string|null, checkFrequency:number}> }}
|
|
80
|
+
* @throws {Error} with a message naming the offending index/field on invalid input.
|
|
81
|
+
*/
|
|
82
|
+
export function parseMonitorConfig(raw) {
|
|
83
|
+
const list = Array.isArray(raw) ? raw : raw && Array.isArray(raw.monitors) ? raw.monitors : null;
|
|
84
|
+
if (!list) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
"monitor config must be a JSON array of monitor entries, or an object with a `monitors` array."
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
monitors: list.map((entry, i) => validateMonitorEntry(entry, i)),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function validateMonitorEntry(entry, index) {
|
|
95
|
+
if (!entry || typeof entry !== "object") {
|
|
96
|
+
throw new Error(`monitor config entry [${index}] must be an object.`);
|
|
97
|
+
}
|
|
98
|
+
if (typeof entry.url !== "string" || !/^https?:\/\//.test(entry.url)) {
|
|
99
|
+
throw new Error(`monitor config entry [${index}] is missing a valid http(s) "url".`);
|
|
100
|
+
}
|
|
101
|
+
let host;
|
|
102
|
+
try {
|
|
103
|
+
host = new URL(entry.url).host;
|
|
104
|
+
} catch {
|
|
105
|
+
throw new Error(`monitor config entry [${index}] has an unparsable "url": ${entry.url}`);
|
|
106
|
+
}
|
|
107
|
+
if (entry.name !== undefined && typeof entry.name !== "string") {
|
|
108
|
+
throw new Error(`monitor config entry [${index}] "name" must be a string when present.`);
|
|
109
|
+
}
|
|
110
|
+
if (entry.alertEmail !== undefined && typeof entry.alertEmail !== "string") {
|
|
111
|
+
throw new Error(`monitor config entry [${index}] "alertEmail" must be a string when present.`);
|
|
112
|
+
}
|
|
113
|
+
if (entry.checkFrequency !== undefined && !(Number.isInteger(entry.checkFrequency) && entry.checkFrequency > 0)) {
|
|
114
|
+
throw new Error(`monitor config entry [${index}] "checkFrequency" must be a positive integer (seconds) when present.`);
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
url: entry.url,
|
|
118
|
+
name: entry.name ?? host,
|
|
119
|
+
alertEmail: entry.alertEmail ?? null,
|
|
120
|
+
checkFrequency: entry.checkFrequency ?? DEFAULT_CHECK_FREQUENCY_SECONDS,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export const DEFAULT_CHECK_FREQUENCY_SECONDS = 30;
|
|
125
|
+
export const BETTERSTACK_API_BASE = "https://uptime.betterstack.com/api/v2";
|
|
126
|
+
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
// Diff — pure, no I/O. Compares desired monitor entries to Better Stack's
|
|
129
|
+
// live monitor list (already normalized to {id, url, name} by the caller).
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* @param {Array<{url:string,name:string,alertEmail:string|null,checkFrequency:number}>} desired
|
|
134
|
+
* @param {Array<{id:string,url:string}>} live
|
|
135
|
+
* @returns {{
|
|
136
|
+
* toCreate: typeof desired,
|
|
137
|
+
* toUpdate: Array<{id:string, entry: typeof desired[number]}>,
|
|
138
|
+
* unchanged: string[]
|
|
139
|
+
* }}
|
|
140
|
+
*/
|
|
141
|
+
export function diffMonitors(desired, live) {
|
|
142
|
+
const liveByUrl = new Map(live.map((m) => [normalizeUrl(m.url), m]));
|
|
143
|
+
const toCreate = [];
|
|
144
|
+
const toUpdate = [];
|
|
145
|
+
const unchanged = [];
|
|
146
|
+
for (const entry of desired) {
|
|
147
|
+
const match = liveByUrl.get(normalizeUrl(entry.url));
|
|
148
|
+
if (!match) {
|
|
149
|
+
toCreate.push(entry);
|
|
150
|
+
} else if (monitorNeedsUpdate(match, entry)) {
|
|
151
|
+
toUpdate.push({ id: match.id, entry });
|
|
152
|
+
} else {
|
|
153
|
+
unchanged.push(entry.url);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return { toCreate, toUpdate, unchanged };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function normalizeUrl(url) {
|
|
160
|
+
return url.replace(/\/+$/, "").toLowerCase();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function monitorNeedsUpdate(live, desired) {
|
|
164
|
+
if (live.name !== undefined && live.name !== desired.name) return true;
|
|
165
|
+
if (live.checkFrequency !== undefined && live.checkFrequency !== desired.checkFrequency) return true;
|
|
166
|
+
if (live.alertEmail !== undefined && desired.alertEmail !== null && live.alertEmail !== desired.alertEmail) return true;
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
// Better Stack API client — injectable fetch seam for offline testing.
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* @param {object} opts
|
|
176
|
+
* @param {string} opts.token
|
|
177
|
+
* @param {typeof fetch} [opts.fetchImpl]
|
|
178
|
+
* @param {string} [opts.apiBase] Override the Better Stack API base URL.
|
|
179
|
+
* Defaults to BETTERSTACK_API_BASE. Exists so a local/offline consumer-
|
|
180
|
+
* caller simulation (see apply-uptime-monitors.test.mjs) can point the
|
|
181
|
+
* real CLI at an in-process HTTP server instead of the live API — never
|
|
182
|
+
* set this in a production caller.
|
|
183
|
+
* @returns {{
|
|
184
|
+
* listMonitors: () => Promise<Array<{id:string,url:string,name?:string,checkFrequency?:number,alertEmail?:string}>>,
|
|
185
|
+
* createMonitor: (entry: object) => Promise<{id:string}>,
|
|
186
|
+
* updateMonitor: (id:string, entry: object) => Promise<{id:string}>
|
|
187
|
+
* }}
|
|
188
|
+
*/
|
|
189
|
+
export function createBetterStackClient({ token, fetchImpl = fetch, apiBase = BETTERSTACK_API_BASE }) {
|
|
190
|
+
const headers = {
|
|
191
|
+
Authorization: `Bearer ${token}`,
|
|
192
|
+
"Content-Type": "application/json",
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
async function request(path, init) {
|
|
196
|
+
const res = await fetchImpl(`${apiBase}${path}`, { ...init, headers: { ...headers, ...(init?.headers ?? {}) } });
|
|
197
|
+
if (!res.ok) {
|
|
198
|
+
const body = await res.text().catch(() => "");
|
|
199
|
+
throw new Error(`Better Stack API ${init?.method ?? "GET"} ${path} failed: ${res.status} ${res.statusText} ${body}`);
|
|
200
|
+
}
|
|
201
|
+
return res.json();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
async listMonitors() {
|
|
206
|
+
const page = await request("/monitors");
|
|
207
|
+
return (page.data ?? []).map((m) => ({
|
|
208
|
+
id: m.id,
|
|
209
|
+
url: m.attributes?.url ?? "",
|
|
210
|
+
name: m.attributes?.pronounceable_name,
|
|
211
|
+
checkFrequency: m.attributes?.check_frequency,
|
|
212
|
+
alertEmail: m.attributes?.email,
|
|
213
|
+
}));
|
|
214
|
+
},
|
|
215
|
+
async createMonitor(entry) {
|
|
216
|
+
const body = toBetterStackPayload(entry);
|
|
217
|
+
const res = await request("/monitors", { method: "POST", body: JSON.stringify(body) });
|
|
218
|
+
return { id: res.data?.id };
|
|
219
|
+
},
|
|
220
|
+
async updateMonitor(id, entry) {
|
|
221
|
+
const body = toBetterStackPayload(entry);
|
|
222
|
+
const res = await request(`/monitors/${id}`, { method: "PATCH", body: JSON.stringify(body) });
|
|
223
|
+
return { id: res.data?.id ?? id };
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function toBetterStackPayload(entry) {
|
|
229
|
+
return {
|
|
230
|
+
monitor_type: "status",
|
|
231
|
+
url: entry.url,
|
|
232
|
+
pronounceable_name: entry.name,
|
|
233
|
+
check_frequency: entry.checkFrequency,
|
|
234
|
+
...(entry.alertEmail ? { email: entry.alertEmail } : {}),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
// Orchestration — apply the desired config against a client, honoring
|
|
240
|
+
// dry-run. Pure aside from the injected client.
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* @param {object} opts
|
|
245
|
+
* @param {{monitors: Array}} opts.config
|
|
246
|
+
* @param {ReturnType<typeof createBetterStackClient>} opts.client
|
|
247
|
+
* @param {boolean} opts.dryRun
|
|
248
|
+
* @param {string|null} [opts.defaultAlertEmail]
|
|
249
|
+
* @returns {Promise<{created: string[], updated: string[], unchanged: string[], dryRun: boolean}>}
|
|
250
|
+
*/
|
|
251
|
+
export async function applyMonitorConfig({ config, client, dryRun, defaultAlertEmail = null }) {
|
|
252
|
+
const desired = config.monitors.map((m) => ({
|
|
253
|
+
...m,
|
|
254
|
+
alertEmail: m.alertEmail ?? defaultAlertEmail,
|
|
255
|
+
}));
|
|
256
|
+
const live = await client.listMonitors();
|
|
257
|
+
const { toCreate, toUpdate, unchanged } = diffMonitors(desired, live);
|
|
258
|
+
|
|
259
|
+
if (dryRun) {
|
|
260
|
+
return {
|
|
261
|
+
created: toCreate.map((m) => m.url),
|
|
262
|
+
updated: toUpdate.map((m) => m.entry.url),
|
|
263
|
+
unchanged,
|
|
264
|
+
dryRun: true,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const created = [];
|
|
269
|
+
for (const entry of toCreate) {
|
|
270
|
+
await client.createMonitor(entry);
|
|
271
|
+
created.push(entry.url);
|
|
272
|
+
}
|
|
273
|
+
const updated = [];
|
|
274
|
+
for (const { id, entry } of toUpdate) {
|
|
275
|
+
await client.updateMonitor(id, entry);
|
|
276
|
+
updated.push(entry.url);
|
|
277
|
+
}
|
|
278
|
+
return { created, updated, unchanged, dryRun: false };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ---------------------------------------------------------------------------
|
|
282
|
+
// CLI
|
|
283
|
+
// ---------------------------------------------------------------------------
|
|
284
|
+
|
|
285
|
+
function parseArgs(argv) {
|
|
286
|
+
const opts = {
|
|
287
|
+
config: null,
|
|
288
|
+
dryRun: true,
|
|
289
|
+
token: process.env.BETTERSTACK_API_TOKEN ?? null,
|
|
290
|
+
alertEmail: process.env.UPTIME_ALERT_EMAIL ?? null,
|
|
291
|
+
};
|
|
292
|
+
for (let i = 0; i < argv.length; i++) {
|
|
293
|
+
const a = argv[i];
|
|
294
|
+
if (a === "--config" && argv[i + 1]) {
|
|
295
|
+
opts.config = argv[++i];
|
|
296
|
+
} else if (a === "--dry-run") {
|
|
297
|
+
opts.dryRun = true;
|
|
298
|
+
} else if (a === "--apply") {
|
|
299
|
+
opts.dryRun = false;
|
|
300
|
+
} else if (a === "--token" && argv[i + 1]) {
|
|
301
|
+
opts.token = argv[++i];
|
|
302
|
+
} else if (a === "--alert-email" && argv[i + 1]) {
|
|
303
|
+
opts.alertEmail = argv[++i];
|
|
304
|
+
} else if (a === "--help" || a === "-h") {
|
|
305
|
+
opts.help = true;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return opts;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async function main() {
|
|
312
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
313
|
+
if (opts.help) {
|
|
314
|
+
process.stdout.write(
|
|
315
|
+
"Usage: node scripts/apply-uptime-monitors.mjs --config <path> [--dry-run|--apply] " +
|
|
316
|
+
"[--token <token>] [--alert-email <email>]\n"
|
|
317
|
+
);
|
|
318
|
+
process.exit(0);
|
|
319
|
+
}
|
|
320
|
+
if (!opts.config) {
|
|
321
|
+
process.stderr.write("[apply-uptime-monitors] ERROR: --config <path> is required.\n");
|
|
322
|
+
process.exit(1);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Graceful degradation: no token → skip-with-notice, exit 0. Preserves the
|
|
326
|
+
// pre-existing per-consumer behaviour when Better Stack secrets are not
|
|
327
|
+
// yet provisioned (acceptance criterion — see docs/reusable-workflows.md).
|
|
328
|
+
if (!opts.token) {
|
|
329
|
+
process.stdout.write(
|
|
330
|
+
"⏭️ apply-uptime-monitors: BETTERSTACK_API_TOKEN not provided — skipping uptime-monitor apply (Better Stack not provisioned for this consumer yet).\n"
|
|
331
|
+
);
|
|
332
|
+
process.exit(0);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
let config;
|
|
336
|
+
try {
|
|
337
|
+
const raw = JSON.parse(readFileSync(resolve(opts.config), "utf8"));
|
|
338
|
+
config = parseMonitorConfig(raw);
|
|
339
|
+
} catch (err) {
|
|
340
|
+
process.stderr.write(`[apply-uptime-monitors] ERROR: invalid monitor config: ${err.message}\n`);
|
|
341
|
+
process.exit(1);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Test-only escape hatch: point the CLI at a local/offline server instead
|
|
345
|
+
// of the live Better Stack API. Never set in a production caller — see
|
|
346
|
+
// createBetterStackClient's apiBase docblock.
|
|
347
|
+
const apiBaseOverride = process.env.BETTERSTACK_API_BASE_OVERRIDE || undefined;
|
|
348
|
+
const client = createBetterStackClient({
|
|
349
|
+
token: opts.token,
|
|
350
|
+
...(apiBaseOverride ? { apiBase: apiBaseOverride } : {}),
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
try {
|
|
354
|
+
const result = await applyMonitorConfig({
|
|
355
|
+
config,
|
|
356
|
+
client,
|
|
357
|
+
dryRun: opts.dryRun,
|
|
358
|
+
defaultAlertEmail: opts.alertEmail,
|
|
359
|
+
});
|
|
360
|
+
const verb = result.dryRun ? "would create" : "created";
|
|
361
|
+
const verbUpdate = result.dryRun ? "would update" : "updated";
|
|
362
|
+
process.stdout.write(
|
|
363
|
+
`${result.dryRun ? "🔍 [dry-run] " : "✅ "}${result.created.length} monitor(s) ${verb}, ` +
|
|
364
|
+
`${result.updated.length} ${verbUpdate}, ${result.unchanged.length} unchanged.\n`
|
|
365
|
+
);
|
|
366
|
+
if (result.created.length) process.stdout.write(` create: ${result.created.join(", ")}\n`);
|
|
367
|
+
if (result.updated.length) process.stdout.write(` update: ${result.updated.join(", ")}\n`);
|
|
368
|
+
process.exit(0);
|
|
369
|
+
} catch (err) {
|
|
370
|
+
process.stderr.write(`[apply-uptime-monitors] ERROR: ${err.message}\n`);
|
|
371
|
+
process.exit(1);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Only run the CLI when invoked directly, not when imported by the self-test.
|
|
376
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
377
|
+
main();
|
|
378
|
+
}
|