breakaway 2.0.0-main.98 → 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-provider.js
CHANGED
|
@@ -15,8 +15,12 @@ export const CHANGE_KINDS = ['create', 'update', 'delete', 'scale', 'restart'];
|
|
|
15
15
|
export const BASE_CHANGES = ['create', 'update', 'delete'];
|
|
16
16
|
/** The change kinds a provider may add per resource kind, for envelopes. */
|
|
17
17
|
export const ENVELOPE_CHANGES = ['scale', 'restart'];
|
|
18
|
-
/**
|
|
19
|
-
|
|
18
|
+
/**
|
|
19
|
+
* A resource's health, from `observe`. `idle` is deployed and reachable with no traffic and no errors seen (BRK-266):
|
|
20
|
+
* it counts as healthy, never as unknown. `unknown` is only for what the provider couldn't read, and its text says
|
|
21
|
+
* which call and what to do.
|
|
22
|
+
*/
|
|
23
|
+
export const HEALTH_STATES = ['healthy', 'idle', 'degraded', 'down', 'unknown'];
|
|
20
24
|
/** What a signal is about: health, the platform's alerts, and cost (BRK-172: no metrics, logs, or traces yet). */
|
|
21
25
|
export const SIGNAL_KINDS = ['health', 'alert', 'cost'];
|
|
22
26
|
/** How loud a signal is. */
|
|
@@ -61,6 +65,7 @@ export const COST_NOTE_MAX = 500;
|
|
|
61
65
|
* What should exist in one environment, from its desired-state file (BRK-180).
|
|
62
66
|
* @typedef {object} DesiredState
|
|
63
67
|
* @property {Resource[]} resources
|
|
68
|
+
* @property {{ url: string }} [health] the owner's health URL, checked on each refresh (BRK-266); providers ignore it
|
|
64
69
|
*/
|
|
65
70
|
|
|
66
71
|
/**
|
|
@@ -162,6 +167,8 @@ export const COST_NOTE_MAX = 500;
|
|
|
162
167
|
* Without it, every attr is a setting.
|
|
163
168
|
* @property {string} [scales] the whole-number setting a `scale` changes (like `instances`): required when the kind
|
|
164
169
|
* declares `scale`, so an envelope (BRK-186) knows what its bounds are bounds of
|
|
170
|
+
* @property {boolean} [target] one of the kind can be an environment's target, what discovery starts from (a Worker):
|
|
171
|
+
* a change that adds one to an environment with no target gives it that target (BRK-291)
|
|
165
172
|
*/
|
|
166
173
|
|
|
167
174
|
/**
|
|
@@ -191,6 +198,23 @@ export const COST_NOTE_MAX = 500;
|
|
|
191
198
|
* @property {string} url where the owner makes one, on the platform
|
|
192
199
|
* @property {(ctx: { token: string, fetch?: typeof fetch }) => Promise<TokenCheck>} [check] asks the platform about
|
|
193
200
|
* a pasted token before the board keeps it
|
|
201
|
+
* @property {(permissions: TokenPermission[], name: string) => TokenTemplate} [template] the link that opens the
|
|
202
|
+
* platform's create-token page with what it can prefill (BRK-304)
|
|
203
|
+
*/
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* A link to the platform's create-token page with some permissions filled in, and the ones to add by hand.
|
|
207
|
+
* @typedef {{ url: string, prefilled: string[], byHand: string[] }} TokenTemplate
|
|
208
|
+
*/
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Each environment's write token (BRK-304), which the board never holds: guided setup says what to make and where to
|
|
212
|
+
* put it, and checks the GitHub environment has a secret by that name. Never a token's value.
|
|
213
|
+
* @typedef {object} WriteToken
|
|
214
|
+
* @property {string} secret the secret the apply workflow reads it from, in the GitHub environment
|
|
215
|
+
* @property {(desired: DesiredState | null, options: { running: string[] }) => Array<TokenPermission & { scope: string, workers?: string[], once?: boolean }>} permissions
|
|
216
|
+
* what one environment's token needs, from its desired state and the targets that already run there
|
|
217
|
+
* @property {(permissions: TokenPermission[], name: string) => TokenTemplate} [template] the prefilled link
|
|
194
218
|
*/
|
|
195
219
|
|
|
196
220
|
/**
|
|
@@ -207,6 +231,7 @@ export const COST_NOTE_MAX = 500;
|
|
|
207
231
|
* Worker, `scope.board`, or another environment's target, `scope.others`), in words, or null when it is; an act asks
|
|
208
232
|
* it before it plans (BRK-251)
|
|
209
233
|
* @property {ReadToken} [readToken] the read-only token it needs, if any
|
|
234
|
+
* @property {WriteToken} [writeToken] what each environment's write token needs, for guided setup (BRK-304)
|
|
210
235
|
* @property {(ctx: ProviderContext) => Promise<Discovery>} discover
|
|
211
236
|
* @property {(ctx: ProviderContext, desired: DesiredState) => Promise<PlanDiff>} plan
|
|
212
237
|
* @property {(ctx: ProviderContext, plan: PlanDiff) => Promise<ApplyResult>} apply
|
|
@@ -217,6 +242,8 @@ export const COST_NOTE_MAX = 500;
|
|
|
217
242
|
* month once `change` is applied, or null when the provider can't say; a plan's cost change uses it (BRK-178)
|
|
218
243
|
* @property {(kind: string) => Editable | null} [editable] the settings the console may change on resources of `kind`,
|
|
219
244
|
* or null for a kind it changes nothing on; checked by `checkEditable` (BRK-262)
|
|
245
|
+
* @property {(kind: string) => Creatable | null} [creatable] whether the console may add a resource of `kind`, its
|
|
246
|
+
* name's rule, and what a new one is given, or null for a kind it can't add; checked by `checkCreatable` (BRK-270)
|
|
220
247
|
* @property {(ctx: ProviderContext, options: { board: string[] }) => Promise<AlertSetup>} [alerts] reads which of the
|
|
221
248
|
* platform's alerts are set up and which reach the board (`board` is its https origins), for a provider whose
|
|
222
249
|
* platform sends alerts to the board
|
|
@@ -251,13 +278,17 @@ const PATH = /^[A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z][A-Za-z0-9_]*)*$/u;
|
|
|
251
278
|
* @property {string[]} [kinds] the kinds a `resource` may name
|
|
252
279
|
* @property {BindingTarget[]} [targets] the binding types a `bindings` field changes: a binding of any other type (a
|
|
253
280
|
* variable, a secret) is shown by its name and type, and kept as it is
|
|
281
|
+
* @property {Record<string, string>} [typeLabels] a `bindings` field's words for binding types its `targets` don't
|
|
282
|
+
* list (a Durable Object, static assets), so a change reads '+ CHAT (Durable Object)' (WEB-110)
|
|
254
283
|
* @property {EditableField[]} [fields] a `rules` field's fields, inside one rule
|
|
255
284
|
* @property {Record<string, unknown>} [template] what a new rule starts from
|
|
256
285
|
*/
|
|
257
286
|
|
|
258
287
|
/**
|
|
259
288
|
* One type of binding a `bindings` field changes, and how it names its resource: a binding is `{ name, type, [field]: … }`,
|
|
260
|
-
*
|
|
289
|
+
* the field the provider's plan compares with what runs (BRK-285), holding the resource's ID on the platform (`by:
|
|
290
|
+
* 'id'`, its inventory ID after `<kind>:`) or its name (`by: 'name'`). One the plan still makes has no platform ID yet,
|
|
291
|
+
* so a `by: 'id'` binding names it by its ID in the desired state, as `{ name, type, resource }`.
|
|
261
292
|
* @typedef {object} BindingTarget
|
|
262
293
|
* @property {string} type the binding's type, as the platform calls it
|
|
263
294
|
* @property {string} label what people call it
|
|
@@ -283,6 +314,39 @@ const PATH = /^[A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z][A-Za-z0-9_]*)*$/u;
|
|
|
283
314
|
* @property {ShownSetting[]} [shown] settings shown by name, read only
|
|
284
315
|
*/
|
|
285
316
|
|
|
317
|
+
/**
|
|
318
|
+
* A resource kind the console may add (BRK-270), from the provider's `creatable(kind)`. A new one is the kind's
|
|
319
|
+
* editable fields (BRK-262) and `fields`, with `defaults` filled in and `required` given; a `create` edit
|
|
320
|
+
* (src/infra-changes.js) checks them, and the plan says what it makes.
|
|
321
|
+
* @typedef {object} Creatable
|
|
322
|
+
* @property {string} label what one is called, like `Queue`
|
|
323
|
+
* @property {string} help one line on what it's for, in plain words
|
|
324
|
+
* @property {{ label: string, help: string, pattern?: string, max?: number }} name its name's rule: a regular
|
|
325
|
+
* expression and a length, and help saying them. A name is unique among the kind in the environment's file and what
|
|
326
|
+
* runs there; the plan checks the rest of the platform's account.
|
|
327
|
+
* @property {EditableField[]} [fields] what only a new one is given (like a route's zone), beyond its editable fields:
|
|
328
|
+
* the plan reads them when it makes it, and never compares them
|
|
329
|
+
* @property {string[]} [required] the paths, of `fields` or the editable fields, a new one must be given
|
|
330
|
+
* @property {Record<string, unknown>} [defaults] what a new one starts with, by path
|
|
331
|
+
* @property {{ kind: string, list: string, target: BindingTarget, required?: boolean }} [bind] how a resource of another
|
|
332
|
+
* kind (`kind`, like a Worker) binds a new one: a binding in its `list` setting, as `target` says; `required` when the
|
|
333
|
+
* plan makes one only if something binds it, so a `create` edit must bind it. None for a kind nothing binds.
|
|
334
|
+
* @property {string} [needsCode] what code must exist before the plan can make one (a class, an image), in words: the
|
|
335
|
+
* console adds it to the file, and the plan waits until the code's deploy has made it
|
|
336
|
+
*/
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* A kind the console may add, as the board answers it (BRK-270): one list of fields, the kind's editable ones after
|
|
340
|
+
* its own, each marked `required` or carrying its `default`.
|
|
341
|
+
* @typedef {object} CreatableKind
|
|
342
|
+
* @property {string} label
|
|
343
|
+
* @property {string} help
|
|
344
|
+
* @property {{ label: string, help: string, pattern?: string, max?: number }} name
|
|
345
|
+
* @property {Array<EditableField & { required?: boolean, default?: unknown }>} fields
|
|
346
|
+
* @property {{ kind: string, list: string, target: BindingTarget, required?: boolean }} [bind]
|
|
347
|
+
* @property {string} [needsCode]
|
|
348
|
+
*/
|
|
349
|
+
|
|
286
350
|
/**
|
|
287
351
|
* Which of a platform's alerts are set up, and which reach the board: each alert type with how many policies use it
|
|
288
352
|
* and whether one sends to the board, each policy (on or off, the routines on the board it fires), and how many
|
|
@@ -330,8 +394,11 @@ export function checkProvider(provider) {
|
|
|
330
394
|
fail(what, `${kind}'s settings is not a list of settings' names`);
|
|
331
395
|
if (spec.changes.includes('scale') ? !text(spec.scales) : spec.scales !== undefined)
|
|
332
396
|
fail(what, `${kind}'s scales names the setting a scale changes, and only a kind that scales has one`);
|
|
397
|
+
if (spec.target !== undefined && typeof spec.target !== 'boolean')
|
|
398
|
+
fail(what, `${kind}'s target is not true or false`);
|
|
333
399
|
}
|
|
334
400
|
if (provider.readToken !== undefined) checkReadToken(what, provider.readToken);
|
|
401
|
+
if (provider.writeToken !== undefined) checkWriteToken(what, provider.writeToken);
|
|
335
402
|
if (provider.refuses !== undefined && typeof provider.refuses !== 'function') fail(what, 'refuses is not a function');
|
|
336
403
|
if (provider.outside !== undefined && typeof provider.outside !== 'function') fail(what, 'outside is not a function');
|
|
337
404
|
if (provider.estimate !== undefined && typeof provider.estimate !== 'function')
|
|
@@ -340,6 +407,10 @@ export function checkProvider(provider) {
|
|
|
340
407
|
if (typeof provider.editable !== 'function') fail(what, 'editable is not a function');
|
|
341
408
|
for (const kind of Object.keys(provider.kinds)) checkEditable(provider, kind, provider.editable(kind));
|
|
342
409
|
}
|
|
410
|
+
if (provider.creatable !== undefined) {
|
|
411
|
+
if (typeof provider.creatable !== 'function') fail(what, 'creatable is not a function');
|
|
412
|
+
for (const kind of Object.keys(provider.kinds)) checkCreatable(provider, kind, provider.creatable(kind));
|
|
413
|
+
}
|
|
343
414
|
return provider;
|
|
344
415
|
}
|
|
345
416
|
|
|
@@ -467,6 +538,137 @@ export function editableKinds(provider) {
|
|
|
467
538
|
return out;
|
|
468
539
|
}
|
|
469
540
|
|
|
541
|
+
/**
|
|
542
|
+
* What's wrong with a value for one of the console's fields (BRK-262's types), in words, or null when it fits. A
|
|
543
|
+
* `resource` field's value is a name: whether the environment has it is the caller's to check.
|
|
544
|
+
* @param {EditableField} field
|
|
545
|
+
* @param {unknown} value
|
|
546
|
+
* @returns {string | null}
|
|
547
|
+
*/
|
|
548
|
+
export function fieldProblem(field, value) {
|
|
549
|
+
const label = field.label;
|
|
550
|
+
const fits = (pattern, v) => !pattern || new RegExp(pattern, 'u').test(v);
|
|
551
|
+
switch (field.type) {
|
|
552
|
+
case 'number': {
|
|
553
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) return `${label} is a number`;
|
|
554
|
+
if (field.integer && !Number.isInteger(value)) return `${label} is a whole number`;
|
|
555
|
+
const unit = field.unit ? ` ${field.unit}` : '';
|
|
556
|
+
if (field.min !== undefined && value < field.min) return `${label} is at least ${field.min}${unit}`;
|
|
557
|
+
if (field.max !== undefined && value > field.max) return `${label} is at most ${field.max}${unit}`;
|
|
558
|
+
return null;
|
|
559
|
+
}
|
|
560
|
+
case 'yesno':
|
|
561
|
+
return typeof value === 'boolean' ? null : `${label} is yes or no (true or false)`;
|
|
562
|
+
case 'choice':
|
|
563
|
+
return field.options?.some((o) => o.value === value)
|
|
564
|
+
? null
|
|
565
|
+
: `${label} is one of ${(field.options ?? []).map((o) => o.value).join(', ')}`;
|
|
566
|
+
case 'text':
|
|
567
|
+
case 'resource':
|
|
568
|
+
if (typeof value !== 'string' || !value.trim()) return `${label} is text`;
|
|
569
|
+
return fits(field.pattern, value) ? null : `${value} doesn’t fit ${label}: ${field.help}`;
|
|
570
|
+
case 'names':
|
|
571
|
+
if (!Array.isArray(value) || !value.every((v) => typeof v === 'string')) return `${label} is a list of names`;
|
|
572
|
+
for (const v of value) if (!fits(field.pattern, v)) return `${v} doesn’t fit ${label}: ${field.help}`;
|
|
573
|
+
return null;
|
|
574
|
+
default:
|
|
575
|
+
return Array.isArray(value) ? null : `${label} is a list`;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Checks what a provider's `creatable(kind)` returned, or throws saying what's wrong: a label, help, and a name's rule;
|
|
581
|
+
* its own fields are well formed and none is also editable; what's required or defaulted is one of the fields; each
|
|
582
|
+
* default fits its field; and a binding names a kind the provider declares, binding this one.
|
|
583
|
+
* @param {Provider} provider
|
|
584
|
+
* @param {string} kind
|
|
585
|
+
* @param {Creatable | null} creatable
|
|
586
|
+
* @returns {Creatable | null}
|
|
587
|
+
*/
|
|
588
|
+
export function checkCreatable(provider, kind, creatable) {
|
|
589
|
+
const what = `${provider.id} creatable ${kind}`;
|
|
590
|
+
if (creatable === null) return null;
|
|
591
|
+
if (!provider.kinds[kind]) fail(what, `${provider.id} doesn't declare ${kind}`);
|
|
592
|
+
if (!isObject(creatable) || !text(creatable.label) || !text(creatable.help)) fail(what, 'has no label or no help');
|
|
593
|
+
const name = creatable.name;
|
|
594
|
+
if (!isObject(name) || !text(name.label) || !text(name.help)) fail(what, 'name has no label or no help');
|
|
595
|
+
if (name.pattern !== undefined) checkPattern(what, 'name', name.pattern);
|
|
596
|
+
if (name.max !== undefined && (!Number.isInteger(name.max) || name.max < 1))
|
|
597
|
+
fail(what, 'name’s max is not a whole number of characters');
|
|
598
|
+
const own = creatable.fields ?? [];
|
|
599
|
+
checkFields(provider, what, own, undefined);
|
|
600
|
+
const editable = typeof provider.editable === 'function' ? (provider.editable(kind)?.fields ?? []) : [];
|
|
601
|
+
for (const f of own) if (editable.some((e) => e.path === f.path)) fail(`${what} ${f.path}`, 'is editable already');
|
|
602
|
+
const byPath = new Map([...own, ...editable].map((f) => [f.path, f]));
|
|
603
|
+
if (creatable.required !== undefined) {
|
|
604
|
+
if (!Array.isArray(creatable.required)) fail(what, 'required is not a list');
|
|
605
|
+
for (const p of creatable.required) if (!byPath.has(p)) fail(what, `requires ${p}, which isn't one of its fields`);
|
|
606
|
+
}
|
|
607
|
+
if (creatable.defaults !== undefined) {
|
|
608
|
+
if (!isObject(creatable.defaults)) fail(what, 'defaults is not an object');
|
|
609
|
+
for (const [p, v] of Object.entries(creatable.defaults)) {
|
|
610
|
+
const f = byPath.get(p);
|
|
611
|
+
if (!f) fail(what, `has a default for ${p}, which isn't one of its fields`);
|
|
612
|
+
const problem = fieldProblem(/** @type {EditableField} */ (f), v);
|
|
613
|
+
if (problem) fail(`${what} default ${p}`, problem);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
if (creatable.bind !== undefined) {
|
|
617
|
+
const b = creatable.bind;
|
|
618
|
+
if (!isObject(b) || !provider.kinds[b.kind]) fail(what, 'bind names no kind the provider declares');
|
|
619
|
+
if (!text(b.list) || !PATH.test(b.list)) fail(what, 'bind names no list');
|
|
620
|
+
const t = b.target;
|
|
621
|
+
if (!isObject(t) || !text(t.type) || !text(t.label) || !text(t.field))
|
|
622
|
+
fail(what, 'bind’s target has no type, label, or field');
|
|
623
|
+
if (t.kind !== kind) fail(what, `bind’s target binds ${t.kind}, not ${kind}`);
|
|
624
|
+
if (t.by !== 'id' && t.by !== 'name') fail(what, 'bind’s target names its resource by neither id nor name');
|
|
625
|
+
if (b.required !== undefined && typeof b.required !== 'boolean') fail(what, 'bind’s required is not true or false');
|
|
626
|
+
}
|
|
627
|
+
if (creatable.needsCode !== undefined && !text(creatable.needsCode)) fail(what, 'needsCode is empty');
|
|
628
|
+
return creatable;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* The kinds one of which can be an environment's target (BRK-291): the provider's kinds marked `target`.
|
|
633
|
+
* @param {Provider} provider
|
|
634
|
+
* @returns {string[]}
|
|
635
|
+
*/
|
|
636
|
+
export const targetKinds = (provider) =>
|
|
637
|
+
Object.entries(provider.kinds)
|
|
638
|
+
.filter(([, spec]) => spec.target === true)
|
|
639
|
+
.map(([kind]) => kind);
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* The kinds the console may add (BRK-270), checked, each with one list of fields: its own, then its editable ones,
|
|
643
|
+
* marked `required` or carrying their `default`. A provider without `creatable` adds nothing from the console.
|
|
644
|
+
* @param {Provider} provider
|
|
645
|
+
* @returns {Record<string, CreatableKind>}
|
|
646
|
+
*/
|
|
647
|
+
export function creatableKinds(provider) {
|
|
648
|
+
if (typeof provider.creatable !== 'function') return {};
|
|
649
|
+
/** @type {Record<string, CreatableKind>} */
|
|
650
|
+
const out = {};
|
|
651
|
+
for (const kind of Object.keys(provider.kinds)) {
|
|
652
|
+
const c = checkCreatable(provider, kind, provider.creatable(kind));
|
|
653
|
+
if (!c) continue;
|
|
654
|
+
const editable = typeof provider.editable === 'function' ? (provider.editable(kind)?.fields ?? []) : [];
|
|
655
|
+
const fields = [...(c.fields ?? []), ...editable].map((f) => ({
|
|
656
|
+
...f,
|
|
657
|
+
...(c.required?.includes(f.path) ? { required: true } : {}),
|
|
658
|
+
...(c.defaults && Object.hasOwn(c.defaults, f.path) ? { default: structuredClone(c.defaults[f.path]) } : {}),
|
|
659
|
+
}));
|
|
660
|
+
out[kind] = {
|
|
661
|
+
label: c.label,
|
|
662
|
+
help: c.help,
|
|
663
|
+
name: { ...c.name },
|
|
664
|
+
fields,
|
|
665
|
+
...(c.bind ? { bind: structuredClone(c.bind) } : {}),
|
|
666
|
+
...(c.needsCode ? { needsCode: c.needsCode } : {}),
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
return out;
|
|
670
|
+
}
|
|
671
|
+
|
|
470
672
|
/** @param {string} what @param {ReadToken} token */
|
|
471
673
|
function checkReadToken(what, token) {
|
|
472
674
|
if (!isObject(token)) fail(what, 'readToken is not an object');
|
|
@@ -478,6 +680,18 @@ function checkReadToken(what, token) {
|
|
|
478
680
|
}
|
|
479
681
|
if (typeof token.url !== 'string' || !token.url.startsWith('https://')) fail(what, 'readToken has no https url');
|
|
480
682
|
if (token.check !== undefined && typeof token.check !== 'function') fail(what, 'readToken check is not a function');
|
|
683
|
+
if (token.template !== undefined && typeof token.template !== 'function')
|
|
684
|
+
fail(what, 'readToken template is not a function');
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/** What guided setup needs of a provider's write token (BRK-304): a secret's name and the permissions, from a function. */
|
|
688
|
+
function checkWriteToken(what, token) {
|
|
689
|
+
if (!isObject(token)) fail(what, 'writeToken is not an object');
|
|
690
|
+
if (!text(token.secret) || !/^[A-Z][A-Z0-9_]{0,63}$/u.test(token.secret))
|
|
691
|
+
fail(what, 'writeToken secret is not a GitHub secret’s name');
|
|
692
|
+
if (typeof token.permissions !== 'function') fail(what, 'writeToken permissions is not a function');
|
|
693
|
+
if (token.template !== undefined && typeof token.template !== 'function')
|
|
694
|
+
fail(what, 'writeToken template is not a function');
|
|
481
695
|
}
|
|
482
696
|
|
|
483
697
|
/**
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The apply workflow, rendered (CLI-12, BRK-307; docs/specs/IDEA-19-architect.md, "Executor"):
|
|
3
|
+
* `.github/workflows/breakaway-infra.yml` from template/infra/apply.yml, for every environment with a desired-state
|
|
4
|
+
* file. `npx breakaway infra init` writes it in a checkout, and the board's change brings it in the same commit as the
|
|
5
|
+
* desired state (store-infra-changes.js), so both render byte-identical files from the same template: the CLI reads
|
|
6
|
+
* it from disk, the Worker from src/infra-runner-template.json, which scripts/board-files.mjs generates from it.
|
|
7
|
+
*
|
|
8
|
+
* Pure and Node-safe: no store, no network, no disk.
|
|
9
|
+
*/
|
|
10
|
+
import { DESIRED_DIR, environmentOfFile } from './infra-desired.js';
|
|
11
|
+
import { RUNNER_WORKFLOW } from './infra-runner.js';
|
|
12
|
+
import { SHORT_LIVED_FILE } from './infra-short-lived.js';
|
|
13
|
+
import { fill, lintWorkflow } from './workflow-render.js';
|
|
14
|
+
|
|
15
|
+
/** Where the template is, in breakaway's own files. */
|
|
16
|
+
export const RUNNER_TEMPLATE = 'template/infra/apply.yml';
|
|
17
|
+
|
|
18
|
+
/** The first line of the rendered workflow: how `--update`, and the board, know the file is theirs to replace. */
|
|
19
|
+
export const RUNNER_RENDER_HEADER = `Rendered by npx breakaway infra init from the environments in ${DESIRED_DIR}/: add one there and run npx breakaway infra init --update, not this file.`;
|
|
20
|
+
|
|
21
|
+
/** What the change card and the plan check say when the board couldn't bring the workflow itself. */
|
|
22
|
+
export const RUNNER_NEEDS_WORKFLOWS = `Applying needs ${RUNNER_WORKFLOW}: give the board’s GitHub App Workflows: write and propose again, or run npx breakaway infra init in the repository and merge it.`;
|
|
23
|
+
|
|
24
|
+
/** The same, for a pull request the board didn't write, where proposing again isn't the way. */
|
|
25
|
+
export const RUNNER_NEEDS_INIT = `Applying needs ${RUNNER_WORKFLOW}: run npx breakaway infra init in the repository and merge it.`;
|
|
26
|
+
|
|
27
|
+
const BRANCH = /^(?!.*\.\.)[A-Za-z0-9._/-]{1,100}$/u;
|
|
28
|
+
const VERSION = /^\d+\.\d+\.\d+(?:-[\w.]+)?$/u;
|
|
29
|
+
|
|
30
|
+
/** A reason the workflow can't be rendered, in words for the owner or the agent. */
|
|
31
|
+
export class RunnerRenderError extends Error {}
|
|
32
|
+
const bad = (message) => {
|
|
33
|
+
throw new RunnerRenderError(message);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The environments the runner offers, from the file names in the desired-state folder: each `<environment>.json`,
|
|
38
|
+
* sorted, skipping policy.json, scaling.json, and anything that isn't JSON.
|
|
39
|
+
* @param {string[]} names
|
|
40
|
+
*/
|
|
41
|
+
export function environmentsIn(names) {
|
|
42
|
+
const environments = new Set();
|
|
43
|
+
for (const name of names) {
|
|
44
|
+
const found = environmentOfFile(name);
|
|
45
|
+
if (!found) continue;
|
|
46
|
+
if ('problem' in found) throw new RunnerRenderError(`${DESIRED_DIR}/${found.problem}.`);
|
|
47
|
+
environments.add(found.environment);
|
|
48
|
+
}
|
|
49
|
+
if (!environments.size)
|
|
50
|
+
bad(
|
|
51
|
+
`${DESIRED_DIR}/ has no environment's file, so there's nothing to apply to: write <environment>.json for each (like staging.json), then run this again.`,
|
|
52
|
+
);
|
|
53
|
+
return [...environments].sort();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The runner's workflow for these environments, applied from `branch`, running breakaway `version`. With `shortLived`
|
|
58
|
+
* (the folder has short-lived.json), the environment is any name, since each short-lived one is new, and the run may
|
|
59
|
+
* name the GitHub environment that holds the write token: `short-lived` for all of them (BRK-242).
|
|
60
|
+
* @param {{ environments: string[], branch: string, version: string, shortLived?: boolean }} input
|
|
61
|
+
* @param {string} template template/infra/apply.yml
|
|
62
|
+
* @returns {{ path: string, text: string }}
|
|
63
|
+
*/
|
|
64
|
+
export function renderRunner({ environments, branch, version, shortLived = false }, template) {
|
|
65
|
+
if (!BRANCH.test(branch)) bad(`${branch.slice(0, 80)} isn't a branch's name.`);
|
|
66
|
+
if (!VERSION.test(version)) bad(`${version} isn't a breakaway release.`);
|
|
67
|
+
const text = fill(template, {
|
|
68
|
+
header: RUNNER_RENDER_HEADER,
|
|
69
|
+
branchName: branch,
|
|
70
|
+
version,
|
|
71
|
+
shortLived,
|
|
72
|
+
fixedOnly: !shortLived,
|
|
73
|
+
environments: environments.map((name) => `- ${JSON.stringify(name)}`),
|
|
74
|
+
});
|
|
75
|
+
const problems = lintWorkflow(text);
|
|
76
|
+
if (problems.length) throw new Error(`${RUNNER_WORKFLOW} renders with problems: ${problems.join('; ')}`);
|
|
77
|
+
return { path: RUNNER_WORKFLOW, text };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Whether a workflow's text is the board's own render, which `--update` and the board may replace. */
|
|
81
|
+
export const isRenderedRunner = (text) => String(text ?? '').startsWith(`# ${RUNNER_RENDER_HEADER}`);
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* What to do with the rendered file, given what's there (null when nothing): write it, leave it (the same), or refuse
|
|
85
|
+
* it (the repository's own, or rendered before when `update` isn't set).
|
|
86
|
+
* @param {{ path: string, text: string }} file
|
|
87
|
+
* @param {string | null} there
|
|
88
|
+
* @param {{ update?: boolean }} [options]
|
|
89
|
+
* @returns {{ write: boolean, same?: boolean, refused?: string }}
|
|
90
|
+
*/
|
|
91
|
+
export function runnerStep(file, there, { update = false } = {}) {
|
|
92
|
+
if (there === null) return { write: true };
|
|
93
|
+
if (there === file.text) return { write: false, same: true };
|
|
94
|
+
if (!isRenderedRunner(there))
|
|
95
|
+
return { write: false, refused: "it's the repository's own, not rendered: rename it, then run infra init again" };
|
|
96
|
+
if (!update) return { write: false, refused: 'it differs from what infra init renders: run infra init --update' };
|
|
97
|
+
return { write: true };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* What a change does with the apply workflow (BRK-307): the environments come from the desired-state folder's file
|
|
102
|
+
* names on the default branch plus the environment the change writes, and the workflow is rendered for them.
|
|
103
|
+
* - `add`: there's none; `update`: it's the board's own render and differs (an environment, the branch, or the
|
|
104
|
+
* release); the change commits `file` either way.
|
|
105
|
+
* - `current`: it's already what the board renders.
|
|
106
|
+
* - `own`: it's the repository's own, which the board never overwrites; `missing` names the environments it doesn't
|
|
107
|
+
* offer, as far as the board can tell.
|
|
108
|
+
* @param {{ names: string[], environment: string, there: string | null, branch: string, version: string,
|
|
109
|
+
* template: string }} input
|
|
110
|
+
* @returns {{ action: 'add' | 'update' | 'current' | 'own', file: { path: string, text: string },
|
|
111
|
+
* environments: string[], missing: string[] }}
|
|
112
|
+
*/
|
|
113
|
+
export function runnerChange({ names, environment, there, branch, version, template }) {
|
|
114
|
+
const environments = environmentsIn([...names, `${environment}.json`]);
|
|
115
|
+
const shortLived = names.includes(SHORT_LIVED_FILE);
|
|
116
|
+
const file = renderRunner({ environments, branch, version, shortLived }, template);
|
|
117
|
+
const out = { file, environments, missing: [] };
|
|
118
|
+
if (there === null) return { ...out, action: 'add' };
|
|
119
|
+
if (there === file.text) return { ...out, action: 'current' };
|
|
120
|
+
if (isRenderedRunner(there)) return { ...out, action: 'update' };
|
|
121
|
+
return { ...out, action: 'own', missing: runnerMissing(there, environments) };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The environments a workflow doesn't offer: none when it takes any environment's name (an input of type string, as
|
|
126
|
+
* the render does with short-lived environments, or a repository's own that the board can't read), else the ones not
|
|
127
|
+
* among its environment input's choices.
|
|
128
|
+
* @param {string} text
|
|
129
|
+
* @param {string[]} environments
|
|
130
|
+
* @returns {string[]}
|
|
131
|
+
*/
|
|
132
|
+
export function runnerMissing(text, environments) {
|
|
133
|
+
const options = /\n {6}environment:\n(?: {8}.*\n)*? {8}options:\n((?: {10}- .*\n?)+)/u.exec(String(text ?? ''));
|
|
134
|
+
if (!options) return [];
|
|
135
|
+
const offered = new Set(
|
|
136
|
+
options[1]
|
|
137
|
+
.split('\n')
|
|
138
|
+
.map((line) => line.replace(/^\s*-\s*/u, '').trim())
|
|
139
|
+
.filter(Boolean)
|
|
140
|
+
.map((value) => {
|
|
141
|
+
try {
|
|
142
|
+
return String(JSON.parse(value));
|
|
143
|
+
} catch {
|
|
144
|
+
return value.replace(/^['"]|['"]$/gu, '');
|
|
145
|
+
}
|
|
146
|
+
}),
|
|
147
|
+
);
|
|
148
|
+
return environments.filter((name) => !offered.has(name));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The words for an apply workflow the board left alone because it's the repository's own. */
|
|
152
|
+
export function runnerOwnWords(missing) {
|
|
153
|
+
if (!missing.length) return null;
|
|
154
|
+
return `${RUNNER_WORKFLOW} is the repository’s own, and it doesn’t offer ${missing.join(', ')}: add ${missing.length === 1 ? 'it' : 'them'} to its environment choices, or rename it and run npx breakaway infra init, then merge it.`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* What a pull request's plan check says about the apply workflow (BRK-307), given the workflow at its head (null when
|
|
159
|
+
* there's none) and the environments it plans: null when the workflow offers them all, else what to do before
|
|
160
|
+
* Approve. A waiting note, never a failure.
|
|
161
|
+
* @param {string | null} there
|
|
162
|
+
* @param {string[]} environments
|
|
163
|
+
* @returns {string | null}
|
|
164
|
+
*/
|
|
165
|
+
export function runnerNote(there, environments) {
|
|
166
|
+
if (!environments.length) return null;
|
|
167
|
+
if (there === null) return RUNNER_NEEDS_INIT;
|
|
168
|
+
const missing = runnerMissing(there, environments);
|
|
169
|
+
if (!missing.length) return null;
|
|
170
|
+
if (!isRenderedRunner(there)) return runnerOwnWords(missing);
|
|
171
|
+
return `${RUNNER_WORKFLOW} doesn’t offer ${missing.join(', ')} yet: run npx breakaway infra init --update in the repository and merge it.`;
|
|
172
|
+
}
|
package/src/init.js
CHANGED
|
@@ -19,7 +19,7 @@ export { routinePrompt };
|
|
|
19
19
|
* The CLI on npm (BRK-7), as a repository runs it: `npx breakaway`, pinned to the major (BRK-47), so a breaking change
|
|
20
20
|
* never reaches a repository by itself. It was the `next` channel until the first stable release.
|
|
21
21
|
*/
|
|
22
|
-
export const CLI_PACKAGE = 'breakaway@
|
|
22
|
+
export const CLI_PACKAGE = 'breakaway@2';
|
|
23
23
|
/**
|
|
24
24
|
* Where the CLI starts: the command, and the two session hooks. repos init no longer copies them (BRK-7): an old copy
|
|
25
25
|
* is replaced by npx, and these still version the CLI and say which files an old copy holds.
|