breakaway 2.0.0-main.54 → 2.0.0-main.56
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.
|
@@ -116,7 +116,8 @@ Open it the way the repository's prompt's **Pull requests** says, closing the ta
|
|
|
116
116
|
- the helper scripts, with `repos init <slug> --update`, when `pipeline init` named any;
|
|
117
117
|
- Workers Builds disconnected, when the repository used it;
|
|
118
118
|
- for a package: the GitHub environment `npm`, restricted to the default branch; on npm, a trusted publisher for `release.yml` and the `npm` environment, or a granular `NPM_TOKEN` in that environment that can't bypass 2FA; and, after each run, approving the staged version on npm with 2FA (`npm stage approve <id>`, or Staged Packages on npmjs.com). The board shows what waits and never approves;
|
|
119
|
-
-
|
|
119
|
+
- read and write on **Variables** for the App, so freezing production on the board sets `DEPLOYS_PAUSED` (`true` stops Promote and Release; setting it on GitHub freezes production on the board);
|
|
120
|
+
- optional: the health-check addresses;
|
|
120
121
|
- after the merge, **Turn on deploys** on the repository's GitHub page, which sets its pipeline from the files.
|
|
121
122
|
|
|
122
123
|
## Before handing over
|
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.56",
|
|
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.56",
|
|
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.56",
|
|
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",
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
"src/infra-provider.js",
|
|
32
32
|
"src/infra-plans.js",
|
|
33
33
|
"src/infra-policy.js",
|
|
34
|
+
"src/infra-scaling.js",
|
|
34
35
|
"src/infra-providers.js",
|
|
35
36
|
"src/infra-runner.js",
|
|
36
37
|
"src/infra-templates.js",
|
|
@@ -64,6 +64,7 @@ export async function infraCheck(args, { files, repo, post }) {
|
|
|
64
64
|
if (f.kind === 'skipped') continue;
|
|
65
65
|
if (!f.ok) out.push(problemLine(f.path, /** @type {any} */ (f.error)));
|
|
66
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')}`);
|
|
67
68
|
else out.push(`${f.path}: checks, ${plural(f.resources ?? 0, 'resource')}`);
|
|
68
69
|
}
|
|
69
70
|
if (!environments.length) out.push(`No environment’s file yet: add one, like ${DESIRED_DIR}/staging.json.`);
|
package/src/infra-check.js
CHANGED
|
@@ -1,33 +1,35 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `npx breakaway infra check` (docs/specs/IDEA-19-architect.md, "Desired state" and "CLI"; CLI-14), the pure part: it
|
|
3
3
|
* checks a checkout's `.github/breakaway-infra/` folder before a pull request, the way `pipeline check` checks the
|
|
4
|
-
* deploy config. Each environment's file goes through BRK-180's checkDesiredFile
|
|
5
|
-
* checkPolicyFile, so the CLI says what the board would: the file, the line, and the field that's wrong. The board's
|
|
4
|
+
* deploy config. Each environment's file goes through BRK-180's checkDesiredFile, `policy.json` through BRK-181's
|
|
5
|
+
* checkPolicyFile, and `scaling.json` through BRK-241's checkScalingFile, so the CLI says what the board would: the file, the line, and the field that's wrong. The board's
|
|
6
6
|
* preview (store-infra-check.js) then shows the plan a valid file would make, kept nowhere.
|
|
7
7
|
*
|
|
8
8
|
* Pure and Node-safe, so the CLI imports it: no store and no network.
|
|
9
9
|
*/
|
|
10
10
|
import { checkDesiredFile, DESIRED_DIR, environmentOfFile } from './infra-desired.js';
|
|
11
11
|
import { checkPolicyFile, POLICY_FILE } from './infra-policy.js';
|
|
12
|
+
import { checkScalingFile, SCALING_FILE } from './infra-scaling.js';
|
|
12
13
|
|
|
13
14
|
/** How many previews one repository asks the board for a minute: each one asks the provider for a plan. */
|
|
14
15
|
export const CHECK_PER_MINUTE = 10;
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
|
-
* One file's result. `kind` is `desired` (an environment's file), `policy`, `skipped` (not JSON, or
|
|
18
|
-
* later piece
|
|
18
|
+
* One file's result. `kind` is `desired` (an environment's file), `policy`, `scaling`, `skipped` (not JSON, or
|
|
19
|
+
* reserved for a later piece), or `problem` (a name no environment could have).
|
|
19
20
|
* @typedef {object} FileCheck
|
|
20
21
|
* @property {string} path the file's path in the repository
|
|
21
|
-
* @property {'desired' | 'policy' | 'skipped' | 'problem'} kind
|
|
22
|
+
* @property {'desired' | 'policy' | 'scaling' | 'skipped' | 'problem'} kind
|
|
22
23
|
* @property {string | null} environment the environment it's for, when it's an environment's
|
|
23
24
|
* @property {boolean} ok
|
|
24
25
|
* @property {import('./infra-desired.js').DesiredError | null} error
|
|
25
26
|
* @property {number} [resources] how many resources a valid desired state declares
|
|
27
|
+
* @property {number} [rules] how many rules a valid scaling file holds
|
|
26
28
|
*/
|
|
27
29
|
|
|
28
30
|
/**
|
|
29
|
-
* Checks the folder's files, by name and text, in name order. `policy.json` is checked as the policy
|
|
30
|
-
* `.json` file is an environment's.
|
|
31
|
+
* Checks the folder's files, by name and text, in name order. `policy.json` is checked as the policy and
|
|
32
|
+
* `scaling.json` as the scaling rules; every other `.json` file is an environment's.
|
|
31
33
|
* @param {{ name: string, text: string }[]} files the folder's files, without the folder in their names
|
|
32
34
|
* @returns {{ ok: boolean, files: FileCheck[] }}
|
|
33
35
|
*/
|
|
@@ -47,6 +49,18 @@ export function checkInfraFolder(files) {
|
|
|
47
49
|
});
|
|
48
50
|
continue;
|
|
49
51
|
}
|
|
52
|
+
if (name === SCALING_FILE) {
|
|
53
|
+
const checked = checkScalingFile(text);
|
|
54
|
+
out.push({
|
|
55
|
+
path,
|
|
56
|
+
kind: 'scaling',
|
|
57
|
+
environment: null,
|
|
58
|
+
ok: checked.ok,
|
|
59
|
+
error: 'error' in checked ? checked.error : null,
|
|
60
|
+
...('rules' in checked ? { rules: checked.rules.length } : {}),
|
|
61
|
+
});
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
50
64
|
const of = environmentOfFile(name);
|
|
51
65
|
if (of === null) {
|
|
52
66
|
out.push({ path, kind: 'skipped', environment: null, ok: true, error: null });
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Architect's scaling rules, the pure part (docs/specs/IDEA-19-architect.md, "Envelopes"; BRK-241): rules in the
|
|
3
|
+
* repository, at `.github/breakaway-infra/scaling.json` on its default branch, that turn a signal into one scale or
|
|
4
|
+
* restart. A rule only asks: the board acts through the environment's envelope (BRK-186), whose bounds the owner set on
|
|
5
|
+
* the board, so inside them the act applies with no press and outside them it's a plan that waits for the owner. A rule
|
|
6
|
+
* can't widen an envelope, and a repository without the file acts on nothing.
|
|
7
|
+
*
|
|
8
|
+
* A file is:
|
|
9
|
+
*
|
|
10
|
+
* { "version": 1,
|
|
11
|
+
* "rules": [
|
|
12
|
+
* { "name": "api is busy", "environments": ["production"], "resource": "api",
|
|
13
|
+
* "kinds": ["alert"], "level": "warning", "above": 80, "act": "scale", "step": 1 },
|
|
14
|
+
* { "name": "worker is down", "resourceKinds": ["container"], "kinds": ["health"], "act": "restart" } ] }
|
|
15
|
+
*
|
|
16
|
+
* A rule hears signals about one resource (`resource`, by name or ID) or every resource of its `resourceKinds`, in its
|
|
17
|
+
* `environments` (every one when left out), of its signal `kinds` (health or alert, never cost), at or above `level` (critical when left out), and,
|
|
18
|
+
* with `above` or `below`, only when the signal's value is past it. It acts on the resource the signal is about: `act`
|
|
19
|
+
* is `scale`, to a whole number (`to`) or by one (`step`, from what the inventory says it runs), or `restart`.
|
|
20
|
+
*
|
|
21
|
+
* Pure and Node-safe, so `npx breakaway infra check` (CLI-14) can use it: no store and no network.
|
|
22
|
+
*/
|
|
23
|
+
import { DESIRED_DIR, parseWithLines } from './infra-desired.js';
|
|
24
|
+
import { SIGNAL_KINDS, SIGNAL_LEVELS } from './infra-provider.js';
|
|
25
|
+
|
|
26
|
+
/** Where the rules are, on the default branch, beside the environments' files and the policy. */
|
|
27
|
+
export const SCALING_FILE = 'scaling.json';
|
|
28
|
+
export const SCALING_PATH = `${DESIRED_DIR}/${SCALING_FILE}`;
|
|
29
|
+
export const SCALING_VERSION = 1;
|
|
30
|
+
export const SCALING_MAX_BYTES = 64 * 1024;
|
|
31
|
+
/** The most rules one file holds, and the most names in any one list. */
|
|
32
|
+
export const SCALING_MAX_RULES = 50;
|
|
33
|
+
/** What a rule does: the acts an envelope bounds. */
|
|
34
|
+
export const SCALING_ACTS = ['scale', 'restart'];
|
|
35
|
+
/** The signal kinds a rule hears: never cost, so a budget's alert never scales anything up. */
|
|
36
|
+
export const SCALING_KINDS = SIGNAL_KINDS.filter((k) => k !== 'cost');
|
|
37
|
+
const LIST_MAX = 20;
|
|
38
|
+
const SCALE_MAX = 10_000;
|
|
39
|
+
const VALUE_MAX = 1e12;
|
|
40
|
+
const NAME_MAX = 80;
|
|
41
|
+
const RESOURCE_MAX = 100;
|
|
42
|
+
|
|
43
|
+
const TOP = ['version', 'rules'];
|
|
44
|
+
const RULE = [
|
|
45
|
+
'name',
|
|
46
|
+
'environments',
|
|
47
|
+
'resource',
|
|
48
|
+
'resourceKinds',
|
|
49
|
+
'kinds',
|
|
50
|
+
'level',
|
|
51
|
+
'above',
|
|
52
|
+
'below',
|
|
53
|
+
'act',
|
|
54
|
+
'to',
|
|
55
|
+
'step',
|
|
56
|
+
];
|
|
57
|
+
const ENV_NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/u;
|
|
58
|
+
const KIND = /^[a-z][a-z0-9-]{0,39}$/u;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A rule as kept: empty lists match anything.
|
|
62
|
+
* @typedef {object} ScalingRule
|
|
63
|
+
* @property {string} name what the audit trail and the board call it
|
|
64
|
+
* @property {string[]} environments
|
|
65
|
+
* @property {string | null} resource the resource's name or ID, or null for any of `resourceKinds`
|
|
66
|
+
* @property {string[]} resourceKinds
|
|
67
|
+
* @property {string[]} kinds the signal kinds it hears
|
|
68
|
+
* @property {string} level the lowest level it hears
|
|
69
|
+
* @property {number | null} above
|
|
70
|
+
* @property {number | null} below
|
|
71
|
+
* @property {'scale' | 'restart'} act
|
|
72
|
+
* @property {number | null} to a scale's whole number
|
|
73
|
+
* @property {number | null} step a scale's step, up or down
|
|
74
|
+
*/
|
|
75
|
+
|
|
76
|
+
/** @typedef {import('./infra-desired.js').DesiredError} ScalingError */
|
|
77
|
+
|
|
78
|
+
const isObject = (v) => v != null && typeof v === 'object' && !Array.isArray(v);
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Checks a scaling file's text. The result is its rules, with what each leaves out filled in, or the first thing wrong
|
|
82
|
+
* with it, with its line and field.
|
|
83
|
+
* @param {string} source
|
|
84
|
+
* @returns {{ ok: true, rules: ScalingRule[] } | { ok: false, error: ScalingError }}
|
|
85
|
+
*/
|
|
86
|
+
export function checkScalingFile(source) {
|
|
87
|
+
/** @type {Map<string, number>} */
|
|
88
|
+
let lines = new Map();
|
|
89
|
+
/** @param {string | null} field @param {string} message @param {number | null} [at] */
|
|
90
|
+
const wrong = (field, message, at) => ({
|
|
91
|
+
ok: /** @type {const} */ (false),
|
|
92
|
+
error: { line: at ?? (field === null ? null : (lines.get(field) ?? null)), field, message },
|
|
93
|
+
});
|
|
94
|
+
const raw = String(source ?? '');
|
|
95
|
+
if (new TextEncoder().encode(raw).length > SCALING_MAX_BYTES)
|
|
96
|
+
return wrong(null, `the file is over ${SCALING_MAX_BYTES / 1024} KB: trim it`);
|
|
97
|
+
let file;
|
|
98
|
+
try {
|
|
99
|
+
({ value: file, lines } = parseWithLines(raw));
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (error instanceof Error && 'line' in error)
|
|
102
|
+
return wrong(null, `it isn’t JSON: ${error.message}`, Number(error.line));
|
|
103
|
+
throw error;
|
|
104
|
+
}
|
|
105
|
+
const top = lines.get('') ?? 1;
|
|
106
|
+
if (!isObject(file)) return wrong(null, 'the file is one JSON object: { "version": 1, "rules": [ … ] }', top);
|
|
107
|
+
for (const key of Object.keys(file))
|
|
108
|
+
if (!TOP.includes(key)) return wrong(key, `“${key}” isn’t part of scaling rules: it has ${TOP.join(', ')}`);
|
|
109
|
+
if (file.version !== SCALING_VERSION)
|
|
110
|
+
return wrong(
|
|
111
|
+
file.version === undefined ? null : 'version',
|
|
112
|
+
`version is ${SCALING_VERSION}: add "version": ${SCALING_VERSION}`,
|
|
113
|
+
file.version === undefined ? top : undefined,
|
|
114
|
+
);
|
|
115
|
+
if (!Array.isArray(file.rules))
|
|
116
|
+
return wrong(
|
|
117
|
+
file.rules === undefined ? null : 'rules',
|
|
118
|
+
'rules is a list of what to scale or restart on a signal: [ { "name": …, "act": "scale", … } ]',
|
|
119
|
+
file.rules === undefined ? top : undefined,
|
|
120
|
+
);
|
|
121
|
+
if (file.rules.length > SCALING_MAX_RULES)
|
|
122
|
+
return wrong('rules', `rules has ${file.rules.length}: at most ${SCALING_MAX_RULES}`);
|
|
123
|
+
|
|
124
|
+
/** @param {unknown} value @param {string} field @param {(v: string) => boolean} test @param {string} what */
|
|
125
|
+
const names = (value, field, test, what) => {
|
|
126
|
+
if (!Array.isArray(value) || value.length === 0)
|
|
127
|
+
return wrong(field, `${field.split('.').pop()} is a list of ${what}`);
|
|
128
|
+
if (value.length > LIST_MAX)
|
|
129
|
+
return wrong(field, `${field.split('.').pop()} has ${value.length}: at most ${LIST_MAX}`);
|
|
130
|
+
for (let n = 0; n < value.length; n += 1)
|
|
131
|
+
if (typeof value[n] !== 'string' || !test(value[n])) return wrong(`${field}[${n}]`, `each one is ${what}`);
|
|
132
|
+
return null;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/** @type {ScalingRule[]} */
|
|
136
|
+
const rules = [];
|
|
137
|
+
const seen = new Map();
|
|
138
|
+
for (let n = 0; n < file.rules.length; n += 1) {
|
|
139
|
+
const at = `rules[${n}]`;
|
|
140
|
+
const r = file.rules[n];
|
|
141
|
+
if (!isObject(r)) return wrong(at, 'each rule is an object with a name, what it hears, and what it does');
|
|
142
|
+
for (const key of Object.keys(r))
|
|
143
|
+
if (!RULE.includes(key)) return wrong(`${at}.${key}`, `“${key}” isn’t part of a rule: it has ${RULE.join(', ')}`);
|
|
144
|
+
if (typeof r.name !== 'string' || r.name.trim() === '' || r.name.length > NAME_MAX)
|
|
145
|
+
return wrong(
|
|
146
|
+
r.name === undefined ? at : `${at}.name`,
|
|
147
|
+
`name says what the rule does, up to ${NAME_MAX} characters, like api is busy`,
|
|
148
|
+
);
|
|
149
|
+
const name = r.name.trim();
|
|
150
|
+
if (seen.has(name))
|
|
151
|
+
return wrong(`${at}.name`, `${name} is the name of rules[${seen.get(name)}] too: give each its own`);
|
|
152
|
+
seen.set(name, n);
|
|
153
|
+
|
|
154
|
+
/** @type {ScalingRule} */
|
|
155
|
+
const rule = {
|
|
156
|
+
name,
|
|
157
|
+
environments: [],
|
|
158
|
+
resource: null,
|
|
159
|
+
resourceKinds: [],
|
|
160
|
+
kinds: [],
|
|
161
|
+
level: 'critical',
|
|
162
|
+
above: null,
|
|
163
|
+
below: null,
|
|
164
|
+
act: 'restart',
|
|
165
|
+
to: null,
|
|
166
|
+
step: null,
|
|
167
|
+
};
|
|
168
|
+
if (r.environments !== undefined) {
|
|
169
|
+
const bad = names(r.environments, `${at}.environments`, (v) => ENV_NAME.test(v), 'an environment’s name');
|
|
170
|
+
if (bad) return bad;
|
|
171
|
+
rule.environments = [...new Set(/** @type {string[]} */ (r.environments))];
|
|
172
|
+
}
|
|
173
|
+
if (r.resource !== undefined) {
|
|
174
|
+
if (typeof r.resource !== 'string' || r.resource.trim() === '' || r.resource.length > RESOURCE_MAX)
|
|
175
|
+
return wrong(`${at}.resource`, `resource is the resource’s name or ID, up to ${RESOURCE_MAX} characters`);
|
|
176
|
+
rule.resource = r.resource.trim();
|
|
177
|
+
}
|
|
178
|
+
if (r.resourceKinds !== undefined) {
|
|
179
|
+
const bad = names(r.resourceKinds, `${at}.resourceKinds`, (v) => KIND.test(v), 'a resource kind, like container');
|
|
180
|
+
if (bad) return bad;
|
|
181
|
+
rule.resourceKinds = [...new Set(/** @type {string[]} */ (r.resourceKinds))];
|
|
182
|
+
}
|
|
183
|
+
if (rule.resource === null && rule.resourceKinds.length === 0)
|
|
184
|
+
return wrong(at, 'a rule names the resource it acts on (resource), or the kinds it acts on (resourceKinds)');
|
|
185
|
+
if (r.kinds !== undefined) {
|
|
186
|
+
const bad = names(
|
|
187
|
+
r.kinds,
|
|
188
|
+
`${at}.kinds`,
|
|
189
|
+
(v) => SCALING_KINDS.includes(v),
|
|
190
|
+
`a signal kind: ${SCALING_KINDS.join(' or ')} (a cost signal never scales or restarts anything)`,
|
|
191
|
+
);
|
|
192
|
+
if (bad) return bad;
|
|
193
|
+
rule.kinds = [...new Set(/** @type {string[]} */ (r.kinds))];
|
|
194
|
+
}
|
|
195
|
+
if (r.level !== undefined) {
|
|
196
|
+
if (!SIGNAL_LEVELS.includes(r.level))
|
|
197
|
+
return wrong(`${at}.level`, `level is the lowest a signal is to count: ${SIGNAL_LEVELS.join(', ')}`);
|
|
198
|
+
rule.level = r.level;
|
|
199
|
+
}
|
|
200
|
+
for (const key of /** @type {const} */ (['above', 'below']))
|
|
201
|
+
if (r[key] !== undefined) {
|
|
202
|
+
if (typeof r[key] !== 'number' || !Number.isFinite(r[key]) || Math.abs(r[key]) > VALUE_MAX)
|
|
203
|
+
return wrong(`${at}.${key}`, `${key} is a number the signal’s value has to be ${key}, like 80`);
|
|
204
|
+
rule[key] = r[key];
|
|
205
|
+
}
|
|
206
|
+
if (rule.above !== null && rule.below !== null && rule.above >= rule.below)
|
|
207
|
+
return wrong(`${at}.below`, `no value is above ${rule.above} and below ${rule.below}: pick one, or widen them`);
|
|
208
|
+
|
|
209
|
+
if (!SCALING_ACTS.includes(r.act))
|
|
210
|
+
return wrong(r.act === undefined ? at : `${at}.act`, 'act is scale or restart: what the rule asks for');
|
|
211
|
+
rule.act = r.act;
|
|
212
|
+
if (rule.act === 'restart') {
|
|
213
|
+
for (const key of ['to', 'step'])
|
|
214
|
+
if (r[key] !== undefined) return wrong(`${at}.${key}`, `a restart takes no ${key}: leave it out`);
|
|
215
|
+
} else {
|
|
216
|
+
if ((r.to === undefined) === (r.step === undefined))
|
|
217
|
+
return wrong(at, 'a scale has to (a whole number) or step (up or down by a whole number), not both');
|
|
218
|
+
if (r.to !== undefined) {
|
|
219
|
+
if (!Number.isInteger(r.to) || r.to < 0 || r.to > SCALE_MAX)
|
|
220
|
+
return wrong(`${at}.to`, `to is a whole number from 0 to ${SCALE_MAX}`);
|
|
221
|
+
rule.to = r.to;
|
|
222
|
+
} else {
|
|
223
|
+
if (!Number.isInteger(r.step) || r.step === 0 || Math.abs(r.step) > SCALE_MAX)
|
|
224
|
+
return wrong(`${at}.step`, `step is a whole number up or down, like 1 or -1, and never 0`);
|
|
225
|
+
rule.step = r.step;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
rules.push(rule);
|
|
229
|
+
}
|
|
230
|
+
return { ok: true, rules };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Whether a rule hears a signal about a resource. A cost signal never matches. A signal with no resource, or one the inventory doesn't know, never
|
|
235
|
+
* matches a rule that names kinds; a rule's `resource` matches the resource's name or its ID.
|
|
236
|
+
* @param {ScalingRule} rule
|
|
237
|
+
* @param {{ environment: string, kind: string, level: string, value: number | null }} signal
|
|
238
|
+
* @param {{ id: string, name: string | null, kind: string | null }} resource
|
|
239
|
+
*/
|
|
240
|
+
export function ruleMatches(rule, signal, resource) {
|
|
241
|
+
if (!SCALING_KINDS.includes(signal.kind)) return false;
|
|
242
|
+
if (SIGNAL_LEVELS.indexOf(signal.level) < SIGNAL_LEVELS.indexOf(rule.level)) return false;
|
|
243
|
+
if (rule.environments.length && !rule.environments.includes(signal.environment)) return false;
|
|
244
|
+
if (rule.kinds.length && !rule.kinds.includes(signal.kind)) return false;
|
|
245
|
+
if (rule.resource !== null && rule.resource !== resource.id && rule.resource !== resource.name) return false;
|
|
246
|
+
if (rule.resourceKinds.length && !(resource.kind && rule.resourceKinds.includes(resource.kind))) return false;
|
|
247
|
+
if (rule.above !== null && !(typeof signal.value === 'number' && signal.value > rule.above)) return false;
|
|
248
|
+
if (rule.below !== null && !(typeof signal.value === 'number' && signal.value < rule.below)) return false;
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* What a matching rule asks the envelope for: a restart, or a scale to a whole number. A step needs what the resource
|
|
254
|
+
* runs now; when that isn't known, the answer says so and nothing is asked.
|
|
255
|
+
* @param {ScalingRule} rule
|
|
256
|
+
* @param {number | null} current what the resource's scaled setting is now, from the inventory
|
|
257
|
+
* @returns {{ change: 'scale' | 'restart', value: number | null } | { unknown: string }}
|
|
258
|
+
*/
|
|
259
|
+
export function ruleAct(rule, current) {
|
|
260
|
+
if (rule.act === 'restart') return { change: 'restart', value: null };
|
|
261
|
+
if (rule.to !== null) return { change: 'scale', value: rule.to };
|
|
262
|
+
if (!Number.isInteger(current)) return { unknown: 'what it runs now isn’t known yet, so a step can’t be worked out' };
|
|
263
|
+
return {
|
|
264
|
+
change: 'scale',
|
|
265
|
+
value: Math.min(SCALE_MAX, Math.max(0, /** @type {number} */ (current) + /** @type {number} */ (rule.step))),
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** A rule in words, for the board: "scale api by 1 on an alert, warning and up, above 80, in production". */
|
|
270
|
+
export function ruleWords(rule) {
|
|
271
|
+
const what =
|
|
272
|
+
rule.act === 'restart'
|
|
273
|
+
? 'restart'
|
|
274
|
+
: rule.to !== null
|
|
275
|
+
? `scale to ${rule.to}`
|
|
276
|
+
: `scale ${/** @type {number} */ (rule.step) > 0 ? 'up' : 'down'} by ${Math.abs(/** @type {number} */ (rule.step))}`;
|
|
277
|
+
const on = rule.resource ?? `every ${rule.resourceKinds.join(' or ')}`;
|
|
278
|
+
const heard = rule.kinds.length
|
|
279
|
+
? `${rule.kinds[0] === 'alert' ? 'an' : 'a'} ${rule.kinds.join(' or ')} signal`
|
|
280
|
+
: 'a signal';
|
|
281
|
+
const past = [
|
|
282
|
+
...(rule.above === null ? [] : [`above ${rule.above}`]),
|
|
283
|
+
...(rule.below === null ? [] : [`below ${rule.below}`]),
|
|
284
|
+
];
|
|
285
|
+
const where = rule.environments.length ? `, in ${rule.environments.join(', ')}` : '';
|
|
286
|
+
return `${what} ${on} on ${heard}, ${rule.level}${rule.level === 'critical' ? '' : ' and up'}${past.length ? `, ${past.join(' and ')}` : ''}${where}`;
|
|
287
|
+
}
|