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.
- package/.agents/skills/tasks/SKILL.md +3 -2
- package/LICENSE +45 -68
- package/README.md +17 -14
- package/npm-shrinkwrap.json +3 -3
- package/package.json +6 -2
- package/prompts/core.md +7 -4
- package/scripts/board-files.mjs +8 -1
- package/scripts/brand-lint.mjs +1 -0
- package/scripts/lib/brand-lint.js +25 -1
- package/scripts/site-roadmap.mjs +40 -0
- package/scripts/tasks/cli.js +14 -1
- package/scripts/tasks/infra-read.js +2 -0
- package/scripts/tasks/infra.js +15 -61
- package/scripts/tasks/pipeline.js +3 -170
- package/scripts/tasks.mjs +23 -4
- package/src/infra-cloudflare-analytics.js +24 -5
- package/src/infra-cloudflare-plan.js +38 -3
- package/src/infra-cloudflare-tokens.js +133 -0
- package/src/infra-cloudflare.js +385 -101
- package/src/infra-desired.js +10 -2
- package/src/infra-health.js +196 -0
- package/src/infra-inventory.js +11 -8
- package/src/infra-plans.js +3 -2
- package/src/infra-policy.js +198 -92
- package/src/infra-provider.js +217 -3
- package/src/infra-runner-render.js +172 -0
- package/src/init.js +1 -1
- package/src/workflow-render.js +180 -0
- package/template/README.md +1 -1
- package/template/infra/apply.yml +4 -0
package/src/infra-desired.js
CHANGED
|
@@ -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.
|
|
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
|
+
}
|
package/src/infra-inventory.js
CHANGED
|
@@ -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
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
package/src/infra-plans.js
CHANGED
|
@@ -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.
|
|
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
|
|