breakaway 2.0.0-main.99 → 2.0.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.
@@ -6,10 +6,13 @@
6
6
  * and it runs in Node as well as the Worker.
7
7
  *
8
8
  * A file is `{ "version": 1, "provider": "<id>", "resources": [ … ] }`: `resources` is BRK-173's DesiredState, each
9
- * one `{ id, kind, name, attrs? }`. `provider` is optional and, when given, must be the environment's. Nothing else
9
+ * one `{ id, kind, name, attrs? }`. `provider` is optional and, when given, must be the environment's. `health` is
10
+ * optional too: `{ "url": "https://…" }`, the owner's own health address the board GETs on each refresh as an active
11
+ * check of the environment's front door (BRK-266, src/infra-health.js). Nothing else
10
12
  * goes at the top level: policy (BRK-181) is `policy.json` and scaling rules (BRK-186) are `scaling.json` in the same
11
13
  * folder, so neither name is an environment's, and envelope bounds live on the board, never in the repository.
12
14
  */
15
+ import { checkHealthField } from './infra-health.js';
13
16
  import { checkDesired } from './infra-provider.js';
14
17
  import { redact } from './redact.js';
15
18
 
@@ -32,7 +35,7 @@ export const DESIRED_MAX_FILES = 60;
32
35
  /** How deep a file may nest: far more than any real one, and well inside the stack. */
33
36
  export const MAX_DEPTH = 64;
34
37
 
35
- const TOP = ['version', 'provider', 'resources'];
38
+ const TOP = ['version', 'provider', 'resources', 'health'];
36
39
  const RESOURCE = ['id', 'kind', 'name', 'attrs'];
37
40
  const NAME = /^[a-z0-9][a-z0-9-]{0,39}$/u;
38
41
  const PROVIDER = /^[a-z][a-z0-9-]{0,31}$/u;
@@ -264,6 +267,10 @@ export function checkDesiredFile(source, { provider = null, expectProvider = nul
264
267
  `provider is ${named}, and the environment runs on ${expectProvider}: change one so they match`,
265
268
  );
266
269
  }
270
+ if (file.health !== undefined) {
271
+ const bad = checkHealthField(file.health);
272
+ if (bad) return wrong(lines.has(bad.field) ? bad.field : 'health', bad.message);
273
+ }
267
274
  if (!Array.isArray(file.resources))
