@wtfalch/foundry 0.62.0 → 0.63.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/bootstrap/declare.js +4 -1
- package/dist/bootstrap/versions.d.ts +2 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.js +2 -1
- package/dist/migrations/0067_requested_changes_kosmos.sql +6 -0
- package/dist/migrations/0068_platform_registry.sql +17 -0
- package/dist/native-build.d.ts +7 -27
- package/dist/native-build.js +7 -27
- package/dist/plan/archon-blue-green.d.ts +5 -0
- package/dist/plan/archon-blue-green.js +2 -0
- package/dist/plan/blue-green-apply.d.ts +5 -0
- package/dist/plan/blue-green-apply.js +197 -22
- package/dist/plan/blue-green.d.ts +61 -2
- package/dist/plan/blue-green.js +80 -6
- package/dist/plan/boule.d.ts +1 -1
- package/dist/plan/boule.js +2 -0
- package/dist/plan/broker-entries.d.ts +9 -0
- package/dist/plan/broker-entries.js +29 -0
- package/dist/plan/diff.d.ts +5 -0
- package/dist/plan/diff.js +4 -0
- package/dist/plan/edge-access.d.ts +2 -3
- package/dist/plan/edge-access.js +7 -0
- package/dist/plan/executor.d.ts +71 -1
- package/dist/plan/executor.js +426 -24
- package/dist/plan/index.d.ts +2 -0
- package/dist/plan/index.js +1 -0
- package/dist/plan/jobs-scheduler-http.d.ts +2 -0
- package/dist/plan/jobs-scheduler-http.js +11 -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 +51 -1
- package/dist/plan/plan-apply.js +298 -25
- package/dist/plan/platform-diff.d.ts +6 -0
- package/dist/plan/platform-diff.js +74 -0
- package/dist/plan/platform-org.d.ts +2 -0
- package/dist/plan/platform-providers.d.ts +46 -1
- package/dist/plan/platform-providers.js +156 -1
- package/dist/plan/platform-registry-keys.d.ts +58 -0
- package/dist/plan/platform-registry-keys.js +69 -0
- package/dist/plan/platform-registry.d.ts +67 -0
- package/dist/plan/platform-registry.js +298 -0
- package/dist/plan/release-host.d.ts +31 -2
- package/dist/plan/release-host.js +114 -6
- package/dist/plan/stage-copy-host.d.ts +104 -0
- package/dist/plan/stage-copy-host.js +259 -0
- package/dist/plan/stage-copy.js +44 -0
- package/dist/plan/stage-env.d.ts +9 -0
- package/dist/plan/stage-env.js +38 -0
- package/dist/plan/stage-guards.d.ts +57 -1
- package/dist/plan/stage-guards.js +106 -0
- package/dist/plan/stage-isolation.js +56 -0
- package/dist/plan/stage-services.d.ts +93 -0
- package/dist/plan/stage-services.js +153 -0
- package/dist/plan/stage-signin.d.ts +152 -0
- package/dist/plan/stage-signin.js +133 -0
- package/dist/plan/stage-sink.d.ts +2 -0
- package/dist/plan/stage-sink.js +6 -1
- package/dist/platform.d.ts +7 -1
- package/dist/platform.js +1 -0
- package/dist/release/decide.d.ts +12 -0
- package/dist/release/decide.js +37 -2
- package/dist/release/run.d.ts +71 -1
- package/dist/release/run.js +92 -8
- package/dist/release/state.d.ts +14 -1
- package/dist/release/state.js +43 -0
- package/dist/release/types.d.ts +20 -0
- package/dist/release/types.js +2 -0
- package/dist/requested-changes.d.ts +11 -2
- package/dist/requested-changes.js +11 -0
- package/dist/store/postgres.js +8 -0
- package/dist/store/schema.d.ts +51 -0
- package/dist/store/schema.js +4 -0
- package/package.json +3 -2
- package/versions.digests.json +1 -0
- package/versions.json +1 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { bgKey } from './blue-green.js';
|
|
3
|
+
/** Tries the broker write this many times (CLAUDE.md, Platform steps, rule 1). */
|
|
4
|
+
export const STAGE_SIGNIN_DELIVERY_TRIES = 3;
|
|
5
|
+
/** The guard value for a subject that does not sign in. */
|
|
6
|
+
export const STAGE_SIGNIN_NONE = 'none';
|
|
7
|
+
/** Fixed text and ids only: safe to log, record and return. */
|
|
8
|
+
export class StageSignInError extends Error {
|
|
9
|
+
name = 'StageSignInError';
|
|
10
|
+
}
|
|
11
|
+
const sha256 = (value) => createHash('sha256').update(value).digest('hex');
|
|
12
|
+
/**
|
|
13
|
+
* Makes stage's client, mints its app key once and writes its broker entry. Order: the client first (its ids
|
|
14
|
+
* recorded and persisted at once, a lost answer is adopted by name), then the key (held, its ref recorded under
|
|
15
|
+
* {@link bgKey.stageSignInKey} and persisted before anything is delivered), then the broker entry (retried).
|
|
16
|
+
* A run that failed halfway finds both and does the rest; the key is never minted twice. When the ref is
|
|
17
|
+
* recorded but the held store no longer has the key, this stops: it never mints another.
|
|
18
|
+
*/
|
|
19
|
+
export async function ensureStageSignIn(input) {
|
|
20
|
+
const { id, port } = input;
|
|
21
|
+
const at = `stage sign-in for ${id}`;
|
|
22
|
+
let appId = input.recorded()[bgKey.stageClient(id)];
|
|
23
|
+
let clientId = input.recorded()[bgKey.stageClientId(id)];
|
|
24
|
+
if (!appId || !clientId) {
|
|
25
|
+
const found = await port.findClient(port.spec.clientName);
|
|
26
|
+
if (found) {
|
|
27
|
+
if (!found.clientId) {
|
|
28
|
+
throw new StageSignInError(`${at}: client ${found.appId} exists but its id cannot be read`);
|
|
29
|
+
}
|
|
30
|
+
appId = found.appId;
|
|
31
|
+
clientId = found.clientId;
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
const created = await port.createClient(port.spec);
|
|
35
|
+
appId = created.appId;
|
|
36
|
+
clientId = created.clientId;
|
|
37
|
+
}
|
|
38
|
+
input.record(bgKey.stageClient(id), appId);
|
|
39
|
+
input.record(bgKey.stageClientId(id), clientId);
|
|
40
|
+
await input.persist();
|
|
41
|
+
}
|
|
42
|
+
// One mint: the key goes to the held store, its ref is recorded and persisted, and only then is it used.
|
|
43
|
+
let appKey = await port.held.resolve();
|
|
44
|
+
if (appKey) {
|
|
45
|
+
input.secret(appKey);
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
if (input.recorded()[bgKey.stageSignInKey(id)]) {
|
|
49
|
+
throw new StageSignInError(`${at}: key ${port.held.ref} was minted but is no longer held; restore the held value`);
|
|
50
|
+
}
|
|
51
|
+
appKey = input.generate();
|
|
52
|
+
input.secret(appKey);
|
|
53
|
+
await port.held.put(appKey);
|
|
54
|
+
input.record(bgKey.stageSignInKey(id), port.held.ref);
|
|
55
|
+
await input.persist();
|
|
56
|
+
}
|
|
57
|
+
const entry = {
|
|
58
|
+
name: port.spec.brokerName,
|
|
59
|
+
organizationId: port.spec.organizationId,
|
|
60
|
+
clientId,
|
|
61
|
+
origins: port.spec.origins,
|
|
62
|
+
from: port.spec.from,
|
|
63
|
+
registration: port.spec.registration,
|
|
64
|
+
...(port.spec.emailLink === undefined ? {} : { emailLink: port.spec.emailLink }),
|
|
65
|
+
};
|
|
66
|
+
const sleep = input.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
67
|
+
for (let attempt = 1;; attempt++) {
|
|
68
|
+
try {
|
|
69
|
+
await port.writeBrokerEntry(entry, appKey);
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
// The cause can echo the key; the ref is all a reader needs.
|
|
74
|
+
if (attempt >= STAGE_SIGNIN_DELIVERY_TRIES) {
|
|
75
|
+
throw new StageSignInError(`${at}: delivering key ${port.held.ref} to the broker entry ${port.spec.brokerName} failed after ${STAGE_SIGNIN_DELIVERY_TRIES} tries`);
|
|
76
|
+
}
|
|
77
|
+
await sleep(1000 * attempt);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
input.record(bgKey.stageBroker(id), JSON.stringify({ name: entry.name, organizationId: entry.organizationId }));
|
|
81
|
+
await input.persist();
|
|
82
|
+
return {
|
|
83
|
+
env: [
|
|
84
|
+
{ key: 'AUTH_CLIENT_ID', value: clientId },
|
|
85
|
+
{ key: 'AUTH_APP_KEY', value: appKey },
|
|
86
|
+
],
|
|
87
|
+
guard: stageSignInGuard({ appId, clientId, brokerName: entry.name, origins: entry.origins }),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/** `ok:<sha256 of the ids>`: the client, the app, the broker entry's name and its origins. No value goes in. */
|
|
91
|
+
export function stageSignInGuard(input) {
|
|
92
|
+
return `ok:${sha256(JSON.stringify([input.appId, input.clientId, input.brokerName, [...input.origins].sort()]))}`;
|
|
93
|
+
}
|
|
94
|
+
/** Whether the recorded `bg.<id>.stagesignin` lets the copy go on. */
|
|
95
|
+
export const stageSignInReady = (value) => value === STAGE_SIGNIN_NONE || value?.startsWith('ok:') === true;
|
|
96
|
+
/**
|
|
97
|
+
* Makes the public client of a static SPA's stage (Agora): found by name or created, its ids recorded and
|
|
98
|
+
* persisted at once. There is no key and no broker entry, so nothing secret exists. The guard hashes the app id,
|
|
99
|
+
* the client id, the client name and the origins.
|
|
100
|
+
*/
|
|
101
|
+
export async function ensureStagePublicClient(input) {
|
|
102
|
+
const { id, port } = input;
|
|
103
|
+
const at = `stage sign-in for ${id}`;
|
|
104
|
+
let appId = input.recorded()[bgKey.stageClient(id)];
|
|
105
|
+
let clientId = input.recorded()[bgKey.stageClientId(id)];
|
|
106
|
+
if (!appId || !clientId) {
|
|
107
|
+
const found = await port.findClient(port.spec.clientName);
|
|
108
|
+
if (found) {
|
|
109
|
+
if (!found.clientId) {
|
|
110
|
+
throw new StageSignInError(`${at}: client ${found.appId} exists but its id cannot be read`);
|
|
111
|
+
}
|
|
112
|
+
appId = found.appId;
|
|
113
|
+
clientId = found.clientId;
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
const created = await port.createClient(port.spec);
|
|
117
|
+
appId = created.appId;
|
|
118
|
+
clientId = created.clientId;
|
|
119
|
+
}
|
|
120
|
+
input.record(bgKey.stageClient(id), appId);
|
|
121
|
+
input.record(bgKey.stageClientId(id), clientId);
|
|
122
|
+
await input.persist();
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
clientId,
|
|
126
|
+
guard: stageSignInGuard({
|
|
127
|
+
appId,
|
|
128
|
+
clientId,
|
|
129
|
+
brokerName: port.spec.clientName,
|
|
130
|
+
origins: port.spec.origins,
|
|
131
|
+
}),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
@@ -26,6 +26,8 @@ type SinkOrg = Pick<DeclaredOrg, 'slug'>;
|
|
|
26
26
|
export declare const stageSinkAppName: (org: SinkOrg) => string;
|
|
27
27
|
/** The sink's HTTP address on the company's Docker network. */
|
|
28
28
|
export declare const stageSinkInternalUrl: (org: SinkOrg) => string;
|
|
29
|
+
/** The sender stage mails carry. Apps send only when `MAIL_FROM` is set; the address cannot receive mail. */
|
|
30
|
+
export declare const STAGE_MAIL_FROM = "Stage <noreply@stage.invalid>";
|
|
29
31
|
/**
|
|
30
32
|
* The only mail env stage holds. `@wtfalch/mailer` 0.6.0 posts every message to `MAIL_SINK_URL` when it is set,
|
|
31
33
|
* so stage holds no mail token and no Lettermint setting.
|
package/dist/plan/stage-sink.js
CHANGED
|
@@ -24,10 +24,15 @@ export const STAGE_SINK_VERSION = 1;
|
|
|
24
24
|
export const stageSinkAppName = (org) => `${org.slug}-stage-sink`;
|
|
25
25
|
/** The sink's HTTP address on the company's Docker network. */
|
|
26
26
|
export const stageSinkInternalUrl = (org) => `http://${stageSinkAppName(org)}:${STAGE_SINK_HTTP_PORT}`;
|
|
27
|
+
/** The sender stage mails carry. Apps send only when `MAIL_FROM` is set; the address cannot receive mail. */
|
|
28
|
+
export const STAGE_MAIL_FROM = 'Stage <noreply@stage.invalid>';
|
|
27
29
|
/**
|
|
28
30
|
* The only mail env stage holds. `@wtfalch/mailer` 0.6.0 posts every message to `MAIL_SINK_URL` when it is set,
|
|
29
31
|
* so stage holds no mail token and no Lettermint setting.
|
|
30
32
|
*/
|
|
31
33
|
export function stageMailEnv(org) {
|
|
32
|
-
return [
|
|
34
|
+
return [
|
|
35
|
+
{ key: 'MAIL_SINK_URL', value: stageSinkInternalUrl(org) },
|
|
36
|
+
{ key: 'MAIL_FROM', value: STAGE_MAIL_FROM },
|
|
37
|
+
];
|
|
33
38
|
}
|
package/dist/platform.d.ts
CHANGED
|
@@ -72,7 +72,7 @@ export declare function uptimeArtifactUrl(tag: string): string;
|
|
|
72
72
|
* holds the Cloudflare token `<username>.<org domain>` claims use, so no
|
|
73
73
|
* hosting entry ever holds that credential itself.
|
|
74
74
|
*/
|
|
75
|
-
export type PlatformServiceKey = 'auth' | 'mail' | 'sfu' | 'files' | 'ai' | 'jobs' | 'integrations' | 'archon' | 'edge' | 'hc' | 'coolify';
|
|
75
|
+
export type PlatformServiceKey = 'auth' | 'mail' | 'sfu' | 'files' | 'ai' | 'jobs' | 'integrations' | 'archon' | 'edge' | 'hc' | 'registry' | 'coolify';
|
|
76
76
|
export declare const PLATFORM_SERVICE_KEYS: readonly PlatformServiceKey[];
|
|
77
77
|
/**
|
|
78
78
|
* The one shared platform every org's services run on -- decided in #165 as
|
|
@@ -123,6 +123,12 @@ export interface DeclaredPlatform {
|
|
|
123
123
|
* `plan/platform-diff.ts`'s `planHeartbeats`.
|
|
124
124
|
*/
|
|
125
125
|
hc: PlatformService;
|
|
126
|
+
/**
|
|
127
|
+
* The platform's image registry (`registry:3`, htpasswd sign-in): the images the build workflow
|
|
128
|
+
* pushes and the company servers pull. `gitRef` is the image tag, pinned by digest in
|
|
129
|
+
* `versions.json` like `hc`'s. See `plan/platform-registry.ts`.
|
|
130
|
+
*/
|
|
131
|
+
registry: PlatformService;
|
|
126
132
|
/**
|
|
127
133
|
* The Coolify panel. Its address is declared so the platform's hostnames
|
|
128
134
|
* include it; foundry never deploys it, so `enabled` stays false and
|
package/dist/platform.js
CHANGED
package/dist/release/decide.d.ts
CHANGED
|
@@ -51,6 +51,8 @@ export interface Recorded {
|
|
|
51
51
|
/** Set while window.kind is step, inconclusive, window or cap. */
|
|
52
52
|
release: ActiveRelease | null;
|
|
53
53
|
held: string[];
|
|
54
|
+
/** Stage holds the sink env. Absent reads as no. */
|
|
55
|
+
stageSink?: boolean;
|
|
54
56
|
}
|
|
55
57
|
export interface ColorCounts {
|
|
56
58
|
requests: number;
|
|
@@ -85,6 +87,14 @@ export type ReleaseEffect = {
|
|
|
85
87
|
| {
|
|
86
88
|
kind: 'deploy_stage';
|
|
87
89
|
ref: string;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Run the stage copy (or undo it) for the first open request of that kind. The runner does not execute it: it
|
|
93
|
+
* hands it to its caller, who runs it after this run's other effects, writes and weights are done.
|
|
94
|
+
*/
|
|
95
|
+
| {
|
|
96
|
+
kind: 'stage_copy';
|
|
97
|
+
mode: 'copy' | 'undo';
|
|
88
98
|
};
|
|
89
99
|
export interface Decision {
|
|
90
100
|
recorded: Recorded;
|
|
@@ -94,6 +104,8 @@ export interface Decision {
|
|
|
94
104
|
blocked: string[];
|
|
95
105
|
/** Actions that changed nothing, with a fixed reason. */
|
|
96
106
|
refused: string[];
|
|
107
|
+
/** The reason a `copy_prod_to_stage` request was refused here, for `bg.<id>.stagecopy`; else null. */
|
|
108
|
+
stageRefusal: string | null;
|
|
97
109
|
}
|
|
98
110
|
export declare function normalizeSteps(steps: readonly number[] | undefined): number[];
|
|
99
111
|
/** The time a health read should cover: from the step or window start to now. */
|
package/dist/release/decide.js
CHANGED
|
@@ -81,6 +81,7 @@ function applyActions(r, actions, now, out, kind) {
|
|
|
81
81
|
let ended = false;
|
|
82
82
|
let approved = false;
|
|
83
83
|
let opened = false;
|
|
84
|
+
const stageRequests = [];
|
|
84
85
|
for (const a of actions) {
|
|
85
86
|
if (a.kind === 'deploy_stage') {
|
|
86
87
|
if (typeof a.ref === 'string' && STAGE_REF_PATTERN.test(a.ref))
|
|
@@ -135,6 +136,9 @@ function applyActions(r, actions, now, out, kind) {
|
|
|
135
136
|
promoted = true;
|
|
136
137
|
}
|
|
137
138
|
}
|
|
139
|
+
else if (a.kind === 'copy_prod_to_stage' || a.kind === 'undo_stage_copy') {
|
|
140
|
+
stageRequests.push(a.kind);
|
|
141
|
+
}
|
|
138
142
|
else if (a.kind === 'rollback') {
|
|
139
143
|
if (!cur.release)
|
|
140
144
|
out.refused.push('rollback refused: no release is running');
|
|
@@ -157,6 +161,26 @@ function applyActions(r, actions, now, out, kind) {
|
|
|
157
161
|
}
|
|
158
162
|
}
|
|
159
163
|
}
|
|
164
|
+
// The stage requests are judged last, against the release the other actions of this run left.
|
|
165
|
+
for (const kind of stageRequests) {
|
|
166
|
+
const copy = kind === 'copy_prod_to_stage';
|
|
167
|
+
const name = copy ? 'copy' : 'undo';
|
|
168
|
+
if (out.effects.some((e) => e.kind === 'stage_copy')) {
|
|
169
|
+
out.refused.push(`${name} refused: one stage copy request runs at a time`);
|
|
170
|
+
}
|
|
171
|
+
else if (cur.release) {
|
|
172
|
+
// A copy takes up to ~45 minutes; a release that is stepping needs the runner free to roll back.
|
|
173
|
+
out.refused.push(`${name} refused: a release is running`);
|
|
174
|
+
out.stageRefusal = 'release';
|
|
175
|
+
}
|
|
176
|
+
else if (copy && !cur.stageSink) {
|
|
177
|
+
out.refused.push('copy refused: stage sink is not applied');
|
|
178
|
+
out.stageRefusal = 'sink';
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
181
|
+
out.effects.push({ kind: 'stage_copy', mode: copy ? 'copy' : 'undo' });
|
|
182
|
+
}
|
|
183
|
+
}
|
|
160
184
|
return { recorded: cur, promoted, ended, approved, opened };
|
|
161
185
|
}
|
|
162
186
|
/**
|
|
@@ -168,7 +192,11 @@ export function decide(input) {
|
|
|
168
192
|
const { now, observation } = input;
|
|
169
193
|
const steps = normalizeSteps(input.steps);
|
|
170
194
|
const holdMs = (input.stepHoldMinutes ?? DEFAULT_STEP_HOLD_MINUTES) * MINUTE;
|
|
171
|
-
const out = {
|
|
195
|
+
const out = {
|
|
196
|
+
effects: [],
|
|
197
|
+
refused: [],
|
|
198
|
+
stageRefusal: null,
|
|
199
|
+
};
|
|
172
200
|
const advance = input.advance ?? DEFAULT_ADVANCE;
|
|
173
201
|
const minRequests = input.minRequests ?? RELATIVE_MIN_REQUESTS;
|
|
174
202
|
const errorMargin = input.errorMargin ?? ERROR_RATE_MARGIN;
|
|
@@ -285,7 +313,14 @@ export function decide(input) {
|
|
|
285
313
|
}
|
|
286
314
|
if (r.window.kind === 'cap')
|
|
287
315
|
blocked.push('release window reached the cap without enough data: pass or roll back in Archon');
|
|
288
|
-
return {
|
|
316
|
+
return {
|
|
317
|
+
recorded: r,
|
|
318
|
+
signal,
|
|
319
|
+
effects: out.effects,
|
|
320
|
+
blocked,
|
|
321
|
+
refused: out.refused,
|
|
322
|
+
stageRefusal: out.stageRefusal,
|
|
323
|
+
};
|
|
289
324
|
}
|
|
290
325
|
function stepTo(r, rel, percent, now) {
|
|
291
326
|
const at = now.toISOString();
|
package/dist/release/run.d.ts
CHANGED
|
@@ -1,6 +1,22 @@
|
|
|
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
|
|
3
|
+
import { type Color } from './types.js';
|
|
4
|
+
/** What a stage copy request came to. Fixed text only: a reason, a step name or an id. */
|
|
5
|
+
export type StageCopyOutcome = {
|
|
6
|
+
outcome: 'ok';
|
|
7
|
+
detail: string;
|
|
8
|
+
recorded: boolean;
|
|
9
|
+
} | {
|
|
10
|
+
outcome: 'refused';
|
|
11
|
+
reason: string;
|
|
12
|
+
} | {
|
|
13
|
+
outcome: 'failed';
|
|
14
|
+
step: string;
|
|
15
|
+
}
|
|
16
|
+
/** Another run holds the copy lock: leave the request open and write nothing. */
|
|
17
|
+
| {
|
|
18
|
+
outcome: 'busy';
|
|
19
|
+
};
|
|
4
20
|
/** What the runner needs from the outside. Each call is bounded by its own timeout. */
|
|
5
21
|
export interface ReleaseDeps {
|
|
6
22
|
/** The reporting health read for the range, or null when it failed. */
|
|
@@ -11,6 +27,17 @@ export interface ReleaseDeps {
|
|
|
11
27
|
/** The Coolify status and the own-address check of a color. */
|
|
12
28
|
observeColor(color: Color): Promise<Pick<Observation, 'status' | 'address'>>;
|
|
13
29
|
effect(effect: ReleaseEffect): Promise<void>;
|
|
30
|
+
/**
|
|
31
|
+
* Runs one stage copy request (the copy can take ~45 minutes); {@link runStageCopy} calls it after the run
|
|
32
|
+
* that decided on the copy is done. It answers with the outcome and throws only for a lost lock. Absent: the
|
|
33
|
+
* request is refused with `no-copy-port`.
|
|
34
|
+
*/
|
|
35
|
+
stageCopy?(request: {
|
|
36
|
+
mode: 'copy' | 'undo';
|
|
37
|
+
requestId: number;
|
|
38
|
+
actor: string;
|
|
39
|
+
runId?: string;
|
|
40
|
+
}): Promise<StageCopyOutcome>;
|
|
14
41
|
}
|
|
15
42
|
export interface ReleaseRunResult {
|
|
16
43
|
/** Scope keys to record (null removes one). The caller persists them with the rest of the run. */
|
|
@@ -20,6 +47,22 @@ export interface ReleaseRunResult {
|
|
|
20
47
|
decision: Decision | null;
|
|
21
48
|
/** The requests this run acted on. The caller consumes them with `store.consumeRequests` after it persists `writes`. */
|
|
22
49
|
requestIds: number[];
|
|
50
|
+
/** A stage copy request was left open because this run does not run copies. */
|
|
51
|
+
deferredCopy: boolean;
|
|
52
|
+
/**
|
|
53
|
+
* The stage copy this run decided on, not yet run. The caller runs it (`runStageCopy`) after it has persisted
|
|
54
|
+
* `writes` and written the weights, so a rollback decided in the same run never waits for the copy. Its
|
|
55
|
+
* request is not in `requestIds`: {@link settleStageCopy} says when it is consumed.
|
|
56
|
+
*/
|
|
57
|
+
copy: StageCopyJob | null;
|
|
58
|
+
}
|
|
59
|
+
/** One stage copy request a run decided to act on. */
|
|
60
|
+
export interface StageCopyJob {
|
|
61
|
+
mode: 'copy' | 'undo';
|
|
62
|
+
requestId: number;
|
|
63
|
+
actor: string;
|
|
64
|
+
/** The token of the run that took the copy lock. Set by the caller that claims the lock. */
|
|
65
|
+
runId?: string;
|
|
23
66
|
}
|
|
24
67
|
/**
|
|
25
68
|
* One run for one entry. Reads the recorded keys fresh and the open
|
|
@@ -36,4 +79,31 @@ export declare function runRelease(input: {
|
|
|
36
79
|
deps: ReleaseDeps;
|
|
37
80
|
/** No reporting health read: only hard fails count (see `decide`). */
|
|
38
81
|
hardOnly?: boolean;
|
|
82
|
+
/** Leave stage copy requests open and untouched (an apply, and the tick's first phase). */
|
|
83
|
+
deferCopies?: boolean;
|
|
39
84
|
}): Promise<ReleaseRunResult>;
|
|
85
|
+
/** What a finished stage copy writes and whether its request is done. */
|
|
86
|
+
export interface StageCopySettled {
|
|
87
|
+
writes: Record<string, string | null>;
|
|
88
|
+
/** Fixed texts for the operator. */
|
|
89
|
+
refused: string[];
|
|
90
|
+
/** The request is consumed. False: it stays open (busy, or the first failure). */
|
|
91
|
+
consume: boolean;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Runs the copy `job` with `deps.stageCopy` (absent: refused with `no-copy-port`). Call it after the run that
|
|
95
|
+
* decided on it has persisted its writes and written the weights: the copy takes up to ~45 minutes.
|
|
96
|
+
*/
|
|
97
|
+
export declare function runStageCopy(deps: ReleaseDeps, job: StageCopyJob): Promise<StageCopyOutcome>;
|
|
98
|
+
/**
|
|
99
|
+
* What a copy outcome records. `ids` are the recorded keys now (the retry mark is read from them). A failure is
|
|
100
|
+
* retried once, and only a failure before the switch can come back as `failed`: the host reports a failure after
|
|
101
|
+
* the switch as `ok`, so a retry never sweeps the database stage runs on or its undo target.
|
|
102
|
+
*/
|
|
103
|
+
export declare function settleStageCopy(input: {
|
|
104
|
+
ids: Readonly<Record<string, string>>;
|
|
105
|
+
entryKey: string;
|
|
106
|
+
now: Date;
|
|
107
|
+
job: StageCopyJob;
|
|
108
|
+
outcome: StageCopyOutcome;
|
|
109
|
+
}): StageCopySettled;
|
package/dist/release/run.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { decide, healthRange, } from './decide.js';
|
|
2
2
|
import { formatWeights, formatWindow, recordedFrom, releaseKeys, } from './state.js';
|
|
3
|
+
import { isStageCopyAction } from './types.js';
|
|
4
|
+
const REFUSAL_TEXT = {
|
|
5
|
+
'no-copy-port': 'copy refused: this host cannot restore',
|
|
6
|
+
};
|
|
3
7
|
/**
|
|
4
8
|
* One run for one entry. Reads the recorded keys fresh and the open
|
|
5
9
|
* requests (it consumes none), decides and runs the effects. A failure, or a
|
|
@@ -10,9 +14,25 @@ export async function runRelease(input) {
|
|
|
10
14
|
const { store, orgId, entryKey, deps, now } = input;
|
|
11
15
|
const ids = await store.getScopeIds(orgId);
|
|
12
16
|
const recorded = recordedFrom(ids, entryKey);
|
|
13
|
-
if (!recorded)
|
|
14
|
-
return {
|
|
15
|
-
|
|
17
|
+
if (!recorded) {
|
|
18
|
+
return {
|
|
19
|
+
writes: {},
|
|
20
|
+
blocked: [],
|
|
21
|
+
refused: [],
|
|
22
|
+
decision: null,
|
|
23
|
+
requestIds: [],
|
|
24
|
+
deferredCopy: false,
|
|
25
|
+
copy: null,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
const k = releaseKeys(entryKey);
|
|
29
|
+
const all = await store.openRequests({ orgId, entryKey });
|
|
30
|
+
// A stage request whose result was persisted before a crash is consumed, never run a second time.
|
|
31
|
+
const handledId = ids[k.stageCopyHandled];
|
|
32
|
+
const replayed = all.filter((r) => isStageCopyAction(r.action) && String(r.id) === handledId);
|
|
33
|
+
const fresh = all.filter((r) => !replayed.includes(r));
|
|
34
|
+
const deferred = input.deferCopies ? fresh.filter((r) => isStageCopyAction(r.action)) : [];
|
|
35
|
+
const open = fresh.filter((r) => !deferred.includes(r));
|
|
16
36
|
const actions = open.map((r) => r.action);
|
|
17
37
|
const range = healthRange(recorded, now);
|
|
18
38
|
const target = recorded.release?.to ?? null;
|
|
@@ -33,11 +53,25 @@ export async function runRelease(input) {
|
|
|
33
53
|
observation: { health, ...seen },
|
|
34
54
|
...(input.hardOnly ? { hardOnly: true } : {}),
|
|
35
55
|
});
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const
|
|
56
|
+
const extra = {};
|
|
57
|
+
const refused = [...decision.refused];
|
|
58
|
+
const stamp = now.toISOString();
|
|
59
|
+
let copy = null;
|
|
60
|
+
const requestOf = (kind) => open.find((r) => r.action.kind === kind);
|
|
61
|
+
for (const effect of decision.effects) {
|
|
62
|
+
if (effect.kind !== 'stage_copy') {
|
|
63
|
+
await deps.effect(effect);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const request = requestOf(effect.mode === 'copy' ? 'copy_prod_to_stage' : 'undo_stage_copy');
|
|
67
|
+
if (request)
|
|
68
|
+
copy = { mode: effect.mode, requestId: request.id, actor: request.actor };
|
|
69
|
+
}
|
|
70
|
+
if (decision.stageRefusal)
|
|
71
|
+
extra[k.stageCopy] = `${stamp}|refused|${decision.stageRefusal}`;
|
|
39
72
|
const next = decision.recorded;
|
|
40
73
|
const writes = {
|
|
74
|
+
...extra,
|
|
41
75
|
[k.live]: next.live,
|
|
42
76
|
[k.weights]: formatWeights(next.weights),
|
|
43
77
|
[k.digest('blue')]: next.digests.blue,
|
|
@@ -54,8 +88,58 @@ export async function runRelease(input) {
|
|
|
54
88
|
return {
|
|
55
89
|
writes,
|
|
56
90
|
blocked: decision.blocked,
|
|
57
|
-
refused
|
|
91
|
+
refused,
|
|
58
92
|
decision,
|
|
59
|
-
requestIds: open.map((r) => r.id),
|
|
93
|
+
requestIds: [...replayed, ...open.filter((r) => r.id !== copy?.requestId)].map((r) => r.id),
|
|
94
|
+
deferredCopy: deferred.length > 0,
|
|
95
|
+
copy,
|
|
60
96
|
};
|
|
61
97
|
}
|
|
98
|
+
/**
|
|
99
|
+
* Runs the copy `job` with `deps.stageCopy` (absent: refused with `no-copy-port`). Call it after the run that
|
|
100
|
+
* decided on it has persisted its writes and written the weights: the copy takes up to ~45 minutes.
|
|
101
|
+
*/
|
|
102
|
+
export async function runStageCopy(deps, job) {
|
|
103
|
+
return deps.stageCopy
|
|
104
|
+
? deps.stageCopy({
|
|
105
|
+
mode: job.mode,
|
|
106
|
+
requestId: job.requestId,
|
|
107
|
+
actor: job.actor,
|
|
108
|
+
...(job.runId ? { runId: job.runId } : {}),
|
|
109
|
+
})
|
|
110
|
+
: { outcome: 'refused', reason: 'no-copy-port' };
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* What a copy outcome records. `ids` are the recorded keys now (the retry mark is read from them). A failure is
|
|
114
|
+
* retried once, and only a failure before the switch can come back as `failed`: the host reports a failure after
|
|
115
|
+
* the switch as `ok`, so a retry never sweeps the database stage runs on or its undo target.
|
|
116
|
+
*/
|
|
117
|
+
export function settleStageCopy(input) {
|
|
118
|
+
const { ids, outcome: result, job } = input;
|
|
119
|
+
const k = releaseKeys(input.entryKey);
|
|
120
|
+
const stamp = input.now.toISOString();
|
|
121
|
+
const writes = {};
|
|
122
|
+
const refused = [];
|
|
123
|
+
if (result.outcome === 'busy')
|
|
124
|
+
return { writes, refused, consume: false };
|
|
125
|
+
if (result.outcome === 'ok') {
|
|
126
|
+
if (!result.recorded)
|
|
127
|
+
writes[k.stageCopy] = `${stamp}|${result.detail}|ok`;
|
|
128
|
+
writes[k.stageCopyRetry] = null;
|
|
129
|
+
return { writes, refused, consume: true };
|
|
130
|
+
}
|
|
131
|
+
if (result.outcome === 'refused') {
|
|
132
|
+
writes[k.stageCopy] = `${stamp}|refused|${result.reason}`;
|
|
133
|
+
writes[k.stageCopyRetry] = null;
|
|
134
|
+
refused.push(REFUSAL_TEXT[result.reason] ?? `copy refused: ${result.reason}`);
|
|
135
|
+
return { writes, refused, consume: true };
|
|
136
|
+
}
|
|
137
|
+
writes[k.stageCopy] = `${stamp}|failed|${result.step}`;
|
|
138
|
+
if (ids[k.stageCopyRetry] === String(job.requestId)) {
|
|
139
|
+
writes[k.stageCopyRetry] = null;
|
|
140
|
+
return { writes, refused, consume: true };
|
|
141
|
+
}
|
|
142
|
+
// The first failure leaves the request open for the next run; the second consumes it.
|
|
143
|
+
writes[k.stageCopyRetry] = String(job.requestId);
|
|
144
|
+
return { writes, refused, consume: false };
|
|
145
|
+
}
|
package/dist/release/state.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { type Recorded, resolveReleaseConfig } from './decide.js';
|
|
3
|
-
import type { Color, ReleaseAction, ReleaseSignal, ReleaseState, WindowState } from './types.js';
|
|
3
|
+
import type { Color, ReleaseAction, ReleaseSignal, ReleaseState, StageCopyState, WindowState } from './types.js';
|
|
4
4
|
/**
|
|
5
5
|
* The persistence the release runner and Archon share. `getScopeIds` is the
|
|
6
6
|
* recorded `bg.*` keys of an org's applied state (or of the platform, for the
|
|
@@ -33,10 +33,21 @@ export declare function releaseKeys(entry: string): {
|
|
|
33
33
|
digest: (c: Color) => string;
|
|
34
34
|
weights: string;
|
|
35
35
|
stage: string;
|
|
36
|
+
stageDeploy: string;
|
|
36
37
|
window: string;
|
|
37
38
|
signal: string;
|
|
38
39
|
heldPrefix: string;
|
|
39
40
|
held: (digest: string) => string;
|
|
41
|
+
/** Set once stage holds the sink env (`bg.<id>.stagesink`, written by the structure apply). */
|
|
42
|
+
stageSink: string;
|
|
43
|
+
/** `<ISO time>|<execution id>|ok`, `<ISO time>|undo|ok`, `<ISO time>|refused|<reason>` or `<ISO time>|failed|<step>`. */
|
|
44
|
+
stageCopy: string;
|
|
45
|
+
/** `<expiry ms>|<request id>` while a copy runs. An unexpired lock makes another run leave the request open. */
|
|
46
|
+
stageCopyLock: string;
|
|
47
|
+
/** The id of the last stage request whose result was persisted: a replay of it is consumed, not run again. */
|
|
48
|
+
stageCopyHandled: string;
|
|
49
|
+
/** The id of a request whose copy failed once; a second failure consumes it. */
|
|
50
|
+
stageCopyRetry: string;
|
|
40
51
|
};
|
|
41
52
|
export declare function formatWeights(w: Record<Color, number>): string;
|
|
42
53
|
export declare function parseWeights(value: string | undefined): Record<Color, number> | null;
|
|
@@ -47,6 +58,8 @@ export declare function parseWindow(value: string | undefined): {
|
|
|
47
58
|
};
|
|
48
59
|
export declare function formatWindow(window: WindowState, release: Recorded['release']): string;
|
|
49
60
|
export declare function recordedFrom(ids: Record<string, string>, entryKey: string): Recorded | null;
|
|
61
|
+
/** Reads `bg.<id>.stagecopy`; a damaged value reads as absent. */
|
|
62
|
+
export declare function parseStageCopy(value: string | undefined): StageCopyState | undefined;
|
|
50
63
|
export declare function readSignal(ids: Record<string, string>, entryKey: string): ReleaseSignal | null;
|
|
51
64
|
/** C2: the release state of one hosting entry, or null before blue/green recorded anything. */
|
|
52
65
|
export declare function readReleaseState(store: ReleaseStore, input: {
|
package/dist/release/state.js
CHANGED
|
@@ -10,10 +10,21 @@ export function releaseKeys(entry) {
|
|
|
10
10
|
digest: (c) => `${p}.digest.${c}`,
|
|
11
11
|
weights: `${p}.weights`,
|
|
12
12
|
stage: `${p}.stage`,
|
|
13
|
+
stageDeploy: `${p}.stagedeploy`,
|
|
13
14
|
window: `${p}.window`,
|
|
14
15
|
signal: `${p}.signal`,
|
|
15
16
|
heldPrefix: `${p}.held.`,
|
|
16
17
|
held: (digest) => `${p}.held.${digest}`,
|
|
18
|
+
/** Set once stage holds the sink env (`bg.<id>.stagesink`, written by the structure apply). */
|
|
19
|
+
stageSink: `${p}.stagesink`,
|
|
20
|
+
/** `<ISO time>|<execution id>|ok`, `<ISO time>|undo|ok`, `<ISO time>|refused|<reason>` or `<ISO time>|failed|<step>`. */
|
|
21
|
+
stageCopy: `${p}.stagecopy`,
|
|
22
|
+
/** `<expiry ms>|<request id>` while a copy runs. An unexpired lock makes another run leave the request open. */
|
|
23
|
+
stageCopyLock: `${p}.stagecopylock`,
|
|
24
|
+
/** The id of the last stage request whose result was persisted: a replay of it is consumed, not run again. */
|
|
25
|
+
stageCopyHandled: `${p}.stagecopyhandled`,
|
|
26
|
+
/** The id of a request whose copy failed once; a second failure consumes it. */
|
|
27
|
+
stageCopyRetry: `${p}.stagecopyretry`,
|
|
17
28
|
};
|
|
18
29
|
}
|
|
19
30
|
export function formatWeights(w) {
|
|
@@ -87,12 +98,30 @@ export function recordedFrom(ids, entryKey) {
|
|
|
87
98
|
weights: parseWeights(ids[k.weights]) ?? { ...{ blue: 0, green: 0 }, [live.data]: 100 },
|
|
88
99
|
window,
|
|
89
100
|
release,
|
|
101
|
+
stageSink: Boolean(ids[k.stageSink]),
|
|
90
102
|
held: Object.keys(ids)
|
|
91
103
|
.filter((key) => key.startsWith(k.heldPrefix))
|
|
92
104
|
.map((key) => key.slice(k.heldPrefix.length))
|
|
93
105
|
.sort(),
|
|
94
106
|
};
|
|
95
107
|
}
|
|
108
|
+
const STAGE_COPY_DETAIL = /^[A-Za-z0-9_-]{1,64}$/;
|
|
109
|
+
/** Reads `bg.<id>.stagecopy`; a damaged value reads as absent. */
|
|
110
|
+
export function parseStageCopy(value) {
|
|
111
|
+
const parts = (value ?? '').split('|');
|
|
112
|
+
if (parts.length !== 3)
|
|
113
|
+
return undefined;
|
|
114
|
+
const [at, middle, last] = parts;
|
|
115
|
+
if (Number.isNaN(Date.parse(at)))
|
|
116
|
+
return undefined;
|
|
117
|
+
if (last === 'ok') {
|
|
118
|
+
return STAGE_COPY_DETAIL.test(middle) ? { at, outcome: 'ok', detail: middle } : undefined;
|
|
119
|
+
}
|
|
120
|
+
if ((middle === 'refused' || middle === 'failed') && STAGE_COPY_DETAIL.test(last)) {
|
|
121
|
+
return { at, outcome: middle, detail: last };
|
|
122
|
+
}
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
96
125
|
export function readSignal(ids, entryKey) {
|
|
97
126
|
const parsed = signalSchema.safeParse(parseJson(ids[releaseKeys(entryKey).signal]));
|
|
98
127
|
return parsed.success ? parsed.data : null;
|
|
@@ -103,6 +132,7 @@ export async function readReleaseState(store, input) {
|
|
|
103
132
|
const r = recordedFrom(ids, input.entryKey);
|
|
104
133
|
if (!r)
|
|
105
134
|
return null;
|
|
135
|
+
const stageCopy = parseStageCopy(ids[releaseKeys(input.entryKey).stageCopy]);
|
|
106
136
|
return {
|
|
107
137
|
entryKey: input.entryKey,
|
|
108
138
|
live: r.live,
|
|
@@ -113,6 +143,17 @@ export async function readReleaseState(store, input) {
|
|
|
113
143
|
signal: readSignal(ids, input.entryKey),
|
|
114
144
|
held: r.held,
|
|
115
145
|
config: resolveReleaseConfig(input.entry),
|
|
146
|
+
...(stageCopy ? { stageCopy } : {}),
|
|
147
|
+
...stageDeployOf(ids[releaseKeys(input.entryKey).stageDeploy]),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
/** The last stage deploy from `<ISO time>|ok|<commit>` or `<ISO time>|failed|<step>`; nothing for a damaged value. */
|
|
151
|
+
function stageDeployOf(value) {
|
|
152
|
+
const m = /^([^|]+)\|(ok|failed)\|([^|]*)$/.exec(value ?? '');
|
|
153
|
+
if (!m || Number.isNaN(Date.parse(m[1])))
|
|
154
|
+
return {};
|
|
155
|
+
return {
|
|
156
|
+
stageDeploy: { at: m[1], outcome: m[2], detail: m[3] },
|
|
116
157
|
};
|
|
117
158
|
}
|
|
118
159
|
const actionSchema = z.discriminatedUnion('kind', [
|
|
@@ -123,6 +164,8 @@ const actionSchema = z.discriminatedUnion('kind', [
|
|
|
123
164
|
z.object({ kind: z.literal('approve_step') }),
|
|
124
165
|
z.object({ kind: z.literal('open') }),
|
|
125
166
|
z.object({ kind: z.literal('deploy_stage'), ref: z.string().regex(STAGE_REF_PATTERN) }),
|
|
167
|
+
z.object({ kind: z.literal('copy_prod_to_stage') }),
|
|
168
|
+
z.object({ kind: z.literal('undo_stage_copy') }),
|
|
126
169
|
]);
|
|
127
170
|
/** C2: ask the runner to apply an action on its next run. Archon audits it. */
|
|
128
171
|
export async function requestReleaseAction(store, input) {
|