breakaway 2.0.0-main.49 → 2.0.0-main.50
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/src/infra-cloudflare-plan.js +92 -7
- package/src/infra-cloudflare.js +51 -3
- package/src/infra-provider.js +4 -0
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.50",
|
|
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.50",
|
|
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.50",
|
|
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",
|
|
@@ -34,7 +34,9 @@ import {
|
|
|
34
34
|
discover,
|
|
35
35
|
priceResource,
|
|
36
36
|
reader,
|
|
37
|
+
refuses,
|
|
37
38
|
round,
|
|
39
|
+
workerConsumer,
|
|
38
40
|
} from './infra-cloudflare.js';
|
|
39
41
|
|
|
40
42
|
/** @typedef {import('./infra-provider.js').ProviderContext} ProviderContext */
|
|
@@ -65,6 +67,8 @@ const LOST = {
|
|
|
65
67
|
const ORDER = [
|
|
66
68
|
['create', ['d1', 'kv', 'r2', 'queue']],
|
|
67
69
|
['update', ['d1', 'kv', 'r2', 'queue', 'container']],
|
|
70
|
+
['scale', ['queue', 'container']],
|
|
71
|
+
['restart', ['container']],
|
|
68
72
|
['create', ['worker']],
|
|
69
73
|
['update', ['worker']],
|
|
70
74
|
['create', ['route', 'custom-domain']],
|
|
@@ -73,6 +77,13 @@ const ORDER = [
|
|
|
73
77
|
['delete', ['worker']],
|
|
74
78
|
['delete', ['container', 'queue', 'r2', 'kv', 'd1']],
|
|
75
79
|
];
|
|
80
|
+
/**
|
|
81
|
+
* Settings Cloudflare moves by itself between a plan and its apply (a container application's instance counts), so
|
|
82
|
+
* they never make a change look out of date.
|
|
83
|
+
*/
|
|
84
|
+
const LIVE = { container: ['instances', 'active', 'assigned'] };
|
|
85
|
+
const settled = (kind, attrs) =>
|
|
86
|
+
Object.fromEntries(Object.entries(attrs ?? {}).filter(([k]) => !(LIVE[kind] ?? []).includes(k)));
|
|
76
87
|
const rank = (c) => ORDER.findIndex(([op, kinds]) => op === c.op && kinds.includes(c.kind));
|
|
77
88
|
|
|
78
89
|
/** Binding types whose targets are a resource of a kind Architect makes, and the field that names it. */
|
|
@@ -581,8 +592,10 @@ export async function apply(ctx, p) {
|
|
|
581
592
|
const exists = h ?? [...have.values()].find((x) => x.kind === c.kind && x.name === c.name);
|
|
582
593
|
if (exists) throw new Error(`${c.name} already exists: plan again`);
|
|
583
594
|
} else if (!h) throw new Error(`${c.name} is no longer in ${ctx.environment}: plan again`);
|
|
584
|
-
else if (!same(redactAttrs(h.attrs ?? {}), c.before))
|
|
595
|
+
else if (!same(settled(c.kind, redactAttrs(h.attrs ?? {})), settled(c.kind, c.before)))
|
|
585
596
|
throw new Error(`${c.name} changed since it was planned: plan again`);
|
|
597
|
+
const refused = h ? refuses(h, c.op) : null;
|
|
598
|
+
if (refused) throw new Error(refused);
|
|
586
599
|
await applyChange(c, { call, a, live, have, made, resolve });
|
|
587
600
|
steps.push({ resource: c.resource, op: c.op, ok: true });
|
|
588
601
|
} catch (error) {
|
|
@@ -670,6 +683,7 @@ async function applyChange(c, { call, a, live, have, made, resolve }) {
|
|
|
670
683
|
case 'queue': {
|
|
671
684
|
const perm = { permission: 'Queues Write' };
|
|
672
685
|
if (c.op === 'delete') return call('DELETE', `${a}/queues/${id}`, perm);
|
|
686
|
+
if (c.op === 'scale') return scaleConsumer(call, `${a}/queues/${id}`, c.name, after.maxConcurrency, perm);
|
|
673
687
|
const settings = Object.fromEntries(
|
|
674
688
|
[
|
|
675
689
|
['delivery_delay', after.deliveryDelay],
|
|
@@ -677,7 +691,13 @@ async function applyChange(c, { call, a, live, have, made, resolve }) {
|
|
|
677
691
|
['message_retention_period', after.retention],
|
|
678
692
|
].filter(([, v]) => v !== undefined && v !== null),
|
|
679
693
|
);
|
|
680
|
-
if (c.op === 'update')
|
|
694
|
+
if (c.op === 'update') {
|
|
695
|
+
if (['deliveryDelay', 'deliveryPaused', 'retention'].some(changed))
|
|
696
|
+
await call('PATCH', `${a}/queues/${id}`, { ...perm, json: { settings } });
|
|
697
|
+
if (changed('maxConcurrency'))
|
|
698
|
+
await scaleConsumer(call, `${a}/queues/${id}`, c.name, after.maxConcurrency, perm);
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
681
701
|
await call('POST', `${a}/queues`, {
|
|
682
702
|
...perm,
|
|
683
703
|
json: { queue_name: c.name, ...(Object.keys(settings).length ? { settings } : {}) },
|
|
@@ -687,11 +707,17 @@ async function applyChange(c, { call, a, live, have, made, resolve }) {
|
|
|
687
707
|
}
|
|
688
708
|
case 'container': {
|
|
689
709
|
const perm = { permission: 'Containers Write' };
|
|
690
|
-
|
|
691
|
-
return call('
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
710
|
+
const p = `${a}/containers/applications/${id}`;
|
|
711
|
+
if (c.op === 'delete') return call('DELETE', p, perm);
|
|
712
|
+
if (c.op === 'restart') return restartContainer(call, p, c.name, perm);
|
|
713
|
+
await call('PATCH', p, { ...perm, json: { max_instances: after.maxInstances } });
|
|
714
|
+
// Read it back: the scale is done only once Cloudflare reports the new maximum.
|
|
715
|
+
const app = await call('GET', p, perm);
|
|
716
|
+
if (app?.max_instances !== after.maxInstances)
|
|
717
|
+
throw new Error(
|
|
718
|
+
`Cloudflare still reports ${c.name} at ${app?.max_instances ?? 'no'} max instances, not ${after.maxInstances}`,
|
|
719
|
+
);
|
|
720
|
+
return;
|
|
695
721
|
}
|
|
696
722
|
case 'route': {
|
|
697
723
|
const perm = { permission: 'Workers Routes Write' };
|
|
@@ -724,6 +750,65 @@ async function applyChange(c, { call, a, live, have, made, resolve }) {
|
|
|
724
750
|
}
|
|
725
751
|
}
|
|
726
752
|
|
|
753
|
+
/**
|
|
754
|
+
* Sets a queue's Worker consumer's `max_concurrency` (BRK-188: `PUT …/consumers/{consumer}`, which replaces the
|
|
755
|
+
* consumer, so its other settings are sent as Cloudflare has them), then reads it back. The consumer is read with the
|
|
756
|
+
* write token at apply, so the plan never holds its ID.
|
|
757
|
+
* @param {ReturnType<typeof writer>} call
|
|
758
|
+
* @param {string} q the queue's path
|
|
759
|
+
* @param {string} name
|
|
760
|
+
* @param {unknown} to
|
|
761
|
+
* @param {{ permission: string }} perm
|
|
762
|
+
*/
|
|
763
|
+
async function scaleConsumer(call, q, name, to, perm) {
|
|
764
|
+
const consumer = workerConsumer(await call('GET', `${q}/consumers`, perm));
|
|
765
|
+
if (!consumer?.consumer_id) throw new Error(`${name} has no Worker consuming it, so there’s no concurrency to scale`);
|
|
766
|
+
const p = `${q}/consumers/${encodeURIComponent(String(consumer.consumer_id))}`;
|
|
767
|
+
await call('PUT', p, {
|
|
768
|
+
...perm,
|
|
769
|
+
json: {
|
|
770
|
+
type: 'worker',
|
|
771
|
+
script_name: String(consumer.script ?? consumer.script_name ?? consumer.service),
|
|
772
|
+
...(consumer.dead_letter_queue ? { dead_letter_queue: consumer.dead_letter_queue } : {}),
|
|
773
|
+
settings: { ...(consumer.settings ?? {}), max_concurrency: to ?? undefined },
|
|
774
|
+
},
|
|
775
|
+
});
|
|
776
|
+
const now = workerConsumer(await call('GET', `${q}/consumers`, perm));
|
|
777
|
+
if ((now?.settings?.max_concurrency ?? null) !== (to ?? null))
|
|
778
|
+
throw new Error(
|
|
779
|
+
`Cloudflare still reports ${name}’s consumer at ${now?.settings?.max_concurrency ?? 'automatic'} concurrency, not ${to ?? 'automatic'}`,
|
|
780
|
+
);
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* Restarts a container application (BRK-188's Restart row): a rollout of the configuration it runs now, so every
|
|
785
|
+
* instance is replaced step by step, each after SIGTERM and up to 15 minutes to drain. The body is the one Wrangler
|
|
786
|
+
* sends for a deploy's rollout (`strategy: rolling`, `kind: full_auto`); Cloudflare's API reference shows the endpoint
|
|
787
|
+
* but not its body, so BRK-207's staging run confirms it. The configuration is read with the write token at apply and
|
|
788
|
+
* passed through as it is: the board never stores it.
|
|
789
|
+
* @param {ReturnType<typeof writer>} call
|
|
790
|
+
* @param {string} p the application's path
|
|
791
|
+
* @param {string} name
|
|
792
|
+
* @param {{ permission: string }} perm
|
|
793
|
+
*/
|
|
794
|
+
async function restartContainer(call, p, name, perm) {
|
|
795
|
+
const app = await call('GET', p, perm);
|
|
796
|
+
if (!isObject(app?.configuration)) throw new Error(`Cloudflare didn’t say what ${name} runs, so it wasn’t restarted`);
|
|
797
|
+
const max = Number(app.max_instances ?? 0);
|
|
798
|
+
const rollout = await call('POST', `${p}/rollouts`, {
|
|
799
|
+
...perm,
|
|
800
|
+
json: {
|
|
801
|
+
description: 'Restarted by breakaway',
|
|
802
|
+
strategy: 'rolling',
|
|
803
|
+
kind: 'full_auto',
|
|
804
|
+
// One step for a single instance, as Wrangler does; otherwise a tenth at a time.
|
|
805
|
+
step_percentage: max < 2 ? 100 : 10,
|
|
806
|
+
target_configuration: app.configuration,
|
|
807
|
+
},
|
|
808
|
+
});
|
|
809
|
+
if (!rollout?.id) throw new Error(`Cloudflare didn’t start a rollout of ${name}`);
|
|
810
|
+
}
|
|
811
|
+
|
|
727
812
|
/**
|
|
728
813
|
* Rolls a Worker back to the versions that were live before an apply (`before.versions` of its change): a deployment
|
|
729
814
|
* of them, as they were split. Cloudflare refuses a rollback across a secret change unless it's forced, and only the
|
package/src/infra-cloudflare.js
CHANGED
|
@@ -42,17 +42,57 @@ export const MANAGED = {
|
|
|
42
42
|
d1: [],
|
|
43
43
|
kv: [],
|
|
44
44
|
r2: ['cors', 'lifecycle'],
|
|
45
|
-
queue: ['deliveryDelay', 'deliveryPaused', 'retention'],
|
|
45
|
+
queue: ['deliveryDelay', 'deliveryPaused', 'retention', 'maxConcurrency'],
|
|
46
46
|
container: ['maxInstances'],
|
|
47
47
|
route: ['worker'],
|
|
48
48
|
'custom-domain': ['worker', 'environment'],
|
|
49
49
|
};
|
|
50
50
|
|
|
51
|
-
/**
|
|
51
|
+
/**
|
|
52
|
+
* What envelopes may change, by kind (BRK-188's "Scale and restart, for envelopes"; BRK-227): a container application
|
|
53
|
+
* scales its `max_instances` and restarts with a rollout of its configuration, and a queue scales its Worker
|
|
54
|
+
* consumer's `max_concurrency`. Cloudflare scales every other kind itself, so none of them scales or restarts.
|
|
55
|
+
*/
|
|
56
|
+
const ENVELOPE = {
|
|
57
|
+
container: { changes: ['scale', 'restart'], scales: 'maxInstances' },
|
|
58
|
+
queue: { changes: ['scale'], scales: 'maxConcurrency' },
|
|
59
|
+
};
|
|
52
60
|
export const CLOUDFLARE_KINDS = Object.fromEntries(
|
|
53
|
-
Object.entries(MANAGED).map(([k, settings]) => [
|
|
61
|
+
Object.entries(MANAGED).map(([k, settings]) => [
|
|
62
|
+
k,
|
|
63
|
+
ENVELOPE[k]
|
|
64
|
+
? { changes: [...BASE, ...ENVELOPE[k].changes], scales: ENVELOPE[k].scales, settings: [...settings] }
|
|
65
|
+
: { changes: [...BASE], settings: [...settings] },
|
|
66
|
+
]),
|
|
54
67
|
);
|
|
55
68
|
|
|
69
|
+
/** The scheduling policy whose container applications Cloudflare scales and rolls out; any other is the code's. */
|
|
70
|
+
export const DEFAULT_SCHEDULING = 'default';
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Why Cloudflare can't make an envelope's change to this resource, in words, or null when it can. A kind that never
|
|
74
|
+
* scales or restarts says the platform does it; a container application on another scheduling policy (the Durable
|
|
75
|
+
* Object one) is started and stopped by its own code; a queue with no Worker consuming it has no concurrency to set.
|
|
76
|
+
* @param {Resource} r
|
|
77
|
+
* @param {string} op
|
|
78
|
+
* @returns {string | null}
|
|
79
|
+
*/
|
|
80
|
+
export function refuses(r, op) {
|
|
81
|
+
if (op !== 'scale' && op !== 'restart') return null;
|
|
82
|
+
if (!CLOUDFLARE_KINDS[r.kind]?.changes.includes(op))
|
|
83
|
+
return r.kind === 'queue'
|
|
84
|
+
? `${r.name} is a queue: Cloudflare has no restart for one, only its consumer’s concurrency scales`
|
|
85
|
+
: `${r.name} is a ${r.kind}: Cloudflare scales it by itself, so there’s nothing to ${op}`;
|
|
86
|
+
const attrs = /** @type {Record<string, unknown>} */ (r.attrs ?? {});
|
|
87
|
+
if (r.kind === 'container' && attrs.schedulingPolicy !== DEFAULT_SCHEDULING)
|
|
88
|
+
return attrs.schedulingPolicy
|
|
89
|
+
? `${r.name} runs on the ${attrs.schedulingPolicy} scheduling policy: its own code starts and stops its instances, so Cloudflare can’t ${op} it`
|
|
90
|
+
: `${r.name}’s scheduling policy isn’t known, so it can’t ${op}: discover it again`;
|
|
91
|
+
if (r.kind === 'queue' && !(/** @type {any[]} */ (attrs.consumers ?? []).some((c) => c?.type === 'worker')))
|
|
92
|
+
return `${r.name} has no Worker consuming it, so there’s no concurrency to scale`;
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
56
96
|
/**
|
|
57
97
|
* The board's read-only token (BRK-188, "Tokens"; BRK-194 keeps it). Read only: no permission ends in Edit or Write.
|
|
58
98
|
* @type {import('./infra-provider.js').ReadToken['permissions']}
|
|
@@ -70,6 +110,10 @@ export const READ_PERMISSIONS = [
|
|
|
70
110
|
{ name: 'Workers Routes Read', for: 'routes' },
|
|
71
111
|
];
|
|
72
112
|
|
|
113
|
+
/** The consumer a queue's scale changes: its Worker consumer (Cloudflare allows one), or undefined. */
|
|
114
|
+
export const workerConsumer = (consumers) =>
|
|
115
|
+
(consumers ?? []).find((c) => String(c?.type ?? 'worker') === 'worker' && (c.script ?? c.script_name ?? c.service));
|
|
116
|
+
|
|
73
117
|
/** At most this many Workers in one environment's scope, and pages of one list: far more than a repository runs. */
|
|
74
118
|
const MAX_WORKERS = 50;
|
|
75
119
|
const MAX_PAGES = 20;
|
|
@@ -430,7 +474,10 @@ export async function discover(ctx, { live = false } = {}) {
|
|
|
430
474
|
deliveryDelay: q.settings?.delivery_delay ?? null,
|
|
431
475
|
deliveryPaused: Boolean(q.settings?.delivery_paused),
|
|
432
476
|
retention: q.settings?.message_retention_period ?? null,
|
|
477
|
+
// The Worker consumer's concurrency, which a scale sets (null: Cloudflare picks it).
|
|
478
|
+
maxConcurrency: workerConsumer(consumers)?.settings?.max_concurrency ?? null,
|
|
433
479
|
consumers: consumers.map((c) => ({
|
|
480
|
+
type: String(c.type ?? 'worker'),
|
|
434
481
|
worker: String(c.script ?? c.service ?? ''),
|
|
435
482
|
batchSize: c.settings?.batch_size ?? null,
|
|
436
483
|
maxRetries: c.settings?.max_retries ?? null,
|
|
@@ -1133,6 +1180,7 @@ export const cloudflare = {
|
|
|
1133
1180
|
url: 'https://dash.cloudflare.com/profile/api-tokens',
|
|
1134
1181
|
check: checkToken,
|
|
1135
1182
|
},
|
|
1183
|
+
refuses,
|
|
1136
1184
|
discover,
|
|
1137
1185
|
plan: (ctx, desired) => plan(ctx, desired),
|
|
1138
1186
|
apply: (ctx, p) => apply(ctx, p),
|
package/src/infra-provider.js
CHANGED
|
@@ -187,6 +187,9 @@ export const COST_NOTE_MAX = 500;
|
|
|
187
187
|
* @property {string} id short and lowercase, like `fake`
|
|
188
188
|
* @property {string} name what people see
|
|
189
189
|
* @property {Record<string, KindSpec>} kinds the resource kinds it discovers and plans
|
|
190
|
+
* @property {(resource: Resource, op: string) => string | null} [refuses] why the platform can't make an envelope's
|
|
191
|
+
* `scale` or `restart` to this one resource although its kind declares it (a setting of the resource rules it out),
|
|
192
|
+
* in words, or null when it can; an act asks it before it plans (BRK-227)
|
|
190
193
|
* @property {ReadToken} [readToken] the read-only token it needs, if any
|
|
191
194
|
* @property {(ctx: ProviderContext) => Promise<Discovery>} discover
|
|
192
195
|
* @property {(ctx: ProviderContext, desired: DesiredState) => Promise<PlanDiff>} plan
|
|
@@ -250,6 +253,7 @@ export function checkProvider(provider) {
|
|
|
250
253
|
fail(what, `${kind}'s scales names the setting a scale changes, and only a kind that scales has one`);
|
|
251
254
|
}
|
|
252
255
|
if (provider.readToken !== undefined) checkReadToken(what, provider.readToken);
|
|
256
|
+
if (provider.refuses !== undefined && typeof provider.refuses !== 'function') fail(what, 'refuses is not a function');
|
|
253
257
|
if (provider.estimate !== undefined && typeof provider.estimate !== 'function')
|
|
254
258
|
fail(what, 'estimate is not a function');
|
|
255
259
|
return provider;
|