268
275
  return wrong(
269
276
  file.resources === undefined ? null : 'resources',
@@ -316,6 +323,7 @@ export function checkDesiredFile(source, { provider = null, expectProvider = nul
316
323
  /** @type {import('./infra-provider.js').DesiredState} */
317
324
  const desired = {
318
325
  resources: file.resources.map(({ id, kind, name, attrs }) => ({ id, kind, name, ...(attrs ? { attrs } : {}) })),
326
+ ...(file.health ? { health: { url: file.health.url } } : {}),
319
327
  };
320
328
  if (provider) checkDesired(provider, desired);
321
329
  return { ok: true, desired, provider: named };
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Health you can trust (BRK-266; docs/specs/IDEA-19-architect.md, "Health and alerts, as built"): what the inventory
3
+ * keeps when a read fails, and the environment's own health URL.
4
+ *
5
+ * A provider's `observe` says `unknown` only when it couldn't read a resource, and says which call. The inventory then
6
+ * keeps the resource's last known health with its age (`keepLastHealth`), and notes what failed, instead of replacing
7
+ * it with unknown. A quiet resource is `idle`, which counts as healthy.
8
+ *
9
+ * An environment's desired state may name a health URL (`"health": { "url": "https://staging.example/health" }`): the
10
+ * owner's own service, which the board GETs on each refresh as an active check of the front door, with no credentials
11
+ * and a short timeout (`readHealthUrl`). What it answers becomes the health of the environment's routes and custom
12
+ * domains (`withHealthUrl`). Pure apart from the `fetch` it's handed, so the CLI's `infra check` can use the field check.
13
+ */
14
+
15
+ /** @typedef {import('./infra-provider.js').Health} Health */
16
+
17
+ /** How long the board waits for a health URL before calling the front door down. */
18
+ export const HEALTH_URL_TIMEOUT_MS = 5000;
19
+ /** The longest health URL a desired state may name. */
20
+ export const HEALTH_URL_MAX = 300;
21
+
22
+ /** Worst first. Idle ranks with healthy: a quiet resource is fine. */
23
+ const RANK = { down: 0, degraded: 1, unknown: 2, healthy: 3, idle: 3 };
24
+
25
+ /**
26
+ * What's wrong with a desired state's `health`, in words, or null when it's fine: `{ "url": "https://…" }`, an https
27
+ * URL with no user name or password in it, up to HEALTH_URL_MAX characters.
28
+ * @param {unknown} value
29
+ * @returns {{ field: string, message: string } | null}
30
+ */
31
+ export function checkHealthField(value) {
32
+ if (!value || typeof value !== 'object' || Array.isArray(value))
33
+ return { field: 'health', message: 'health is an object: { "url": "https://staging.example/health" }' };
34
+ for (const key of Object.keys(value))
35
+ if (key !== 'url') return { field: `health.${key}`, message: `“${key}” isn’t part of health: it has url` };
36
+ const url = /** @type {any} */ (value).url;
37
+ const said = {
38
+ field: 'health.url',
39
+ message: `url is an https address the board GETs on each refresh, up to ${HEALTH_URL_MAX} characters, like https://staging.example/health`,
40
+ };
41
+ if (typeof url !== 'string' || !url || url.length > HEALTH_URL_MAX) return said;
42
+ let parsed;
43
+ try {
44
+ parsed = new URL(url);
45
+ } catch {
46
+ return said;
47
+ }
48
+ if (parsed.protocol !== 'https:') return said;
49
+ if (parsed.username || parsed.password)
50
+ return {
51
+ field: 'health.url',
52
+ message: 'url has a user name or password in it: the repository is read by the board, so leave credentials out',
53
+ };
54
+ return null;
55
+ }
56
+
57
+ /**
58
+ * GETs an environment's health URL once: no credentials, no redirects followed, and HEALTH_URL_TIMEOUT_MS at most.
59
+ * An answer under 400 is healthy; a 4xx is degraded (it answers, but not at that address); a 5xx, a timeout, or no
60
+ * answer is down. The text names the host, so the owner sees what the board calls.
61
+ * @param {string} url
62
+ * @param {typeof fetch} doFetch
63
+ * @param {{ timeoutMs?: number, now?: () => number }} [options]
64
+ * @returns {Promise<{ state: 'healthy' | 'degraded' | 'down', text: string }>}
65
+ */
66
+ export async function readHealthUrl(url, doFetch, { timeoutMs = HEALTH_URL_TIMEOUT_MS, now = Date.now } = {}) {
67
+ const host = new URL(url).host;
68
+ const controller = new AbortController();
69
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
70
+ const started = now();
71
+ try {
72
+ const res = await doFetch(url, {
73
+ method: 'GET',
74
+ headers: { accept: '*/*' },
75
+ redirect: 'manual',
76
+ signal: controller.signal,
77
+ });
78
+ await res.body?.cancel().catch(() => {});
79
+ const ms = Math.max(0, Math.round(now() - started));
80
+ const said = `The health URL on ${host} answered ${res.status} in ${ms} ms`;
81
+ if (res.status >= 500) return { state: 'down', text: said };
82
+ if (res.status >= 400) return { state: 'degraded', text: `${said}: check the address in the desired state` };
83
+ return { state: 'healthy', text: said };
84
+ } catch (error) {
85
+ const timedOut = controller.signal.aborted;
86
+ return {
87
+ state: 'down',
88
+ text: timedOut
89
+ ? `The health URL on ${host} didn’t answer within ${Math.round(timeoutMs / 1000)} s`
90
+ : `The health URL on ${host} couldn’t be reached (${String(/** @type {any} */ (error)?.message ?? error).slice(0, 80)})`,
91
+ };
92
+ } finally {
93
+ clearTimeout(timer);
94
+ }
95
+ }
96
+
97
+ /**
98
+ * One flaky probe never makes the front door down (BRK-266): a failure (down) right after a pass reads degraded, with
99
+ * the reason, and only a second failure in a row reads down. A pass, or a 4xx, clears the count.
100
+ * @param {{ state: string, text: string }} check from `readHealthUrl`
101
+ * @param {number} failsBefore how many checks in a row failed before this one
102
+ * @returns {{ check: { state: string, text: string }, fails: number }}
103
+ */
104
+ export function debounceHealthUrl(check, failsBefore) {
105
+ if (check.state !== 'down') return { check, fails: 0 };
106
+ const fails = (Number(failsBefore) || 0) + 1;
107
+ if (fails >= 2) return { check: { state: 'down', text: `${check.text}, ${fails} checks in a row` }, fails };
108
+ return { check: { state: 'degraded', text: `${check.text}; down if the next check fails too` }, fails };
109
+ }
110
+
111
+ /**
112
+ * Puts a health URL's answer on the environment's front door: the routes and custom domains whose host is the URL's,
113
+ * or every route and custom domain when none matches. A front door's health is then the worse of its own and the
114
+ * URL's, and a passing check lifts an idle or unknown one to healthy, since it just served a request. With no front
115
+ * door, nothing changes.
116
+ * @param {Array<{ id: string, kind: string, name: string, attrs?: Record<string, unknown> }>} resources
117
+ * @param {Health[]} health from `observe`
118
+ * @param {string} url
119
+ * @param {{ state: string, text: string }} check from `readHealthUrl`
120
+ * @param {string} at when it was checked, ISO 8601
121
+ * @returns {Health[]}
122
+ */
123
+ export function withHealthUrl(resources, health, url, check, at) {
124
+ const host = new URL(url).hostname.toLowerCase();
125
+ const front = resources.filter((r) => r.kind === 'route' || r.kind === 'custom-domain');
126
+ if (!front.length) return health;
127
+ const hostOf = (r) => {
128
+ const a = /** @type {any} */ (r.attrs ?? {});
129
+ const raw = String(r.kind === 'custom-domain' ? (a.hostname ?? r.name) : (a.pattern ?? r.name));
130
+ return raw
131
+ .toLowerCase()
132
+ .replace(/^\*\.?/u, '')
133
+ .split('/')[0];
134
+ };
135
+ const matched = front.filter((r) => hostOf(r) === host);
136
+ const on = new Set((matched.length ? matched : front).map((r) => r.id));
137
+ const byId = new Map(health.map((h) => [h.resource, h]));
138
+ for (const id of on) {
139
+ const own = byId.get(id);
140
+ const ownRank = own ? (RANK[own.state] ?? 2) : 2;
141
+ const urlRank = RANK[check.state] ?? 2;
142
+ let state = check.state;
143
+ let text = check.text;
144
+ // Its own health stays when it's worse than the URL's: a passing check doesn't hide failing requests.
145
+ if (own && ownRank < urlRank && own.state !== 'unknown') {
146
+ state = own.state;
147
+ text = `${own.text ?? ''}; ${check.text}`.replace(/^; /u, '');
148
+ }
149
+ byId.set(id, { resource: id, state, at, text });
150
+ }
151
+ return health.map((h) => byId.get(h.resource) ?? h);
152
+ }
153
+
154
+ /**
155
+ * What the inventory stores for a resource's health (BRK-266): what `observe` said, unless it said unknown (or failed
156
+ * altogether) and the resource had a known health before. Then the last health stays, with its time, so its age shows,
157
+ * and `note` says what couldn't be read.
158
+ * @param {Health | null} h what observe said about it, or null when observe failed
159
+ * @param {{ health?: string | null, health_at?: number | null, health_text?: string | null } | undefined} last
160
+ * @param {string | null} failed why observe failed, when it did
161
+ * @returns {{ state: string | null, at: number | null, text: string | null, note: string | null }}
162
+ */
163
+ export function keepLastHealth(h, last, failed = null) {
164
+ const known = last?.health && last.health !== 'unknown';
165
+ const kept = () => ({
166
+ state: last?.health ?? null,
167
+ at: last?.health_at ?? null,
168
+ text: last?.health_text ?? null,
169
+ });
170
+ if (!h) return { ...kept(), note: failed ? `Couldn’t read its health: ${failed}` : null };
171
+ if (h.state === 'unknown' && known) return { ...kept(), note: h.text ?? 'Couldn’t read its health' };
172
+ return { state: h.state, at: Date.parse(h.at), text: h.text ?? null, note: null };
173
+ }
174
+
175
+ /**
176
+ * An environment's health from its resources' (BRK-266): down and degraded win, with how many; otherwise healthy
177
+ * when any resource is healthy or idle (idle alone reads idle), with how many couldn't be read (unknown, or kept from
178
+ * an earlier read) as `notRead`; unknown only when none is healthy or idle.
179
+ * @param {Array<{ state?: string | null, note?: string | null } | null | undefined>} healths
180
+ * @returns {{ state: string, count: number, total: number, notRead: number } | null}
181
+ */
182
+ export function rollUpHealth(healths) {
183
+ if (!healths.length) return null;
184
+ const states = healths.map((h) => h?.state ?? 'unknown');
185
+ const total = states.length;
186
+ for (const bad of ['down', 'degraded']) {
187
+ const count = states.filter((s) => s === bad).length;
188
+ if (count) return { state: bad, count, total, notRead: 0 };
189
+ }
190
+ const notRead = healths.filter((h) => !h?.state || h.state === 'unknown' || h?.note).length;
191
+ const healthy = states.filter((s) => s === 'healthy').length;
192
+ const idle = states.filter((s) => s === 'idle').length;
193
+ if (healthy) return { state: 'healthy', count: healthy + idle, total, notRead };
194
+ if (idle) return { state: 'idle', count: idle, total, notRead };
195
+ return { state: 'unknown', count: total, total, notRead };
196
+ }
@@ -62,7 +62,7 @@ export function redactAttrs(value) {
62
62
 
63
63
  /**
64
64
  * A resource as the API shows it.
65
- * @param {{ provider: string, rid: string, kind: string, name: string, attrs: string | null, health: string | null, health_at: number | null, health_text: string | null, cost: number | null, currency: string | null, cost_note?: string | null, seen: number }} row
65
+ * @param {{ provider: string, rid: string, kind: string, name: string, attrs: string | null, health: string | null, health_at: number | null, health_text: string | null, health_note?: string | null, cost: number | null, currency: string | null, cost_note?: string | null, seen: number }} row
66
66
  * @param {{ id: number, repo: string, name: string }} environment
67
67
  * @param {{ uuid: string, wid: string | null, description: string } | null} task the task that owns a short-lived environment
68
68
  */
@@ -74,13 +74,16 @@ export function resourceView(row, environment, task) {
74
74
  name: row.name,
75
75
  attrs: row.attrs ? JSON.parse(row.attrs) : {},
76
76
  owner: { repo: environment.repo, environment: environment.name, environmentId: environment.id, task },
77
- health: row.health
78
- ? {
79
- state: row.health,
80
- at: row.health_at ? new Date(row.health_at).toISOString() : null,
81
- ...(row.health_text ? { text: row.health_text } : {}),
82
- }
83
- : null,
77
+ // `note` is what couldn't be read on the last refresh, while the last known health, and its time, stay (BRK-266).
78
+ health:
79
+ row.health || row.health_note
80
+ ? {
81
+ state: row.health ?? 'unknown',
82
+ at: row.health_at ? new Date(row.health_at).toISOString() : null,
83
+ ...(row.health_text ? { text: row.health_text } : {}),
84
+ ...(row.health_note ? { note: row.health_note } : {}),
85
+ }
86
+ : null,
84
87
  cost:
85
88
  row.cost === null || row.cost === undefined
86
89
  ? null
@@ -29,7 +29,8 @@ export const PLAN_SOURCES = ['pull-request', 'drift', 'envelope', 'incident', 'd
29
29
  /**
30
30
  * Which states a plan may move to from each. A draft waits for the owner or is dropped; only an approved plan is
31
31
  * applied, and one that hasn't started applying can still be rejected; an apply ends applied or failed, and either can
32
- * be rolled back. Rejected and rolled back are the end.
32
+ * be rolled back. A failed plan goes back to approved only through the owner's Start the run again, for a run that
33
+ * applied nothing (BRK-308, store-infra-runs.js). Rejected and rolled back are the end.
33
34
  * @type {Record<string, string[]>}
34
35
  */
35
36
  export const PLAN_MOVES = {
@@ -39,7 +40,7 @@ export const PLAN_MOVES = {
39
40
  rejected: [],
40
41
  applying: ['applied', 'failed'],
41
42
  applied: ['rolled back'],
42
- failed: ['rolled back'],
43
+ failed: ['rolled back', 'approved'],
43
44
  'rolled back': [],
44
45
  };
45
46