breakaway 2.0.0-main.60 → 2.0.0-main.61
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/npm-shrinkwrap.json +2 -2
- package/package.json +2 -1
- package/src/infra-desired.js +13 -6
- package/src/infra-environments.js +100 -0
- package/src/infra-plans.js +3 -3
- package/src/infra-policy.js +27 -5
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "breakaway",
|
|
3
|
-
"version": "2.0.0-main.
|
|
3
|
+
"version": "2.0.0-main.61",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "breakaway",
|
|
9
|
-
"version": "2.0.0-main.
|
|
9
|
+
"version": "2.0.0-main.61",
|
|
10
10
|
"license": "FSL-1.1-Apache-2.0",
|
|
11
11
|
"bin": {
|
|
12
12
|
"breakaway": "scripts/tasks.mjs"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "breakaway",
|
|
3
|
-
"version": "2.0.0-main.
|
|
3
|
+
"version": "2.0.0-main.61",
|
|
4
4
|
"description": "The task board for you and your coding agents: a Cloudflare Worker, its web app, Taskwarrior sync, and the CLI (npx breakaway).",
|
|
5
5
|
"license": "FSL-1.1-Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
"src/infra-check.js",
|
|
25
25
|
"src/infra-currency.js",
|
|
26
26
|
"src/infra-desired.js",
|
|
27
|
+
"src/infra-environments.js",
|
|
27
28
|
"src/infra-inventory.js",
|
|
28
29
|
"src/infra-cloudflare.js",
|
|
29
30
|
"src/infra-cloudflare-analytics.js",
|
package/src/infra-desired.js
CHANGED
|
@@ -17,8 +17,11 @@ import { redact } from './redact.js';
|
|
|
17
17
|
export const DESIRED_DIR = '.github/breakaway-infra';
|
|
18
18
|
/** @param {string} environment */
|
|
19
19
|
export const desiredPath = (environment) => `${DESIRED_DIR}/${environment}.json`;
|
|
20
|
-
/**
|
|
21
|
-
|
|
20
|
+
/**
|
|
21
|
+
* Files in the folder that aren't an environment's: policy (BRK-181), scaling rules (BRK-186), and the template
|
|
22
|
+
* short-lived environments are made from (BRK-200).
|
|
23
|
+
*/
|
|
24
|
+
export const RESERVED_FILES = ['policy', 'scaling', 'short-lived'];
|
|
22
25
|
/** The only version there is. */
|
|
23
26
|
export const DESIRED_VERSION = 1;
|
|
24
27
|
/** Bigger than any one environment needs, and small enough to keep a copy of each in the store. */
|
|
@@ -204,10 +207,11 @@ function secretIn(value, path) {
|
|
|
204
207
|
* resource's kind must be one it declares. With `expectProvider` (the environment's provider ID), a `provider` in the
|
|
205
208
|
* file must be that one.
|
|
206
209
|
* @param {string} source the file's text
|
|
207
|
-
* @param {{ provider?: import('./infra-provider.js').Provider | null, expectProvider?: string | null }} [options]
|
|
210
|
+
* @param {{ provider?: import('./infra-provider.js').Provider | null, expectProvider?: string | null, extra?: string[] }} [options]
|
|
211
|
+
* `extra` names more top-level keys a caller reads itself, like a short-lived template's `target` (BRK-200)
|
|
208
212
|
* @returns {{ ok: true, desired: import('./infra-provider.js').DesiredState, provider: string | null } | { ok: false, error: DesiredError }}
|
|
209
213
|
*/
|
|
210
|
-
export function checkDesiredFile(source, { provider = null, expectProvider = null } = {}) {
|
|
214
|
+
export function checkDesiredFile(source, { provider = null, expectProvider = null, extra = [] } = {}) {
|
|
211
215
|
/** @param {string | null} field @param {string} message @param {number | null} [at] */
|
|
212
216
|
const wrong = (field, message, at) => ({
|
|
213
217
|
ok: /** @type {const} */ (false),
|
|
@@ -228,8 +232,11 @@ export function checkDesiredFile(source, { provider = null, expectProvider = nul
|
|
|
228
232
|
if (!isObject(file))
|
|
229
233
|
return wrong(null, 'the file is one JSON object: { "version": 1, "resources": [ … ] }', lines.get('') ?? 1);
|
|
230
234
|
for (const key of Object.keys(file))
|
|
231
|
-
if (!TOP.includes(key))
|
|
232
|
-
return wrong(
|
|
235
|
+
if (!TOP.includes(key) && !extra.includes(key))
|
|
236
|
+
return wrong(
|
|
237
|
+
key,
|
|
238
|
+
`“${key}” isn’t part of a desired state: the file has ${[...TOP, ...extra].join(', ').replace(/, (?=[^,]*$)/u, ', and ')}`,
|
|
239
|
+
);
|
|
233
240
|
if (file.version !== DESIRED_VERSION)
|
|
234
241
|
return wrong(
|
|
235
242
|
file.version === undefined ? null : 'version',
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Architect's environments (docs/specs/IDEA-19-architect.md, "Environments"; BRK-174): a named target in one of the
|
|
3
|
+
* board's repositories that plans apply to. Pure, so the rules are tested without the Durable Object; the table and
|
|
4
|
+
* the API are in store-infra-environments.js.
|
|
5
|
+
*/
|
|
6
|
+
import { RESERVED_FILES } from './infra-desired.js';
|
|
7
|
+
import { InputError } from './model.js';
|
|
8
|
+
|
|
9
|
+
/** What an environment is for; production gates apply to `production` unless the owner says otherwise. */
|
|
10
|
+
export const ENVIRONMENT_KINDS = ['production', 'staging', 'short-lived'];
|
|
11
|
+
/** Per repository: enough for staging, production, and a handful of short-lived ones at once. */
|
|
12
|
+
export const MAX_ENVIRONMENTS = 50;
|
|
13
|
+
|
|
14
|
+
/** A name that's also a file name: `.github/breakaway-infra/<environment>.json` (BRK-180). */
|
|
15
|
+
const NAME = /^[a-z0-9][a-z0-9-]{0,39}$/u;
|
|
16
|
+
/** A provider's ID in the registry (BRK-173), like `cloudflare`. */
|
|
17
|
+
const PROVIDER = /^[a-z][a-z0-9-]{0,31}$/u;
|
|
18
|
+
/** What the environment points at on its provider, like a Worker's name. */
|
|
19
|
+
const TARGET = /^[\w.:/-]{1,100}$/u;
|
|
20
|
+
|
|
21
|
+
/** @param {unknown} value */
|
|
22
|
+
export function checkEnvironmentName(value) {
|
|
23
|
+
const name = String(value ?? '').trim();
|
|
24
|
+
if (!NAME.test(name))
|
|
25
|
+
throw new InputError(
|
|
26
|
+
'name the environment with lowercase letters, digits, and - (up to 40), like staging or preview-12: it’s also its desired-state file’s name',
|
|
27
|
+
);
|
|
28
|
+
if (RESERVED_FILES.includes(name))
|
|
29
|
+
throw new InputError(
|
|
30
|
+
`${name} is the name of Architect’s ${name} file, so an environment can’t have it: pick another`,
|
|
31
|
+
);
|
|
32
|
+
return name;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** @param {unknown} value */
|
|
36
|
+
export function checkEnvironmentKind(value) {
|
|
37
|
+
const kind = String(value ?? '').trim();
|
|
38
|
+
if (!ENVIRONMENT_KINDS.includes(kind)) throw new InputError('kind is production, staging, or short-lived');
|
|
39
|
+
return kind;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** @param {unknown} value */
|
|
43
|
+
export function checkProvider(value) {
|
|
44
|
+
const provider = String(value ?? '').trim();
|
|
45
|
+
if (!PROVIDER.test(provider))
|
|
46
|
+
throw new InputError('provider is the ID of the provider the environment runs on, like cloudflare');
|
|
47
|
+
return provider;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** @param {unknown} value @returns {string | null} */
|
|
51
|
+
export function checkTarget(value) {
|
|
52
|
+
if (value === null || value === undefined || value === '') return null;
|
|
53
|
+
const target = String(value).trim();
|
|
54
|
+
if (!TARGET.test(target))
|
|
55
|
+
throw new InputError(
|
|
56
|
+
'target is what the environment runs on at its provider, like a Worker’s name (up to 100 characters)',
|
|
57
|
+
);
|
|
58
|
+
return target;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** @param {unknown} value @param {string} field */
|
|
62
|
+
export function checkSwitch(value, field) {
|
|
63
|
+
if (typeof value !== 'boolean') throw new InputError(`${field} is true or false`);
|
|
64
|
+
return value;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Whether an environment runs the board's own install: it points at the install's Worker (src/install.js). That one
|
|
69
|
+
* is always observe only (BRK-169), whatever its row says, so Architect never applies to the board it runs on.
|
|
70
|
+
* @param {{ target: string | null }} row
|
|
71
|
+
* @param {string} worker the install's Worker
|
|
72
|
+
*/
|
|
73
|
+
export const runsTheBoard = (row, worker) => Boolean(row.target) && row.target === worker;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The environment as the API shows it.
|
|
77
|
+
* @param {{ id: number, repo: string, name: string, kind: string, provider: string | null, target: string | null, task: string | null, frozen: number, frozen_at: number | null, gates: number | null, observe_only: number, pipeline?: string | null, created: number, edited: number }} row
|
|
78
|
+
* @param {{ worker: string, task?: { uuid: string, wid: string | null, description: string } | null }} context
|
|
79
|
+
*/
|
|
80
|
+
export function environmentView(row, { worker, task = null }) {
|
|
81
|
+
const own = runsTheBoard(row, worker);
|
|
82
|
+
return {
|
|
83
|
+
id: row.id,
|
|
84
|
+
repo: row.repo,
|
|
85
|
+
name: row.name,
|
|
86
|
+
kind: row.kind,
|
|
87
|
+
provider: row.provider ?? null,
|
|
88
|
+
target: row.target ?? null,
|
|
89
|
+
task,
|
|
90
|
+
frozen: Boolean(row.frozen),
|
|
91
|
+
frozenAt: row.frozen && row.frozen_at ? new Date(row.frozen_at).toISOString() : null,
|
|
92
|
+
gates: row.gates === null || row.gates === undefined ? row.kind === 'production' : Boolean(row.gates),
|
|
93
|
+
observeOnly: own || Boolean(row.observe_only),
|
|
94
|
+
runsTheBoard: own,
|
|
95
|
+
// `staging` or `production` when it's its repository's pipeline's (BRK-195), else null.
|
|
96
|
+
pipeline: row.pipeline ?? null,
|
|
97
|
+
created: new Date(row.created).toISOString(),
|
|
98
|
+
edited: new Date(row.edited).toISOString(),
|
|
99
|
+
};
|
|
100
|
+
}
|
package/src/infra-plans.js
CHANGED
|
@@ -21,10 +21,10 @@ export const PLAN_STATES = ['draft', 'waiting', 'approved', 'rejected', 'applyin
|
|
|
21
21
|
|
|
22
22
|
/**
|
|
23
23
|
* Where a plan comes from: a pull request that changed a desired-state file (BRK-185), drift (BRK-184), an envelope
|
|
24
|
-
* acting (BRK-186), an incident's runbook (BRK-197), the deploy flow's Promote and Roll back (BRK-195),
|
|
25
|
-
*
|
|
24
|
+
* acting (BRK-186), an incident's runbook (BRK-197), the deploy flow's Promote and Roll back (BRK-195), removing what
|
|
25
|
+
* nobody owns once its grace period is over (BRK-201), or a task's short-lived environment being made or removed (BRK-200).
|
|
26
26
|
*/
|
|
27
|
-
export const PLAN_SOURCES = ['pull-request', 'drift', 'envelope', 'incident', 'deploy', 'cleanup'];
|
|
27
|
+
export const PLAN_SOURCES = ['pull-request', 'drift', 'envelope', 'incident', 'deploy', 'cleanup', 'short-lived'];
|
|
28
28
|
|
|
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
|
package/src/infra-policy.js
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
import { CHANGE_KINDS } from './infra-provider.js';
|
|
26
26
|
import { DESIRED_DIR, parseWithLines } from './infra-desired.js';
|
|
27
27
|
import { rateWords } from './infra-currency.js';
|
|
28
|
+
import { ENVIRONMENT_KINDS } from './infra-environments.js';
|
|
28
29
|
|
|
29
30
|
/** @typedef {import('./infra-provider.js').PlanDiff} PlanDiff */
|
|
30
31
|
/** @typedef {import('./infra-provider.js').Change} Change */
|
|
@@ -67,6 +68,8 @@ export const DEFAULT_POLICY = Object.freeze({
|
|
|
67
68
|
* @typedef {object} AllowRule
|
|
68
69
|
* @property {string} name what the plan and the audit trail call it
|
|
69
70
|
* @property {string[]} [environments] the environments it covers, by name; every one when left out
|
|
71
|
+
* @property {string[]} [environmentKinds] the kinds of environment it covers (ENVIRONMENT_KINDS), like `short-lived`
|
|
72
|
+
* for the environments tasks ask for (BRK-200), whose names aren't known ahead; every kind when left out
|
|
70
73
|
* @property {string[]} [changes] the change kinds it covers (CHANGE_KINDS); every one when left out
|
|
71
74
|
* @property {string[]} [kinds] the resource kinds it covers; every one when left out
|
|
72
75
|
* @property {number} [maxChanges] the most changes a plan it lets through has
|
|
@@ -86,7 +89,7 @@ export const DEFAULT_POLICY = Object.freeze({
|
|
|
86
89
|
const TOP = ['version', 'costLimit', 'budget', 'environments', 'access', 'allow'];
|
|
87
90
|
const LIMITS = ['costLimit', 'budget'];
|
|
88
91
|
const ACCESS = ['kinds', 'settings'];
|
|
89
|
-
const RULE = ['name', 'environments', 'changes', 'kinds', 'maxChanges'];
|
|
92
|
+
const RULE = ['name', 'environments', 'environmentKinds', 'changes', 'kinds', 'maxChanges'];
|
|
90
93
|
const ENV_NAME = /^[a-z0-9][a-z0-9-]{0,39}$/u;
|
|
91
94
|
const KIND = /^[a-z][a-z0-9-]{0,39}$/u;
|
|
92
95
|
const SETTING = /^[A-Za-z_][\w.-]{0,79}$/u;
|
|
@@ -239,6 +242,22 @@ export function checkPolicyFile(source) {
|
|
|
239
242
|
if (bad) return bad;
|
|
240
243
|
rule.environments = [...r.environments];
|
|
241
244
|
}
|
|
245
|
+
if (r.environmentKinds !== undefined) {
|
|
246
|
+
const bad = names(
|
|
247
|
+
r.environmentKinds,
|
|
248
|
+
`${at}.environmentKinds`,
|
|
249
|
+
KIND,
|
|
250
|
+
`a kind of environment: ${ENVIRONMENT_KINDS.join(', ')}`,
|
|
251
|
+
);
|
|
252
|
+
if (bad) return bad;
|
|
253
|
+
const unknown = r.environmentKinds.findIndex((k) => !ENVIRONMENT_KINDS.includes(k));
|
|
254
|
+
if (unknown >= 0)
|
|
255
|
+
return wrong(
|
|
256
|
+
`${at}.environmentKinds[${unknown}]`,
|
|
257
|
+
`${r.environmentKinds[unknown]} isn’t a kind of environment: ${ENVIRONMENT_KINDS.join(', ')}`,
|
|
258
|
+
);
|
|
259
|
+
rule.environmentKinds = [...r.environmentKinds];
|
|
260
|
+
}
|
|
242
261
|
if (r.changes !== undefined) {
|
|
243
262
|
const bad = names(r.changes, `${at}.changes`, KIND, `a change kind: ${CHANGE_KINDS.join(', ')}`);
|
|
244
263
|
if (bad) return bad;
|
|
@@ -369,8 +388,9 @@ const upper = (s) => s.charAt(0).toUpperCase() + s.slice(1);
|
|
|
369
388
|
* keeps and shows. Pure: the caller gives the environment as it is now, and the plan's diff and cost change.
|
|
370
389
|
* @param {Policy} policy
|
|
371
390
|
* @param {object} plan
|
|
372
|
-
* @param {{ name: string, frozen: boolean, gates: boolean }} plan.environment `gates` is production's
|
|
373
|
-
* (environmentView's `gates`: on for a production environment unless the owner turned them off)
|
|
391
|
+
* @param {{ name: string, kind?: string, frozen: boolean, gates: boolean }} plan.environment `gates` is production's
|
|
392
|
+
* gates (environmentView's `gates`: on for a production environment unless the owner turned them off); `kind` is
|
|
393
|
+
* what an allow rule's `environmentKinds` matches
|
|
374
394
|
* @param {PlanDiff} plan.diff
|
|
375
395
|
* @param {CostChange} plan.cost
|
|
376
396
|
* @param {import('./infra-provider.js').Provider | null} [plan.provider] for the kinds and settings it marks as access
|
|
@@ -471,7 +491,7 @@ export function evaluatePolicy(
|
|
|
471
491
|
outcome = 'needs-owner';
|
|
472
492
|
rule = asked[0].rule;
|
|
473
493
|
} else {
|
|
474
|
-
const allowed = policy.allow.find((a) => allows(a, env, diff));
|
|
494
|
+
const allowed = policy.allow.find((a) => allows(a, env, diff, environment.kind));
|
|
475
495
|
if (allowed) {
|
|
476
496
|
outcome = 'allowed';
|
|
477
497
|
rule = allowed.name;
|
|
@@ -511,9 +531,11 @@ export function evaluatePolicy(
|
|
|
511
531
|
* @param {AllowRule} rule
|
|
512
532
|
* @param {string} environment
|
|
513
533
|
* @param {PlanDiff} diff
|
|
534
|
+
* @param {string} [kind] the environment's kind, for a rule with `environmentKinds`
|
|
514
535
|
*/
|
|
515
|
-
export function allows(rule, environment, diff) {
|
|
536
|
+
export function allows(rule, environment, diff, kind) {
|
|
516
537
|
if (rule.environments && !rule.environments.includes(environment)) return false;
|
|
538
|
+
if (rule.environmentKinds && !rule.environmentKinds.includes(String(kind))) return false;
|
|
517
539
|
if (rule.maxChanges !== undefined && diff.changes.length > rule.maxChanges) return false;
|
|
518
540
|
return diff.changes.every(
|
|
519
541
|
(c) => (!rule.changes || rule.changes.includes(c.op)) && (!rule.kinds || rule.kinds.includes(c.kind)),
|