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.
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "breakaway",
3
- "version": "2.0.0-main.60",
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.60",
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.60",
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",
@@ -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
- /** Files in the folder that aren't an environment's: policy (BRK-181) and scaling rules (BRK-186). */
21
- export const RESERVED_FILES = ['policy', 'scaling'];
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(key, `“${key}” isn’t part of a desired state: the file has version, provider, and resources`);
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
+ }
@@ -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), or removing
25
- * what nobody owns once its grace period is over (BRK-201).
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
@@ -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 gates
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)),