breakaway 2.0.0-main.99 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,10 +14,15 @@
14
14
  * A file is:
15
15
  *
16
16
  * { "version": 1, "costLimit": 5, "budget": 20,
17
- * "environments": { "production": { "costLimit": 50, "budget": 200 } },
17
+ * "environments": { "production": { "costLimit": 50, "budget": 200, "allow": [] } },
18
18
  * "access": { "kinds": ["route"], "settings": ["public"] },
19
19
  * "allow": [ { "name": "small staging changes", "environments": ["staging"], "changes": ["update", "scale"] } ] }
20
20
  *
21
+ * Two levels (WEB-123): the top of the file is the repository's rules, and `environments` overrides them for one
22
+ * environment by name. An environment's `costLimit` and `budget` replace the repository's, its `access` adds kinds and
23
+ * settings to the repository's (it can't take one away), and its `allow`, when it has one, replaces the repository's
24
+ * rules for that environment: `"allow": []` makes every plan there wait for the owner whatever the repository allows.
25
+ *
21
26
  * Limits are a month, in the board's currency (BRK-226): the owner's, set in Settings, which the plan's cost change is
22
27
  * converted into before it's checked (infra-currency.js). Pure and Node-safe, so `npx
23
28
  * breakaway infra check` (CLI-14) can use it: no store and no network.
@@ -75,12 +80,22 @@ export const DEFAULT_POLICY = Object.freeze({
75
80
  * @property {number} [maxChanges] the most changes a plan it lets through has
76
81
  */
77
82
 
83
+ /**
84
+ * One environment's rules (WEB-123): limits that replace the repository's, access kinds and settings added to the
85
+ * repository's, and allow rules that replace the repository's for it when present.
86
+ * @typedef {object} EnvironmentRules
87
+ * @property {number} [costLimit]
88
+ * @property {number} [budget]
89
+ * @property {{ kinds: string[], settings: string[] }} [access]
90
+ * @property {AllowRule[]} [allow]
91
+ */
92
+
78
93
  /**
79
94
  * @typedef {object} Policy
80
95
  * @property {number} version
81
96
  * @property {number} costLimit the most one plan may add to an environment's monthly cost before it waits
82
97
  * @property {number} budget what one environment may cost a month
83
- * @property {Record<string, { costLimit?: number, budget?: number }>} environments limits for one environment by name
98
+ * @property {Record<string, EnvironmentRules>} environments one environment's rules by name, over the repository's
84
99
  * @property {{ kinds: string[], settings: string[] }} access resource kinds and settings that decide who or what can
85
100
  * reach something, on top of the ones the provider declares
86
101
  * @property {AllowRule[]} allow
@@ -90,6 +105,9 @@ const TOP = ['version', 'costLimit', 'budget', 'environments', 'access', 'allow'
90
105
  const LIMITS = ['costLimit', 'budget'];
91
106
  const ACCESS = ['kinds', 'settings'];
92
107
  const RULE = ['name', 'environments', 'environmentKinds', 'changes', 'kinds', 'maxChanges'];
108
+ /** An environment's own rule names no environments: it's already that environment's. */
109
+ const ENV_RULE = ['name', 'changes', 'kinds', 'maxChanges'];
110
+ const ENV_KEYS = [...LIMITS, 'access', 'allow'];
93
111
  const ENV_NAME = /^[a-z0-9][a-z0-9-]{0,39}$/u;
94
112
  const KIND = /^[a-z][a-z0-9-]{0,39}$/u;
95
113
  const SETTING = /^[A-Za-z_][\w.-]{0,79}$/u;
@@ -153,8 +171,9 @@ export function checkPolicyFile(source) {
153
171
 
154
172
  /** @param {unknown} value @param {string} field @param {RegExp} shape @param {string} what */
155
173
  const names = (value, field, shape, what) => {
156
- if (!Array.isArray(value) || value.length === 0)
157
- return wrong(field, `${field.split('.').pop()} is a list of ${what}`);
174
+ if (Array.isArray(value) && value.length === 0)
175
+ return wrong(field, `${field.split('.').pop()} is empty: leave it out, or list ${what}`);
176
+ if (!Array.isArray(value)) return wrong(field, `${field.split('.').pop()} is a list of ${what}`);
158
177
  if (value.length > LIST_MAX)
159
178
  return wrong(field, `${field.split('.').pop()} has ${value.length}: at most ${LIST_MAX}`);
160
179
  for (let n = 0; n < value.length; n += 1)
@@ -162,125 +181,176 @@ export function checkPolicyFile(source) {
162
181
  return null;
163
182
  };
164
183
 
165
- /** @type {Policy['environments']} */
166
- const environments = {};
167
- if (file.environments !== undefined) {
168
- if (!isObject(file.environments))
169
- return wrong(
170
- 'environments',
171
- 'environments is an object of limits by environment: { "production": { "budget": 200 } }',
172
- );
173
- for (const [name, limits] of Object.entries(file.environments)) {
174
- const at = `environments.${name}`;
175
- if (!ENV_NAME.test(name))
176
- return wrong(at, `${name} isn’t an environment’s name: lowercase letters, digits, and -, like staging`);
177
- if (!isObject(limits)) return wrong(at, 'each environment has a costLimit, a budget, or both');
178
- for (const key of Object.keys(limits))
179
- if (!LIMITS.includes(key))
180
- return wrong(`${at}.${key}`, `“${key}” isn’t an environment’s limit: it has costLimit and budget`);
181
- for (const key of LIMITS)
182
- if (limits[key] !== undefined) {
183
- const bad = limit(limits[key], `${at}.${key}`);
184
- if (bad) return bad;
185
- }
186
- environments[name] = { ...limits };
187
- }
188
- }
189
-
190
- const access = { kinds: /** @type {string[]} */ ([]), settings: /** @type {string[]} */ ([]) };
191
- if (file.access !== undefined) {
192
- if (!isObject(file.access))
193
- return wrong(
194
- 'access',
195
- 'access names more resource kinds and settings that decide who or what can reach something',
196
- );
197
- for (const key of Object.keys(file.access))
184
+ /**
185
+ * An `access` object, at the top or for one environment.
186
+ * @param {any} value @param {string} at
187
+ */
188
+ const accessOf = (value, at) => {
189
+ const out = { kinds: /** @type {string[]} */ ([]), settings: /** @type {string[]} */ ([]) };
190
+ if (!isObject(value))
191
+ return {
192
+ bad: wrong(at, 'access names more resource kinds and settings that decide who or what can reach something'),
193
+ };
194
+ for (const key of Object.keys(value))
198
195
  if (!ACCESS.includes(key))
199
- return wrong(`access.${key}`, `“${key}” isn’t part of access: it has kinds and settings`);
200
- if (file.access.kinds !== undefined) {
201
- const bad = names(file.access.kinds, 'access.kinds', KIND, 'a resource kind, lowercase, like route');
202
- if (bad) return bad;
203
- access.kinds = [...file.access.kinds];
196
+ return { bad: wrong(`${at}.${key}`, `“${key}” isn’t part of access: it has kinds and settings`) };
197
+ if (value.kinds !== undefined) {
198
+ const bad = names(value.kinds, `${at}.kinds`, KIND, 'a resource kind, lowercase, like route');
199
+ if (bad) return { bad };
200
+ out.kinds = [...value.kinds];
204
201
  }
205
- if (file.access.settings !== undefined) {
206
- const bad = names(file.access.settings, 'access.settings', SETTING, 'a setting’s name, like public');
207
- if (bad) return bad;
208
- access.settings = [...file.access.settings];
202
+ if (value.settings !== undefined) {
203
+ const bad = names(value.settings, `${at}.settings`, SETTING, 'a setting’s name, like public');
204
+ if (bad) return { bad };
205
+ out.settings = [...value.settings];
209
206
  }
210
- }
207
+ return { access: out };
208
+ };
211
209
 
212
- /** @type {AllowRule[]} */
213
- const allow = [];
214
- if (file.allow !== undefined) {
215
- if (!Array.isArray(file.allow))
216
- return wrong('allow', 'allow is a list of rules that let a kind of plan through: [ { "name": …, … } ]');
217
- if (file.allow.length > POLICY_MAX_RULES)
218
- return wrong('allow', `allow has ${file.allow.length} rules: at most ${POLICY_MAX_RULES}`);
210
+ /**
211
+ * An `allow` list, at the top or for one environment, whose rules may not name environments.
212
+ * @param {unknown} list @param {string} at @param {string[]} keys
213
+ */
214
+ const allowOf = (list, at, keys) => {
215
+ /** @type {AllowRule[]} */
216
+ const allow = [];
217
+ if (!Array.isArray(list))
218
+ return { bad: wrong(at, 'allow is a list of rules that let a kind of plan through: [ { "name": …, … } ]') };
219
+ if (list.length > POLICY_MAX_RULES)
220
+ return { bad: wrong(at, `allow has ${list.length} rules: at most ${POLICY_MAX_RULES}`) };
219
221
  const seen = new Map();
220
- for (let n = 0; n < file.allow.length; n += 1) {
221
- const at = `allow[${n}]`;
222
- const r = file.allow[n];
223
- if (!isObject(r)) return wrong(at, 'each rule is an object with a name, and what it covers');
222
+ for (let n = 0; n < list.length; n += 1) {
223
+ const here = `${at}[${n}]`;
224
+ const r = list[n];
225
+ if (!isObject(r)) return { bad: wrong(here, 'each rule is an object with a name, and what it covers') };
224
226
  for (const key of Object.keys(r))
225
- if (!RULE.includes(key))
226
- return wrong(`${at}.${key}`, `“${key}” isn’t part of a rule: it has ${RULE.join(', ')}`);
227
+ if (!keys.includes(key))
228
+ return {
229
+ bad: wrong(
230
+ `${here}.${key}`,
231
+ RULE.includes(key)
232
+ ? `“${key}” isn’t part of an environment’s own rule: it’s already that environment’s`
233
+ : `“${key}” isn’t part of a rule: it has ${keys.join(', ')}`,
234
+ ),
235
+ };
227
236
  if (typeof r.name !== 'string' || r.name.trim() === '' || r.name.length > RULE_NAME_MAX)
228
- return wrong(
229
- r.name === undefined ? at : `${at}.name`,
230
- `name says what the rule lets through, up to ${RULE_NAME_MAX} characters, like small staging changes`,
231
- );
237
+ return {
238
+ bad: wrong(
239
+ r.name === undefined ? here : `${here}.name`,
240
+ `name says what the rule lets through, up to ${RULE_NAME_MAX} characters, like small staging changes`,
241
+ ),
242
+ };
232
243
  const name = r.name.trim();
233
244
  if (seen.has(name))
234
- return wrong(`${at}.name`, `${name} is the name of allow[${seen.get(name)}] too: give each its own`);
245
+ return { bad: wrong(`${here}.name`, `${name} is the name of ${at}[${seen.get(name)}] too: give each its own`) };
235
246
  seen.set(name, n);
236
247
  if (GUARDS.includes(name) || name === EVERY)
237
- return wrong(`${at}.name`, `${name} is the name of one of the board’s own rules: pick another`);
248
+ return { bad: wrong(`${here}.name`, `${name} is the name of one of the board’s own rules: pick another`) };
238
249
  /** @type {AllowRule} */
239
250
  const rule = { name };
240
251
  if (r.environments !== undefined) {
241
- const bad = names(r.environments, `${at}.environments`, ENV_NAME, 'an environment’s name, like staging');
242
- if (bad) return bad;
252
+ const bad = names(r.environments, `${here}.environments`, ENV_NAME, 'an environment’s name, like staging');
253
+ if (bad) return { bad };
243
254
  rule.environments = [...r.environments];
244
255
  }
245
256
  if (r.environmentKinds !== undefined) {
246
257
  const bad = names(
247
258
  r.environmentKinds,
248
- `${at}.environmentKinds`,
259
+ `${here}.environmentKinds`,
249
260
  KIND,
250
261
  `a kind of environment: ${ENVIRONMENT_KINDS.join(', ')}`,
251
262
  );
252
- if (bad) return bad;
263
+ if (bad) return { bad };
253
264
  const unknown = r.environmentKinds.findIndex((k) => !ENVIRONMENT_KINDS.includes(k));
254
265
  if (unknown >= 0)
255
- return wrong(
256
- `${at}.environmentKinds[${unknown}]`,
257
- `${r.environmentKinds[unknown]} isn’t a kind of environment: ${ENVIRONMENT_KINDS.join(', ')}`,
258
- );
266
+ return {
267
+ bad: wrong(
268
+ `${here}.environmentKinds[${unknown}]`,
269
+ `${r.environmentKinds[unknown]} isn’t a kind of environment: ${ENVIRONMENT_KINDS.join(', ')}`,
270
+ ),
271
+ };
259
272
  rule.environmentKinds = [...r.environmentKinds];
260
273
  }
261
274
  if (r.changes !== undefined) {
262
- const bad = names(r.changes, `${at}.changes`, KIND, `a change kind: ${CHANGE_KINDS.join(', ')}`);
263
- if (bad) return bad;
275
+ const bad = names(r.changes, `${here}.changes`, KIND, `a change kind: ${CHANGE_KINDS.join(', ')}`);
276
+ if (bad) return { bad };
264
277
  const unknown = r.changes.findIndex((c) => !CHANGE_KINDS.includes(c));
265
278
  if (unknown >= 0)
266
- return wrong(
267
- `${at}.changes[${unknown}]`,
268
- `${r.changes[unknown]} isn’t a change kind: ${CHANGE_KINDS.join(', ')}`,
269
- );
279
+ return {
280
+ bad: wrong(
281
+ `${here}.changes[${unknown}]`,
282
+ `${r.changes[unknown]} isn’t a change kind: ${CHANGE_KINDS.join(', ')}`,
283
+ ),
284
+ };
270
285
  rule.changes = [...r.changes];
271
286
  }
272
287
  if (r.kinds !== undefined) {
273
- const bad = names(r.kinds, `${at}.kinds`, KIND, 'a resource kind, lowercase, like service');
274
- if (bad) return bad;
288
+ const bad = names(r.kinds, `${here}.kinds`, KIND, 'a resource kind, lowercase, like service');
289
+ if (bad) return { bad };
275
290
  rule.kinds = [...r.kinds];
276
291
  }
277
292
  if (r.maxChanges !== undefined) {
278
293
  if (!Number.isInteger(r.maxChanges) || r.maxChanges < 1 || r.maxChanges > 1000)
279
- return wrong(`${at}.maxChanges`, 'maxChanges is a whole number from 1 to 1000');
294
+ return { bad: wrong(`${here}.maxChanges`, 'maxChanges is a whole number from 1 to 1000') };
280
295
  rule.maxChanges = r.maxChanges;
281
296
  }
282
297
  allow.push(rule);
283
298
  }
299
+ return { allow };
300
+ };
301
+
302
+ /** @type {Policy['environments']} */
303
+ const environments = {};
304
+ if (file.environments !== undefined) {
305
+ if (!isObject(file.environments))
306
+ return wrong(
307
+ 'environments',
308
+ 'environments is an object of rules by environment: { "production": { "budget": 200 } }',
309
+ );
310
+ for (const [name, rules] of Object.entries(file.environments)) {
311
+ const at = `environments.${name}`;
312
+ if (!ENV_NAME.test(name))
313
+ return wrong(at, `${name} isn’t an environment’s name: lowercase letters, digits, and -, like staging`);
314
+ if (!isObject(rules))
315
+ return wrong(at, 'each environment has a costLimit, a budget, access, allow, or some of them');
316
+ for (const key of Object.keys(rules))
317
+ if (!ENV_KEYS.includes(key))
318
+ return wrong(`${at}.${key}`, `“${key}” isn’t an environment’s rule: it has ${ENV_KEYS.join(', ')}`);
319
+ /** @type {EnvironmentRules} */
320
+ const own = {};
321
+ for (const key of LIMITS)
322
+ if (rules[key] !== undefined) {
323
+ const bad = limit(rules[key], `${at}.${key}`);
324
+ if (bad) return bad;
325
+ own[key] = rules[key];
326
+ }
327
+ if (rules.access !== undefined) {
328
+ const got = accessOf(rules.access, `${at}.access`);
329
+ if (got.bad) return got.bad;
330
+ own.access = got.access;
331
+ }
332
+ if (rules.allow !== undefined) {
333
+ const got = allowOf(rules.allow, `${at}.allow`, ENV_RULE);
334
+ if (got.bad) return got.bad;
335
+ own.allow = got.allow;
336
+ }
337
+ environments[name] = own;
338
+ }
339
+ }
340
+
341
+ let access = { kinds: /** @type {string[]} */ ([]), settings: /** @type {string[]} */ ([]) };
342
+ if (file.access !== undefined) {
343
+ const got = accessOf(file.access, 'access');
344
+ if (got.bad) return got.bad;
345
+ access = got.access;
346
+ }
347
+
348
+ /** @type {AllowRule[]} */
349
+ let allow = [];
350
+ if (file.allow !== undefined) {
351
+ const got = allowOf(file.allow, 'allow', RULE);
352
+ if (got.bad) return got.bad;
353
+ allow = got.allow;
284
354
  }
285
355
 
286
356
  return {
@@ -302,6 +372,31 @@ export function limitsFor(policy, environment) {
302
372
  return { costLimit: own.costLimit ?? policy.costLimit, budget: own.budget ?? policy.budget };
303
373
  }
304
374
 
375
+ /**
376
+ * The allow rules that decide for one environment (WEB-123): its own when it has an `allow`, else the repository's.
377
+ * @param {Policy} policy
378
+ * @param {string} environment
379
+ * @returns {{ allow: AllowRule[], level: 'environment' | 'repository' }}
380
+ */
381
+ export function allowFor(policy, environment) {
382
+ const own = policy.environments?.[environment]?.allow;
383
+ return own ? { allow: own, level: 'environment' } : { allow: policy.allow ?? [], level: 'repository' };
384
+ }
385
+
386
+ /**
387
+ * The access kinds and settings a policy names for one environment: the repository's, with the environment's added.
388
+ * @param {Policy} policy
389
+ * @param {string} environment
390
+ */
391
+ export function accessFor(policy, environment) {
392
+ const own = policy.environments?.[environment]?.access;
393
+ const union = (a = [], b = []) => [...new Set([...a, ...b])];
394
+ return {
395
+ kinds: union(policy.access?.kinds, own?.kinds),
396
+ settings: union(policy.access?.settings, own?.settings),
397
+ };
398
+ }
399
+
305
400
  /**
306
401
  * An amount in words, in the plan's currency when it has one: "$6", "€4.60".
307
402
  * @param {number} amount
@@ -343,16 +438,17 @@ function accessChange(change, access) {
343
438
  return keys.length ? keys : null;
344
439
  }
345
440
 
346
- /** The provider's access kinds and settings, with the policy's added. */
347
- function accessOf(policy, provider) {
348
- const kinds = new Set(policy.access?.kinds ?? []);
441
+ /** The provider's access kinds and settings, with the policy's for the environment added. */
442
+ function accessOf(policy, provider, environment) {
443
+ const named = accessFor(policy, environment);
444
+ const kinds = new Set(named.kinds);
349
445
  /** @type {Map<string, Set<string>>} */
350
446
  const settings = new Map();
351
447
  for (const [kind, spec] of Object.entries(provider?.kinds ?? {})) {
352
448
  if (spec?.access) kinds.add(kind);
353
449
  if (Array.isArray(spec?.accessSettings)) settings.set(kind, new Set(spec.accessSettings));
354
450
  }
355
- return { kinds, settings, anyKind: new Set(policy.access?.settings ?? []) };
451
+ return { kinds, settings, anyKind: new Set(named.settings) };
356
452
  }
357
453
 
358
454
  /** A change in a few words, for a reason: "deletes the `main` database". */
@@ -438,7 +534,7 @@ export function evaluatePolicy(
438
534
  destroys.length ? `Can’t be undone: it ${andMore(undo)}.` : 'Every change can be undone, and nothing is deleted.',
439
535
  );
440
536
 
441
- const access = accessOf(policy, provider);
537
+ const access = accessOf(policy, provider, env);
442
538
  const reaching = diff.changes.map((c) => ({ c, keys: accessChange(c, access) })).filter((x) => x.keys !== null);
443
539
  add(
444
540
  'access',
@@ -491,11 +587,19 @@ export function evaluatePolicy(
491
587
  outcome = 'needs-owner';
492
588
  rule = asked[0].rule;
493
589
  } else {
494
- const allowed = policy.allow.find((a) => allows(a, env, diff, environment.kind));
590
+ const { allow, level } = allowFor(policy, env);
591
+ const allowed = allow.find((a) => allows(a, env, diff, environment.kind));
495
592
  if (allowed) {
496
593
  outcome = 'allowed';
497
594
  rule = allowed.name;
498
- add(allowed.name, true, 'allow', `Allowed by your policy’s rule “${allowed.name}”.`);
595
+ add(
596
+ allowed.name,
597
+ true,
598
+ 'allow',
599
+ level === 'environment'
600
+ ? `Allowed by ${env}’s own rule “${allowed.name}” in your policy.`
601
+ : `Allowed by your policy’s rule “${allowed.name}”.`,
602
+ );
499
603
  } else {
500
604
  outcome = 'needs-owner';
501
605
  rule = EVERY;
@@ -503,9 +607,11 @@ export function evaluatePolicy(
503
607
  EVERY,
504
608
  true,
505
609
  'ask',
506
- policy.allow.length
610
+ allow.length
507
611
  ? 'No rule in your policy lets it through, so it needs you.'
508
- : 'Every plan needs you: the default policy lets nothing through.',
612
+ : level === 'environment'
613
+ ? `Every plan in ${env} needs you: your policy lets nothing through there.`
614
+ : 'Every plan needs you: the default policy lets nothing through.',
509
615
  );
510
616
  }
511
617
  }