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.
@@ -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
+ }