@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,275 @@
|
|
|
1
|
+
var __addDisposableResource = (this && this.__addDisposableResource) || function (env, value, async) {
|
|
2
|
+
if (value !== null && value !== void 0) {
|
|
3
|
+
if (typeof value !== "object" && typeof value !== "function") throw new TypeError("Object expected.");
|
|
4
|
+
var dispose, inner;
|
|
5
|
+
if (async) {
|
|
6
|
+
if (!Symbol.asyncDispose) throw new TypeError("Symbol.asyncDispose is not defined.");
|
|
7
|
+
dispose = value[Symbol.asyncDispose];
|
|
8
|
+
}
|
|
9
|
+
if (dispose === void 0) {
|
|
10
|
+
if (!Symbol.dispose) throw new TypeError("Symbol.dispose is not defined.");
|
|
11
|
+
dispose = value[Symbol.dispose];
|
|
12
|
+
if (async) inner = dispose;
|
|
13
|
+
}
|
|
14
|
+
if (typeof dispose !== "function") throw new TypeError("Object not disposable.");
|
|
15
|
+
if (inner) dispose = function() { try { inner.call(this); } catch (e) { return Promise.reject(e); } };
|
|
16
|
+
env.stack.push({ value: value, dispose: dispose, async: async });
|
|
17
|
+
}
|
|
18
|
+
else if (async) {
|
|
19
|
+
env.stack.push({ async: true });
|
|
20
|
+
}
|
|
21
|
+
return value;
|
|
22
|
+
};
|
|
23
|
+
var __disposeResources = (this && this.__disposeResources) || (function (SuppressedError) {
|
|
24
|
+
return function (env) {
|
|
25
|
+
function fail(e) {
|
|
26
|
+
env.error = env.hasError ? new SuppressedError(e, env.error, "An error was suppressed during disposal.") : e;
|
|
27
|
+
env.hasError = true;
|
|
28
|
+
}
|
|
29
|
+
var r, s = 0;
|
|
30
|
+
function next() {
|
|
31
|
+
while (r = env.stack.pop()) {
|
|
32
|
+
try {
|
|
33
|
+
if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next);
|
|
34
|
+
if (r.dispose) {
|
|
35
|
+
var result = r.dispose.call(r.value);
|
|
36
|
+
if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) { fail(e); return next(); });
|
|
37
|
+
}
|
|
38
|
+
else s |= 1;
|
|
39
|
+
}
|
|
40
|
+
catch (e) {
|
|
41
|
+
fail(e);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve();
|
|
45
|
+
if (env.hasError) throw env.error;
|
|
46
|
+
}
|
|
47
|
+
return next();
|
|
48
|
+
};
|
|
49
|
+
})(typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
|
|
50
|
+
var e = new Error(message);
|
|
51
|
+
return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
|
|
52
|
+
});
|
|
53
|
+
/** One invocation's resources against a target: the abort signal,
|
|
54
|
+
* Cloudflare, the database, the deploy lease and a scratch dir. */
|
|
55
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
56
|
+
import { tmpdir } from "node:os";
|
|
57
|
+
import { join } from "node:path";
|
|
58
|
+
import { setTimeout } from "node:timers/promises";
|
|
59
|
+
import { componentDirs, mintDeployInfo, pullSiblings, requireCleanCheckouts, } from "@astralarium/repo-deploy-info/server";
|
|
60
|
+
import { ExpectedError, log } from "@astralarium/repo-telemetry";
|
|
61
|
+
import postgres from "postgres";
|
|
62
|
+
import { wranglerConfigName } from "./app.js";
|
|
63
|
+
import { provisionedTarget } from "./check.js";
|
|
64
|
+
import { Cloudflare } from "./cloudflare.js";
|
|
65
|
+
import { Lease } from "./lease.js";
|
|
66
|
+
import { abortReason, INTERRUPT_EXIT, Interrupted, killChildren, reportFailure, run, sleep, } from "./process.js";
|
|
67
|
+
import { confirm, readSecret } from "./prompt.js";
|
|
68
|
+
import { databaseGap, unprovisioned } from "./provision.js";
|
|
69
|
+
import { scriptsOf, } from "./target.js";
|
|
70
|
+
/** Longest wait for the lease drop at disposal. */
|
|
71
|
+
const RELEASE_TIMEOUT_MS = 5_000;
|
|
72
|
+
/** Signals that stop a command: the first aborts the session, which then
|
|
73
|
+
* drops the lease; the second kills children outright; the third exits
|
|
74
|
+
* at once, leaving the lease to expire. */
|
|
75
|
+
const STOP_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"];
|
|
76
|
+
/** A command function running `fn` in a session against `--env`'s
|
|
77
|
+
* target, held up by its gaps in `needs` (`provisionedTarget`); `fn`
|
|
78
|
+
* resolves to the exit status, void is 0. Failures log as
|
|
79
|
+
* `deploy_release.failed`. */
|
|
80
|
+
export function inSession(app, invocation, needs, fn) {
|
|
81
|
+
return (flags) => runSession(app, invocation, flags, needs, fn).catch(reportFailure);
|
|
82
|
+
}
|
|
83
|
+
/** `inSession` for a blue/green command: an in-place env refuses. */
|
|
84
|
+
export function inSlots(app, invocation, needs, fn) {
|
|
85
|
+
return inSession(app, invocation, needs, (flags, s) => fn(flags, slotSession(s)));
|
|
86
|
+
}
|
|
87
|
+
/** `s` against its blue/green target; throws for an in-place env,
|
|
88
|
+
* naming its commands. */
|
|
89
|
+
export function slotSession(s) {
|
|
90
|
+
if (s.target.kind === "blue-green")
|
|
91
|
+
return s;
|
|
92
|
+
const own = [
|
|
93
|
+
"deploy",
|
|
94
|
+
"secrets",
|
|
95
|
+
...(s.target.dbUrlVar ? ["unlock"] : []),
|
|
96
|
+
"check",
|
|
97
|
+
];
|
|
98
|
+
const lines = own.map((command) => s.cli(command));
|
|
99
|
+
throw new ExpectedError(`${s.target.env} deploys at once (${scriptsOf(s.target).join(", ")}); its commands are ${lines.slice(0, -1).join(", ")} and ${lines.at(-1)}.`);
|
|
100
|
+
}
|
|
101
|
+
/** Runs `fn` in a session for `flags`, interrupts aborting it; throws what
|
|
102
|
+
* `fn` throws. */
|
|
103
|
+
async function runSession(app, invocation, flags, needs, fn) {
|
|
104
|
+
const session = new Session(app, invocation, provisionedTarget(app, invocation, flags.env, needs));
|
|
105
|
+
let interrupts = 0;
|
|
106
|
+
const onInterrupt = () => {
|
|
107
|
+
interrupts++;
|
|
108
|
+
if (interrupts === 1)
|
|
109
|
+
session.abort(new Interrupted());
|
|
110
|
+
else if (interrupts === 2) {
|
|
111
|
+
killChildren();
|
|
112
|
+
log.info("deploy_release.lease_dropping", "Dropping the deploy lease; interrupt again to exit now (the lease then holds until it expires, or {unlock})", { unlock: session.cli("unlock") });
|
|
113
|
+
}
|
|
114
|
+
else
|
|
115
|
+
process.exit(INTERRUPT_EXIT);
|
|
116
|
+
};
|
|
117
|
+
// Handlers outlive disposal, which drops the lease.
|
|
118
|
+
for (const signal of STOP_SIGNALS)
|
|
119
|
+
process.on(signal, onInterrupt);
|
|
120
|
+
try {
|
|
121
|
+
const env_1 = { stack: [], error: void 0, hasError: false };
|
|
122
|
+
try {
|
|
123
|
+
const owned = __addDisposableResource(env_1, session, true);
|
|
124
|
+
const status = await fn(flags, owned);
|
|
125
|
+
if (status)
|
|
126
|
+
process.exitCode = status;
|
|
127
|
+
}
|
|
128
|
+
catch (e_1) {
|
|
129
|
+
env_1.error = e_1;
|
|
130
|
+
env_1.hasError = true;
|
|
131
|
+
}
|
|
132
|
+
finally {
|
|
133
|
+
const result_1 = __disposeResources(env_1);
|
|
134
|
+
if (result_1)
|
|
135
|
+
await result_1;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
for (const signal of STOP_SIGNALS)
|
|
140
|
+
process.off(signal, onInterrupt);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/** Disposal drops the lease, closes the database and removes the scratch
|
|
144
|
+
* dir. */
|
|
145
|
+
export class Session {
|
|
146
|
+
app;
|
|
147
|
+
/** Starts each printed command line: the program and its `--app`. */
|
|
148
|
+
invocation;
|
|
149
|
+
target;
|
|
150
|
+
cf;
|
|
151
|
+
/** The deployer is up with this session's code (`deployDeployer`). */
|
|
152
|
+
deployerDeployed = false;
|
|
153
|
+
#abort = new AbortController();
|
|
154
|
+
#sql;
|
|
155
|
+
#lease;
|
|
156
|
+
#tmp;
|
|
157
|
+
constructor(app, invocation, target) {
|
|
158
|
+
this.app = app;
|
|
159
|
+
this.invocation = invocation;
|
|
160
|
+
this.target = target;
|
|
161
|
+
this.cf = new Cloudflare(this.signal, target.kind === "blue-green"
|
|
162
|
+
? target.packages.worker
|
|
163
|
+
: target.workers[0].package, wranglerConfigName(app));
|
|
164
|
+
}
|
|
165
|
+
/** Aborts on Ctrl-C or a lost lease: children die, waits end. */
|
|
166
|
+
get signal() {
|
|
167
|
+
return this.#abort.signal;
|
|
168
|
+
}
|
|
169
|
+
/** Aborts with `reason`. */
|
|
170
|
+
abort(reason) {
|
|
171
|
+
this.#abort.abort(reason);
|
|
172
|
+
}
|
|
173
|
+
/** `command` as a runnable line for this target. */
|
|
174
|
+
cli(command) {
|
|
175
|
+
return `${this.invocation} ${command} --env ${this.target.env}`;
|
|
176
|
+
}
|
|
177
|
+
/** Runs `cmd` with `args` until it exits, aborting with the session;
|
|
178
|
+
* resolves to its stdout when captured. */
|
|
179
|
+
run(cmd, args, options) {
|
|
180
|
+
return run(cmd, args, this.signal, options);
|
|
181
|
+
}
|
|
182
|
+
/** `fetch` aborting with the session. */
|
|
183
|
+
async fetch(input, init) {
|
|
184
|
+
try {
|
|
185
|
+
return await fetch(input, { ...init, signal: this.signal });
|
|
186
|
+
}
|
|
187
|
+
catch (e) {
|
|
188
|
+
throw this.signal.aborted ? abortReason(this.signal) : e;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
/** Pauses `ms`, aborting with the session. */
|
|
192
|
+
sleep(ms) {
|
|
193
|
+
return sleep(ms, this.signal);
|
|
194
|
+
}
|
|
195
|
+
/** Reads a hidden line from the operator after `question`. */
|
|
196
|
+
secret(question) {
|
|
197
|
+
return readSecret(question, this.signal);
|
|
198
|
+
}
|
|
199
|
+
/** True when the operator answers yes to `question`. */
|
|
200
|
+
confirm(question) {
|
|
201
|
+
return confirm(question, this.signal);
|
|
202
|
+
}
|
|
203
|
+
/** Throws while the app checkout holds uncommitted or untracked
|
|
204
|
+
* changes (the deployer bundles it). */
|
|
205
|
+
requireClean() {
|
|
206
|
+
const { app } = this.app.checkouts;
|
|
207
|
+
requireCleanCheckouts({ [app.name]: app.dir });
|
|
208
|
+
}
|
|
209
|
+
/** Readies the checkouts for a build: throws while any holds
|
|
210
|
+
* uncommitted or untracked changes (builds bundle them), then
|
|
211
|
+
* fast-forwards the `pull` siblings. */
|
|
212
|
+
ready() {
|
|
213
|
+
requireCleanCheckouts(componentDirs(this.app.checkouts));
|
|
214
|
+
pullSiblings(this.app.checkouts);
|
|
215
|
+
}
|
|
216
|
+
/** Fresh deploy info for the target: a build id and each checkout's
|
|
217
|
+
* commit; throws on a dirty checkout. */
|
|
218
|
+
mint() {
|
|
219
|
+
return mintDeployInfo(this.target.env, componentDirs(this.app.checkouts));
|
|
220
|
+
}
|
|
221
|
+
/** Variable holding the target's database URL; throws for a target
|
|
222
|
+
* without a database. */
|
|
223
|
+
get dbUrlVar() {
|
|
224
|
+
if (this.target.dbUrlVar)
|
|
225
|
+
return this.target.dbUrlVar;
|
|
226
|
+
throw new ExpectedError(`${this.target.env} has no database.`);
|
|
227
|
+
}
|
|
228
|
+
/** True while the target has a database and its URL is set. */
|
|
229
|
+
get hasDb() {
|
|
230
|
+
const { dbUrlVar } = this.target;
|
|
231
|
+
return dbUrlVar !== undefined && !!process.env[dbUrlVar];
|
|
232
|
+
}
|
|
233
|
+
/** The target's database; throws while its URL is unset. */
|
|
234
|
+
get sql() {
|
|
235
|
+
const url = process.env[this.dbUrlVar];
|
|
236
|
+
if (!url)
|
|
237
|
+
throw unprovisioned(this.invocation, this.target.env, [
|
|
238
|
+
databaseGap(this.target.env, this.dbUrlVar),
|
|
239
|
+
]);
|
|
240
|
+
this.#sql ??= postgres(url, { max: 2 });
|
|
241
|
+
return this.#sql;
|
|
242
|
+
}
|
|
243
|
+
/** Takes the deploy lease for `operation`; a held lease keeps its
|
|
244
|
+
* operation. Throws while another command holds it. */
|
|
245
|
+
async takeLease(operation) {
|
|
246
|
+
this.#lease ??= await Lease.take(this.sql, operation, {
|
|
247
|
+
onLost: (e) => this.abort(e),
|
|
248
|
+
unlock: this.cli("unlock"),
|
|
249
|
+
});
|
|
250
|
+
return this.#lease;
|
|
251
|
+
}
|
|
252
|
+
/** The held lease. */
|
|
253
|
+
get lease() {
|
|
254
|
+
if (!this.#lease)
|
|
255
|
+
throw new Error("The deploy lease is not held.");
|
|
256
|
+
return this.#lease;
|
|
257
|
+
}
|
|
258
|
+
/** A scratch dir, removed at disposal. */
|
|
259
|
+
tmpDir() {
|
|
260
|
+
this.#tmp ??= mkdtemp(join(tmpdir(), "deploy-release-"));
|
|
261
|
+
return this.#tmp;
|
|
262
|
+
}
|
|
263
|
+
async [Symbol.asyncDispose]() {
|
|
264
|
+
if (this.#lease)
|
|
265
|
+
await Promise.race([
|
|
266
|
+
this.#lease.release(),
|
|
267
|
+
setTimeout(RELEASE_TIMEOUT_MS, undefined, { ref: false }).then(() => {
|
|
268
|
+
throw new Error(`no answer in ${RELEASE_TIMEOUT_MS / 1000}s`);
|
|
269
|
+
}),
|
|
270
|
+
]).catch((e) => log.warn("deploy_release.lease_drop_failed", "could not drop the deploy lease (it expires within an hour, or run {unlock})", { unlock: this.cli("unlock"), exception: e }));
|
|
271
|
+
await this.#sql?.end({ timeout: 5 });
|
|
272
|
+
if (this.#tmp)
|
|
273
|
+
await rm(await this.#tmp, { recursive: true, force: true });
|
|
274
|
+
}
|
|
275
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/** Deploy state (`deploy.state`) as the app's workers read and write
|
|
2
|
+
* it: the live build and a scheduled update. One row; every write raises
|
|
3
|
+
* its `version` under the row lock. Update leads compare on the database
|
|
4
|
+
* clock. Workers-safe. */
|
|
5
|
+
import { type Sql, type TransactionSql } from "@astralarium/repo-postgres/sql";
|
|
6
|
+
/** The deploy state. */
|
|
7
|
+
export interface DeployState {
|
|
8
|
+
/** Raised by every write. */
|
|
9
|
+
version: number;
|
|
10
|
+
/** The live build; null before the first release. */
|
|
11
|
+
live_build: string | null;
|
|
12
|
+
/** The scheduled update; null while none. */
|
|
13
|
+
update_at: Date | null;
|
|
14
|
+
/** The cutover run owning the update; null while none. */
|
|
15
|
+
update_run: string | null;
|
|
16
|
+
/** The database clock has reached the update's notice. */
|
|
17
|
+
noticed: boolean;
|
|
18
|
+
}
|
|
19
|
+
/** The deploy state from one snapshot; clients hear of the update
|
|
20
|
+
* `noticeLeadMs` ahead of it. */
|
|
21
|
+
export declare function readDeployState(sql: Sql, noticeLeadMs: number): Promise<DeployState>;
|
|
22
|
+
/** The live build; null before the first release. */
|
|
23
|
+
export declare function liveBuildOf(sql: Sql): Promise<string | null>;
|
|
24
|
+
/** True while `run` owns the scheduled update. */
|
|
25
|
+
export declare function updateOwnedBy(sql: Sql, run: string): Promise<boolean>;
|
|
26
|
+
/** True while `run` owns the scheduled update and the database clock has
|
|
27
|
+
* reached its notice, `noticeLeadMs` ahead of it. */
|
|
28
|
+
export declare function updateNoticedBy(sql: Sql, run: string, noticeLeadMs: number): Promise<boolean>;
|
|
29
|
+
/** True while `run` owns the scheduled update and the database clock has
|
|
30
|
+
* reached it. */
|
|
31
|
+
export declare function updateDueBy(sql: Sql, run: string): Promise<boolean>;
|
|
32
|
+
/**
|
|
33
|
+
* Schedules `run`'s update at `update_at`, replacing the update; refused
|
|
34
|
+
* while another run's update is due (a cutover past its sleep). Resolves
|
|
35
|
+
* whether it landed.
|
|
36
|
+
*/
|
|
37
|
+
export declare function scheduleUpdate(sql: Sql, run: string, update_at: Date): Promise<boolean>;
|
|
38
|
+
/**
|
|
39
|
+
* Withdraws the update, fenced: with `run`, only while no other run owns
|
|
40
|
+
* an update; null withdraws unconditionally. Resolves whether the fence
|
|
41
|
+
* held.
|
|
42
|
+
*/
|
|
43
|
+
export declare function withdrawUpdate(sql: Sql, run: string | null): Promise<boolean>;
|
|
44
|
+
/**
|
|
45
|
+
* `withdrawUpdate` within `tx`: `withdrawn` once it withdrew `run`'s (or,
|
|
46
|
+
* with null, any) update; `none` while no update is scheduled; `fenced`
|
|
47
|
+
* while another run owns it.
|
|
48
|
+
*/
|
|
49
|
+
export declare function withdrawUpdateIn(tx: TransactionSql, run: string | null): Promise<"withdrawn" | "none" | "fenced">;
|
|
50
|
+
/** Names `build` as live within `tx`. */
|
|
51
|
+
export declare function setLiveBuild(tx: TransactionSql, build: string): Promise<void>;
|
|
52
|
+
/** Names `build` as live and withdraws as `withdrawUpdate`, in one
|
|
53
|
+
* transaction; the build lands whether the fence holds. Resolves whether
|
|
54
|
+
* the fence held. */
|
|
55
|
+
export declare function releaseBuild(sql: Sql, build: string, run: string | null): Promise<boolean>;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/** Deploy state (`deploy.state`) as the app's workers read and write
|
|
2
|
+
* it: the live build and a scheduled update. One row; every write raises
|
|
3
|
+
* its `version` under the row lock. Update leads compare on the database
|
|
4
|
+
* clock. Workers-safe. */
|
|
5
|
+
import { msInterval, } from "@astralarium/repo-postgres/sql";
|
|
6
|
+
/** The deploy state from one snapshot; clients hear of the update
|
|
7
|
+
* `noticeLeadMs` ahead of it. */
|
|
8
|
+
export async function readDeployState(sql, noticeLeadMs) {
|
|
9
|
+
const [row] = await sql `
|
|
10
|
+
SELECT version::float8 AS version, live_build, update_at, update_run,
|
|
11
|
+
coalesce(update_at - ${msInterval(sql, noticeLeadMs)} <= clock_timestamp(), false)
|
|
12
|
+
AS noticed
|
|
13
|
+
FROM deploy.state
|
|
14
|
+
`;
|
|
15
|
+
return row;
|
|
16
|
+
}
|
|
17
|
+
/** The live build; null before the first release. */
|
|
18
|
+
export async function liveBuildOf(sql) {
|
|
19
|
+
const [row] = await sql `
|
|
20
|
+
SELECT live_build FROM deploy.state
|
|
21
|
+
`;
|
|
22
|
+
return row.live_build;
|
|
23
|
+
}
|
|
24
|
+
/** True while `run` owns the scheduled update. */
|
|
25
|
+
export async function updateOwnedBy(sql, run) {
|
|
26
|
+
const rows = await sql `
|
|
27
|
+
SELECT 1 FROM deploy.state WHERE update_run = ${run}
|
|
28
|
+
`;
|
|
29
|
+
return rows.length > 0;
|
|
30
|
+
}
|
|
31
|
+
/** True while `run` owns the scheduled update and the database clock has
|
|
32
|
+
* reached its notice, `noticeLeadMs` ahead of it. */
|
|
33
|
+
export async function updateNoticedBy(sql, run, noticeLeadMs) {
|
|
34
|
+
const rows = await sql `
|
|
35
|
+
SELECT 1 FROM deploy.state
|
|
36
|
+
WHERE update_run = ${run}
|
|
37
|
+
AND update_at - ${msInterval(sql, noticeLeadMs)} <= clock_timestamp()
|
|
38
|
+
`;
|
|
39
|
+
return rows.length > 0;
|
|
40
|
+
}
|
|
41
|
+
/** True while `run` owns the scheduled update and the database clock has
|
|
42
|
+
* reached it. */
|
|
43
|
+
export function updateDueBy(sql, run) {
|
|
44
|
+
return updateNoticedBy(sql, run, 0);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Schedules `run`'s update at `update_at`, replacing the update; refused
|
|
48
|
+
* while another run's update is due (a cutover past its sleep). Resolves
|
|
49
|
+
* whether it landed.
|
|
50
|
+
*/
|
|
51
|
+
export function scheduleUpdate(sql, run, update_at) {
|
|
52
|
+
return sql.begin(async (tx) => {
|
|
53
|
+
// A locking SELECT projects before its lock wait; the clock reads once
|
|
54
|
+
// the lock is held.
|
|
55
|
+
await tx `SELECT 1 FROM deploy.state FOR UPDATE`;
|
|
56
|
+
const [row] = await tx `
|
|
57
|
+
SELECT update_run IS NULL
|
|
58
|
+
OR update_run = ${run}
|
|
59
|
+
OR update_at > clock_timestamp() AS open
|
|
60
|
+
FROM deploy.state
|
|
61
|
+
`;
|
|
62
|
+
if (!row.open)
|
|
63
|
+
return false;
|
|
64
|
+
await tx `
|
|
65
|
+
UPDATE deploy.state
|
|
66
|
+
SET update_at = ${update_at}::timestamptz, update_run = ${run},
|
|
67
|
+
version = version + 1
|
|
68
|
+
`;
|
|
69
|
+
return true;
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Withdraws the update, fenced: with `run`, only while no other run owns
|
|
74
|
+
* an update; null withdraws unconditionally. Resolves whether the fence
|
|
75
|
+
* held.
|
|
76
|
+
*/
|
|
77
|
+
export async function withdrawUpdate(sql, run) {
|
|
78
|
+
return (await sql.begin((tx) => withdrawUpdateIn(tx, run))) !== "fenced";
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* `withdrawUpdate` within `tx`: `withdrawn` once it withdrew `run`'s (or,
|
|
82
|
+
* with null, any) update; `none` while no update is scheduled; `fenced`
|
|
83
|
+
* while another run owns it.
|
|
84
|
+
*/
|
|
85
|
+
export async function withdrawUpdateIn(tx, run) {
|
|
86
|
+
const [row] = await tx `
|
|
87
|
+
SELECT update_run FROM deploy.state FOR UPDATE
|
|
88
|
+
`;
|
|
89
|
+
if (row.update_run === null)
|
|
90
|
+
return "none";
|
|
91
|
+
if (run !== null && row.update_run !== run)
|
|
92
|
+
return "fenced";
|
|
93
|
+
await tx `
|
|
94
|
+
UPDATE deploy.state
|
|
95
|
+
SET update_at = NULL, update_run = NULL, version = version + 1
|
|
96
|
+
`;
|
|
97
|
+
return "withdrawn";
|
|
98
|
+
}
|
|
99
|
+
/** Names `build` as live within `tx`. */
|
|
100
|
+
export async function setLiveBuild(tx, build) {
|
|
101
|
+
await tx `
|
|
102
|
+
UPDATE deploy.state
|
|
103
|
+
SET live_build = ${build}, version = version + 1
|
|
104
|
+
`;
|
|
105
|
+
}
|
|
106
|
+
/** Names `build` as live and withdraws as `withdrawUpdate`, in one
|
|
107
|
+
* transaction; the build lands whether the fence holds. Resolves whether
|
|
108
|
+
* the fence held. */
|
|
109
|
+
export async function releaseBuild(sql, build, run) {
|
|
110
|
+
const withdrawn = await sql.begin(async (tx) => {
|
|
111
|
+
await setLiveBuild(tx, build);
|
|
112
|
+
return withdrawUpdateIn(tx, run);
|
|
113
|
+
});
|
|
114
|
+
return withdrawn !== "fenced";
|
|
115
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/** `status`: reads the deployment's facts, then checks its invariants. */
|
|
2
|
+
import { type Slot } from "@astralarium/repo-deploy-info";
|
|
3
|
+
import type { SlotSession } from "./session.ts";
|
|
4
|
+
/** A fact whose read failed. */
|
|
5
|
+
export declare class Unreadable {
|
|
6
|
+
readonly reason: string;
|
|
7
|
+
constructor(reason: unknown);
|
|
8
|
+
}
|
|
9
|
+
/** A fact, or why it is unreadable. */
|
|
10
|
+
type Fact<T> = T | Unreadable;
|
|
11
|
+
/** A web version's slot and build, as its vars name them. */
|
|
12
|
+
interface WebFact {
|
|
13
|
+
id: string;
|
|
14
|
+
slot: string | undefined;
|
|
15
|
+
build: string | undefined;
|
|
16
|
+
}
|
|
17
|
+
/** The deploy state row. */
|
|
18
|
+
interface StateFact {
|
|
19
|
+
liveBuild: string | null;
|
|
20
|
+
updateAt: Date | null;
|
|
21
|
+
/** The database clock has reached `updateAt`. */
|
|
22
|
+
updateDue: boolean;
|
|
23
|
+
updateRun: string | null;
|
|
24
|
+
/** `describeLease` of the lease; null while free. */
|
|
25
|
+
lease: string | null;
|
|
26
|
+
leaseValid: boolean;
|
|
27
|
+
}
|
|
28
|
+
/** What `status` checks. */
|
|
29
|
+
export interface StatusFacts {
|
|
30
|
+
/** The 100% web version; undefined when none. */
|
|
31
|
+
live: Fact<WebFact | undefined>;
|
|
32
|
+
/** The 0% web version; undefined when none. */
|
|
33
|
+
next: Fact<WebFact | undefined>;
|
|
34
|
+
/** Each slot's 100% build (undefined when none) and crons. */
|
|
35
|
+
slots: Record<Slot, {
|
|
36
|
+
build: Fact<string | undefined>;
|
|
37
|
+
crons: Fact<string[]>;
|
|
38
|
+
}>;
|
|
39
|
+
/** Build the public site answers on each of the target's `buildPaths`. */
|
|
40
|
+
site: Record<string, Fact<string>>;
|
|
41
|
+
/** Cutover runs waiting for their time, and past it. */
|
|
42
|
+
waiting: Fact<string[]>;
|
|
43
|
+
running: Fact<string[]>;
|
|
44
|
+
/** Errored cutover runs on record. */
|
|
45
|
+
errored: Fact<number>;
|
|
46
|
+
/** Undefined while the database URL is unset. */
|
|
47
|
+
state: Fact<StateFact> | undefined;
|
|
48
|
+
}
|
|
49
|
+
/** A check's verdict: only `fail` fails `status`. */
|
|
50
|
+
type Level = "ok" | "fail" | "warn" | "info";
|
|
51
|
+
/** One verdict line. */
|
|
52
|
+
export interface Check {
|
|
53
|
+
level: Level;
|
|
54
|
+
text: string;
|
|
55
|
+
}
|
|
56
|
+
/** Reads every fact, in parallel. */
|
|
57
|
+
export declare function readFacts(s: SlotSession): Promise<StatusFacts>;
|
|
58
|
+
/** The facts' summary lines: web versions, then slots. */
|
|
59
|
+
export declare function summarize(f: StatusFacts, webScript: string): string[];
|
|
60
|
+
/** Checks `f`; `cli` renders a fix command; `dbUrlVar` holds the
|
|
61
|
+
* database URL. */
|
|
62
|
+
export declare function checkFacts(f: StatusFacts, cli: (command: string) => string, dbUrlVar: string): Check[];
|
|
63
|
+
/** `check` as an output line. */
|
|
64
|
+
export declare const formatCheck: ({ level, text }: Check) => string;
|
|
65
|
+
export {};
|