@wtfalch/foundry 0.65.0 → 0.67.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/dist/apps/cloudflare.d.ts +5 -0
- package/dist/apps/deploy.js +33 -1
- package/dist/apps/migrations.d.ts +3 -0
- package/dist/apps/migrations.js +32 -7
- package/dist/catalogue/copy-en.js +5 -0
- package/dist/catalogue/integrations.js +5 -0
- package/dist/entry-rules.d.ts +11 -0
- package/dist/entry-rules.js +70 -0
- package/dist/index.d.ts +9 -4
- package/dist/index.js +5 -2
- package/dist/migrations/0069_release_events.sql +16 -0
- package/dist/modules/analytics.d.ts +8 -0
- package/dist/modules/analytics.js +28 -0
- package/dist/modules/database.js +22 -3
- package/dist/modules/index.js +2 -0
- package/dist/native-build.d.ts +16 -12
- package/dist/native-build.js +16 -12
- package/dist/org.d.ts +9 -1
- package/dist/org.js +4 -1
- package/dist/plan/analytics-boule.d.ts +17 -0
- package/dist/plan/analytics-boule.js +43 -0
- package/dist/plan/analytics.d.ts +58 -0
- package/dist/plan/analytics.js +89 -0
- package/dist/plan/boule.js +2 -0
- package/dist/plan/company-backups.js +2 -0
- package/dist/plan/diff.d.ts +4 -0
- package/dist/plan/diff.js +103 -3
- package/dist/plan/entry-analytics-key.d.ts +35 -0
- package/dist/plan/entry-analytics-key.js +134 -0
- package/dist/plan/executor.d.ts +79 -6
- package/dist/plan/executor.js +379 -19
- package/dist/plan/hostnames.js +4 -0
- package/dist/plan/index.d.ts +2 -0
- package/dist/plan/index.js +1 -0
- package/dist/plan/live.d.ts +1 -1
- package/dist/plan/locked-hostnames.js +3 -0
- package/dist/plan/login-host.d.ts +8 -0
- package/dist/plan/login-host.js +8 -0
- package/dist/plan/org-names.d.ts +3 -3
- package/dist/plan/org-names.js +3 -0
- package/dist/plan/own-limits.d.ts +6 -0
- package/dist/plan/own-limits.js +1 -0
- package/dist/plan/plan-apply.d.ts +12 -0
- package/dist/plan/plan-apply.js +40 -1
- package/dist/plan/platform-diff.d.ts +17 -1
- package/dist/plan/platform-diff.js +40 -2
- package/dist/plan/platform-org.d.ts +6 -0
- package/dist/plan/platform-providers.d.ts +8 -6
- package/dist/plan/platform-providers.js +56 -10
- package/dist/plan/providers-readers.js +16 -1
- package/dist/plan/release-host.d.ts +30 -1
- package/dist/plan/release-host.js +84 -41
- package/dist/plan/release-series-reader.d.ts +26 -0
- package/dist/plan/release-series-reader.js +40 -0
- package/dist/plan/sign-in-archon.d.ts +1 -1
- package/dist/plan/sign-in-archon.js +2 -2
- package/dist/plan/stage-services.d.ts +3 -1
- package/dist/plan/stage-services.js +2 -1
- package/dist/plan/uptime-worker.js +53 -3
- package/dist/plan/worker-app-database.d.ts +12 -1
- package/dist/plan/worker-app-database.js +13 -0
- package/dist/plan/worker-app.d.ts +27 -2
- package/dist/plan/worker-app.js +52 -7
- package/dist/plan/workspace.d.ts +2 -2
- package/dist/plan/workspace.js +3 -3
- package/dist/release/decide.d.ts +2 -0
- package/dist/release/decide.js +67 -10
- package/dist/release/events.d.ts +13 -0
- package/dist/release/events.js +67 -0
- package/dist/release/health.d.ts +5 -0
- package/dist/release/health.js +3 -3
- package/dist/release/run.d.ts +7 -2
- package/dist/release/run.js +8 -0
- package/dist/release/series.d.ts +58 -0
- package/dist/release/series.js +65 -0
- package/dist/release/state.d.ts +58 -1
- package/dist/release/state.js +45 -0
- package/dist/release/types.d.ts +44 -0
- package/dist/slug.d.ts +1 -1
- package/dist/slug.js +5 -2
- package/dist/store/postgres.js +29 -3
- package/dist/store/schema.d.ts +112 -0
- package/dist/store/schema.js +9 -0
- package/dist/worker-app-config.d.ts +22 -1
- package/dist/worker-app-config.js +73 -15
- package/dist/worker-app-migrations.d.ts +6 -2
- package/dist/worker-app-migrations.js +1 -0
- package/package.json +1 -1
package/dist/plan/worker-app.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
-
import { APPS_DOMAIN } from '../org.js';
|
|
2
|
+
import { APPS_DOMAIN, heldEnvRef } from '../org.js';
|
|
3
3
|
import { WORKER_APP_KEYS_SECRET, WORKER_APP_KEYS_URL_VAR, workerAppHasBuild, workerAppHostId, } from '../worker-app-config.js';
|
|
4
4
|
import { WORKER_APP_DNS_RECORD, pendingWorkerAppMigrations, strayContainerImage, withCallTimeout, workerAppRegistryPrefix, workerAppShape, workerAppUpload, } from '../worker-app-upload.js';
|
|
5
5
|
import { entryAuthEnv } from './broker-entries.js';
|
|
@@ -92,6 +92,10 @@ export const WORKER_APP_AUTH_KEY_SECRET = 'AUTH_APP_KEY';
|
|
|
92
92
|
*/
|
|
93
93
|
export const workerAppSecretRef = (orgId, entryKey, name) => `foundry:worker-app:${orgId}:${createHash('sha256').update(`${entryKey}:${name}`).digest('hex').slice(0, 32)}`;
|
|
94
94
|
const secretVersion = (value) => `g1-${createHash('sha256').update(value).digest('hex').slice(0, 16)}`;
|
|
95
|
+
/** The version recorded for a `heldSecret` value: a short hash, so a changed value is delivered again. Never the value. */
|
|
96
|
+
export const heldSecretVersion = (value) => `h1-${createHash('sha256').update(value).digest('hex').slice(0, 16)}`;
|
|
97
|
+
/** `<entry key>/<NAME>` of a `heldSecret`: how the plan's readiness names it. */
|
|
98
|
+
export const heldSecretId = (entryKey, name) => `${entryKey}/${name}`;
|
|
95
99
|
/** Foundry mints a new credential this long before the held one expires. */
|
|
96
100
|
export const WORKER_APP_CREDENTIAL_RENEW_BEFORE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
97
101
|
/**
|
|
@@ -112,8 +116,8 @@ export const workerAppApex = (org, entry) => entry && workspaceWorkerParent(entr
|
|
|
112
116
|
: org.hosting.usesDomain && org.domain
|
|
113
117
|
? org.domain
|
|
114
118
|
: WORKER_APP_OIKOS_ZONE;
|
|
115
|
-
/** True when the want has a secret
|
|
116
|
-
const holdsGenerated = (want) => want.secrets.
|
|
119
|
+
/** True when the want has a secret foundry must hold (a generated one, the broker key, a custody credential). A `heldSecret` is the company's: it is only read. */
|
|
120
|
+
const holdsGenerated = (want) => want.secrets.some((s) => s.source !== 'heldSecret');
|
|
117
121
|
/**
|
|
118
122
|
* What the Worker must look like, or the inputs still missing (names only).
|
|
119
123
|
* `issuer` is the sign-in issuer the plan read; `keysUrl` the custody Worker.
|
|
@@ -123,8 +127,16 @@ export function wantedWorkerApp(org, entry, recorded, input) {
|
|
|
123
127
|
const missing = [];
|
|
124
128
|
const vars = { ...app.vars };
|
|
125
129
|
const secrets = [];
|
|
130
|
+
const unset = [];
|
|
126
131
|
for (const [name, source] of Object.entries(app.secrets ?? {})) {
|
|
127
|
-
if (source.from === '
|
|
132
|
+
if (source.from === 'heldSecret') {
|
|
133
|
+
const version = input.heldSecrets?.[heldSecretId(entry.key, name)];
|
|
134
|
+
if (version)
|
|
135
|
+
secrets.push({ name, source: 'heldSecret', version });
|
|
136
|
+
else
|
|
137
|
+
unset.push(name);
|
|
138
|
+
}
|
|
139
|
+
else if (source.from === 'keysCustody') {
|
|
128
140
|
const hostId = workerAppHostId(org.id, entry.key);
|
|
129
141
|
if (input.keysUrl === undefined)
|
|
130
142
|
missing.push('the keys custody port');
|
|
@@ -165,7 +177,9 @@ export function wantedWorkerApp(org, entry, recorded, input) {
|
|
|
165
177
|
if (missing.length > 0)
|
|
166
178
|
return { missing };
|
|
167
179
|
const shape = workerAppShape(app);
|
|
180
|
+
const crons = [...(app.triggers?.crons ?? [])].sort();
|
|
168
181
|
return {
|
|
182
|
+
unset: unset.sort(),
|
|
169
183
|
want: {
|
|
170
184
|
name: workerAppName(org, entry),
|
|
171
185
|
artifactUrl: shape.artifactUrl,
|
|
@@ -182,6 +196,9 @@ export function wantedWorkerApp(org, entry, recorded, input) {
|
|
|
182
196
|
? {
|
|
183
197
|
database: {
|
|
184
198
|
appendOnly: [...(app.database.appendOnly ?? [])].sort(),
|
|
199
|
+
...(app.database.noDelete?.length
|
|
200
|
+
? { noDelete: [...app.database.noDelete].sort() }
|
|
201
|
+
: {}),
|
|
185
202
|
...(app.database.grants?.length ? { grants: [...app.database.grants].sort() } : {}),
|
|
186
203
|
...(app.database.migrationsSha256
|
|
187
204
|
? { migrationsSha256: app.database.migrationsSha256 }
|
|
@@ -189,6 +206,7 @@ export function wantedWorkerApp(org, entry, recorded, input) {
|
|
|
189
206
|
},
|
|
190
207
|
}
|
|
191
208
|
: {}),
|
|
209
|
+
...(crons.length > 0 ? { crons } : {}),
|
|
192
210
|
secrets: secrets.sort((a, b) => a.name.localeCompare(b.name)),
|
|
193
211
|
},
|
|
194
212
|
};
|
|
@@ -321,7 +339,7 @@ export function planWorkerApps(org, recordedIds, input) {
|
|
|
321
339
|
block(`${wanted.missing.join(', ')} not recorded yet`);
|
|
322
340
|
continue;
|
|
323
341
|
}
|
|
324
|
-
const { want } = wanted;
|
|
342
|
+
const { want, unset } = wanted;
|
|
325
343
|
const stray = strayContainerImage(app, input.registryAccountId);
|
|
326
344
|
if (stray) {
|
|
327
345
|
block(input.registryAccountId === undefined
|
|
@@ -345,7 +363,9 @@ export function planWorkerApps(org, recordedIds, input) {
|
|
|
345
363
|
? `its Access service token ${recordedIds[remoteDatabaseKeys(entry.key).undelivered]} was minted and its secret never held; delete the token in Cloudflare and clear the recorded id before minting again`
|
|
346
364
|
: !input.canHoldSecrets
|
|
347
365
|
? 'its database credentials need a held-key store that can put, and the host has none'
|
|
348
|
-
:
|
|
366
|
+
: want.database.noDelete?.length && !db.noDeleteCheck
|
|
367
|
+
? "its noDelete tables need a database port that reads the role's rights back (deletableTables), and the host's has none"
|
|
368
|
+
: null;
|
|
349
369
|
if (why) {
|
|
350
370
|
block(why);
|
|
351
371
|
continue;
|
|
@@ -381,7 +401,7 @@ export function planWorkerApps(org, recordedIds, input) {
|
|
|
381
401
|
desired: { mode: 'apply', ...want },
|
|
382
402
|
},
|
|
383
403
|
step: {
|
|
384
|
-
description: `${first ? 'deploy' : 'update'} ${at} at ${want.host}${risk}${renew ? ' (renews its keys custody credential, which expires within 30 days)' : ''}`,
|
|
404
|
+
description: `${first ? 'deploy' : 'update'} ${at} at ${want.host}${risk}${renew ? ' (renews its keys custody credential, which expires within 30 days)' : ''}${unset.length > 0 ? ` (no value is held yet for secret ${unset.join(', ')}: the Worker runs without ${unset.length === 1 ? 'it' : 'them'} until the company adds ${unset.length === 1 ? 'it' : 'them'}, then the next apply sets ${unset.length === 1 ? 'it' : 'them'})` : ''}`,
|
|
385
405
|
destroys: deletes.length > 0,
|
|
386
406
|
migrates: renames.length > 0,
|
|
387
407
|
},
|
|
@@ -465,6 +485,15 @@ export async function applyWorkerApp(deps, org, action, context) {
|
|
|
465
485
|
}
|
|
466
486
|
// 2. Secrets: before the route, so the Worker never serves without them.
|
|
467
487
|
await deliverSecrets(deps, org, entryKey, want, context, at, call);
|
|
488
|
+
// 2b. Cron triggers, once the script and its secrets are there. An empty list clears the ones a
|
|
489
|
+
// previous apply set; a Worker that never had any makes no call. Idempotent, so a crash here is
|
|
490
|
+
// repaired by the next apply (the applied marker is not written yet).
|
|
491
|
+
const crons = want.crons ?? [];
|
|
492
|
+
const hadCrons = (parseJson(recorded0[workerAppAppliedScopeKey(entryKey)], {}).crons ?? [])
|
|
493
|
+
.length > 0;
|
|
494
|
+
if (crons.length > 0 || hadCrons) {
|
|
495
|
+
await call('set the cron triggers', () => workers.setSchedules(want.name, crons));
|
|
496
|
+
}
|
|
468
497
|
// 3. Containers.
|
|
469
498
|
await syncContainers(deps, entryKey, want, context, at, call);
|
|
470
499
|
// 4. The record on the org's own zone, 5. the route.
|
|
@@ -532,6 +561,22 @@ async function deliverSecrets(deps, org, entryKey, want, context, at, call) {
|
|
|
532
561
|
if (delivered[secret.name] !== version)
|
|
533
562
|
generated.push({ name: secret.name, value, version });
|
|
534
563
|
}
|
|
564
|
+
// Held secrets: the company's value, read from the held-key store. The plan found each one held; a
|
|
565
|
+
// value gone since stops the step, and the error names the ref, never a value.
|
|
566
|
+
for (const secret of want.secrets) {
|
|
567
|
+
if (secret.source !== 'heldSecret')
|
|
568
|
+
continue;
|
|
569
|
+
const ref = heldEnvRef(org.id, entryKey, secret.name);
|
|
570
|
+
const value = await call('read a held secret', async () => heldKeys?.resolve(ref));
|
|
571
|
+
if (!value) {
|
|
572
|
+
throw new Error(`${at}: held secret ${secret.name} is no longer held (ref ${ref}); the next plan leaves it off the Worker`);
|
|
573
|
+
}
|
|
574
|
+
deps.secret?.(value);
|
|
575
|
+
const version = heldSecretVersion(value);
|
|
576
|
+
if (delivered[secret.name] !== version) {
|
|
577
|
+
generated.push({ name: secret.name, value, version });
|
|
578
|
+
}
|
|
579
|
+
}
|
|
535
580
|
if (generated.length > 0) {
|
|
536
581
|
await call('deliver secrets', () => workers.setSecrets(want.name, generated));
|
|
537
582
|
for (const { name, version } of generated)
|
package/dist/plan/workspace.d.ts
CHANGED
|
@@ -37,8 +37,8 @@ export declare const peopleHash: (emails: readonly string[]) => string;
|
|
|
37
37
|
export declare function withoutDarkEntries(org: DeclaredOrg): DeclaredOrg;
|
|
38
38
|
/**
|
|
39
39
|
* The org with a second Worker app per Worker-app workspace, keyed `<key>-work`, so the Worker app planner and
|
|
40
|
-
* executor deploy it like any other. The copy has no database
|
|
41
|
-
* sign-in; a database would be the live one) and is served at the workspace host. `declared` names the org the
|
|
40
|
+
* executor deploy it like any other. The copy has no database, no sign-in env and no cron triggers (the workspace gate is its
|
|
41
|
+
* sign-in; a database would be the live one, and a schedule would run its jobs with nothing to run them on) and is served at the workspace host. `declared` names the org the
|
|
42
42
|
* workspaces are read from when `org` has had dark entries removed.
|
|
43
43
|
*/
|
|
44
44
|
export declare function withWorkspaceWorkers(org: DeclaredOrg, declared?: DeclaredOrg): DeclaredOrg;
|
package/dist/plan/workspace.js
CHANGED
|
@@ -67,8 +67,8 @@ export function withoutDarkEntries(org) {
|
|
|
67
67
|
}
|
|
68
68
|
/**
|
|
69
69
|
* The org with a second Worker app per Worker-app workspace, keyed `<key>-work`, so the Worker app planner and
|
|
70
|
-
* executor deploy it like any other. The copy has no database
|
|
71
|
-
* sign-in; a database would be the live one) and is served at the workspace host. `declared` names the org the
|
|
70
|
+
* executor deploy it like any other. The copy has no database, no sign-in env and no cron triggers (the workspace gate is its
|
|
71
|
+
* sign-in; a database would be the live one, and a schedule would run its jobs with nothing to run them on) and is served at the workspace host. `declared` names the org the
|
|
72
72
|
* workspaces are read from when `org` has had dark entries removed.
|
|
73
73
|
*/
|
|
74
74
|
export function withWorkspaceWorkers(org, declared = org) {
|
|
@@ -79,7 +79,7 @@ export function withWorkspaceWorkers(org, declared = org) {
|
|
|
79
79
|
if (!entry.workspace || !entry.workerApp)
|
|
80
80
|
continue;
|
|
81
81
|
const { workspace: _w, live: _l, hostedName: _h, uses: _u, workerApp, ...rest } = entry;
|
|
82
|
-
const { database: _d, authEnv: _a, ...app } = workerApp;
|
|
82
|
+
const { database: _d, authEnv: _a, triggers: _t, ...app } = workerApp;
|
|
83
83
|
const copy = {
|
|
84
84
|
...rest,
|
|
85
85
|
key: `${entry.key}-work`,
|
package/dist/release/decide.d.ts
CHANGED
|
@@ -106,6 +106,8 @@ export interface Decision {
|
|
|
106
106
|
refused: string[];
|
|
107
107
|
/** The reason a `copy_prod_to_stage` request was refused here, for `bg.<id>.stagecopy`; else null. */
|
|
108
108
|
stageRefusal: string | null;
|
|
109
|
+
/** The requested actions that took effect, in order (the same objects as `input.actions`). */
|
|
110
|
+
applied: ReleaseAction[];
|
|
109
111
|
}
|
|
110
112
|
export declare function normalizeSteps(steps: readonly number[] | undefined): number[];
|
|
111
113
|
/** The time a health read should cover: from the step or window start to now. */
|
package/dist/release/decide.js
CHANGED
|
@@ -68,7 +68,11 @@ function dailyRate(h) {
|
|
|
68
68
|
/** The time a health read should cover: from the step or window start to now. */
|
|
69
69
|
export function healthRange(r, now) {
|
|
70
70
|
const w = r.window;
|
|
71
|
-
if (w.kind === 'step' ||
|
|
71
|
+
if (w.kind === 'step' ||
|
|
72
|
+
w.kind === 'inconclusive' ||
|
|
73
|
+
w.kind === 'paused' ||
|
|
74
|
+
w.kind === 'window' ||
|
|
75
|
+
w.kind === 'cap') {
|
|
72
76
|
if (w.kind === 'inconclusive' && w.percent === 0)
|
|
73
77
|
return null;
|
|
74
78
|
return { from: w.since, to: now.toISOString() };
|
|
@@ -81,6 +85,7 @@ function applyActions(r, actions, now, out, kind) {
|
|
|
81
85
|
let ended = false;
|
|
82
86
|
let approved = false;
|
|
83
87
|
let opened = false;
|
|
88
|
+
let stepNow = false;
|
|
84
89
|
const stageRequests = [];
|
|
85
90
|
for (const a of actions) {
|
|
86
91
|
if (a.kind === 'deploy_stage') {
|
|
@@ -97,8 +102,10 @@ function applyActions(r, actions, now, out, kind) {
|
|
|
97
102
|
const waiting = (kind.advance === 'approval' || capped) &&
|
|
98
103
|
cur.release !== null &&
|
|
99
104
|
((w.kind === 'step' && w.percent > 0) || (w.kind === 'inconclusive' && w.percent > 0));
|
|
100
|
-
if (waiting)
|
|
105
|
+
if (waiting) {
|
|
101
106
|
approved = true;
|
|
107
|
+
out.applied.push(a);
|
|
108
|
+
}
|
|
102
109
|
else
|
|
103
110
|
out.refused.push('approve_step refused: no step is waiting for approval');
|
|
104
111
|
}
|
|
@@ -106,10 +113,42 @@ function applyActions(r, actions, now, out, kind) {
|
|
|
106
113
|
if (cur.window.kind === 'staff' && cur.release) {
|
|
107
114
|
cur = stepTo(cur, cur.release, kind.steps[0], now);
|
|
108
115
|
opened = true;
|
|
116
|
+
out.applied.push(a);
|
|
109
117
|
}
|
|
110
118
|
else
|
|
111
119
|
out.refused.push('open refused: no release is waiting for staff');
|
|
112
120
|
}
|
|
121
|
+
else if (a.kind === 'pause') {
|
|
122
|
+
const w = cur.window;
|
|
123
|
+
if (w.kind === 'paused')
|
|
124
|
+
out.refused.push('pause refused: the release is already paused');
|
|
125
|
+
else if (cur.release && (w.kind === 'step' || (w.kind === 'inconclusive' && w.percent > 0))) {
|
|
126
|
+
cur = { ...cur, window: { kind: 'paused', percent: w.percent, since: w.since } };
|
|
127
|
+
stepNow = false;
|
|
128
|
+
out.applied.push(a);
|
|
129
|
+
}
|
|
130
|
+
else
|
|
131
|
+
out.refused.push('pause refused: no step is running');
|
|
132
|
+
}
|
|
133
|
+
else if (a.kind === 'resume') {
|
|
134
|
+
const w = cur.window;
|
|
135
|
+
if (w.kind === 'paused' && cur.release) {
|
|
136
|
+
cur = { ...cur, window: { kind: 'step', percent: w.percent, since: w.since } };
|
|
137
|
+
out.applied.push(a);
|
|
138
|
+
}
|
|
139
|
+
else
|
|
140
|
+
out.refused.push('resume refused: the release is not paused');
|
|
141
|
+
}
|
|
142
|
+
else if (a.kind === 'step_now') {
|
|
143
|
+
const w = cur.window;
|
|
144
|
+
if (cur.release &&
|
|
145
|
+
(w.kind === 'step' || w.kind === 'paused' || (w.kind === 'inconclusive' && w.percent > 0))) {
|
|
146
|
+
stepNow = true;
|
|
147
|
+
out.applied.push(a);
|
|
148
|
+
}
|
|
149
|
+
else
|
|
150
|
+
out.refused.push('step_now refused: no step is running');
|
|
151
|
+
}
|
|
113
152
|
else if (a.kind === 'clear_held') {
|
|
114
153
|
cur = { ...cur, held: cur.held.filter((d) => d !== a.digest) };
|
|
115
154
|
}
|
|
@@ -134,6 +173,7 @@ function applyActions(r, actions, now, out, kind) {
|
|
|
134
173
|
};
|
|
135
174
|
out.effects.push({ kind: 'deploy', color: to, digest });
|
|
136
175
|
promoted = true;
|
|
176
|
+
out.applied.push(a);
|
|
137
177
|
}
|
|
138
178
|
}
|
|
139
179
|
else if (a.kind === 'copy_prod_to_stage' || a.kind === 'undo_stage_copy') {
|
|
@@ -145,6 +185,7 @@ function applyActions(r, actions, now, out, kind) {
|
|
|
145
185
|
else {
|
|
146
186
|
cur = rollback(cur, now, 'operator');
|
|
147
187
|
ended = true;
|
|
188
|
+
out.applied.push(a);
|
|
148
189
|
}
|
|
149
190
|
}
|
|
150
191
|
else if (a.kind === 'decide_at_cap') {
|
|
@@ -153,11 +194,13 @@ function applyActions(r, actions, now, out, kind) {
|
|
|
153
194
|
else if (a.decision === 'rollback') {
|
|
154
195
|
cur = rollback(cur, now, 'operator at cap');
|
|
155
196
|
ended = true;
|
|
197
|
+
out.applied.push(a);
|
|
156
198
|
}
|
|
157
199
|
else {
|
|
158
200
|
out.effects.push({ kind: 'stop', color: cur.release.from });
|
|
159
201
|
cur = { ...cur, window: { kind: 'passed', at: now.toISOString() }, release: null };
|
|
160
202
|
ended = true;
|
|
203
|
+
out.applied.push(a);
|
|
161
204
|
}
|
|
162
205
|
}
|
|
163
206
|
}
|
|
@@ -181,7 +224,7 @@ function applyActions(r, actions, now, out, kind) {
|
|
|
181
224
|
out.effects.push({ kind: 'stage_copy', mode: copy ? 'copy' : 'undo' });
|
|
182
225
|
}
|
|
183
226
|
}
|
|
184
|
-
return { recorded: cur, promoted, ended, approved, opened };
|
|
227
|
+
return { recorded: cur, promoted, ended, approved, opened, stepNow };
|
|
185
228
|
}
|
|
186
229
|
/**
|
|
187
230
|
* The whole release decision for one run: from the recorded state, the
|
|
@@ -196,6 +239,7 @@ export function decide(input) {
|
|
|
196
239
|
effects: [],
|
|
197
240
|
refused: [],
|
|
198
241
|
stageRefusal: null,
|
|
242
|
+
applied: [],
|
|
199
243
|
};
|
|
200
244
|
const advance = input.advance ?? DEFAULT_ADVANCE;
|
|
201
245
|
const minRequests = input.minRequests ?? RELATIVE_MIN_REQUESTS;
|
|
@@ -268,19 +312,31 @@ export function decide(input) {
|
|
|
268
312
|
};
|
|
269
313
|
if (!atWindow) {
|
|
270
314
|
const pct = r.window.percent;
|
|
315
|
+
const paused = r.window.kind === 'paused';
|
|
271
316
|
const holdDone = elapsed >= holdMs;
|
|
272
|
-
const
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
317
|
+
const nowStep = applied.stepNow && verdict !== 'fail';
|
|
318
|
+
// A pause holds the step: only step_now leaves it. Hard fails and failed checks still roll back above.
|
|
319
|
+
const stepUp = paused
|
|
320
|
+
? nowStep
|
|
321
|
+
: nowStep ||
|
|
322
|
+
(advance === 'time'
|
|
323
|
+
? holdDone && verdict !== 'fail'
|
|
324
|
+
: advance === 'signal'
|
|
325
|
+
? (holdDone && verdict === 'pass') || (applied.approved && verdict !== 'fail')
|
|
326
|
+
: applied.approved && verdict !== 'fail');
|
|
327
|
+
if (applied.approved && advance !== 'time' && verdict === 'fail') {
|
|
278
328
|
out.refused.push('approve_step refused: the error rate is failing');
|
|
329
|
+
out.applied = out.applied.filter((x) => x.kind !== 'approve_step');
|
|
330
|
+
}
|
|
331
|
+
if (applied.stepNow && verdict === 'fail') {
|
|
332
|
+
out.refused.push('step_now refused: the error rate is failing');
|
|
333
|
+
out.applied = out.applied.filter((x) => x.kind !== 'step_now');
|
|
334
|
+
}
|
|
279
335
|
if (stepUp) {
|
|
280
336
|
const next = steps.find((s) => s > pct);
|
|
281
337
|
r = stepTo(r, rel, next, now);
|
|
282
338
|
}
|
|
283
|
-
else {
|
|
339
|
+
else if (!paused) {
|
|
284
340
|
const kind = verdict === 'inconclusive' ? 'inconclusive' : 'step';
|
|
285
341
|
r = {
|
|
286
342
|
...r,
|
|
@@ -320,6 +376,7 @@ export function decide(input) {
|
|
|
320
376
|
blocked,
|
|
321
377
|
refused: out.refused,
|
|
322
378
|
stageRefusal: out.stageRefusal,
|
|
379
|
+
applied: out.applied,
|
|
323
380
|
};
|
|
324
381
|
}
|
|
325
382
|
function stepTo(r, rel, percent, now) {
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Decision, Recorded } from './decide.js';
|
|
2
|
+
import type { ReleaseAction, ReleaseEvent } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* The history lines of one run: the actions that took effect, then the check, then what the run did with the
|
|
5
|
+
* release (a step, a pass, a rollback or a hard fail). Pure: `before` is what the run read, `decision` what it
|
|
6
|
+
* decided. `actors` names who asked for each requested action (by the action object `decide` was given).
|
|
7
|
+
*/
|
|
8
|
+
export declare function eventsOf(input: {
|
|
9
|
+
before: Recorded;
|
|
10
|
+
decision: Decision;
|
|
11
|
+
actors: ReadonlyMap<ReleaseAction, string>;
|
|
12
|
+
now: Date;
|
|
13
|
+
}): ReleaseEvent[];
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
const percentOf = (w) => w.kind === 'step' || w.kind === 'inconclusive' || w.kind === 'paused' ? w.percent : undefined;
|
|
2
|
+
/**
|
|
3
|
+
* The history lines of one run: the actions that took effect, then the check, then what the run did with the
|
|
4
|
+
* release (a step, a pass, a rollback or a hard fail). Pure: `before` is what the run read, `decision` what it
|
|
5
|
+
* decided. `actors` names who asked for each requested action (by the action object `decide` was given).
|
|
6
|
+
*/
|
|
7
|
+
export function eventsOf(input) {
|
|
8
|
+
const { before, decision, actors } = input;
|
|
9
|
+
const after = decision.recorded;
|
|
10
|
+
const at = input.now.toISOString();
|
|
11
|
+
const events = [];
|
|
12
|
+
const percent = percentOf(before.window) ?? percentOf(after.window);
|
|
13
|
+
const withPercent = percent === undefined ? {} : { percent };
|
|
14
|
+
const actorOf = (action) => {
|
|
15
|
+
const actor = action ? actors.get(action) : undefined;
|
|
16
|
+
return actor ? { actor } : {};
|
|
17
|
+
};
|
|
18
|
+
for (const a of decision.applied) {
|
|
19
|
+
if (a.kind === 'promote' && after.release) {
|
|
20
|
+
events.push({
|
|
21
|
+
at,
|
|
22
|
+
kind: 'promote',
|
|
23
|
+
release: { from: after.release.from, to: after.release.to },
|
|
24
|
+
...actorOf(a),
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
else if (a.kind === 'approve_step') {
|
|
28
|
+
events.push({ at, kind: 'approve', ...withPercent, ...actorOf(a) });
|
|
29
|
+
}
|
|
30
|
+
else if (a.kind === 'open' ||
|
|
31
|
+
a.kind === 'pause' ||
|
|
32
|
+
a.kind === 'resume' ||
|
|
33
|
+
a.kind === 'step_now') {
|
|
34
|
+
events.push({ at, kind: a.kind, ...withPercent, ...actorOf(a) });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (decision.signal)
|
|
38
|
+
events.push({ at, kind: 'check', ...withPercent, signal: decision.signal });
|
|
39
|
+
const w = after.window;
|
|
40
|
+
const release = before.release
|
|
41
|
+
? { release: { from: before.release.from, to: before.release.to } }
|
|
42
|
+
: {};
|
|
43
|
+
const operator = decision.applied.find((a) => a.kind === 'rollback' || a.kind === 'decide_at_cap');
|
|
44
|
+
const was = before.window;
|
|
45
|
+
const newStep = w.kind === 'step'
|
|
46
|
+
? !('percent' in was && 'since' in was && was.percent === w.percent && was.since === w.since)
|
|
47
|
+
: w.kind === 'window' && was.kind !== 'window';
|
|
48
|
+
if (newStep && (w.kind === 'step' || w.kind === 'window')) {
|
|
49
|
+
events.push({ at, kind: 'step', percent: w.kind === 'step' ? w.percent : 100 });
|
|
50
|
+
}
|
|
51
|
+
else if (w.kind === 'rolled_back' && was.kind !== 'rolled_back') {
|
|
52
|
+
const hard = w.reason.startsWith('hard fail');
|
|
53
|
+
events.push({
|
|
54
|
+
at,
|
|
55
|
+
kind: hard ? 'hard_fail' : 'rollback',
|
|
56
|
+
...withPercent,
|
|
57
|
+
reason: w.reason,
|
|
58
|
+
...release,
|
|
59
|
+
...(hard && decision.signal ? { signal: decision.signal } : {}),
|
|
60
|
+
...(w.reason.startsWith('operator') ? actorOf(operator) : {}),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
else if (w.kind === 'passed' && was.kind !== 'passed') {
|
|
64
|
+
events.push({ at, kind: 'passed', ...release, ...actorOf(operator) });
|
|
65
|
+
}
|
|
66
|
+
return events;
|
|
67
|
+
}
|
package/dist/release/health.d.ts
CHANGED
|
@@ -6,6 +6,11 @@ export declare const HEALTH_READ_TIMEOUT_MS = 10000;
|
|
|
6
6
|
export declare const ADDRESS_CHECK_TIMEOUT_MS = 10000;
|
|
7
7
|
/** A health body larger than this is refused unread (a runaway or hostile response must not fill memory). */
|
|
8
8
|
export declare const HEALTH_BODY_MAX_BYTES: number;
|
|
9
|
+
/**
|
|
10
|
+
* The response body as text, or null when it is larger than {@link HEALTH_BODY_MAX_BYTES}. Reads in chunks and
|
|
11
|
+
* stops at the cap, so an endless body is never held.
|
|
12
|
+
*/
|
|
13
|
+
export declare function readCappedText(res: Response, maxBytes?: number): Promise<string | null>;
|
|
9
14
|
/** C1's 200 body. */
|
|
10
15
|
export declare const healthBody: z.ZodObject<{
|
|
11
16
|
colors: z.ZodObject<{
|
package/dist/release/health.js
CHANGED
|
@@ -9,9 +9,9 @@ export const HEALTH_BODY_MAX_BYTES = 64 * 1024;
|
|
|
9
9
|
* The response body as text, or null when it is larger than {@link HEALTH_BODY_MAX_BYTES}. Reads in chunks and
|
|
10
10
|
* stops at the cap, so an endless body is never held.
|
|
11
11
|
*/
|
|
12
|
-
async function readCappedText(res) {
|
|
12
|
+
export async function readCappedText(res, maxBytes = HEALTH_BODY_MAX_BYTES) {
|
|
13
13
|
const declared = Number(res.headers.get('content-length'));
|
|
14
|
-
if (Number.isFinite(declared) && declared >
|
|
14
|
+
if (Number.isFinite(declared) && declared > maxBytes)
|
|
15
15
|
return null;
|
|
16
16
|
if (!res.body)
|
|
17
17
|
return '';
|
|
@@ -23,7 +23,7 @@ async function readCappedText(res) {
|
|
|
23
23
|
if (done)
|
|
24
24
|
break;
|
|
25
25
|
total += value.byteLength;
|
|
26
|
-
if (total >
|
|
26
|
+
if (total > maxBytes) {
|
|
27
27
|
await reader.cancel().catch(() => undefined);
|
|
28
28
|
return null;
|
|
29
29
|
}
|
package/dist/release/run.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type Decision, type Observation, type ReleaseEffect, type ReleaseEntryConfig } from './decide.js';
|
|
2
2
|
import { type ReleaseStore } from './state.js';
|
|
3
|
-
import { type Color } from './types.js';
|
|
3
|
+
import { type Color, type ReleaseEvent } from './types.js';
|
|
4
4
|
/** What a stage copy request came to. Fixed text only: a reason, a step name or an id. */
|
|
5
5
|
export type StageCopyOutcome = {
|
|
6
6
|
outcome: 'ok';
|
|
@@ -55,6 +55,11 @@ export interface ReleaseRunResult {
|
|
|
55
55
|
* request is not in `requestIds`: {@link settleStageCopy} says when it is consumed.
|
|
56
56
|
*/
|
|
57
57
|
copy: StageCopyJob | null;
|
|
58
|
+
/**
|
|
59
|
+
* The history lines of this run (`foundry_release_events`). The caller appends them with `store.appendEvents`
|
|
60
|
+
* after it persists `writes`, so a run that fails leaves none.
|
|
61
|
+
*/
|
|
62
|
+
events: ReleaseEvent[];
|
|
58
63
|
}
|
|
59
64
|
/** One stage copy request a run decided to act on. */
|
|
60
65
|
export interface StageCopyJob {
|
|
@@ -71,7 +76,7 @@ export interface StageCopyJob {
|
|
|
71
76
|
* the recorded keys as they were: the next run decides again from them.
|
|
72
77
|
*/
|
|
73
78
|
export declare function runRelease(input: {
|
|
74
|
-
store: ReleaseStore
|
|
79
|
+
store: Pick<ReleaseStore, 'getScopeIds' | 'openRequests'>;
|
|
75
80
|
orgId: string;
|
|
76
81
|
entryKey: string;
|
|
77
82
|
entry: ReleaseEntryConfig;
|
package/dist/release/run.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { decide, healthRange, } from './decide.js';
|
|
2
|
+
import { eventsOf } from './events.js';
|
|
2
3
|
import { formatRefusals, formatWeights, formatWindow, recordedFrom, releaseKeys, } from './state.js';
|
|
3
4
|
import { isStageCopyAction } from './types.js';
|
|
4
5
|
const REFUSAL_TEXT = {
|
|
@@ -23,6 +24,7 @@ export async function runRelease(input) {
|
|
|
23
24
|
requestIds: [],
|
|
24
25
|
deferredCopy: false,
|
|
25
26
|
copy: null,
|
|
27
|
+
events: [],
|
|
26
28
|
};
|
|
27
29
|
}
|
|
28
30
|
const k = releaseKeys(entryKey);
|
|
@@ -97,6 +99,12 @@ export async function runRelease(input) {
|
|
|
97
99
|
requestIds: [...replayed, ...open.filter((r) => r.id !== copy?.requestId)].map((r) => r.id),
|
|
98
100
|
deferredCopy: deferred.length > 0,
|
|
99
101
|
copy,
|
|
102
|
+
events: eventsOf({
|
|
103
|
+
before: recorded,
|
|
104
|
+
decision,
|
|
105
|
+
actors: new Map(open.map((r) => [r.action, r.actor])),
|
|
106
|
+
now,
|
|
107
|
+
}),
|
|
100
108
|
};
|
|
101
109
|
}
|
|
102
110
|
/**
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { ReleaseSeries } from './types.js';
|
|
3
|
+
/** The reporting series read has this long to answer. */
|
|
4
|
+
export declare const SERIES_READ_TIMEOUT_MS = 15000;
|
|
5
|
+
/** 8 days of 10-minute buckets is about 1150 buckets of about 300 bytes; a larger body is refused unread. */
|
|
6
|
+
export declare const SERIES_BODY_MAX_BYTES: number;
|
|
7
|
+
/** The longest range reporting serves. */
|
|
8
|
+
export declare const SERIES_MAX_DAYS = 8;
|
|
9
|
+
export declare const SERIES_BUCKET_MINUTES: readonly [10, 20, 60];
|
|
10
|
+
/** C2's 200 body. */
|
|
11
|
+
export declare const seriesBody: z.ZodObject<{
|
|
12
|
+
bucketMinutes: z.ZodNumber;
|
|
13
|
+
buckets: z.ZodArray<z.ZodObject<{
|
|
14
|
+
start: z.ZodString;
|
|
15
|
+
colors: z.ZodObject<{
|
|
16
|
+
blue: z.ZodObject<{
|
|
17
|
+
requests: z.ZodNumber;
|
|
18
|
+
errors: z.ZodNumber;
|
|
19
|
+
slow: z.ZodNumber;
|
|
20
|
+
durationMsSum: z.ZodNumber;
|
|
21
|
+
signIns: z.ZodNumber;
|
|
22
|
+
signInFailures: z.ZodNumber;
|
|
23
|
+
}, z.core.$strip>;
|
|
24
|
+
green: z.ZodObject<{
|
|
25
|
+
requests: z.ZodNumber;
|
|
26
|
+
errors: z.ZodNumber;
|
|
27
|
+
slow: z.ZodNumber;
|
|
28
|
+
durationMsSum: z.ZodNumber;
|
|
29
|
+
signIns: z.ZodNumber;
|
|
30
|
+
signInFailures: z.ZodNumber;
|
|
31
|
+
}, z.core.$strip>;
|
|
32
|
+
}, z.core.$strip>;
|
|
33
|
+
}, z.core.$strip>>;
|
|
34
|
+
}, z.core.$strip>;
|
|
35
|
+
export interface ReleaseSeriesQuery {
|
|
36
|
+
from: string;
|
|
37
|
+
to: string;
|
|
38
|
+
/** Bucket minutes: 10, 20 (default) or 60. */
|
|
39
|
+
bucket?: (typeof SERIES_BUCKET_MINUTES)[number];
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* `GET <address>/api/reporting/release-series?from&to&bucket` with the bearer token. Returns null for any
|
|
43
|
+
* failure (a bad range, network, timeout, status, body shape): the page then shows no chart rather than a wrong
|
|
44
|
+
* one. Nothing about the response or the token reaches a log or an error.
|
|
45
|
+
*/
|
|
46
|
+
export declare function fetchReleaseSeries(input: {
|
|
47
|
+
address: string;
|
|
48
|
+
token: string;
|
|
49
|
+
headers?: Record<string, string>;
|
|
50
|
+
fetchImpl?: typeof fetch;
|
|
51
|
+
query: ReleaseSeriesQuery;
|
|
52
|
+
}): Promise<ReleaseSeries | null>;
|
|
53
|
+
/** Something that can read one entry's series: a {@link import('../plan/release-host.js').ReleaseHost}. */
|
|
54
|
+
export interface ReleaseSeriesHost {
|
|
55
|
+
readSeries?(entry: string, query: ReleaseSeriesQuery): Promise<ReleaseSeries | null>;
|
|
56
|
+
}
|
|
57
|
+
/** C2: the entry's requests, errors, slow requests and sign-ins per bucket and color, or null when unavailable. */
|
|
58
|
+
export declare function readReleaseSeries(host: ReleaseSeriesHost, entry: string, query: ReleaseSeriesQuery): Promise<ReleaseSeries | null>;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { readCappedText } from './health.js';
|
|
3
|
+
/** The reporting series read has this long to answer. */
|
|
4
|
+
export const SERIES_READ_TIMEOUT_MS = 15_000;
|
|
5
|
+
/** 8 days of 10-minute buckets is about 1150 buckets of about 300 bytes; a larger body is refused unread. */
|
|
6
|
+
export const SERIES_BODY_MAX_BYTES = 1024 * 1024;
|
|
7
|
+
/** The longest range reporting serves. */
|
|
8
|
+
export const SERIES_MAX_DAYS = 8;
|
|
9
|
+
export const SERIES_BUCKET_MINUTES = [10, 20, 60];
|
|
10
|
+
const count = z.number().finite().nonnegative();
|
|
11
|
+
const color = z.object({
|
|
12
|
+
requests: count,
|
|
13
|
+
errors: count,
|
|
14
|
+
slow: count,
|
|
15
|
+
durationMsSum: count,
|
|
16
|
+
signIns: count,
|
|
17
|
+
signInFailures: count,
|
|
18
|
+
});
|
|
19
|
+
/** C2's 200 body. */
|
|
20
|
+
export const seriesBody = z.object({
|
|
21
|
+
bucketMinutes: z.number().int().positive(),
|
|
22
|
+
buckets: z.array(z.object({
|
|
23
|
+
start: z.string().refine((v) => !Number.isNaN(Date.parse(v))),
|
|
24
|
+
colors: z.object({ blue: color, green: color }),
|
|
25
|
+
})),
|
|
26
|
+
});
|
|
27
|
+
/**
|
|
28
|
+
* `GET <address>/api/reporting/release-series?from&to&bucket` with the bearer token. Returns null for any
|
|
29
|
+
* failure (a bad range, network, timeout, status, body shape): the page then shows no chart rather than a wrong
|
|
30
|
+
* one. Nothing about the response or the token reaches a log or an error.
|
|
31
|
+
*/
|
|
32
|
+
export async function fetchReleaseSeries(input) {
|
|
33
|
+
const { from, to } = input.query;
|
|
34
|
+
const bucket = input.query.bucket ?? 20;
|
|
35
|
+
const span = Date.parse(to) - Date.parse(from);
|
|
36
|
+
if (!(span > 0) || span > SERIES_MAX_DAYS * 86_400_000)
|
|
37
|
+
return null;
|
|
38
|
+
if (!SERIES_BUCKET_MINUTES.includes(bucket))
|
|
39
|
+
return null;
|
|
40
|
+
try {
|
|
41
|
+
const url = new URL('/api/reporting/release-series', input.address);
|
|
42
|
+
url.searchParams.set('from', from);
|
|
43
|
+
url.searchParams.set('to', to);
|
|
44
|
+
url.searchParams.set('bucket', String(bucket));
|
|
45
|
+
const res = await (input.fetchImpl ?? fetch)(url, {
|
|
46
|
+
headers: { ...input.headers, authorization: `Bearer ${input.token}` },
|
|
47
|
+
signal: AbortSignal.timeout(SERIES_READ_TIMEOUT_MS),
|
|
48
|
+
});
|
|
49
|
+
if (!res.ok)
|
|
50
|
+
return null;
|
|
51
|
+
const text = await readCappedText(res, SERIES_BODY_MAX_BYTES);
|
|
52
|
+
if (text === null)
|
|
53
|
+
return null;
|
|
54
|
+
const parsed = seriesBody.safeParse(JSON.parse(text));
|
|
55
|
+
return parsed.success ? parsed.data : null;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// Network error, timeout or invalid JSON: all the same to the caller.
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/** C2: the entry's requests, errors, slow requests and sign-ins per bucket and color, or null when unavailable. */
|
|
63
|
+
export async function readReleaseSeries(host, entry, query) {
|
|
64
|
+
return host.readSeries ? host.readSeries(entry, query) : null;
|
|
65
|
+
}
|