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.
- 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-cloudflare.js
CHANGED
|
@@ -13,15 +13,24 @@
|
|
|
13
13
|
* (src/infra-cloudflare-analytics.js, one query per dataset for the whole environment), times PRICES, a price table kept
|
|
14
14
|
* as data with where and when it was read. Plan and apply (BRK-192) are in infra-cloudflare-plan.js.
|
|
15
15
|
*
|
|
16
|
-
* Observe (BRK-191) reads each resource's health from the same analytics over the last few minutes,
|
|
17
|
-
*
|
|
16
|
+
* Observe (BRK-191, BRK-266) reads each resource's health from the same analytics over the last few minutes, allowing
|
|
17
|
+
* for their lag and looking further back for a quiet resource before it calls it idle, plus a queue's backlog and a
|
|
18
|
+
* container application's instance counts; a route or custom domain takes its Worker's health. Events
|
|
18
19
|
* (BRK-191) reads the account's alert history and reports each alert as a signal on the Worker it names, the same way
|
|
19
20
|
* the board's alert webhook does (`alertFields`), and `alertSetup` reads which alerts are set up and which reach the
|
|
20
21
|
* board. Alerts need Notifications Read; the token never has Notifications Write. Pure apart from `fetch`, so the CLI
|
|
21
22
|
* can import it.
|
|
22
23
|
*/
|
|
23
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
COST_DATASETS,
|
|
26
|
+
HEALTH_DATASETS,
|
|
27
|
+
HEALTH_LAG_MINUTES,
|
|
28
|
+
HEALTH_WINDOWS,
|
|
29
|
+
HEALTH_WINDOW_MINUTES,
|
|
30
|
+
readDataset,
|
|
31
|
+
} from './infra-cloudflare-analytics.js';
|
|
24
32
|
import { apply, estimate, plan } from './infra-cloudflare-plan.js';
|
|
33
|
+
import { tokenTemplate, WRITE_SECRET, writePermissions } from './infra-cloudflare-tokens.js';
|
|
25
34
|
|
|
26
35
|
/** @typedef {import('./infra-provider.js').Provider} Provider */
|
|
27
36
|
/** @typedef {import('./infra-provider.js').ProviderContext} ProviderContext */
|
|
@@ -71,22 +80,44 @@ export const CLOUDFLARE_KINDS = Object.fromEntries(
|
|
|
71
80
|
? { changes: [...BASE, ...ENVELOPE[k].changes], scales: ENVELOPE[k].scales, settings: [...settings] }
|
|
72
81
|
: { changes: [...BASE], settings: [...settings] }),
|
|
73
82
|
...(ACCESS.includes(k) ? { access: true } : {}),
|
|
83
|
+
// A Worker is what an environment's discovery starts from (BRK-291).
|
|
84
|
+
...(k === 'worker' ? { target: true } : {}),
|
|
74
85
|
},
|
|
75
86
|
]),
|
|
76
87
|
);
|
|
77
88
|
|
|
78
89
|
/**
|
|
79
|
-
* How a Worker's binding names the resource it binds, for the console (BRK-262):
|
|
80
|
-
*
|
|
90
|
+
* How a Worker's binding names the resource it binds, for the console (BRK-262): in Cloudflare's own field, the one
|
|
91
|
+
* the plan compares with what runs (BINDING_TARGETS; BRK-285), by the resource's ID on Cloudflare (a D1 database, a KV
|
|
92
|
+
* namespace) or its name (a bucket, a queue, a Worker). A database or namespace the plan still makes has no ID yet, so
|
|
93
|
+
* a binding names it by its ID in the desired state, in `resource`, until it runs.
|
|
81
94
|
* @type {import('./infra-provider.js').BindingTarget[]}
|
|
82
95
|
*/
|
|
83
96
|
const BINDABLE = [
|
|
84
|
-
{ type: 'd1', label: 'D1 database', kind: 'd1', field: '
|
|
85
|
-
{ type: 'kv_namespace', label: 'KV namespace', kind: 'kv', field: '
|
|
86
|
-
{ type: 'r2_bucket', label: 'R2 bucket', kind: 'r2', field: '
|
|
87
|
-
{ type: 'queue', label: 'Queue', kind: 'queue', field: '
|
|
97
|
+
{ type: 'd1', label: 'D1 database', kind: 'd1', field: 'id', by: 'id' },
|
|
98
|
+
{ type: 'kv_namespace', label: 'KV namespace', kind: 'kv', field: 'namespace_id', by: 'id' },
|
|
99
|
+
{ type: 'r2_bucket', label: 'R2 bucket', kind: 'r2', field: 'bucket_name', by: 'name' },
|
|
100
|
+
{ type: 'queue', label: 'Queue', kind: 'queue', field: 'queue_name', by: 'name' },
|
|
88
101
|
{ type: 'service', label: 'Worker', kind: 'worker', field: 'service', by: 'name' },
|
|
89
102
|
];
|
|
103
|
+
/** The binding types BINDABLE doesn't list, in words, for a change's lines (WEB-110). */
|
|
104
|
+
const BINDING_WORDS = {
|
|
105
|
+
durable_object_namespace: 'Durable Object',
|
|
106
|
+
assets: 'static assets',
|
|
107
|
+
plain_text: 'variable',
|
|
108
|
+
secret_text: 'secret',
|
|
109
|
+
json: 'variable',
|
|
110
|
+
analytics_engine: 'Analytics Engine',
|
|
111
|
+
ai: 'Workers AI',
|
|
112
|
+
browser: 'Browser Rendering',
|
|
113
|
+
hyperdrive: 'Hyperdrive',
|
|
114
|
+
vectorize: 'Vectorize index',
|
|
115
|
+
version_metadata: 'version metadata',
|
|
116
|
+
workflow: 'Workflow',
|
|
117
|
+
send_email: 'email',
|
|
118
|
+
mtls_certificate: 'mTLS certificate',
|
|
119
|
+
dispatch_namespace: 'dispatch namespace',
|
|
120
|
+
};
|
|
90
121
|
const CRON = '^\\S+( \\S+){4}$';
|
|
91
122
|
const VALUES_ELSEWHERE = 'Set with the Worker’s deploy, never here.';
|
|
92
123
|
|
|
@@ -151,6 +182,7 @@ export const EDITABLE = {
|
|
|
151
182
|
label: 'Bindings',
|
|
152
183
|
type: 'bindings',
|
|
153
184
|
targets: BINDABLE,
|
|
185
|
+
typeLabels: BINDING_WORDS,
|
|
154
186
|
help: 'The environment’s databases, namespaces, buckets, queues, and Workers it reaches, each by a name its code uses. Variables and secrets are kept as they are.',
|
|
155
187
|
},
|
|
156
188
|
],
|
|
@@ -316,6 +348,193 @@ export const EDITABLE = {
|
|
|
316
348
|
*/
|
|
317
349
|
export const editable = (kind) => EDITABLE[kind] ?? null;
|
|
318
350
|
|
|
351
|
+
/**
|
|
352
|
+
* How a Worker binds each kind the console adds, as BINDABLE says; `required` when the plan makes one only if a Worker
|
|
353
|
+
* binds it.
|
|
354
|
+
*/
|
|
355
|
+
const bindAs = (kind, required = false) => {
|
|
356
|
+
const target = BINDABLE.find((t) => t.kind === kind);
|
|
357
|
+
return target ? { bind: { kind: 'worker', list: 'bindings', target, ...(required ? { required } : {}) } } : {};
|
|
358
|
+
};
|
|
359
|
+
/** Names Cloudflare gives Workers, queues, and containers: lowercase letters, digits, and dashes, up to 63. */
|
|
360
|
+
const DNS_LABEL = '^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$';
|
|
361
|
+
const HOSTNAME = '^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z]{2,63}$';
|
|
362
|
+
const ZONE_FIELD = {
|
|
363
|
+
path: 'zone',
|
|
364
|
+
label: 'Zone',
|
|
365
|
+
type: 'text',
|
|
366
|
+
pattern: HOSTNAME,
|
|
367
|
+
help: 'The domain on Cloudflare it’s on, like acme.example: one the environment’s token reaches.',
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* The kinds the console may add (BRK-270), every kind Architect plans. Name rules are Cloudflare's as last read
|
|
372
|
+
* (developers.cloudflare.com: Workers, Queues, R2, KV, and D1 limits). A database, namespace, bucket, or queue is made
|
|
373
|
+
* only when a Worker in the environment binds it, so each says how one binds it. A Durable Object class and a container
|
|
374
|
+
* application are made by their Worker's code and deploy, so the plan won't make one: the console adds it to the file,
|
|
375
|
+
* `needsCode` says what code must exist, and the plan finds it by name once the deploy has made it.
|
|
376
|
+
* @type {Record<string, Omit<import('./infra-provider.js').Creatable, 'defaults'> & { defaults?: () => Record<string, unknown> }>}
|
|
377
|
+
*/
|
|
378
|
+
export const CREATABLE = {
|
|
379
|
+
worker: {
|
|
380
|
+
label: 'Worker',
|
|
381
|
+
help: 'Runs the code that answers requests. It starts with a script that answers /health, until the repository’s deploy puts its own code on it.',
|
|
382
|
+
name: {
|
|
383
|
+
label: 'Name',
|
|
384
|
+
pattern: DNS_LABEL,
|
|
385
|
+
max: 63,
|
|
386
|
+
help: 'Lowercase letters, digits, and dashes, up to 63, unique in the account, like acme-jobs.',
|
|
387
|
+
},
|
|
388
|
+
required: ['compatibilityDate'],
|
|
389
|
+
defaults: () => ({ compatibilityDate: new Date().toISOString().slice(0, 10), observability: true }),
|
|
390
|
+
...bindAs('worker'),
|
|
391
|
+
},
|
|
392
|
+
queue: {
|
|
393
|
+
label: 'Queue',
|
|
394
|
+
help: 'Holds messages one Worker sends until another takes them, one batch at a time.',
|
|
395
|
+
name: {
|
|
396
|
+
label: 'Name',
|
|
397
|
+
pattern: DNS_LABEL,
|
|
398
|
+
max: 63,
|
|
399
|
+
help: 'Lowercase letters, digits, and dashes, up to 63, unique in the account, like acme-jobs.',
|
|
400
|
+
},
|
|
401
|
+
defaults: () => ({ deliveryDelay: 0, deliveryPaused: false, retention: 345_600 }),
|
|
402
|
+
...bindAs('queue', true),
|
|
403
|
+
},
|
|
404
|
+
r2: {
|
|
405
|
+
label: 'R2 bucket',
|
|
406
|
+
help: 'Keeps files (objects) by key: uploads, images, exports.',
|
|
407
|
+
name: {
|
|
408
|
+
label: 'Name',
|
|
409
|
+
pattern: '^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$',
|
|
410
|
+
max: 63,
|
|
411
|
+
help: 'Lowercase letters, digits, and dashes, 3 to 63, unique in the account, like acme-uploads.',
|
|
412
|
+
},
|
|
413
|
+
fields: [
|
|
414
|
+
{
|
|
415
|
+
path: 'location',
|
|
416
|
+
label: 'Location',
|
|
417
|
+
type: 'choice',
|
|
418
|
+
optional: true,
|
|
419
|
+
options: [
|
|
420
|
+
{ value: 'wnam', label: 'Western North America' },
|
|
421
|
+
{ value: 'enam', label: 'Eastern North America' },
|
|
422
|
+
{ value: 'weur', label: 'Western Europe' },
|
|
423
|
+
{ value: 'eeur', label: 'Eastern Europe' },
|
|
424
|
+
{ value: 'apac', label: 'Asia-Pacific' },
|
|
425
|
+
{ value: 'oc', label: 'Oceania' },
|
|
426
|
+
],
|
|
427
|
+
help: 'Where Cloudflare keeps it, as a hint. Unset puts it near whoever makes it. It can’t move later.',
|
|
428
|
+
},
|
|
429
|
+
],
|
|
430
|
+
...bindAs('r2', true),
|
|
431
|
+
},
|
|
432
|
+
kv: {
|
|
433
|
+
label: 'KV namespace',
|
|
434
|
+
help: 'Keeps small values by key, read often and written now and then: settings, flags, sessions.',
|
|
435
|
+
name: {
|
|
436
|
+
label: 'Title',
|
|
437
|
+
pattern: '^\\S(?:.*\\S)?$',
|
|
438
|
+
max: 512,
|
|
439
|
+
help: 'Up to 512 characters, unique in the account, like acme-flags.',
|
|
440
|
+
},
|
|
441
|
+
...bindAs('kv', true),
|
|
442
|
+
},
|
|
443
|
+
d1: {
|
|
444
|
+
label: 'D1 database',
|
|
445
|
+
help: 'A SQL database (SQLite) for the Worker’s data.',
|
|
446
|
+
name: {
|
|
447
|
+
label: 'Name',
|
|
448
|
+
pattern: '^[a-z0-9][a-z0-9_-]{0,63}$',
|
|
449
|
+
max: 64,
|
|
450
|
+
help: 'Lowercase letters, digits, dashes, and underscores, up to 64, unique in the account, like acme-db.',
|
|
451
|
+
},
|
|
452
|
+
...bindAs('d1', true),
|
|
453
|
+
},
|
|
454
|
+
route: {
|
|
455
|
+
label: 'Route',
|
|
456
|
+
help: 'Sends the requests that match a pattern on one of your domains to a Worker.',
|
|
457
|
+
name: { ...EDITABLE.route.name, label: 'Pattern', max: 255 },
|
|
458
|
+
fields: [ZONE_FIELD],
|
|
459
|
+
required: ['zone', 'worker'],
|
|
460
|
+
},
|
|
461
|
+
'custom-domain': {
|
|
462
|
+
label: 'Custom domain',
|
|
463
|
+
help: 'Makes a hostname answer with a Worker, with its DNS record and certificate.',
|
|
464
|
+
name: {
|
|
465
|
+
label: 'Hostname',
|
|
466
|
+
pattern: HOSTNAME,
|
|
467
|
+
max: 253,
|
|
468
|
+
help: 'A hostname on one of your zones, like api.acme.example, that no other Worker has.',
|
|
469
|
+
},
|
|
470
|
+
fields: [ZONE_FIELD],
|
|
471
|
+
required: ['zone', 'worker'],
|
|
472
|
+
},
|
|
473
|
+
'durable-object': {
|
|
474
|
+
label: 'Durable Object',
|
|
475
|
+
help: 'One object per ID that keeps its own state, made from a class in a Worker’s code: a counter, a room, a lock.',
|
|
476
|
+
name: {
|
|
477
|
+
label: 'Name',
|
|
478
|
+
pattern: '^[a-z0-9][a-z0-9-]{0,62}_[A-Za-z_$][A-Za-z0-9_$]*$',
|
|
479
|
+
max: 128,
|
|
480
|
+
help: 'Its Worker’s name and its class’s, joined by _, like acme-api_Counter.',
|
|
481
|
+
},
|
|
482
|
+
fields: [
|
|
483
|
+
{
|
|
484
|
+
path: 'class',
|
|
485
|
+
label: 'Class',
|
|
486
|
+
type: 'text',
|
|
487
|
+
pattern: '^[A-Za-z_$][A-Za-z0-9_$]*$',
|
|
488
|
+
help: 'The class in the Worker’s code that each object is, like Counter.',
|
|
489
|
+
},
|
|
490
|
+
{
|
|
491
|
+
path: 'script',
|
|
492
|
+
label: 'Worker',
|
|
493
|
+
type: 'resource',
|
|
494
|
+
kinds: ['worker'],
|
|
495
|
+
help: 'The Worker whose code has the class.',
|
|
496
|
+
},
|
|
497
|
+
],
|
|
498
|
+
required: ['class', 'script'],
|
|
499
|
+
needsCode:
|
|
500
|
+
'The Worker’s code exports the class, its wrangler config binds it and adds it in a migration, and the Worker is deployed. Until then, the plan won’t apply.',
|
|
501
|
+
},
|
|
502
|
+
container: {
|
|
503
|
+
label: 'Container',
|
|
504
|
+
help: 'Runs a container image next to a Durable Object, for code that can’t run in a Worker.',
|
|
505
|
+
name: {
|
|
506
|
+
label: 'Name',
|
|
507
|
+
pattern: DNS_LABEL,
|
|
508
|
+
max: 63,
|
|
509
|
+
help: 'Lowercase letters, digits, and dashes, up to 63, unique in the account, like acme-render.',
|
|
510
|
+
},
|
|
511
|
+
fields: [
|
|
512
|
+
{
|
|
513
|
+
path: 'image',
|
|
514
|
+
label: 'Image',
|
|
515
|
+
type: 'text',
|
|
516
|
+
help: 'What it runs: the Dockerfile in the repository, like ./Dockerfile, or an image’s address.',
|
|
517
|
+
},
|
|
518
|
+
],
|
|
519
|
+
required: ['image', 'maxInstances'],
|
|
520
|
+
defaults: () => ({ maxInstances: 1 }),
|
|
521
|
+
needsCode:
|
|
522
|
+
'The image, the Durable Object class that starts it, and the container in the Worker’s wrangler config, and the Worker is deployed. Until then, the plan won’t apply.',
|
|
523
|
+
},
|
|
524
|
+
};
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Whether the console may add a resource of `kind` (BRK-270), what a new one is given, and its name's rule.
|
|
528
|
+
* @param {string} kind
|
|
529
|
+
* @returns {import('./infra-provider.js').Creatable | null}
|
|
530
|
+
*/
|
|
531
|
+
export function creatable(kind) {
|
|
532
|
+
const c = CREATABLE[kind];
|
|
533
|
+
if (!c) return null;
|
|
534
|
+
const { defaults, ...rest } = c;
|
|
535
|
+
return { ...structuredClone(rest), ...(defaults ? { defaults: defaults() } : {}) };
|
|
536
|
+
}
|
|
537
|
+
|
|
319
538
|
/** The scheduling policy whose container applications Cloudflare scales and rolls out; any other is the code's. */
|
|
320
539
|
export const DEFAULT_SCHEDULING = 'default';
|
|
321
540
|
|
|
@@ -619,9 +838,8 @@ function workerAttrs(script, settings, deployments, secrets, schedules) {
|
|
|
619
838
|
usageModel: settings.usage_model ?? script.usage_model ?? null,
|
|
620
839
|
observability: Boolean(settings.observability?.enabled),
|
|
621
840
|
placement: settings.placement?.mode ?? script.placement_mode ?? null,
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
.sort((a, b) => a.name.localeCompare(b.name)),
|
|
841
|
+
// What each binding binds, by ID or name (BINDING_TARGETS), so a draft names it as the plan compares it (BRK-285).
|
|
842
|
+
bindings: (settings.bindings ?? []).map(bindingTarget).sort((a, b) => a.name.localeCompare(b.name)),
|
|
625
843
|
secrets: sorted(secrets.map((s) => String(s.name))),
|
|
626
844
|
crons: sorted((schedules?.schedules ?? []).map((s) => String(s.cron))),
|
|
627
845
|
versions: (live?.versions ?? []).map((v) => ({ id: String(v.version_id), percentage: Number(v.percentage) })),
|
|
@@ -717,7 +935,65 @@ export async function discover(ctx, { live = false } = {}) {
|
|
|
717
935
|
),
|
|
718
936
|
);
|
|
719
937
|
seen.scripts = [...scripts.keys()];
|
|
720
|
-
|
|
938
|
+
/** @param {Set<string>} workers the Workers in scope, whose routes and custom domains are resources */
|
|
939
|
+
async function serving(workers) {
|
|
940
|
+
// Routes, on the zones the token reaches, that send to a Worker in scope. The zones list holds every zone on the
|
|
941
|
+
// account for an account-owned token, so a 403 on one zone's routes is skipped and named in `skipped`, never an
|
|
942
|
+
// error (BRK-254); only when every zone refuses does discovery fail, naming the permission.
|
|
943
|
+
const zones = await cf.all(`/zones?account.id=${a}`, { permission: 'Zone Read' }, 50);
|
|
944
|
+
/** @type {Array<{ zone: string, error: any }>} */
|
|
945
|
+
const refused = [];
|
|
946
|
+
for (const zone of zones) {
|
|
947
|
+
seen.zones[String(zone.name)] = String(zone.id);
|
|
948
|
+
let routes;
|
|
949
|
+
try {
|
|
950
|
+
routes =
|
|
951
|
+
(await cf.get(`/zones/${enc(zone.id)}/workers/routes`, { permission: 'Workers Routes Read' })).result ?? [];
|
|
952
|
+
} catch (error) {
|
|
953
|
+
if (error?.status !== 403) throw error;
|
|
954
|
+
refused.push({ zone: String(zone.name), error });
|
|
955
|
+
continue;
|
|
956
|
+
}
|
|
957
|
+
for (const route of routes) {
|
|
958
|
+
seen.routes.push({
|
|
959
|
+
pattern: String(route.pattern),
|
|
960
|
+
zone: String(zone.name),
|
|
961
|
+
worker: String(route.script ?? ''),
|
|
962
|
+
});
|
|
963
|
+
if (!workers.has(String(route.script))) continue;
|
|
964
|
+
add({
|
|
965
|
+
id: rid('route', route.id),
|
|
966
|
+
kind: 'route',
|
|
967
|
+
name: String(route.pattern),
|
|
968
|
+
attrs: { zone: zone.name, worker: String(route.script) },
|
|
969
|
+
});
|
|
970
|
+
relate(rid('worker', String(route.script)), rid('route', route.id), 'serves');
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
if (refused.length && refused.length === zones.length) throw refused[0].error;
|
|
974
|
+
if (refused.length) skipped.push(routesSkipped(refused));
|
|
975
|
+
|
|
976
|
+
// Custom domains attached to a Worker in scope.
|
|
977
|
+
const domains = (await cf.get(`/accounts/${a}/workers/domains`, { permission: WORKERS_READ })).result ?? [];
|
|
978
|
+
for (const d of domains) {
|
|
979
|
+
seen.domains.push({ hostname: String(d.hostname), worker: String(d.service ?? '') });
|
|
980
|
+
if (!workers.has(String(d.service))) continue;
|
|
981
|
+
add({
|
|
982
|
+
id: rid('custom-domain', d.id),
|
|
983
|
+
kind: 'custom-domain',
|
|
984
|
+
name: String(d.hostname),
|
|
985
|
+
attrs: { zone: d.zone_name ?? null, environment: d.environment ?? null, worker: String(d.service) },
|
|
986
|
+
});
|
|
987
|
+
relate(rid('worker', String(d.service)), rid('custom-domain', d.id), 'serves');
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
// A target that doesn't run yet (a new environment built from the console, BRK-291) has nothing in scope, but a plan
|
|
992
|
+
// still needs the zones, routes, and domains, so its first change can serve a hostname (BRK-292).
|
|
993
|
+
if (!scripts.has(target)) {
|
|
994
|
+
if (live) await serving(new Set());
|
|
995
|
+
return done();
|
|
996
|
+
}
|
|
721
997
|
|
|
722
998
|
// The Workers in scope: the target, and the Workers it calls or whose Durable Objects it binds.
|
|
723
999
|
/** @type {Map<string, any[]>} */
|
|
@@ -949,52 +1225,7 @@ export async function discover(ctx, { live = false } = {}) {
|
|
|
949
1225
|
missing.push('Containers Read');
|
|
950
1226
|
}
|
|
951
1227
|
|
|
952
|
-
|
|
953
|
-
// account for an account-owned token, so a 403 on one zone's routes is skipped and named in `skipped`, never an
|
|
954
|
-
// error (BRK-254); only when every zone refuses does discovery fail, naming the permission.
|
|
955
|
-
const zones = await cf.all(`/zones?account.id=${a}`, { permission: 'Zone Read' }, 50);
|
|
956
|
-
/** @type {Array<{ zone: string, error: any }>} */
|
|
957
|
-
const refused = [];
|
|
958
|
-
for (const zone of zones) {
|
|
959
|
-
seen.zones[String(zone.name)] = String(zone.id);
|
|
960
|
-
let routes;
|
|
961
|
-
try {
|
|
962
|
-
routes =
|
|
963
|
-
(await cf.get(`/zones/${enc(zone.id)}/workers/routes`, { permission: 'Workers Routes Read' })).result ?? [];
|
|
964
|
-
} catch (error) {
|
|
965
|
-
if (error?.status !== 403) throw error;
|
|
966
|
-
refused.push({ zone: String(zone.name), error });
|
|
967
|
-
continue;
|
|
968
|
-
}
|
|
969
|
-
for (const route of routes) {
|
|
970
|
-
seen.routes.push({ pattern: String(route.pattern), zone: String(zone.name), worker: String(route.script ?? '') });
|
|
971
|
-
if (!workers.has(String(route.script))) continue;
|
|
972
|
-
add({
|
|
973
|
-
id: rid('route', route.id),
|
|
974
|
-
kind: 'route',
|
|
975
|
-
name: String(route.pattern),
|
|
976
|
-
attrs: { zone: zone.name, worker: String(route.script) },
|
|
977
|
-
});
|
|
978
|
-
relate(rid('worker', String(route.script)), rid('route', route.id), 'serves');
|
|
979
|
-
}
|
|
980
|
-
}
|
|
981
|
-
if (refused.length && refused.length === zones.length) throw refused[0].error;
|
|
982
|
-
if (refused.length) skipped.push(routesSkipped(refused));
|
|
983
|
-
|
|
984
|
-
// Custom domains attached to a Worker in scope.
|
|
985
|
-
const domains = (await cf.get(`/accounts/${a}/workers/domains`, { permission: WORKERS_READ })).result ?? [];
|
|
986
|
-
for (const d of domains) {
|
|
987
|
-
seen.domains.push({ hostname: String(d.hostname), worker: String(d.service ?? '') });
|
|
988
|
-
if (!workers.has(String(d.service))) continue;
|
|
989
|
-
add({
|
|
990
|
-
id: rid('custom-domain', d.id),
|
|
991
|
-
kind: 'custom-domain',
|
|
992
|
-
name: String(d.hostname),
|
|
993
|
-
attrs: { zone: d.zone_name ?? null, environment: d.environment ?? null, worker: String(d.service) },
|
|
994
|
-
});
|
|
995
|
-
relate(rid('worker', String(d.service)), rid('custom-domain', d.id), 'serves');
|
|
996
|
-
}
|
|
997
|
-
|
|
1228
|
+
await serving(workers);
|
|
998
1229
|
return done();
|
|
999
1230
|
}
|
|
1000
1231
|
|
|
@@ -1245,11 +1476,16 @@ export const HEALTH_LIMITS = {
|
|
|
1245
1476
|
|
|
1246
1477
|
const MINUTE = 60_000;
|
|
1247
1478
|
const pct = (n) => `${Math.round(n * 100)}%`;
|
|
1248
|
-
|
|
1479
|
+
/** A health window in words: "in the last 15 minutes", "in the last hour", "in the last day". */
|
|
1480
|
+
const sinceWords = (minutes) =>
|
|
1481
|
+
minutes === 60 ? 'in the last hour' : minutes === 24 * 60 ? 'in the last day' : `in the last ${minutes} minutes`;
|
|
1249
1482
|
|
|
1250
|
-
/**
|
|
1251
|
-
|
|
1252
|
-
|
|
1483
|
+
/**
|
|
1484
|
+
* Health from requests and the ones that failed over a window: idle with none (BRK-266: deployed, no traffic, no
|
|
1485
|
+
* errors seen), then by HEALTH_LIMITS.
|
|
1486
|
+
*/
|
|
1487
|
+
function byErrors(requests, errors, since, noun = 'requests', alive = '') {
|
|
1488
|
+
if (!requests) return { state: 'idle', text: `Idle: no ${noun} ${since}${alive}` };
|
|
1253
1489
|
const share = errors / requests;
|
|
1254
1490
|
const said = `${pct(share)} of ${requests} ${noun} failed ${since}`;
|
|
1255
1491
|
if (share >= HEALTH_LIMITS.downErrors) return { state: 'down', text: said };
|
|
@@ -1258,23 +1494,31 @@ function byErrors(requests, errors, noun = 'requests') {
|
|
|
1258
1494
|
}
|
|
1259
1495
|
|
|
1260
1496
|
/**
|
|
1261
|
-
* One resource's health from what the analytics and the APIs said.
|
|
1497
|
+
* One resource's health from what the analytics and the APIs said. A quiet resource is idle, never unknown: discover
|
|
1498
|
+
* just found it, which is the metadata read that says it exists, and a Worker's deployment says it serves (BRK-266).
|
|
1499
|
+
* Unknown is only for what the board couldn't read, and says which call.
|
|
1262
1500
|
* @param {Resource} r
|
|
1263
1501
|
* @param {Record<string, number> | undefined} u its usage over the window
|
|
1264
|
-
* @param {{
|
|
1502
|
+
* @param {{ result?: any, refused?: string, missing?: boolean } | undefined} backlog a queue's backlog now, or why not
|
|
1265
1503
|
* @param {number} now
|
|
1504
|
+
* @param {number} minutes the window its usage came from
|
|
1266
1505
|
* @returns {{ state: string, text: string }}
|
|
1267
1506
|
*/
|
|
1268
|
-
function judge(r, u = {}, backlog, now) {
|
|
1507
|
+
function judge(r, u = {}, backlog, now, minutes = HEALTH_WINDOW_MINUTES) {
|
|
1269
1508
|
const n = (k) => Number(u[k] ?? 0) || 0;
|
|
1270
1509
|
const attrs = /** @type {Record<string, any>} */ (r.attrs ?? {});
|
|
1510
|
+
const since = sinceWords(minutes);
|
|
1271
1511
|
switch (r.kind) {
|
|
1272
|
-
case 'worker':
|
|
1512
|
+
case 'worker': {
|
|
1273
1513
|
if (Array.isArray(attrs.versions) && attrs.versions.length === 0)
|
|
1274
1514
|
return { state: 'down', text: 'No deployment: it serves nothing' };
|
|
1275
|
-
|
|
1515
|
+
const live = Array.isArray(attrs.versions)
|
|
1516
|
+
? attrs.versions.reduce((sum, v) => sum + (Number(v?.percentage) || 0), 0)
|
|
1517
|
+
: 0;
|
|
1518
|
+
return byErrors(n('requests'), n('errors'), since, 'requests', live >= 100 ? '; deployed and serving' : '');
|
|
1519
|
+
}
|
|
1276
1520
|
case 'durable-object':
|
|
1277
|
-
return byErrors(n('requests'), n('errors'));
|
|
1521
|
+
return byErrors(n('requests'), n('errors'), since);
|
|
1278
1522
|
case 'r2': {
|
|
1279
1523
|
let all = 0;
|
|
1280
1524
|
let failed = 0;
|
|
@@ -1283,11 +1527,11 @@ function judge(r, u = {}, backlog, now) {
|
|
|
1283
1527
|
all += v;
|
|
1284
1528
|
if (Number(k.slice('requests:'.length)) >= 500) failed += v;
|
|
1285
1529
|
}
|
|
1286
|
-
return byErrors(all, failed, 'operations');
|
|
1530
|
+
return byErrors(all, failed, since, 'operations');
|
|
1287
1531
|
}
|
|
1288
1532
|
case 'd1': {
|
|
1289
1533
|
const queries = n('readQueries') + n('writeQueries');
|
|
1290
|
-
if (!queries) return { state: '
|
|
1534
|
+
if (!queries) return { state: 'idle', text: `Idle: no queries ${since}` };
|
|
1291
1535
|
const ms = Math.round(n('queryBatchTimeMs'));
|
|
1292
1536
|
if (ms > HEALTH_LIMITS.slowQueryMs)
|
|
1293
1537
|
return { state: 'degraded', text: `Queries took ${ms} ms on average ${since}` };
|
|
@@ -1296,12 +1540,22 @@ function judge(r, u = {}, backlog, now) {
|
|
|
1296
1540
|
case 'kv':
|
|
1297
1541
|
return n('requests')
|
|
1298
1542
|
? { state: 'healthy', text: `${n('requests')} operations ${since}` }
|
|
1299
|
-
: { state: '
|
|
1543
|
+
: { state: 'idle', text: `Idle: no operations ${since}` };
|
|
1300
1544
|
case 'queue': {
|
|
1301
1545
|
if (attrs.deliveryPaused) return { state: 'degraded', text: 'Delivery is paused' };
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1546
|
+
const consumers = Array.isArray(attrs.consumers) ? attrs.consumers : null;
|
|
1547
|
+
if (consumers && consumers.length === 0)
|
|
1548
|
+
return { state: 'degraded', text: 'No consumer is attached, so nothing reads its messages' };
|
|
1549
|
+
if (backlog?.refused) return { state: 'unknown', text: backlog.refused };
|
|
1550
|
+
if (!backlog?.result)
|
|
1551
|
+
return {
|
|
1552
|
+
state: 'idle',
|
|
1553
|
+
text: consumers
|
|
1554
|
+
? 'Idle: a consumer is attached, and Cloudflare has no backlog figures for it'
|
|
1555
|
+
: 'Idle: Cloudflare has no backlog figures for it',
|
|
1556
|
+
};
|
|
1557
|
+
const count = Number(backlog.result.backlog_count ?? 0) || 0;
|
|
1558
|
+
const oldest = Number(backlog.result.oldest_message_timestamp_ms ?? 0) || 0;
|
|
1305
1559
|
const waited = count && oldest ? Math.floor((now - oldest) / MINUTE) : 0;
|
|
1306
1560
|
if (waited > HEALTH_LIMITS.staleQueueMinutes)
|
|
1307
1561
|
return { state: 'degraded', text: `The oldest message has waited ${waited} minutes; ${count} in the backlog` };
|
|
@@ -1314,23 +1568,32 @@ function judge(r, u = {}, backlog, now) {
|
|
|
1314
1568
|
const active = attrs.active;
|
|
1315
1569
|
const assigned = attrs.assigned;
|
|
1316
1570
|
if (typeof active !== 'number' || typeof assigned !== 'number')
|
|
1317
|
-
return {
|
|
1571
|
+
return {
|
|
1572
|
+
state: 'unknown',
|
|
1573
|
+
text: 'Cloudflare gave no instance counts when its application was read (GET …/containers/applications): try again at the next refresh',
|
|
1574
|
+
};
|
|
1575
|
+
if (assigned === 0 && active === 0) return { state: 'idle', text: 'Idle: no instances are assigned' };
|
|
1318
1576
|
if (assigned > 0 && active === 0) return { state: 'down', text: `None of ${assigned} instances is running` };
|
|
1319
1577
|
if (active < assigned) return { state: 'degraded', text: `${active} of ${assigned} instances are running` };
|
|
1320
1578
|
return { state: 'healthy', text: `${active} of ${assigned} instances are running` };
|
|
1321
1579
|
}
|
|
1322
1580
|
default:
|
|
1323
|
-
return { state: 'unknown', text: 'Cloudflare reports no health for
|
|
1581
|
+
return { state: 'unknown', text: 'Cloudflare reports no health for its kind' };
|
|
1324
1582
|
}
|
|
1325
1583
|
}
|
|
1326
1584
|
|
|
1327
1585
|
/**
|
|
1328
|
-
* Each resource's health now (BRK-188's "Observe" rows): errors and slowness
|
|
1329
|
-
*
|
|
1330
|
-
*
|
|
1331
|
-
*
|
|
1332
|
-
*
|
|
1333
|
-
*
|
|
1586
|
+
* Each resource's health now (BRK-188's "Observe" rows; BRK-266): errors and slowness from the analytics, one query
|
|
1587
|
+
* per dataset for the whole environment, over a window that ends HEALTH_LAG_MINUTES back, since Cloudflare's analytics
|
|
1588
|
+
* arrive late. A resource with no traffic in the first of HEALTH_WINDOWS is read again over the next (an hour, then a
|
|
1589
|
+
* day) before it's idle, and its text names the window its verdict came from. A queue's backlog now; a container
|
|
1590
|
+
* application's instance counts as discover found them; and a route's or custom domain's from the Worker it serves.
|
|
1591
|
+
*
|
|
1592
|
+
* Unknown only when the board couldn't read: a dataset Cloudflare won't answer, or a queue's backlog the token may not
|
|
1593
|
+
* read, says which call and what to do, and the store keeps the last health with its age. A longer window Cloudflare
|
|
1594
|
+
* won't answer only stops the fallback. Anything else (a 429, a 403 on the analytics, Cloudflare out of reach) stops
|
|
1595
|
+
* it, so the store keeps the last health. Uses `ctx.resources` when the store passes what discover just found, and
|
|
1596
|
+
* discovers otherwise.
|
|
1334
1597
|
* @param {ProviderContext} ctx
|
|
1335
1598
|
* @returns {Promise<import('./infra-provider.js').Health[]>}
|
|
1336
1599
|
*/
|
|
@@ -1340,35 +1603,53 @@ export async function observe(ctx) {
|
|
|
1340
1603
|
const cf = reader(ctx);
|
|
1341
1604
|
const account = await accountOf(cf, ctx);
|
|
1342
1605
|
const now = Date.now();
|
|
1343
|
-
const to = new Date(now);
|
|
1344
|
-
const from = new Date(now - HEALTH_WINDOW_MINUTES * MINUTE);
|
|
1606
|
+
const to = new Date(now - HEALTH_LAG_MINUTES * MINUTE);
|
|
1345
1607
|
/** @type {Map<string, Record<string, number>>} usage by resource ID */
|
|
1346
1608
|
const usage = new Map();
|
|
1609
|
+
/** @type {Map<string, number>} the window each resource's verdict comes from, in minutes */
|
|
1610
|
+
const windowOf = new Map();
|
|
1347
1611
|
/** @type {Map<string, string>} what the analytics didn't answer, by resource ID */
|
|
1348
1612
|
const unread = new Map();
|
|
1349
1613
|
for (const d of Object.values(HEALTH_DATASETS)) {
|
|
1350
1614
|
const mine = resources.filter((r) => r.kind === d.kind);
|
|
1351
1615
|
if (!mine.length) continue;
|
|
1352
1616
|
const byKey = new Map(mine.map((r) => [usageKey(r), r.id]));
|
|
1353
|
-
let
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1617
|
+
let quiet = [...byKey.keys()];
|
|
1618
|
+
for (const [i, minutes] of (d.busy ? HEALTH_WINDOWS : HEALTH_WINDOWS.slice(0, 1)).entries()) {
|
|
1619
|
+
if (!quiet.length) break;
|
|
1620
|
+
const from = new Date(to.getTime() - minutes * MINUTE);
|
|
1621
|
+
let found;
|
|
1622
|
+
try {
|
|
1623
|
+
found = await readDataset(ctx, { account, dataset: d, keys: quiet, from, to });
|
|
1624
|
+
} catch (error) {
|
|
1625
|
+
if (error?.status !== 400) throw error;
|
|
1626
|
+
// The first window unanswered leaves them unread; a longer one only stops looking further back.
|
|
1627
|
+
if (i === 0)
|
|
1628
|
+
for (const r of mine)
|
|
1629
|
+
unread.set(
|
|
1630
|
+
r.id,
|
|
1631
|
+
`Couldn’t read its health: Cloudflare’s analytics didn’t answer for its ${d.label} (${d.dataset}). The last health is kept; if it lasts, check the token has Account Analytics Read`,
|
|
1632
|
+
);
|
|
1633
|
+
break;
|
|
1634
|
+
}
|
|
1635
|
+
for (const key of quiet) windowOf.set(/** @type {string} */ (byKey.get(key)), minutes);
|
|
1636
|
+
for (const [key, u] of found) usage.set(/** @type {string} */ (byKey.get(key)), u);
|
|
1637
|
+
quiet = d.busy ? quiet.filter((key) => !d.busy?.(found.get(key) ?? {})) : [];
|
|
1360
1638
|
}
|
|
1361
|
-
for (const [key, u] of found) usage.set(/** @type {string} */ (byKey.get(key)), u);
|
|
1362
1639
|
}
|
|
1363
|
-
/** @type {Map<string, any>} a queue's backlog now,
|
|
1640
|
+
/** @type {Map<string, { result?: any, refused?: string }>} a queue's backlog now, or why it couldn't be read */
|
|
1364
1641
|
const backlogs = new Map();
|
|
1365
1642
|
for (const r of resources.filter((q) => q.kind === 'queue')) {
|
|
1366
1643
|
const path = `/accounts/${enc(account)}/queues/${enc(r.id.slice('queue:'.length))}/metrics`;
|
|
1367
1644
|
try {
|
|
1368
1645
|
const json = await cf.get(path, { permission: 'Queues Read', missingOk: true });
|
|
1369
|
-
|
|
1646
|
+
backlogs.set(r.id, json?.result ? { result: json.result } : {});
|
|
1370
1647
|
} catch (error) {
|
|
1371
1648
|
if (error?.status !== 403) throw error;
|
|
1649
|
+
backlogs.set(r.id, {
|
|
1650
|
+
refused:
|
|
1651
|
+
'Couldn’t read its backlog: Cloudflare refused GET …/queues/<id>/metrics. Give the read-only token Queues Read on Connections',
|
|
1652
|
+
});
|
|
1372
1653
|
}
|
|
1373
1654
|
}
|
|
1374
1655
|
|
|
@@ -1376,12 +1657,12 @@ export async function observe(ctx) {
|
|
|
1376
1657
|
const health = new Map();
|
|
1377
1658
|
for (const r of resources) {
|
|
1378
1659
|
if (r.kind === 'route' || r.kind === 'custom-domain') continue;
|
|
1379
|
-
const
|
|
1660
|
+
const said = unread.get(r.id);
|
|
1380
1661
|
health.set(
|
|
1381
1662
|
r.id,
|
|
1382
|
-
|
|
1383
|
-
? { state: 'unknown', text:
|
|
1384
|
-
: judge(r, usage.get(r.id), backlogs.get(r.id), now),
|
|
1663
|
+
said
|
|
1664
|
+
? { state: 'unknown', text: said }
|
|
1665
|
+
: judge(r, usage.get(r.id), backlogs.get(r.id), now, windowOf.get(r.id) ?? HEALTH_WINDOW_MINUTES),
|
|
1385
1666
|
);
|
|
1386
1667
|
}
|
|
1387
1668
|
for (const r of resources) {
|
|
@@ -1392,10 +1673,10 @@ export async function observe(ctx) {
|
|
|
1392
1673
|
r.id,
|
|
1393
1674
|
its
|
|
1394
1675
|
? { state: its.state, text: `Its Worker, ${worker}: ${its.text}` }
|
|
1395
|
-
: { state: 'unknown', text: 'Its Worker isn’t in this environment' },
|
|
1676
|
+
: { state: 'unknown', text: 'Its Worker isn’t in this environment, so the board can’t read its health' },
|
|
1396
1677
|
);
|
|
1397
1678
|
}
|
|
1398
|
-
const at =
|
|
1679
|
+
const at = new Date(now).toISOString();
|
|
1399
1680
|
return resources.map((r) => {
|
|
1400
1681
|
const h = /** @type {{ state: string, text: string }} */ (health.get(r.id));
|
|
1401
1682
|
return { resource: r.id, state: h.state, at, text: h.text };
|
|
@@ -1659,10 +1940,13 @@ export const cloudflare = {
|
|
|
1659
1940
|
permissions: READ_PERMISSIONS,
|
|
1660
1941
|
url: 'https://dash.cloudflare.com/profile/api-tokens',
|
|
1661
1942
|
check: checkToken,
|
|
1943
|
+
template: tokenTemplate,
|
|
1662
1944
|
},
|
|
1945
|
+
writeToken: { secret: WRITE_SECRET, permissions: writePermissions, template: tokenTemplate },
|
|
1663
1946
|
refuses,
|
|
1664
1947
|
outside,
|
|
1665
1948
|
editable,
|
|
1949
|
+
creatable,
|
|
1666
1950
|
discover,
|
|
1667
1951
|
plan: (ctx, desired) => plan(ctx, desired),
|
|
1668
1952
|
apply: (ctx, p) => apply(ctx, p),
|