breakaway 2.0.0-main.7 → 2.0.0-main.71
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/pipeline/SKILL.md +2 -1
- package/.agents/skills/tasks/SKILL.md +5 -0
- package/README.md +14 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +17 -1
- package/prompts/breakaway.md +1 -1
- package/prompts/core.md +16 -2
- package/scripts/tasks/cli.js +2 -0
- package/scripts/tasks/infra-act.js +108 -0
- package/scripts/tasks/infra-add.js +193 -0
- package/scripts/tasks/infra-adopt.js +115 -0
- package/scripts/tasks/infra-check.js +120 -0
- package/scripts/tasks/infra-read.js +530 -0
- package/scripts/tasks/infra.js +348 -0
- package/scripts/tasks/pipeline.js +1 -1
- package/scripts/tasks.mjs +116 -1
- package/src/infra-check.js +99 -0
- package/src/infra-cloudflare-analytics.js +298 -0
- package/src/infra-cloudflare-plan.js +853 -0
- package/src/infra-cloudflare.js +1218 -0
- package/src/infra-currency.js +181 -0
- package/src/infra-desired.js +322 -0
- package/src/infra-environments.js +100 -0
- package/src/infra-inventory.js +96 -0
- package/src/infra-plans.js +312 -0
- package/src/infra-policy.js +543 -0
- package/src/infra-provider.js +558 -0
- package/src/infra-providers.js +10 -0
- package/src/infra-runner.js +165 -0
- package/src/infra-scaling.js +287 -0
- package/src/infra-short-lived.js +177 -0
- package/src/infra-templates.js +313 -0
- package/src/ping.js +6 -0
- package/template/infra/apply.yml +76 -0
- package/template/infra/templates/queue/send.js.tmpl +9 -0
- package/template/infra/templates/queue/template.json +32 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `npx breakaway infra check [<environment>]` (CLI-14, docs/specs/IDEA-19-architect.md, "Desired state" and "CLI"):
|
|
3
|
+
* before a pull request, checks the checkout's `.github/breakaway-infra/` folder (each environment's desired state and
|
|
4
|
+
* the policy) the way the board will once it's merged, and asks the board for the plan each valid file would make
|
|
5
|
+
* against what runs now, the way `pipeline check` checks the deploy config.
|
|
6
|
+
*
|
|
7
|
+
* The files are checked here, with src/infra-check.js: a file that doesn't check names its line and field, and nothing
|
|
8
|
+
* is sent. A valid one goes to POST /api/infra/check, the board's preview: kept nowhere, with no ID and no state, so it
|
|
9
|
+
* can't be approved or applied, and an agent's token may ask for it. The requests go through `post`, which the CLI gives
|
|
10
|
+
* (and the tests mock), and which answers `{ ok, status, data }` for any answer.
|
|
11
|
+
*/
|
|
12
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import { checkInfraFolder, problemLine } from '../../src/infra-check.js';
|
|
15
|
+
import { DESIRED_DIR } from '../../src/infra-desired.js';
|
|
16
|
+
import { POLICY_FILE } from '../../src/infra-policy.js';
|
|
17
|
+
import { planText } from './infra-read.js';
|
|
18
|
+
|
|
19
|
+
const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The folder's files in a checkout, by name and text, or null when it has no folder.
|
|
23
|
+
* @param {string} root the checkout's top
|
|
24
|
+
* @returns {{ name: string, text: string }[] | null}
|
|
25
|
+
*/
|
|
26
|
+
export function readInfraFolder(root) {
|
|
27
|
+
let names;
|
|
28
|
+
try {
|
|
29
|
+
names = readdirSync(join(root, DESIRED_DIR), { withFileTypes: true })
|
|
30
|
+
.filter((e) => e.isFile())
|
|
31
|
+
.map((e) => e.name);
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
return names.map((name) => ({ name, text: readFileSync(join(root, DESIRED_DIR, name), 'utf8') }));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Runs `infra check`. `code` is what the CLI exits with: 1 when a file doesn't check or the board refuses a preview.
|
|
40
|
+
* @param {string[]} args what follows `infra check`: an environment's name, or nothing for every one
|
|
41
|
+
* @param {{ files: { name: string, text: string }[] | null, repo: string | null,
|
|
42
|
+
* post: (path: string, body: any) => Promise<{ ok: boolean, status: number, data: any }> }} ctx
|
|
43
|
+
* @returns {Promise<{ code: number, data: any, text: string }>}
|
|
44
|
+
*/
|
|
45
|
+
export async function infraCheck(args, { files, repo, post }) {
|
|
46
|
+
const only = args[0] ?? null;
|
|
47
|
+
if (files === null)
|
|
48
|
+
return {
|
|
49
|
+
code: 1,
|
|
50
|
+
data: { ok: false, error: `no ${DESIRED_DIR}/ in this checkout` },
|
|
51
|
+
text: `No ${DESIRED_DIR}/ in this checkout. Add one file per environment, like ${DESIRED_DIR}/staging.json, and ${POLICY_FILE} beside them for the policy.`,
|
|
52
|
+
};
|
|
53
|
+
const checked = checkInfraFolder(files);
|
|
54
|
+
const environments = checked.files.filter((f) => f.kind === 'desired').map((f) => f.environment);
|
|
55
|
+
if (only && !environments.includes(only))
|
|
56
|
+
return {
|
|
57
|
+
code: 1,
|
|
58
|
+
data: { ok: false, error: `no ${DESIRED_DIR}/${only}.json in this checkout`, files: checked.files },
|
|
59
|
+
text: `No ${DESIRED_DIR}/${only}.json in this checkout${environments.length ? `: it has ${environments.join(', ')}` : ''}.`,
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const out = [];
|
|
63
|
+
for (const f of checked.files) {
|
|
64
|
+
if (f.kind === 'skipped') continue;
|
|
65
|
+
if (!f.ok) out.push(problemLine(f.path, /** @type {any} */ (f.error)));
|
|
66
|
+
else if (f.kind === 'policy') out.push(`${f.path}: the policy checks`);
|
|
67
|
+
else if (f.kind === 'scaling') out.push(`${f.path}: checks, ${plural(f.rules ?? 0, 'scaling rule')}`);
|
|
68
|
+
else out.push(`${f.path}: checks, ${plural(f.resources ?? 0, 'resource')}`);
|
|
69
|
+
}
|
|
70
|
+
if (!environments.length) out.push(`No environment’s file yet: add one, like ${DESIRED_DIR}/staging.json.`);
|
|
71
|
+
if (!checked.files.some((f) => f.kind === 'policy'))
|
|
72
|
+
out.push(`No ${POLICY_FILE}: the default policy decides, and every plan waits for you.`);
|
|
73
|
+
if (!checked.ok) {
|
|
74
|
+
out.push('', 'Nothing was sent to the board: fix what’s above, then check again.');
|
|
75
|
+
return { code: 1, data: { ok: false, files: checked.files, previews: [] }, text: out.join('\n') };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const policy = files.find((f) => f.name === POLICY_FILE)?.text ?? null;
|
|
79
|
+
const previews = [];
|
|
80
|
+
let code = 0;
|
|
81
|
+
for (const environment of only ? [only] : environments) {
|
|
82
|
+
const file = /** @type {{ text: string }} */ (files.find((f) => f.name === `${environment}.json`)).text;
|
|
83
|
+
const res = await post('infra/check', { environment, ...(repo ? { repo } : {}), file, policy });
|
|
84
|
+
out.push('');
|
|
85
|
+
if (res.ok) {
|
|
86
|
+
const { preview } = res.data;
|
|
87
|
+
previews.push({ environment, preview });
|
|
88
|
+
out.push(planText({ plan: preview }));
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (res.status === 404 && /^no route for/u.test(String(res.data?.error ?? '')))
|
|
92
|
+
return {
|
|
93
|
+
code: 1,
|
|
94
|
+
data: { ok: false, error: 'this board has no infra check yet', files: checked.files, previews },
|
|
95
|
+
text: [
|
|
96
|
+
...out,
|
|
97
|
+
'This board doesn’t have infra check yet: its owner updates it to a release that does, then try again.',
|
|
98
|
+
].join('\n'),
|
|
99
|
+
};
|
|
100
|
+
// An environment the board doesn't have yet is one to add, as the board shows a file with no environment.
|
|
101
|
+
if (res.status === 404) {
|
|
102
|
+
previews.push({ environment, preview: null, toAdd: true });
|
|
103
|
+
out.push(
|
|
104
|
+
`${environment}: no environment on the board yet, so there’s no plan to show. The owner adds it on the board’s Infrastructure view.`,
|
|
105
|
+
);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
code = 1;
|
|
109
|
+
const problem = res.data?.problem;
|
|
110
|
+
const error = res.status === 0 ? `can’t reach the board (${res.data?.error ?? 'no answer'})` : res.data?.error;
|
|
111
|
+
previews.push({
|
|
112
|
+
environment,
|
|
113
|
+
preview: null,
|
|
114
|
+
error: error ?? `HTTP ${res.status}`,
|
|
115
|
+
...(problem ? { problem } : {}),
|
|
116
|
+
});
|
|
117
|
+
out.push(problem ? problemLine(problem.path, problem) : `${environment}: ${error ?? `HTTP ${res.status}`}`);
|
|
118
|
+
}
|
|
119
|
+
return { code, data: { ok: code === 0, files: checked.files, previews }, text: out.join('\n') };
|
|
120
|
+
}
|
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `npx breakaway infra`'s reads (CLI-13, docs/specs/IDEA-19-architect.md, "CLI"): the environments, one environment's
|
|
3
|
+
* inventory and drift, plans, signals, and incidents, as text or JSON, from the board's API.
|
|
4
|
+
*
|
|
5
|
+
* Read only: every request is a GET, so an agent's token reads Architect and never changes it. Approving, rejecting,
|
|
6
|
+
* freezing, and changing an environment are the owner's, on the board. The requests go through `get`, which the CLI
|
|
7
|
+
* gives (and the tests mock), and which answers `{ ok, status, data }` for any answer.
|
|
8
|
+
*/
|
|
9
|
+
import { sentAt } from './session-messages.js';
|
|
10
|
+
import { rateWords } from '../../src/infra-currency.js';
|
|
11
|
+
|
|
12
|
+
/** The reads `infra` takes; `infra` alone is `infra environments`. */
|
|
13
|
+
export const INFRA_READS = ['environments', 'show', 'plans', 'plan', 'signals', 'incidents'];
|
|
14
|
+
|
|
15
|
+
/** What a plan's state reads as (brand/README.md, "Infrastructure words"). */
|
|
16
|
+
export const PLAN_STATE_LABELS = {
|
|
17
|
+
draft: 'Draft',
|
|
18
|
+
waiting: 'Waiting for you',
|
|
19
|
+
approved: 'Approved',
|
|
20
|
+
rejected: 'Rejected',
|
|
21
|
+
applying: 'Applying',
|
|
22
|
+
applied: 'Applied',
|
|
23
|
+
failed: 'Failed',
|
|
24
|
+
'rolled back': 'Rolled back',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const DESIRED_LABELS = {
|
|
28
|
+
valid: 'valid',
|
|
29
|
+
invalid: 'can’t be read',
|
|
30
|
+
'to-add': 'no environment for it',
|
|
31
|
+
refused: 'not used: observe only',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const POLICY_LABELS = {
|
|
35
|
+
allowed: 'allowed',
|
|
36
|
+
'needs-owner': 'it waits for you',
|
|
37
|
+
refused: 'refused',
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const SOURCE_LABELS = {
|
|
41
|
+
'pull-request': 'a pull request',
|
|
42
|
+
drift: 'drift',
|
|
43
|
+
envelope: 'an envelope',
|
|
44
|
+
incident: 'an incident',
|
|
45
|
+
deploy: 'the deploy flow',
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** A read `infra` can't do, with what to do instead. */
|
|
49
|
+
export class InfraReadError extends Error {}
|
|
50
|
+
const bad = (message) => {
|
|
51
|
+
throw new InfraReadError(message);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const enc = encodeURIComponent;
|
|
55
|
+
const query = (fields) => {
|
|
56
|
+
const q = Object.entries(fields)
|
|
57
|
+
.filter(([, v]) => v !== undefined && v !== null && v !== '')
|
|
58
|
+
.map(([k, v]) => `${k}=${enc(String(v))}`)
|
|
59
|
+
.join('&');
|
|
60
|
+
return q ? `?${q}` : '';
|
|
61
|
+
};
|
|
62
|
+
const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
63
|
+
|
|
64
|
+
/** A board from before a route answers 404 with "no route for …". */
|
|
65
|
+
const noRoute = (res) => res.status === 404 && /^no route for/u.test(String(res.data?.error ?? ''));
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The answer's data, or an InfraReadError that says what failed: `what` names what an older board doesn't have yet.
|
|
69
|
+
* @param {{ ok: boolean, status: number, data: any }} res
|
|
70
|
+
* @param {string} what
|
|
71
|
+
*/
|
|
72
|
+
function answer(res, what) {
|
|
73
|
+
if (res.ok) return res.data;
|
|
74
|
+
if (noRoute(res))
|
|
75
|
+
bad(`this board doesn’t have ${what} yet: its owner updates it to a release that does, then try again.`);
|
|
76
|
+
if (res.status === 0) bad(`can’t reach the board (${res.data?.error ?? 'no answer'}).`);
|
|
77
|
+
return bad(res.data?.error ?? `HTTP ${res.status}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* An amount a month, as the brand writes it: "$4.60 a month", a fall with a minus sign.
|
|
82
|
+
* @param {number | null | undefined} amount
|
|
83
|
+
* @param {string | null | undefined} currency
|
|
84
|
+
*/
|
|
85
|
+
export function money(amount, currency) {
|
|
86
|
+
if (typeof amount !== 'number' || !Number.isFinite(amount)) return null;
|
|
87
|
+
let text;
|
|
88
|
+
try {
|
|
89
|
+
text = new Intl.NumberFormat('en', { style: 'currency', currency: currency || 'USD' }).format(Math.abs(amount));
|
|
90
|
+
} catch {
|
|
91
|
+
text = `${Math.abs(amount).toFixed(2)} ${currency}`;
|
|
92
|
+
}
|
|
93
|
+
return `${amount < 0 ? '−' : ''}${text} a month`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** One value in a change's settings, short. */
|
|
97
|
+
const short = (value) => {
|
|
98
|
+
const text = typeof value === 'string' ? value : JSON.stringify(value);
|
|
99
|
+
return text === undefined ? '–' : text.length > 60 ? `${text.slice(0, 59)}…` : text;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// ---- environments ------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
/** One environment in the list: its name, kind, provider and target, and what's worth knowing now. */
|
|
105
|
+
export function environmentLine(env, { withRepo = false } = {}) {
|
|
106
|
+
const bits = [env.kind, env.provider ?? 'no provider', env.target].filter(Boolean);
|
|
107
|
+
const notes = [];
|
|
108
|
+
if (env.frozen) notes.push('frozen');
|
|
109
|
+
if (env.observeOnly) notes.push(env.runsTheBoard ? 'observe only: runs this board' : 'observe only');
|
|
110
|
+
if (typeof env.driftCount === 'number' && env.driftCount > 0) notes.push(`drift: ${env.driftCount}`);
|
|
111
|
+
if (env.waitingPlan) notes.push(`${env.waitingPlan} waits for you`);
|
|
112
|
+
if (env.task?.wid) notes.push(`for ${env.task.wid}`);
|
|
113
|
+
const name = withRepo ? `${env.repo}/${env.name}` : env.name;
|
|
114
|
+
return `${name.padEnd(withRepo ? 28 : 14)} ${bits.join(' · ')}${notes.length ? ` (${notes.join('; ')})` : ''}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** `infra` and `infra environments`: the repository's environments, or every one when the board doesn't know it. */
|
|
118
|
+
export function environmentsText({ environments }, repo) {
|
|
119
|
+
if (!environments.length)
|
|
120
|
+
return repo
|
|
121
|
+
? `No environments in ${repo} yet. The owner adds one on the board’s Infrastructure view.`
|
|
122
|
+
: 'No environments yet. The owner adds one on the board’s Infrastructure view.';
|
|
123
|
+
return [
|
|
124
|
+
...environments.map((env) => environmentLine(env, { withRepo: !repo })),
|
|
125
|
+
'',
|
|
126
|
+
'One environment: npx breakaway infra show <environment>',
|
|
127
|
+
].join('\n');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ---- one environment ---------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
/** What a resource's health and cost read as, in a few words. */
|
|
133
|
+
function resourceState(r) {
|
|
134
|
+
const bits = [];
|
|
135
|
+
if (r.health) bits.push(`${r.health.state}${r.health.text ? `: ${r.health.text}` : ''}`);
|
|
136
|
+
const cost = r.cost ? money(r.cost.amount, r.cost.currency) : null;
|
|
137
|
+
if (cost) bits.push(`${cost}, estimated`);
|
|
138
|
+
return bits.join(' · ');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The drift lines (BRK-184): the count, the resources that differ with what a plan would do to each, and the plan that
|
|
143
|
+
* covers it. A board from before drift sends no count, which reads like one that hasn't compared yet.
|
|
144
|
+
*/
|
|
145
|
+
function driftLines(env) {
|
|
146
|
+
const drift = env.drift ?? null;
|
|
147
|
+
const checked = drift?.checked ? `, checked ${sentAt(drift.checked)}` : '';
|
|
148
|
+
if (drift?.error) return [` Drift couldn’t compare: ${drift.error}`];
|
|
149
|
+
if (typeof env.driftCount !== 'number') return [' Drift not compared yet'];
|
|
150
|
+
if (!env.driftCount) return [` Drift none: what runs matches the repository${checked}`];
|
|
151
|
+
const out = [
|
|
152
|
+
` Drift ${plural(env.driftCount, 'resource differs', 'resources differ')} from the repository${checked}`,
|
|
153
|
+
];
|
|
154
|
+
for (const r of drift?.resources ?? []) out.push(` ${r.op ? `${r.op} ` : ''}${r.kind} ${r.name}`);
|
|
155
|
+
if (drift?.plan)
|
|
156
|
+
out.push(
|
|
157
|
+
` ${drift.planMatches === false ? `${drift.plan} no longer matches it` : `${drift.plan} puts it back`}: npx breakaway infra plan ${drift.plan}`,
|
|
158
|
+
);
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* `infra show <environment>`: the environment, its desired state and drift, and its inventory with what each resource
|
|
164
|
+
* uses.
|
|
165
|
+
* @param {{ environment: any, resources: any[], relations: any[], desired: any | null }} data
|
|
166
|
+
*/
|
|
167
|
+
export function environmentText({ environment: env, resources, relations, desired }) {
|
|
168
|
+
const out = [`${env.name} · ${env.repo}`, ''];
|
|
169
|
+
const row = (k, v) => v && out.push(` ${k.padEnd(11)} ${v}`);
|
|
170
|
+
row('Kind', env.kind);
|
|
171
|
+
row('Provider', env.provider ?? 'none yet: the owner picks one on the board');
|
|
172
|
+
row('Target', env.target);
|
|
173
|
+
if (env.task) row('Task', `${env.task.wid ?? env.task.uuid} · ${env.task.description}`);
|
|
174
|
+
row('Frozen', env.frozen ? `yes${env.frozenAt ? `, since ${sentAt(env.frozenAt)}` : ''}` : 'no');
|
|
175
|
+
if (env.observeOnly)
|
|
176
|
+
out.push(
|
|
177
|
+
` Observe only: the board watches it and never changes it${env.runsTheBoard ? ', because it runs this board' : ''}.`,
|
|
178
|
+
);
|
|
179
|
+
if (env.waitingPlan) row('Plan', `${env.waitingPlan} waits for you: npx breakaway infra plan ${env.waitingPlan}`);
|
|
180
|
+
if (desired) {
|
|
181
|
+
const problem = desired.problem ?? desired.error?.message;
|
|
182
|
+
row(
|
|
183
|
+
'Desired',
|
|
184
|
+
`${desired.path}${desired.sha ? ` at ${String(desired.sha).slice(0, 7)}` : ''}: ${DESIRED_LABELS[desired.state] ?? desired.state}${problem ? `, ${problem}` : ''}`,
|
|
185
|
+
);
|
|
186
|
+
} else if (!env.observeOnly) {
|
|
187
|
+
row('Desired', `none yet: add .github/breakaway-infra/${env.name}.json to the default branch by pull request`);
|
|
188
|
+
}
|
|
189
|
+
if (!env.observeOnly) out.push(...driftLines(env));
|
|
190
|
+
out.push('');
|
|
191
|
+
if (!resources.length) {
|
|
192
|
+
out.push(
|
|
193
|
+
env.provider
|
|
194
|
+
? 'No resources yet: the board reads them from the provider on its next refresh.'
|
|
195
|
+
: 'No resources: connect a provider and give the environment a target on the board.',
|
|
196
|
+
);
|
|
197
|
+
return out.join('\n');
|
|
198
|
+
}
|
|
199
|
+
const names = new Map(resources.map((r) => [r.id, `${r.kind} ${r.name}`]));
|
|
200
|
+
out.push(`Resources (${resources.length})`);
|
|
201
|
+
const rate = resources.find((r) => r.cost?.rate)?.cost.rate;
|
|
202
|
+
if (rate) out.push(` Estimated costs ${rateWords(rate)}.`);
|
|
203
|
+
for (const r of resources) {
|
|
204
|
+
const state = resourceState(r);
|
|
205
|
+
out.push(state ? ` ${`${r.kind} ${r.name}`.padEnd(32)} ${state}` : ` ${r.kind} ${r.name}`);
|
|
206
|
+
for (const rel of relations.filter((x) => x.from === r.id))
|
|
207
|
+
out.push(` uses ${names.get(rel.to) ?? rel.to} (${rel.kind})`);
|
|
208
|
+
}
|
|
209
|
+
return out.join('\n');
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ---- plans -------------------------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
/** What a plan changes the monthly cost by, in a few words, or null when it isn't known. */
|
|
215
|
+
function costWords(cost) {
|
|
216
|
+
if (!cost || typeof cost.delta !== 'number') return null;
|
|
217
|
+
if (cost.delta === 0) return 'no change in cost';
|
|
218
|
+
const amount = money(Math.abs(cost.delta), cost.currency);
|
|
219
|
+
return `${cost.delta > 0 ? 'adds' : 'saves'} ${amount}`;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Where a plan came from: "a pull request (#12)". */
|
|
223
|
+
function sourceWords(source) {
|
|
224
|
+
if (!source) return null;
|
|
225
|
+
const label = SOURCE_LABELS[source.kind] ?? source.kind;
|
|
226
|
+
if (!source.ref) return label;
|
|
227
|
+
return `${label} (${source.kind === 'pull-request' ? `#${source.ref}` : source.ref})`;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** One plan in the list. */
|
|
231
|
+
export function planLine(plan, { withRepo = false } = {}) {
|
|
232
|
+
const where = withRepo ? `${plan.repo}/${plan.environment.name}` : plan.environment.name;
|
|
233
|
+
const bits = [plural(plan.changes, 'change')];
|
|
234
|
+
const cost = costWords(plan.cost);
|
|
235
|
+
if (cost) bits.push(cost);
|
|
236
|
+
if (!plan.reversible) bits.push('can’t be undone');
|
|
237
|
+
const from = sourceWords(plan.source);
|
|
238
|
+
if (from) bits.push(`from ${from}`);
|
|
239
|
+
return `${plan.id.padEnd(9)} ${(PLAN_STATE_LABELS[plan.state] ?? plan.state).padEnd(15)} ${where.padEnd(withRepo ? 24 : 12)} ${bits.join(' · ')} ${sentAt(plan.created)}`;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* `infra plans`: newest first, with how to see older ones.
|
|
244
|
+
* @param {{ plans: any[], more: boolean }} data
|
|
245
|
+
* @param {string | null} repo
|
|
246
|
+
* @param {{ state?: string }} [filter]
|
|
247
|
+
*/
|
|
248
|
+
export function plansText({ plans, more }, repo, { state } = {}) {
|
|
249
|
+
if (!plans.length)
|
|
250
|
+
return state
|
|
251
|
+
? `No plans ${(PLAN_STATE_LABELS[state] ?? state).toLowerCase()}${repo ? ` in ${repo}` : ''}.`
|
|
252
|
+
: `No plans${repo ? ` in ${repo}` : ''} yet. A plan comes from a pull request that changes an environment’s desired state, from drift, or from an envelope.`;
|
|
253
|
+
const out = plans.map((p) => planLine(p, { withRepo: !repo }));
|
|
254
|
+
out.push('', 'One plan: npx breakaway infra plan <id>');
|
|
255
|
+
if (more) out.push(`Older: npx breakaway infra plans --before ${plans[plans.length - 1].id}`);
|
|
256
|
+
return out.join('\n');
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** A change's settings, key by key: what it sets, or what it changes from and to. */
|
|
260
|
+
function changeLines(change) {
|
|
261
|
+
const before = change.before ?? {};
|
|
262
|
+
const after = change.after ?? {};
|
|
263
|
+
const keys = [...new Set([...Object.keys(before), ...Object.keys(after)])].sort();
|
|
264
|
+
const out = [];
|
|
265
|
+
for (const key of keys) {
|
|
266
|
+
const was = JSON.stringify(before[key]);
|
|
267
|
+
const now = JSON.stringify(after[key]);
|
|
268
|
+
if (change.op === 'create') out.push(` ${key}: ${short(after[key])}`);
|
|
269
|
+
else if (change.op === 'update' && was !== now)
|
|
270
|
+
out.push(` ${key}: ${short(before[key])} → ${key in after ? short(after[key]) : '(removed)'}`);
|
|
271
|
+
}
|
|
272
|
+
return out;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* `infra plan <id>`: what it changes, what it costs, what else it touches, and whether it can be undone. A preview
|
|
277
|
+
* (`infra check`, CLI-14) has no ID or state: it's what a plan would hold, kept nowhere.
|
|
278
|
+
* @param {{ plan: any }} data
|
|
279
|
+
*/
|
|
280
|
+
export function planText({ plan }) {
|
|
281
|
+
const out = [
|
|
282
|
+
plan.id
|
|
283
|
+
? `${plan.id} · ${PLAN_STATE_LABELS[plan.state] ?? plan.state} · ${plan.environment.name} · ${plan.repo}`
|
|
284
|
+
: `${plan.environment.name} · ${plan.repo} · the plan it would make, a preview the board doesn’t keep`,
|
|
285
|
+
'',
|
|
286
|
+
];
|
|
287
|
+
const row = (k, v) => v && out.push(` ${k.padEnd(11)} ${v}`);
|
|
288
|
+
row('From', sourceWords(plan.source));
|
|
289
|
+
row('Made by', plan.agent ? `${plan.agent} (an agent)` : plan.by === 'owner' ? 'you' : plan.by);
|
|
290
|
+
row('Provider', [plan.provider, plan.target].filter(Boolean).join(', '));
|
|
291
|
+
row('Desired', plan.desiredSha ? `at ${String(plan.desiredSha).slice(0, 7)}` : null);
|
|
292
|
+
row('Made', sentAt(plan.created));
|
|
293
|
+
if (plan.updated && plan.updated !== plan.created) row('Updated', sentAt(plan.updated));
|
|
294
|
+
|
|
295
|
+
const cost = plan.cost;
|
|
296
|
+
if (cost) {
|
|
297
|
+
const words = costWords(cost) ?? 'not known';
|
|
298
|
+
const span =
|
|
299
|
+
typeof cost.now === 'number' && typeof cost.after === 'number'
|
|
300
|
+
? `: ${money(cost.now, cost.currency)} → ${money(cost.after, cost.currency)}`
|
|
301
|
+
: '';
|
|
302
|
+
const unknown = cost.unknown?.length ? ` (not known for ${cost.unknown.join(', ')})` : '';
|
|
303
|
+
row('Cost', `${words}${span}, estimated${unknown}${cost.rate ? `, ${rateWords(cost.rate)}` : ''}`);
|
|
304
|
+
}
|
|
305
|
+
row(
|
|
306
|
+
'Undo',
|
|
307
|
+
plan.reversible
|
|
308
|
+
? 'it can be undone'
|
|
309
|
+
: `can’t be undone: ${plan.irreversible.map((c) => `${c.op} ${c.name}${c.why ? ` (${c.why})` : ''}`).join('; ')}`,
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
// The policy's answer (BRK-181), one reason a line; a board from before policy sends none.
|
|
313
|
+
if (plan.policy) {
|
|
314
|
+
row('Policy', POLICY_LABELS[plan.policy.outcome] ?? plan.policy.outcome);
|
|
315
|
+
for (const reason of plan.policy.reasons ?? []) out.push(` ${reason}`);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const changes = plan.diff?.changes ?? [];
|
|
319
|
+
out.push('', `What changes (${plan.changes})`);
|
|
320
|
+
if (!changes.length) out.push(' Nothing: what runs already matches the desired state.');
|
|
321
|
+
for (const c of changes) {
|
|
322
|
+
out.push(` ${c.op.padEnd(8)} ${c.kind} ${c.name}${c.reversible ? '' : ' (can’t be undone)'}`);
|
|
323
|
+
out.push(...changeLines(c));
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const blast = plan.blastRadius;
|
|
327
|
+
if (blast) {
|
|
328
|
+
out.push('', 'What else it touches');
|
|
329
|
+
const all = blast.resources ?? [];
|
|
330
|
+
const named = (id) => all.find((r) => r.id === id)?.name ?? id;
|
|
331
|
+
const leaning = all.filter((r) => !r.changed);
|
|
332
|
+
if (!leaning.length) out.push(' Nothing else leans on what it changes.');
|
|
333
|
+
for (const r of leaning)
|
|
334
|
+
out.push(
|
|
335
|
+
` ${r.kind ?? 'resource'} ${r.name ?? r.id}${r.leansOn ? `, through ${r.leansOn.relation} to ${named(r.leansOn.id)}` : ''}`,
|
|
336
|
+
);
|
|
337
|
+
for (const d of blast.deletesInUse ?? [])
|
|
338
|
+
out.push(
|
|
339
|
+
` It deletes ${d.name}, and ${d.by.map(named).join(', ')} still ${d.by.length === 1 ? 'uses' : 'use'} it.`,
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
return out.join('\n');
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ---- signals -----------------------------------------------------------------------------
|
|
346
|
+
|
|
347
|
+
/** One signal: when, how loud, what kind, where, and what it said. */
|
|
348
|
+
export function signalLine(s) {
|
|
349
|
+
const where = [s.environment, s.resource].filter(Boolean).join(' · ');
|
|
350
|
+
const value = s.value === null || s.value === undefined ? '' : ` (${s.value})`;
|
|
351
|
+
return `${sentAt(s.at)} ${s.level.padEnd(8)} ${s.kind.padEnd(6)} ${where} ${s.text}${value}`;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* `infra signals`: newest first.
|
|
356
|
+
* @param {{ signals: any[], more: boolean }} data
|
|
357
|
+
* @param {{ environment?: string }} [filter]
|
|
358
|
+
*/
|
|
359
|
+
export function signalsText({ signals, more }, { environment } = {}) {
|
|
360
|
+
if (!signals.length)
|
|
361
|
+
return `No signals${environment ? ` for ${environment}` : ''} yet. The board hears them from each environment’s provider: its health, its alerts, and its cost.`;
|
|
362
|
+
const out = signals.map(signalLine);
|
|
363
|
+
if (more) out.push('', `Older: npx breakaway infra signals --before ${signals[signals.length - 1].id}`);
|
|
364
|
+
return out.join('\n');
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** `infra signals --days`: one line a day for each environment, resource, and kind. */
|
|
368
|
+
export function signalDaysText({ days }) {
|
|
369
|
+
if (!days.length) return 'No daily summaries yet: the board folds signals older than a week into them.';
|
|
370
|
+
return days
|
|
371
|
+
.map((d) => {
|
|
372
|
+
const levels = ['critical', 'warning', 'info']
|
|
373
|
+
.filter((l) => d[l])
|
|
374
|
+
.map((l) => `${d[l]} ${l}`)
|
|
375
|
+
.join(', ');
|
|
376
|
+
const where = [d.environment, d.resource].filter(Boolean).join(' · ');
|
|
377
|
+
return `${d.day} ${d.kind.padEnd(6)} ${where} ${plural(d.count, 'signal')}${levels ? ` (${levels})` : ''} last: ${d.text}`;
|
|
378
|
+
})
|
|
379
|
+
.join('\n');
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// ---- incidents ---------------------------------------------------------------------------
|
|
383
|
+
|
|
384
|
+
/** Where an incident is in its steps: the one to do now, or the one that failed. */
|
|
385
|
+
const STEP_WORDS = {
|
|
386
|
+
diagnose: 'diagnose',
|
|
387
|
+
propose: 'propose a plan',
|
|
388
|
+
approve: 'waits for you to approve',
|
|
389
|
+
apply: 'applying',
|
|
390
|
+
verify: 'verify',
|
|
391
|
+
'write-up': 'write it up',
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
/** One incident: its work ID, what broke and where, the step it's on, and how often the signal came. */
|
|
395
|
+
export function incidentLine(incident) {
|
|
396
|
+
const t = incident.task ?? {};
|
|
397
|
+
const where = [incident.environment, incident.resource].filter(Boolean).join(' · ');
|
|
398
|
+
const notes = [`${incident.level} ${incident.kind} in ${where || 'an environment'}`];
|
|
399
|
+
const failed = (incident.steps ?? []).find((s) => s.state === 'failed');
|
|
400
|
+
const now = (incident.steps ?? []).find((s) => s.state === 'now');
|
|
401
|
+
if (incident.closed) notes.push('closed');
|
|
402
|
+
else if (failed) notes.push(`${failed.step} failed`);
|
|
403
|
+
else if (now) notes.push(`now: ${STEP_WORDS[now.step] ?? now.step}`);
|
|
404
|
+
if (incident.recovered && !incident.closed) notes.push('health is back');
|
|
405
|
+
if (incident.signals > 1) notes.push(plural(incident.signals, 'signal'));
|
|
406
|
+
return `${String(t.wid ?? `#${incident.id}`).padEnd(8)} ${t.description ?? ''} (${notes.join('; ')})`;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* `infra incidents`: the board's incidents (BRK-197), each a task tagged +incident with its steps and linked plans.
|
|
411
|
+
* @param {{ incidents: any[], more: boolean }} data
|
|
412
|
+
*/
|
|
413
|
+
export function incidentsText({ incidents, more }, repo, { status = 'pending' } = {}) {
|
|
414
|
+
if (!incidents.length)
|
|
415
|
+
return `No ${status === 'pending' ? 'open ' : status === 'completed' ? 'closed ' : ''}incidents${repo ? ` in ${repo}` : ''}. A signal that crosses a rule opens one, as a task tagged +incident.`;
|
|
416
|
+
const out = [...incidents.map(incidentLine)];
|
|
417
|
+
if (more) out.push('', `Older: npx breakaway infra incidents --before ${incidents[incidents.length - 1].id}`);
|
|
418
|
+
out.push('', 'One incident, with its steps and plans: npx breakaway show <ID>');
|
|
419
|
+
return out.join('\n');
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// ---- the command -------------------------------------------------------------------------
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Runs one `infra` read.
|
|
426
|
+
* @param {string[]} args what follows `infra`
|
|
427
|
+
* @param {{ get: (path: string) => Promise<{ ok: boolean, status: number, data: any }>, repo: string | null,
|
|
428
|
+
* opts: Record<string, any> }} ctx
|
|
429
|
+
* @returns {Promise<{ data: any, text: string }>}
|
|
430
|
+
*/
|
|
431
|
+
export async function infraRead(args, { get, repo, opts = {} }) {
|
|
432
|
+
const sub = args[0] ?? 'environments';
|
|
433
|
+
const repoQuery = repo ?? undefined;
|
|
434
|
+
|
|
435
|
+
if (sub === 'environments') {
|
|
436
|
+
const data = answer(await get(`infra/environments${query({ repo: repoQuery })}`), 'environments');
|
|
437
|
+
return { data, text: environmentsText(data, repo) };
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
if (sub === 'show') {
|
|
441
|
+
const ref = args[1] ?? bad('infra show <environment>: name one, like production (npx breakaway infra lists them).');
|
|
442
|
+
const { environment } = answer(
|
|
443
|
+
await get(`infra/environments/${enc(ref)}${query({ repo: repoQuery })}`),
|
|
444
|
+
'environments',
|
|
445
|
+
);
|
|
446
|
+
const scope = query({ repo: environment.repo, environment: environment.id });
|
|
447
|
+
const inventory = await get(`infra/inventory${scope}`);
|
|
448
|
+
// A board from before the inventory still shows the environment.
|
|
449
|
+
const { resources = [], relations = [] } = noRoute(inventory) ? {} : answer(inventory, 'an inventory');
|
|
450
|
+
const desiredRes = await get(`infra/desired/${enc(environment.name)}${query({ repo: environment.repo })}`);
|
|
451
|
+
const desired = desiredRes.ok
|
|
452
|
+
? desiredRes.data.desired
|
|
453
|
+
: desiredRes.status === 404
|
|
454
|
+
? null
|
|
455
|
+
: answer(desiredRes, 'desired states');
|
|
456
|
+
const data = { environment, resources, relations, desired };
|
|
457
|
+
return { data, text: environmentText(data) };
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
if (sub === 'plans') {
|
|
461
|
+
const path = `infra/plans${query({
|
|
462
|
+
repo: repoQuery,
|
|
463
|
+
environment: opts.environment,
|
|
464
|
+
state: opts.state,
|
|
465
|
+
before: opts.before,
|
|
466
|
+
limit: opts.limit,
|
|
467
|
+
})}`;
|
|
468
|
+
const data = answer(await get(path), 'plans');
|
|
469
|
+
return { data, text: plansText(data, repo, { state: opts.state }) };
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
if (sub === 'plan') {
|
|
473
|
+
const ref = args[1] ?? bad('infra plan <id>: name one, like plan-12 (npx breakaway infra plans lists them).');
|
|
474
|
+
const data = answer(await get(`infra/plans/${enc(ref)}`), 'plans');
|
|
475
|
+
return { data, text: planText(data) };
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
if (sub === 'signals') {
|
|
479
|
+
// An environment's name is only unique within its repository, so name it by its ID once the board says which.
|
|
480
|
+
let environmentId;
|
|
481
|
+
let names = null;
|
|
482
|
+
if (opts.environment) {
|
|
483
|
+
const { environment } = answer(
|
|
484
|
+
await get(`infra/environments/${enc(opts.environment)}${query({ repo: repoQuery })}`),
|
|
485
|
+
'environments',
|
|
486
|
+
);
|
|
487
|
+
environmentId = environment.id;
|
|
488
|
+
} else if (repo && !opts.all) {
|
|
489
|
+
names = answer(await get(`infra/environments${query({ repo })}`), 'environments').environments;
|
|
490
|
+
}
|
|
491
|
+
const fields = { environmentId, resource: opts.resource, source: opts.source, kind: opts.kind };
|
|
492
|
+
if (opts.days) {
|
|
493
|
+
const data = answer(await get(`infra/signals/days${query(fields)}`), 'signals');
|
|
494
|
+
if (names) data.days = data.days.filter((d) => ofRepo(d, names));
|
|
495
|
+
return { data, text: signalDaysText(data) };
|
|
496
|
+
}
|
|
497
|
+
const data = answer(
|
|
498
|
+
await get(`infra/signals${query({ ...fields, level: opts.level, before: opts.before, limit: opts.limit })}`),
|
|
499
|
+
'signals',
|
|
500
|
+
);
|
|
501
|
+
if (names) data.signals = data.signals.filter((s) => ofRepo(s, names));
|
|
502
|
+
return { data, text: signalsText(data, { environment: opts.environment }) };
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
if (sub === 'incidents') {
|
|
506
|
+
const status = opts.status ?? 'pending';
|
|
507
|
+
const open = { pending: true, completed: false, all: undefined };
|
|
508
|
+
if (!(status in open)) bad(`infra incidents --status takes pending, completed, or all, not "${status}".`);
|
|
509
|
+
const path = `infra/incidents${query({
|
|
510
|
+
repo: opts.all ? undefined : repoQuery,
|
|
511
|
+
environment: opts.environment,
|
|
512
|
+
open: open[status],
|
|
513
|
+
before: opts.before,
|
|
514
|
+
limit: opts.limit,
|
|
515
|
+
})}`;
|
|
516
|
+
const data = answer(await get(path), 'incidents');
|
|
517
|
+
return { data, text: incidentsText(data, opts.all ? null : repo, { status }) };
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
return bad(
|
|
521
|
+
`infra has no "${sub}"; it has ${['init', 'check', ...INFRA_READS].join(', ')}. npx breakaway help says what each does.`,
|
|
522
|
+
);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** Whether a signal or a day's summary is about one of these environments: by ID, or by name when it has none. */
|
|
526
|
+
function ofRepo(s, environments) {
|
|
527
|
+
if (s.environmentId !== null && s.environmentId !== undefined)
|
|
528
|
+
return environments.some((e) => e.id === s.environmentId);
|
|
529
|
+
return environments.some((e) => e.name === s.environment);
|
|
530
|
+
}
|