@astralarium/repo-deploy-release 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +61 -2
- package/dist/bin/deploy-release.d.ts +2 -0
- package/dist/bin/deploy-release.js +7 -0
- package/dist/src/app.d.ts +143 -0
- package/dist/src/app.js +102 -0
- package/dist/src/bundle.d.ts +18 -0
- package/dist/src/bundle.js +41 -0
- package/dist/src/check.d.ts +44 -0
- package/dist/src/check.js +128 -0
- package/dist/src/cloudflare.d.ts +70 -0
- package/dist/src/cloudflare.js +170 -0
- package/dist/src/commands.d.ts +55 -0
- package/dist/src/commands.js +651 -0
- package/dist/src/cutover-at.d.ts +23 -0
- package/dist/src/cutover-at.js +152 -0
- package/dist/src/cutovers.d.ts +62 -0
- package/dist/src/cutovers.js +112 -0
- package/dist/src/deployer.d.ts +36 -0
- package/dist/src/deployer.js +63 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.js +2 -0
- package/dist/src/lease.d.ts +36 -0
- package/dist/src/lease.js +112 -0
- package/dist/src/main.d.ts +6 -0
- package/dist/src/main.js +43 -0
- package/dist/src/process.d.ts +34 -0
- package/dist/src/process.js +68 -0
- package/dist/src/prompt.d.ts +5 -0
- package/dist/src/prompt.js +69 -0
- package/dist/src/provision.d.ts +36 -0
- package/dist/src/provision.js +230 -0
- package/dist/src/schema.d.ts +2 -0
- package/dist/src/schema.js +12 -0
- package/dist/src/session.d.ts +81 -0
- package/dist/src/session.js +275 -0
- package/dist/src/state.d.ts +55 -0
- package/dist/src/state.js +115 -0
- package/dist/src/status.d.ts +65 -0
- package/dist/src/status.js +224 -0
- package/dist/src/steps.d.ts +80 -0
- package/dist/src/steps.js +389 -0
- package/dist/src/target.d.ts +94 -0
- package/dist/src/target.js +246 -0
- package/dist/src/web.d.ts +28 -0
- package/dist/src/web.js +70 -0
- package/dist/src/workspace.d.ts +11 -0
- package/dist/src/workspace.js +40 -0
- package/dist/testkit/index.d.ts +4 -0
- package/dist/testkit/index.js +36 -0
- package/dist/testkit/release-bundles.d.ts +16 -0
- package/dist/testkit/release-bundles.js +56 -0
- package/dist/testkit/release-checks.d.ts +19 -0
- package/dist/testkit/release-checks.js +71 -0
- package/dist/testkit/release.d.ts +44 -0
- package/dist/testkit/release.js +61 -0
- package/package.json +64 -3
- package/sql/before-reset.sql +22 -0
- package/sql/schema.sql +164 -0
- package/sql/scrub.sql +5 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/** `status`: reads the deployment's facts, then checks its invariants. */
|
|
2
|
+
import { buildOf, isSlot, otherSlot, SLOT_VAR, SLOTS, varOf, } from "@astralarium/repo-deploy-info";
|
|
3
|
+
import { RUNNING, WAITING } from "./cloudflare.js";
|
|
4
|
+
import { buildOfRun } from "./cutovers.js";
|
|
5
|
+
import { describeLease } from "./lease.js";
|
|
6
|
+
import { slotBuild } from "./web.js";
|
|
7
|
+
/** A fact whose read failed. */
|
|
8
|
+
export class Unreadable {
|
|
9
|
+
reason;
|
|
10
|
+
constructor(reason) {
|
|
11
|
+
this.reason = reason instanceof Error ? reason.message : String(reason);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/** `promise`'s value, or `Unreadable`. */
|
|
15
|
+
const fact = (promise) => promise.catch((e) => new Unreadable(e));
|
|
16
|
+
/** `value` in a message: `?` when unreadable, empty when absent. */
|
|
17
|
+
const show = (value) => value instanceof Unreadable ? "?" : (value ?? "");
|
|
18
|
+
/** Reads every fact, in parallel. */
|
|
19
|
+
export async function readFacts(s) {
|
|
20
|
+
const { webScript, slotScripts, siteOrigin } = s.target.deployment;
|
|
21
|
+
const webAt = (percentage) => fact((async () => {
|
|
22
|
+
const id = await s.cf.versionAt(webScript, percentage);
|
|
23
|
+
if (!id)
|
|
24
|
+
return undefined;
|
|
25
|
+
const version = await s.cf.version(webScript, id);
|
|
26
|
+
return { id, slot: varOf(version, SLOT_VAR), build: buildOf(version) };
|
|
27
|
+
})());
|
|
28
|
+
const siteBuild = (path) => fact((async () => {
|
|
29
|
+
const url = new URL(path, siteOrigin);
|
|
30
|
+
url.searchParams.set("status", String(Date.now()));
|
|
31
|
+
const res = await s.fetch(url, {
|
|
32
|
+
headers: { "Cache-Control": "no-cache" },
|
|
33
|
+
});
|
|
34
|
+
if (!res.ok)
|
|
35
|
+
throw new Error(`${path}: ${res.status}`);
|
|
36
|
+
const { build } = (await res.json());
|
|
37
|
+
if (typeof build !== "string")
|
|
38
|
+
throw new Error(`${path}: no build`);
|
|
39
|
+
return build;
|
|
40
|
+
})());
|
|
41
|
+
const cutovers = (statuses) => Promise.all(statuses.map((status) => s.cf.instances(s.target.flows.cutover, status))).then((ids) => ids.flat());
|
|
42
|
+
const slotFacts = async (slot) => ({
|
|
43
|
+
build: await fact(slotBuild(s, slot)),
|
|
44
|
+
crons: await fact(s.cf.crons(slotScripts[slot])),
|
|
45
|
+
});
|
|
46
|
+
const { buildPaths } = s.target;
|
|
47
|
+
const [live, next, blue, green, site, waiting, running, errored, state] = await Promise.all([
|
|
48
|
+
webAt(100),
|
|
49
|
+
webAt(0),
|
|
50
|
+
slotFacts("blue"),
|
|
51
|
+
slotFacts("green"),
|
|
52
|
+
Promise.all(buildPaths.map(siteBuild)).then((builds) => Object.fromEntries(buildPaths.map((path, i) => [path, builds[i]]))),
|
|
53
|
+
fact(cutovers(WAITING)),
|
|
54
|
+
fact(cutovers(RUNNING)),
|
|
55
|
+
fact(cutovers(["errored"]).then((ids) => ids.length)),
|
|
56
|
+
s.hasDb ? fact(readState(s)) : undefined,
|
|
57
|
+
]);
|
|
58
|
+
// An interrupt turns every pending read unreadable; report it instead.
|
|
59
|
+
s.signal.throwIfAborted();
|
|
60
|
+
return {
|
|
61
|
+
live,
|
|
62
|
+
next,
|
|
63
|
+
slots: { blue, green },
|
|
64
|
+
site,
|
|
65
|
+
waiting,
|
|
66
|
+
running,
|
|
67
|
+
errored,
|
|
68
|
+
state,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/** The deploy state row. */
|
|
72
|
+
async function readState(s) {
|
|
73
|
+
const [row] = await s.sql `
|
|
74
|
+
SELECT live_build, update_at, update_at <= now() AS update_due,
|
|
75
|
+
update_run, lease_holder, lease_operation::text, lease_expires_at,
|
|
76
|
+
lease_expires_at > now() AS lease_valid
|
|
77
|
+
FROM deploy.state
|
|
78
|
+
`;
|
|
79
|
+
return {
|
|
80
|
+
liveBuild: row.live_build,
|
|
81
|
+
updateAt: row.update_at,
|
|
82
|
+
updateDue: row.update_due ?? false,
|
|
83
|
+
updateRun: row.update_run,
|
|
84
|
+
lease: row.lease_holder && row.lease_operation && row.lease_expires_at
|
|
85
|
+
? describeLease({
|
|
86
|
+
holder: row.lease_holder,
|
|
87
|
+
operation: row.lease_operation,
|
|
88
|
+
expires_at: row.lease_expires_at,
|
|
89
|
+
})
|
|
90
|
+
: null,
|
|
91
|
+
leaseValid: row.lease_valid ?? false,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/** The facts' summary lines: web versions, then slots. */
|
|
95
|
+
export function summarize(f, webScript) {
|
|
96
|
+
const web = (v) => v instanceof Unreadable
|
|
97
|
+
? "?"
|
|
98
|
+
: v
|
|
99
|
+
? `${v.id} (slot ${v.slot ?? "?"}, build ${v.build ?? "?"})`
|
|
100
|
+
: "(none)";
|
|
101
|
+
const slot = (name) => {
|
|
102
|
+
const { build, crons } = f.slots[name];
|
|
103
|
+
return `${name} ${build instanceof Unreadable ? "?" : (build ?? "(none)")} [${crons instanceof Unreadable ? "?" : crons.join(" ")}]`;
|
|
104
|
+
};
|
|
105
|
+
return [
|
|
106
|
+
`Web ${webScript}: live ${web(f.live)}${f.next === undefined ? "" : `; next ${web(f.next)}`}`,
|
|
107
|
+
`Slots: ${SLOTS.map(slot).join("; ")}`,
|
|
108
|
+
];
|
|
109
|
+
}
|
|
110
|
+
/** Checks `f`; `cli` renders a fix command; `dbUrlVar` holds the
|
|
111
|
+
* database URL. */
|
|
112
|
+
export function checkFacts(f, cli, dbUrlVar) {
|
|
113
|
+
const checks = [];
|
|
114
|
+
const add = (level, text) => checks.push({ level, text });
|
|
115
|
+
const { live } = f;
|
|
116
|
+
if (live instanceof Unreadable) {
|
|
117
|
+
add("fail", `the live web version is unreadable (${live.reason}); if the web worker was never deployed, run ${cli("init")}`);
|
|
118
|
+
return checks;
|
|
119
|
+
}
|
|
120
|
+
if (!live || !isSlot(live.slot)) {
|
|
121
|
+
add("fail", `no slotted web version is live; fix: ${cli("init")}`);
|
|
122
|
+
return checks;
|
|
123
|
+
}
|
|
124
|
+
const slot = live.slot;
|
|
125
|
+
const build = live.build ?? "";
|
|
126
|
+
const other = otherSlot(slot);
|
|
127
|
+
const held = f.slots[slot].build;
|
|
128
|
+
if (held instanceof Unreadable)
|
|
129
|
+
add("warn", `the build of the live slot (${slot}) is unreadable`);
|
|
130
|
+
else if (held === build)
|
|
131
|
+
add("ok", `the live slot (${slot}) runs the live build`);
|
|
132
|
+
else
|
|
133
|
+
add("fail", `the live slot (${slot}) runs '${held ?? ""}' but the live web version is build '${build}'; fix: ${cli("rollback")}`);
|
|
134
|
+
const site = Object.entries(f.site);
|
|
135
|
+
if (site.every(([, served]) => served === build))
|
|
136
|
+
add("ok", `the public site serves the live build on ${site.map(([path]) => path).join(", ")}`);
|
|
137
|
+
else
|
|
138
|
+
add("fail", `the public site answers ${site.map(([path, served]) => `${path} '${show(served)}'`).join(", ")}; expected '${build}'`);
|
|
139
|
+
const liveCrons = f.slots[slot].crons;
|
|
140
|
+
if (liveCrons instanceof Unreadable)
|
|
141
|
+
add("warn", `crons of ${slot} unreadable`);
|
|
142
|
+
else if (liveCrons.length)
|
|
143
|
+
add("ok", "the live slot holds the crons");
|
|
144
|
+
else
|
|
145
|
+
add("fail", `the live slot holds no crons; fix: ${cli("settle")}`);
|
|
146
|
+
const { state } = f;
|
|
147
|
+
// Waiting runs of the live build are finishing their release.
|
|
148
|
+
const liveBuild = state instanceof Unreadable ? undefined : (state?.liveBuild ?? undefined);
|
|
149
|
+
const finishing = f.waiting instanceof Unreadable || liveBuild === undefined
|
|
150
|
+
? []
|
|
151
|
+
: f.waiting.filter((run) => buildOfRun(run) === liveBuild);
|
|
152
|
+
const handing = [
|
|
153
|
+
...(f.running instanceof Unreadable ? [] : f.running),
|
|
154
|
+
...finishing,
|
|
155
|
+
];
|
|
156
|
+
// A cutover hands the crons over add-before-remove: both slots hold
|
|
157
|
+
// them from before its web switch until after its release.
|
|
158
|
+
const otherCrons = f.slots[other].crons;
|
|
159
|
+
if (!(otherCrons instanceof Unreadable) && otherCrons.length) {
|
|
160
|
+
if (handing.length)
|
|
161
|
+
add("info", `the idle slot (${other}) holds crons too (gated off) while cutover run ${handing.join(" ")} hands them over`);
|
|
162
|
+
else
|
|
163
|
+
add("warn", `the idle slot (${other}) holds crons too (gated off); fix: ${cli("settle")}`);
|
|
164
|
+
}
|
|
165
|
+
if (state === undefined)
|
|
166
|
+
add("info", `deploy state unchecked: set ${dbUrlVar}`);
|
|
167
|
+
else if (state instanceof Unreadable)
|
|
168
|
+
add("fail", `the deploy state is unreadable: ${state.reason}`);
|
|
169
|
+
else {
|
|
170
|
+
if (!state.lease)
|
|
171
|
+
add("ok", "no deploy lease held");
|
|
172
|
+
else if (state.leaseValid)
|
|
173
|
+
add("info", `leased: ${state.lease}`);
|
|
174
|
+
else
|
|
175
|
+
add("warn", `expired lease: ${state.lease} (its command died; the next command takes it over)`);
|
|
176
|
+
if (state.liveBuild === build)
|
|
177
|
+
add("ok", "the deploy state names the live build");
|
|
178
|
+
else
|
|
179
|
+
add("fail", `the deploy state names '${state.liveBuild ?? ""}' but the live web version is build '${build}'; fix: ${cli("settle")}`);
|
|
180
|
+
if (state.updateAt) {
|
|
181
|
+
const at = state.updateAt.toISOString();
|
|
182
|
+
const run = state.updateRun ?? "";
|
|
183
|
+
if (f.waiting instanceof Unreadable || f.running instanceof Unreadable)
|
|
184
|
+
add("warn", `update at ${at}, owned by run ${run}: the cutover runs are unreadable`);
|
|
185
|
+
else if ([...f.waiting, ...f.running].includes(run))
|
|
186
|
+
add("ok", `update at ${at}, owned by cutover run ${run}`);
|
|
187
|
+
else if (state.updateDue)
|
|
188
|
+
add("fail", `update at ${at} is past and its run ${run} has ended; the app stays closed to new work; fix: ${cli("settle")}`);
|
|
189
|
+
else
|
|
190
|
+
add("fail", `update at ${at} belongs to ended run ${run}; the app will still close ahead of it; fix: ${cli("cancel")}`);
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
add("ok", "no update scheduled");
|
|
194
|
+
if (finishing.length)
|
|
195
|
+
add("info", `cutover run ${finishing.join(" ")} finishes its release: the old slot's crons clear and the change publishes; ${cli("settle")} ends it early`);
|
|
196
|
+
const stale = f.waiting instanceof Unreadable
|
|
197
|
+
? []
|
|
198
|
+
: f.waiting.filter((run) => !finishing.includes(run));
|
|
199
|
+
if (stale.length)
|
|
200
|
+
add("warn", `cutover run ${stale.join(" ")} is waiting with no update; it will fail at its next fence; fix: ${cli("cancel")}`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
if (f.running instanceof Unreadable)
|
|
204
|
+
add("warn", `the cutover runs are unreadable (${f.running.reason})`);
|
|
205
|
+
else if (f.running.length)
|
|
206
|
+
add("info", `a cutover runs now: ${f.running.join(" ")}`);
|
|
207
|
+
const { next } = f;
|
|
208
|
+
if (next instanceof Unreadable)
|
|
209
|
+
add("warn", "the 0% web version is unreadable");
|
|
210
|
+
else if (next) {
|
|
211
|
+
const idle = f.slots[other].build;
|
|
212
|
+
if (next.slot === other && idle === next.build)
|
|
213
|
+
add("ok", `next build ${next.build} is ready on ${other}`);
|
|
214
|
+
else
|
|
215
|
+
add("warn", `the 0% version (${next.build ?? ""} on ${next.slot ?? "?"}) no longer matches the idle slot (${show(idle)}); fix: ${cli("stage")}`);
|
|
216
|
+
}
|
|
217
|
+
else
|
|
218
|
+
add("info", "no next build staged");
|
|
219
|
+
if (!(f.errored instanceof Unreadable) && f.errored)
|
|
220
|
+
add("info", `${f.errored} errored cutover runs on record`);
|
|
221
|
+
return checks;
|
|
222
|
+
}
|
|
223
|
+
/** `check` as an output line. */
|
|
224
|
+
export const formatCheck = ({ level, text }) => ` ${level.toUpperCase().padEnd(6)}${text}`;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { type CutoverParams, type Slot } from "@astralarium/repo-deploy-info";
|
|
2
|
+
import { type BuildEnv } from "./app.ts";
|
|
3
|
+
import type { InPlaceSession, Session, SlotSession } from "./session.ts";
|
|
4
|
+
import { type Staged, type WebVersion } from "./web.ts";
|
|
5
|
+
/** Deploys the deployer as a release-checked bundle (`wranglerBundle`),
|
|
6
|
+
* keeping its secrets; once per session. Requires a clean app checkout
|
|
7
|
+
* (`Session.requireClean`). */
|
|
8
|
+
export declare function deployDeployer(s: SlotSession): Promise<void>;
|
|
9
|
+
/** Throws unless every script of the target holds its secrets. */
|
|
10
|
+
export declare function preflightSecrets(s: Session): Promise<void>;
|
|
11
|
+
/** A deployed next build and its web version. */
|
|
12
|
+
interface NextBuild {
|
|
13
|
+
build: string;
|
|
14
|
+
version: string;
|
|
15
|
+
}
|
|
16
|
+
/** Builds the next build with `buildEnv` (`buildEnvOf`), deploys its
|
|
17
|
+
* release-checked worker bundle to `slot` (DO migrations included), then
|
|
18
|
+
* uploads its web version. */
|
|
19
|
+
export declare function deployNext(s: SlotSession, slot: Slot, buildEnv?: BuildEnv): Promise<NextBuild>;
|
|
20
|
+
/** Builds the next build with `buildEnv` (`buildEnvOf`) for every worker
|
|
21
|
+
* of the target, then deploys them in order; resolves to its build. A
|
|
22
|
+
* deploy failing after another worker took the build throws naming the
|
|
23
|
+
* split and the recovery. */
|
|
24
|
+
export declare function deployWorkers(s: InPlaceSession, buildEnv?: BuildEnv): Promise<string>;
|
|
25
|
+
/** Removes the app's state of builds outside `kept`
|
|
26
|
+
* (`ReleaseApp.retireBuilds`), fenced on the held lease; an app without
|
|
27
|
+
* the hook keeps every build's state. */
|
|
28
|
+
export declare function retireBuilds(s: Session, kept: readonly string[]): Promise<void>;
|
|
29
|
+
/** Names `build` the live build in the deploy state, fenced on the held
|
|
30
|
+
* lease: clients compare their build against it. */
|
|
31
|
+
export declare function recordLiveBuild(s: Session, build: string): Promise<void>;
|
|
32
|
+
/** Throws unless the site answers `build` at every path of `paths`,
|
|
33
|
+
* past caches: through a version override of `version` (`VERSION_SMOKE`),
|
|
34
|
+
* else as deployed (`DEPLOYED_SMOKE`). */
|
|
35
|
+
export declare function smokeTest(s: Session, build: string, paths: readonly string[], version?: {
|
|
36
|
+
script: string;
|
|
37
|
+
id: string;
|
|
38
|
+
}): Promise<void>;
|
|
39
|
+
/** Starts workflow `flow` as instance `id` with `params` and waits for
|
|
40
|
+
* it; throws when it fails. */
|
|
41
|
+
export declare function runFlow(s: SlotSession, flow: string, id: string, params: unknown): Promise<void>;
|
|
42
|
+
/** `CUTOVER_TOKEN_MARGIN_MS` for an operator. */
|
|
43
|
+
export declare const TOKEN_MARGIN = "6 hours";
|
|
44
|
+
/** An API token the operator minted for one cutover. */
|
|
45
|
+
export interface Token {
|
|
46
|
+
value: string;
|
|
47
|
+
/** Expiry (ms epoch) the prompt asked for. */
|
|
48
|
+
expiresAt: number;
|
|
49
|
+
}
|
|
50
|
+
/** When a token for a cutover at `at` expires: `TOKEN_MARGIN` past it. */
|
|
51
|
+
export declare const tokenExpiry: (at: number) => number;
|
|
52
|
+
/** Prompts for an API token valid until `expiresAt`. */
|
|
53
|
+
export declare function promptToken(s: Session, expiresAt: number): Promise<Token>;
|
|
54
|
+
/** Deploys the deployer, then puts `token` on it: the deploy creates a
|
|
55
|
+
* missing deployer, which a secret upload alone would ask about. */
|
|
56
|
+
export declare function installToken(s: SlotSession, token: Token): Promise<void>;
|
|
57
|
+
/** Throws unless `token` outlives a cutover at `cutoverAt` by the
|
|
58
|
+
* token-bound steps' worst case (`CUTOVER_TOKEN_BUDGET_MS`). */
|
|
59
|
+
export declare function requireTokenCovers(s: Session, token: Token, cutoverAt: number): void;
|
|
60
|
+
/** Publishes the deploy state to clients through the settle workflow,
|
|
61
|
+
* which names `live`'s build live again; a failure logs. */
|
|
62
|
+
export declare function publishState(s: SlotSession, live: WebVersion): Promise<void>;
|
|
63
|
+
/** The versions, slots, build and time of one cutover; the target fills
|
|
64
|
+
* the rest of its `CutoverParams`. */
|
|
65
|
+
export type CutoverSpec = Omit<CutoverParams, "accountId" | "buildPaths" | "drain">;
|
|
66
|
+
/** Runs the cutover workflow with `params` until its close lands or the
|
|
67
|
+
* run ends. First, from a fresh read: ends terminable runs, withdraws an
|
|
68
|
+
* orphaned update, runs `prepare`. Requires the deployer's token
|
|
69
|
+
* (`installToken`). Resolves true when the run already released; throws
|
|
70
|
+
* on an in-flight cutover or a run ending unchanged or failed. */
|
|
71
|
+
export declare function triggerCutover(s: SlotSession, params: CutoverSpec, prepare?: () => Promise<void>): Promise<boolean>;
|
|
72
|
+
/** Resolves `--at` and `--exact-time` at `now` (ms epoch) to the cutover
|
|
73
|
+
* instant, printing it: intervals and `now` count from `now`. Throws on
|
|
74
|
+
* a malformed, passed or too distant time. */
|
|
75
|
+
export declare function pinCutover(at: string | undefined, exact: boolean, now?: number): number;
|
|
76
|
+
/** Schedules the cutover from `live` to `next` at `at` (ms epoch; now
|
|
77
|
+
* once passed) under `token`: throws unless the token covers it
|
|
78
|
+
* (`requireTokenCovers`), then installs it and triggers the run. */
|
|
79
|
+
export declare function scheduleCutover(s: SlotSession, { live, next }: Staged, at: number, token: Token): Promise<void>;
|
|
80
|
+
export {};
|