@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,152 @@
|
|
|
1
|
+
/** A cutover's time from `--at`: a timestamp, an interval from now,
|
|
2
|
+
* `now`, or the default early-morning slot. */
|
|
3
|
+
import { ExpectedError } from "@astralarium/repo-telemetry";
|
|
4
|
+
/** Zone of the default cutover and of the echoed time. */
|
|
5
|
+
const CUTOVER_ZONE = "America/Los_Angeles";
|
|
6
|
+
/** Hour (in `CUTOVER_ZONE`) of the default cutover, on tomorrow's date. */
|
|
7
|
+
const DEFAULT_HOUR = 4;
|
|
8
|
+
/** Increment a distant cutover rounds to. */
|
|
9
|
+
const ROUND_MS = 30 * 60_000;
|
|
10
|
+
/** A cutover farther out than this rounds to `ROUND_MS`. */
|
|
11
|
+
const ROUND_AFTER_MS = 90 * 60_000;
|
|
12
|
+
/** Farthest a cutover schedules ahead; beyond it, the token and staged
|
|
13
|
+
* build are stale. */
|
|
14
|
+
const MAX_CUTOVER_LEAD_MS = 30 * 24 * 60 * 60_000;
|
|
15
|
+
/** Ms per interval unit. */
|
|
16
|
+
const UNIT_MS = { d: 24 * 60 * 60_000, h: 60 * 60_000, m: 60_000 };
|
|
17
|
+
/** `1d`, `2h`, `90m`, `1h30m`: units in descending order. */
|
|
18
|
+
const INTERVAL = /^(?:(\d+)d)?(?:(\d+)h)?(?:(\d+)m)?$/;
|
|
19
|
+
/** An ISO 8601 date, optionally with a time (to minutes, seconds or ms)
|
|
20
|
+
* and its zone (`Z`, `±hh:mm`). */
|
|
21
|
+
const TIMESTAMP = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,3}))?)?(Z|[+-]\d{2}:\d{2})?)?$/i;
|
|
22
|
+
/** Parses `--at` at `now` (ms epoch): undefined = default; `now` and
|
|
23
|
+
* intervals relative; else ISO 8601, zone-less in `CUTOVER_ZONE`. Throws
|
|
24
|
+
* on malformed text. */
|
|
25
|
+
export function parseCutoverSpec(text, now) {
|
|
26
|
+
if (text === undefined)
|
|
27
|
+
return { kind: "fixed", at: defaultCutover(now) };
|
|
28
|
+
if (text === "now")
|
|
29
|
+
return { kind: "relative", offsetMs: 0 };
|
|
30
|
+
const interval = INTERVAL.exec(text);
|
|
31
|
+
if (interval && text !== "") {
|
|
32
|
+
const [, d = "0", h = "0", m = "0"] = interval;
|
|
33
|
+
return {
|
|
34
|
+
kind: "relative",
|
|
35
|
+
offsetMs: +d * UNIT_MS.d + +h * UNIT_MS.h + +m * UNIT_MS.m,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
if (/^\d+$/.test(text))
|
|
39
|
+
throw new ExpectedError(`--at: "${text}" needs a unit (d, h, m)`);
|
|
40
|
+
const at = parseTimestamp(text);
|
|
41
|
+
if (at === undefined)
|
|
42
|
+
throw new ExpectedError(`--at: unreadable time "${text}"; expected now, an interval (1h30m) or an ISO timestamp (2026-10-09T04:00, Pacific unless zoned)`);
|
|
43
|
+
return { kind: "fixed", at };
|
|
44
|
+
}
|
|
45
|
+
/** The instant ISO timestamp `text` names, zone-less in `CUTOVER_ZONE`;
|
|
46
|
+
* undefined when malformed. */
|
|
47
|
+
function parseTimestamp(text) {
|
|
48
|
+
const match = TIMESTAMP.exec(text);
|
|
49
|
+
if (!match)
|
|
50
|
+
return undefined;
|
|
51
|
+
const [, y, mo, d, h = "0", mi = "0", sec = "0", frac = "0", zone] = match;
|
|
52
|
+
const wall = { year: +y, month: +mo, day: +d, hour: +h, minute: +mi };
|
|
53
|
+
const asUtc = Date.UTC(wall.year, wall.month - 1, wall.day, wall.hour, wall.minute);
|
|
54
|
+
// Date.UTC carries overflow into the next field: a changed field was out
|
|
55
|
+
// of range.
|
|
56
|
+
const check = new Date(asUtc);
|
|
57
|
+
if (check.getUTCFullYear() !== wall.year ||
|
|
58
|
+
check.getUTCMonth() !== wall.month - 1 ||
|
|
59
|
+
check.getUTCDate() !== wall.day ||
|
|
60
|
+
check.getUTCHours() !== wall.hour ||
|
|
61
|
+
check.getUTCMinutes() !== wall.minute ||
|
|
62
|
+
+sec > 59)
|
|
63
|
+
return undefined;
|
|
64
|
+
const extraMs = +sec * 1_000 + +frac.padEnd(3, "0");
|
|
65
|
+
if (!zone)
|
|
66
|
+
return zonedInstant(wall, CUTOVER_ZONE) + extraMs;
|
|
67
|
+
const offset = /^([+-])(\d{2}):(\d{2})$/.exec(zone);
|
|
68
|
+
const offsetMs = offset
|
|
69
|
+
? (offset[1] === "-" ? -1 : 1) * (+offset[2] * 60 + +offset[3]) * 60_000
|
|
70
|
+
: 0;
|
|
71
|
+
return asUtc + extraMs - offsetMs;
|
|
72
|
+
}
|
|
73
|
+
/** Cutover instant (ms epoch) of `spec` at `now`. Beyond `ROUND_AFTER_MS`
|
|
74
|
+
* rounds to `ROUND_MS` unless `exact`. Throws on a passed fixed instant
|
|
75
|
+
* or one beyond `MAX_CUTOVER_LEAD_MS`. */
|
|
76
|
+
export function resolveCutover(spec, now, exact = false) {
|
|
77
|
+
const at = spec.kind === "fixed" ? spec.at : now + spec.offsetMs;
|
|
78
|
+
if (spec.kind === "fixed" && at <= now)
|
|
79
|
+
throw new ExpectedError(`--at: ${new Date(at).toISOString()} has passed`);
|
|
80
|
+
const resolved = exact || at - now <= ROUND_AFTER_MS
|
|
81
|
+
? at
|
|
82
|
+
: Math.round(at / ROUND_MS) * ROUND_MS;
|
|
83
|
+
if (resolved - now > MAX_CUTOVER_LEAD_MS)
|
|
84
|
+
throw new ExpectedError(`--at: ${new Date(resolved).toISOString()} is over ${MAX_CUTOVER_LEAD_MS / (24 * 60 * 60_000)} days out, the farthest a cutover schedules`);
|
|
85
|
+
return resolved;
|
|
86
|
+
}
|
|
87
|
+
/** `DEFAULT_HOUR`:00 in `CUTOVER_ZONE` on the zone's date after `now`'s. */
|
|
88
|
+
function defaultCutover(now) {
|
|
89
|
+
const today = zonedParts(now, CUTOVER_ZONE);
|
|
90
|
+
// Date.UTC normalizes day overflow across months and years.
|
|
91
|
+
const tomorrow = new Date(Date.UTC(today.year, today.month - 1, today.day + 1));
|
|
92
|
+
return zonedInstant({
|
|
93
|
+
year: tomorrow.getUTCFullYear(),
|
|
94
|
+
month: tomorrow.getUTCMonth() + 1,
|
|
95
|
+
day: tomorrow.getUTCDate(),
|
|
96
|
+
hour: DEFAULT_HOUR,
|
|
97
|
+
minute: 0,
|
|
98
|
+
}, CUTOVER_ZONE);
|
|
99
|
+
}
|
|
100
|
+
/** `at`'s wall clock in `zone`. */
|
|
101
|
+
function zonedParts(at, zone) {
|
|
102
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
103
|
+
timeZone: zone,
|
|
104
|
+
hourCycle: "h23",
|
|
105
|
+
year: "numeric",
|
|
106
|
+
month: "numeric",
|
|
107
|
+
day: "numeric",
|
|
108
|
+
hour: "numeric",
|
|
109
|
+
minute: "numeric",
|
|
110
|
+
}).formatToParts(at);
|
|
111
|
+
const field = (type) => Number(parts.find((p) => p.type === type)?.value);
|
|
112
|
+
return {
|
|
113
|
+
year: field("year"),
|
|
114
|
+
month: field("month"),
|
|
115
|
+
day: field("day"),
|
|
116
|
+
hour: field("hour"),
|
|
117
|
+
minute: field("minute"),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
/** The instant `wall` reads in `zone`. */
|
|
121
|
+
function zonedInstant(wall, zone) {
|
|
122
|
+
const asUtc = Date.UTC(wall.year, wall.month - 1, wall.day, wall.hour, wall.minute);
|
|
123
|
+
// Two passes: exact except inside a DST gap.
|
|
124
|
+
let at = asUtc;
|
|
125
|
+
for (let i = 0; i < 2; i++)
|
|
126
|
+
at = asUtc - offsetOf(at, zone);
|
|
127
|
+
return at;
|
|
128
|
+
}
|
|
129
|
+
/** `zone`'s UTC offset (ms) at `at`. */
|
|
130
|
+
function offsetOf(at, zone) {
|
|
131
|
+
const w = zonedParts(at, zone);
|
|
132
|
+
const wallAsUtc = Date.UTC(w.year, w.month - 1, w.day, w.hour, w.minute);
|
|
133
|
+
return wallAsUtc - Math.floor(at / 60_000) * 60_000;
|
|
134
|
+
}
|
|
135
|
+
/** `at` for an operator: the time in `CUTOVER_ZONE` and UTC, and how far
|
|
136
|
+
* from `now`. */
|
|
137
|
+
export function describeCutover(at, now) {
|
|
138
|
+
const zoned = new Intl.DateTimeFormat("en-US", {
|
|
139
|
+
timeZone: CUTOVER_ZONE,
|
|
140
|
+
weekday: "short",
|
|
141
|
+
month: "short",
|
|
142
|
+
day: "numeric",
|
|
143
|
+
hour: "numeric",
|
|
144
|
+
minute: "2-digit",
|
|
145
|
+
timeZoneName: "short",
|
|
146
|
+
}).format(at);
|
|
147
|
+
const minutes = Math.max(0, Math.round((at - now) / 60_000));
|
|
148
|
+
const away = minutes === 0
|
|
149
|
+
? "now"
|
|
150
|
+
: `in ${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, "0")}m`;
|
|
151
|
+
return `${zoned} (${new Date(at).toISOString()}), ${away}`;
|
|
152
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { type InstanceStatus } from "./cloudflare.ts";
|
|
2
|
+
import type { SlotSession } from "./session.ts";
|
|
3
|
+
import { type DeployState } from "./state.ts";
|
|
4
|
+
import { type WebVersion } from "./web.ts";
|
|
5
|
+
/** A cutover run the API lists. */
|
|
6
|
+
interface CutoverRun {
|
|
7
|
+
id: string;
|
|
8
|
+
status: InstanceStatus;
|
|
9
|
+
}
|
|
10
|
+
/** Id of a new cutover run for `build`: `cutover-<build>-<epoch>`. */
|
|
11
|
+
export declare const cutoverRunId: (build: string) => string;
|
|
12
|
+
/** Build of cutover run `id`; undefined for another shape. */
|
|
13
|
+
export declare const buildOfRun: (id: string) => string | undefined;
|
|
14
|
+
/** What a command found after taking the lease. */
|
|
15
|
+
export interface Cutovers {
|
|
16
|
+
/** The 100% web version. */
|
|
17
|
+
live: WebVersion;
|
|
18
|
+
/** The deploy state; `noticed` once the update is due. */
|
|
19
|
+
state: DeployState;
|
|
20
|
+
/** Runs past their sleep, or owning a due update whatever their
|
|
21
|
+
* status (the API reports `waiting` during step retries). */
|
|
22
|
+
inFlight: CutoverRun[];
|
|
23
|
+
/** Waiting runs of the live build: released, retrying the tail (old
|
|
24
|
+
* slot's cron clear, a publish). Commands wait them out: the clear
|
|
25
|
+
* would strip crons a new `cutover` or `rollback` adds to that slot. */
|
|
26
|
+
finishing: CutoverRun[];
|
|
27
|
+
/** Waiting runs ahead of their release: owning no update, or one still
|
|
28
|
+
* ahead. */
|
|
29
|
+
terminable: string[];
|
|
30
|
+
/** The update once no listed run owns it: its run ended. */
|
|
31
|
+
orphan: {
|
|
32
|
+
run: string;
|
|
33
|
+
at: Date;
|
|
34
|
+
} | undefined;
|
|
35
|
+
}
|
|
36
|
+
/** Classifies `runs` against `state`. */
|
|
37
|
+
export declare function classify(live: WebVersion, state: DeployState, runs: readonly CutoverRun[]): Cutovers;
|
|
38
|
+
/** Reads the live web version, the deploy state and the cutover runs,
|
|
39
|
+
* classified. */
|
|
40
|
+
export declare function readCutovers(s: SlotSession): Promise<Cutovers>;
|
|
41
|
+
/** `requireIdle`'s options. */
|
|
42
|
+
interface IdleOptions {
|
|
43
|
+
/** What follows the run's end, for the operator. */
|
|
44
|
+
then?: string;
|
|
45
|
+
/** Ends finishing runs along with terminable ones: the command's own
|
|
46
|
+
* steps redo their tail (`settle`). */
|
|
47
|
+
endFinishing?: boolean;
|
|
48
|
+
}
|
|
49
|
+
/** Throws while a cutover is in flight, or finishing unless
|
|
50
|
+
* `endFinishing`. */
|
|
51
|
+
export declare function requireIdle(c: Cutovers, { then, endFinishing }?: IdleOptions): void;
|
|
52
|
+
/** Throws while the deploy state names a live build other than the live
|
|
53
|
+
* web version's: `settle` realigns them. */
|
|
54
|
+
export declare function requireLiveAligned(s: SlotSession, c: Cutovers): void;
|
|
55
|
+
/** Terminates terminable runs (finishing ones too with `endFinishing`),
|
|
56
|
+
* withdrawing each one's update: a dead run's update closes the app ahead
|
|
57
|
+
* of nothing. Re-reads first: an update may have turned due since the
|
|
58
|
+
* command's read. Resolves to the fresh classification. */
|
|
59
|
+
export declare function terminateCutovers(s: SlotSession, options?: IdleOptions): Promise<Cutovers>;
|
|
60
|
+
/** Withdraws the orphaned update, fenced. Resolves whether one was there. */
|
|
61
|
+
export declare function withdrawOrphan(s: SlotSession, c: Cutovers): Promise<boolean>;
|
|
62
|
+
export {};
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/** Cutover runs and the scheduled update, classified against the deploy
|
|
2
|
+
* state and the live web version. */
|
|
3
|
+
import { ExpectedError, log } from "@astralarium/repo-telemetry";
|
|
4
|
+
import { epoch, RUNNING, WAITING } from "./cloudflare.js";
|
|
5
|
+
import { abortReason } from "./process.js";
|
|
6
|
+
import { readDeployState, withdrawUpdateIn, } from "./state.js";
|
|
7
|
+
import { readLive } from "./web.js";
|
|
8
|
+
/** Id of a new cutover run for `build`: `cutover-<build>-<epoch>`. */
|
|
9
|
+
export const cutoverRunId = (build) => `cutover-${build}-${epoch()}`;
|
|
10
|
+
/** Build of cutover run `id`; undefined for another shape. */
|
|
11
|
+
export const buildOfRun = (id) => /^cutover-(.+)-\d+$/.exec(id)?.[1];
|
|
12
|
+
/** Statuses a listing covers: every unfinished run. */
|
|
13
|
+
const LISTED = [...RUNNING, ...WAITING];
|
|
14
|
+
/** Every unfinished cutover run; throws naming `settle` when the listing
|
|
15
|
+
* fails. */
|
|
16
|
+
async function listRuns(s) {
|
|
17
|
+
const flow = s.target.flows.cutover;
|
|
18
|
+
try {
|
|
19
|
+
const listed = await Promise.all(LISTED.map(async (status) => (await s.cf.instances(flow, status)).map((id) => ({ id, status }))));
|
|
20
|
+
return listed.flat();
|
|
21
|
+
}
|
|
22
|
+
catch (e) {
|
|
23
|
+
if (s.signal.aborted)
|
|
24
|
+
throw abortReason(s.signal);
|
|
25
|
+
throw new ExpectedError(`The cutover runs are unreadable (${e instanceof Error ? e.message : String(e)}): retry; ${s.cli("settle")} realigns the deployment once they read.`, { cause: e });
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** Classifies `runs` against `state`. */
|
|
29
|
+
export function classify(live, state, runs) {
|
|
30
|
+
const inFlight = [];
|
|
31
|
+
const finishing = [];
|
|
32
|
+
const terminable = [];
|
|
33
|
+
for (const run of runs) {
|
|
34
|
+
const owner = run.id === state.update_run;
|
|
35
|
+
if (RUNNING.includes(run.status) || (owner && state.noticed))
|
|
36
|
+
inFlight.push(run);
|
|
37
|
+
else if (buildOfRun(run.id) === state.live_build)
|
|
38
|
+
finishing.push(run);
|
|
39
|
+
else
|
|
40
|
+
terminable.push(run.id);
|
|
41
|
+
}
|
|
42
|
+
const owned = runs.some((run) => run.id === state.update_run);
|
|
43
|
+
const orphan = state.update_run !== null && state.update_at !== null && !owned
|
|
44
|
+
? { run: state.update_run, at: state.update_at }
|
|
45
|
+
: undefined;
|
|
46
|
+
return { live, state, inFlight, finishing, terminable, orphan };
|
|
47
|
+
}
|
|
48
|
+
/** Reads the live web version, the deploy state and the cutover runs,
|
|
49
|
+
* classified. */
|
|
50
|
+
export async function readCutovers(s) {
|
|
51
|
+
const live = await readLive(s);
|
|
52
|
+
const [state, runs] = await Promise.all([
|
|
53
|
+
readDeployState(s.sql, 0),
|
|
54
|
+
listRuns(s),
|
|
55
|
+
]);
|
|
56
|
+
return classify(live, state, runs);
|
|
57
|
+
}
|
|
58
|
+
/** `runs` grouped by status: `running: a b; waiting: c`. */
|
|
59
|
+
function describeRuns(runs) {
|
|
60
|
+
const byStatus = new Map();
|
|
61
|
+
for (const { id, status } of runs) {
|
|
62
|
+
const ids = byStatus.get(status) ?? [];
|
|
63
|
+
ids.push(id);
|
|
64
|
+
byStatus.set(status, ids);
|
|
65
|
+
}
|
|
66
|
+
return [...byStatus]
|
|
67
|
+
.map(([status, ids]) => `${status}: ${ids.join(" ")}`)
|
|
68
|
+
.join("; ");
|
|
69
|
+
}
|
|
70
|
+
/** Throws while a cutover is in flight, or finishing unless
|
|
71
|
+
* `endFinishing`. */
|
|
72
|
+
export function requireIdle(c, { then = "retry once it ends.", endFinishing = false } = {}) {
|
|
73
|
+
if (c.inFlight.length)
|
|
74
|
+
throw new ExpectedError(`A cutover is in progress (${describeRuns(c.inFlight)}); ${then}`);
|
|
75
|
+
if (c.finishing.length && !endFinishing)
|
|
76
|
+
throw new ExpectedError(`A cutover is finishing (${describeRuns(c.finishing)}); ${then}`);
|
|
77
|
+
}
|
|
78
|
+
/** Throws while the deploy state names a live build other than the live
|
|
79
|
+
* web version's: `settle` realigns them. */
|
|
80
|
+
export function requireLiveAligned(s, c) {
|
|
81
|
+
const { live_build } = c.state;
|
|
82
|
+
if (live_build !== null && live_build !== c.live.build)
|
|
83
|
+
throw new ExpectedError(`The deploy state names build '${live_build}' live but the live web version is build '${c.live.build}'; run ${s.cli("settle")} first.`);
|
|
84
|
+
}
|
|
85
|
+
/** Terminates terminable runs (finishing ones too with `endFinishing`),
|
|
86
|
+
* withdrawing each one's update: a dead run's update closes the app ahead
|
|
87
|
+
* of nothing. Re-reads first: an update may have turned due since the
|
|
88
|
+
* command's read. Resolves to the fresh classification. */
|
|
89
|
+
export async function terminateCutovers(s, options = {}) {
|
|
90
|
+
const c = await readCutovers(s);
|
|
91
|
+
requireIdle(c, options);
|
|
92
|
+
const ending = options.endFinishing
|
|
93
|
+
? [...c.terminable, ...c.finishing.map((run) => run.id)]
|
|
94
|
+
: c.terminable;
|
|
95
|
+
for (const id of ending) {
|
|
96
|
+
await s.cf.terminate(s.target.flows.cutover, id);
|
|
97
|
+
const outcome = await s.lease.fenced((tx) => withdrawUpdateIn(tx, id));
|
|
98
|
+
if (outcome === "withdrawn")
|
|
99
|
+
log.info("deploy_release.update_withdrawn", "Withdrew the update of terminated cutover run {run}", { run: id });
|
|
100
|
+
}
|
|
101
|
+
return c;
|
|
102
|
+
}
|
|
103
|
+
/** Withdraws the orphaned update, fenced. Resolves whether one was there. */
|
|
104
|
+
export async function withdrawOrphan(s, c) {
|
|
105
|
+
if (!c.orphan)
|
|
106
|
+
return false;
|
|
107
|
+
const { run } = c.orphan;
|
|
108
|
+
const outcome = await s.lease.fenced((tx) => withdrawUpdateIn(tx, run));
|
|
109
|
+
if (outcome === "withdrawn")
|
|
110
|
+
log.info("deploy_release.orphan_withdrawn", "Withdrew the update at {at} of ended cutover run {run}", { at: c.orphan.at.toISOString(), run: c.orphan.run });
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { SlotTarget } from "./target.ts";
|
|
2
|
+
/** `target`'s deployer wrangler config, entry `main`, on the slotted
|
|
3
|
+
* worker's runtime; its `env.<env>` block holds the workflows,
|
|
4
|
+
* `DEPLOYMENT` and each slot's control. */
|
|
5
|
+
export declare function deployerConfig(target: SlotTarget, main: string): {
|
|
6
|
+
name: string;
|
|
7
|
+
main: string;
|
|
8
|
+
compatibility_date: string;
|
|
9
|
+
compatibility_flags: string[];
|
|
10
|
+
observability: {
|
|
11
|
+
enabled: boolean;
|
|
12
|
+
};
|
|
13
|
+
workers_dev: boolean;
|
|
14
|
+
preview_urls: boolean;
|
|
15
|
+
env: {
|
|
16
|
+
[x: string]: {
|
|
17
|
+
name: string;
|
|
18
|
+
workflows: {
|
|
19
|
+
name: string;
|
|
20
|
+
binding: "CUTOVER" | "SETTLE" | "DRAIN";
|
|
21
|
+
class_name: string;
|
|
22
|
+
}[];
|
|
23
|
+
vars: {
|
|
24
|
+
DEPLOYMENT: import("@astralarium/repo-deploy-info").Deployment;
|
|
25
|
+
};
|
|
26
|
+
services: {
|
|
27
|
+
binding: "CONTROL_BLUE" | "CONTROL_GREEN";
|
|
28
|
+
service: string;
|
|
29
|
+
entrypoint: string;
|
|
30
|
+
}[];
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
};
|
|
34
|
+
/** Writes `target`'s deployer into `dir`: `DEPLOYER_ENTRY` and
|
|
35
|
+
* `wrangler.json` (`deployerConfig`); returns the config's path. */
|
|
36
|
+
export declare function writeDeployer(dir: string, target: SlotTarget): Promise<string>;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/** The deployer worker of a blue/green env: `@astralarium/repo-deploy-workflows/deployer`
|
|
2
|
+
* under a wrangler config `deploy-release` generates from the target. */
|
|
3
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { CONTROL_ENTRYPOINT, controlBinding, DEPLOYER_CLASSES, DEPLOYER_FLOWS, DEPLOYMENT_VAR, SLOTS, } from "@astralarium/repo-deploy-info";
|
|
7
|
+
/** Entry file of the generated deployer, beside its config: re-exports
|
|
8
|
+
* `@astralarium/repo-deploy-workflows/deployer`, so the bundle lands as `index.js`. */
|
|
9
|
+
const DEPLOYER_ENTRY = "index.js";
|
|
10
|
+
/** Flag the deployer adds to the slotted worker's: the site check
|
|
11
|
+
* reaches the site through its public routing. */
|
|
12
|
+
const PUBLIC_FETCH_FLAG = "global_fetch_strictly_public";
|
|
13
|
+
/** `target`'s deployer wrangler config, entry `main`, on the slotted
|
|
14
|
+
* worker's runtime; its `env.<env>` block holds the workflows,
|
|
15
|
+
* `DEPLOYMENT` and each slot's control. */
|
|
16
|
+
export function deployerConfig(target, main) {
|
|
17
|
+
const { env, deployer, deployment, flows, runtime } = target;
|
|
18
|
+
return {
|
|
19
|
+
name: deployer,
|
|
20
|
+
main,
|
|
21
|
+
compatibility_date: runtime.compatibilityDate,
|
|
22
|
+
compatibility_flags: [
|
|
23
|
+
...new Set([...runtime.compatibilityFlags, PUBLIC_FETCH_FLAG]),
|
|
24
|
+
],
|
|
25
|
+
observability: { enabled: true },
|
|
26
|
+
// Reached only through the Workflows API.
|
|
27
|
+
workers_dev: false,
|
|
28
|
+
preview_urls: false,
|
|
29
|
+
env: {
|
|
30
|
+
[env]: {
|
|
31
|
+
name: deployer,
|
|
32
|
+
workflows: Object.keys(flows).flatMap((flow) => {
|
|
33
|
+
const name = flows[flow];
|
|
34
|
+
return name
|
|
35
|
+
? [
|
|
36
|
+
{
|
|
37
|
+
name,
|
|
38
|
+
binding: DEPLOYER_FLOWS[flow],
|
|
39
|
+
class_name: DEPLOYER_CLASSES[flow],
|
|
40
|
+
},
|
|
41
|
+
]
|
|
42
|
+
: [];
|
|
43
|
+
}),
|
|
44
|
+
vars: { [DEPLOYMENT_VAR]: deployment },
|
|
45
|
+
services: SLOTS.map((slot) => ({
|
|
46
|
+
binding: controlBinding(slot),
|
|
47
|
+
service: deployment.slotScripts[slot],
|
|
48
|
+
entrypoint: CONTROL_ENTRYPOINT,
|
|
49
|
+
})),
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/** Writes `target`'s deployer into `dir`: `DEPLOYER_ENTRY` and
|
|
55
|
+
* `wrangler.json` (`deployerConfig`); returns the config's path. */
|
|
56
|
+
export async function writeDeployer(dir, target) {
|
|
57
|
+
const config = join(dir, "wrangler.json");
|
|
58
|
+
const module = JSON.stringify(fileURLToPath(import.meta.resolve("@astralarium/repo-deploy-workflows/deployer")));
|
|
59
|
+
await mkdir(dir, { recursive: true });
|
|
60
|
+
await writeFile(join(dir, DEPLOYER_ENTRY), `export * from ${module};\nexport { default } from ${module};\n`);
|
|
61
|
+
await writeFile(config, JSON.stringify(deployerConfig(target, DEPLOYER_ENTRY)));
|
|
62
|
+
return config;
|
|
63
|
+
}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
/** `deploy-release`'s app declaration: `defineReleaseApp` and its types. */
|
|
2
|
+
export { type BlueGreenEnv, defineReleaseApp, type EnvRelease, type InPlaceEnv, type OngoingWork, type ReleaseApp, type RetireBuilds, type SlotPackages, type SlotSecrets, type WorkerBuild, type WorkerSpec, } from "./app.ts";
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type Sql, type TransactionSql } from "@astralarium/repo-postgres/sql";
|
|
2
|
+
/** The commands that change the deployment, as their lease names them:
|
|
3
|
+
* the `deploy.operation` values. Keep in step with `sql/schema.sql`. */
|
|
4
|
+
export declare const DEPLOY_OPERATIONS: readonly ["init", "stage", "deploy", "cutover", "rollback", "settle", "cancel", "secrets"];
|
|
5
|
+
/** A command that changes the deployment (`DEPLOY_OPERATIONS`). */
|
|
6
|
+
export type DeployOperation = (typeof DEPLOY_OPERATIONS)[number];
|
|
7
|
+
/** The lease as it stands. */
|
|
8
|
+
export interface LeaseRow {
|
|
9
|
+
holder: string;
|
|
10
|
+
operation: DeployOperation;
|
|
11
|
+
expires_at: Date;
|
|
12
|
+
}
|
|
13
|
+
/** `row` for an operator: holder, operation, expiry. */
|
|
14
|
+
export declare const describeLease: (row: LeaseRow) => string;
|
|
15
|
+
/** A held lease. */
|
|
16
|
+
export declare class Lease {
|
|
17
|
+
#private;
|
|
18
|
+
readonly holder: string;
|
|
19
|
+
private constructor();
|
|
20
|
+
/** Takes the lease for `operation`, renewing it while held; `onLost`
|
|
21
|
+
* hears of it lapsing or passing to another holder. Throws while
|
|
22
|
+
* another holds it, naming `unlock`, the command line that breaks it. */
|
|
23
|
+
static take(sql: Sql, operation: DeployOperation, { onLost, unlock }: {
|
|
24
|
+
onLost: (error: Error) => void;
|
|
25
|
+
unlock: string;
|
|
26
|
+
}): Promise<Lease>;
|
|
27
|
+
/** Runs `fn` in a transaction locked behind the lease; throws once it is
|
|
28
|
+
* lost. */
|
|
29
|
+
fenced<T>(fn: (tx: TransactionSql) => Promise<T>): Promise<T>;
|
|
30
|
+
/** Stops renewing and drops the lease. */
|
|
31
|
+
release(): Promise<void>;
|
|
32
|
+
}
|
|
33
|
+
/** The lease; undefined while free. */
|
|
34
|
+
export declare function readLease(sql: Sql): Promise<LeaseRow | undefined>;
|
|
35
|
+
/** Drops `holder`'s lease; false when another holds it now. */
|
|
36
|
+
export declare function breakLease(sql: Sql, holder: string): Promise<boolean>;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/** The deploy lease (`deploy.state`): one mutating command at a time.
|
|
2
|
+
* A heartbeat renews it; a lapse loses it, failing fenced writes. */
|
|
3
|
+
import { hostname, userInfo } from "node:os";
|
|
4
|
+
import { isSqlstate, SQLSTATE, } from "@astralarium/repo-postgres/sql";
|
|
5
|
+
import { ExpectedError, log } from "@astralarium/repo-telemetry";
|
|
6
|
+
/** The commands that change the deployment, as their lease names them:
|
|
7
|
+
* the `deploy.operation` values. Keep in step with `sql/schema.sql`. */
|
|
8
|
+
export const DEPLOY_OPERATIONS = [
|
|
9
|
+
"init",
|
|
10
|
+
"stage",
|
|
11
|
+
"deploy",
|
|
12
|
+
"cutover",
|
|
13
|
+
"rollback",
|
|
14
|
+
"settle",
|
|
15
|
+
"cancel",
|
|
16
|
+
"secrets",
|
|
17
|
+
];
|
|
18
|
+
/** Between heartbeat renewals. */
|
|
19
|
+
const RENEW_MS = 5 * 60_000;
|
|
20
|
+
/** How long a lease holds past its last renewal. */
|
|
21
|
+
const LEASE_TTL = "1 hour";
|
|
22
|
+
/** `row` for an operator: holder, operation, expiry. */
|
|
23
|
+
export const describeLease = (row) => `${row.holder} (${row.operation}, until ${row.expires_at.toISOString()})`;
|
|
24
|
+
/** This invocation as a lease holder: `user@host pid N`. */
|
|
25
|
+
const leaseHolder = () => `${userInfo().username}@${hostname().split(".")[0]} pid ${process.pid}`;
|
|
26
|
+
/** A held lease. */
|
|
27
|
+
export class Lease {
|
|
28
|
+
#sql;
|
|
29
|
+
#heartbeat;
|
|
30
|
+
/** The heartbeat's renewal in flight; `release` awaits it. */
|
|
31
|
+
#renewal = Promise.resolve();
|
|
32
|
+
holder;
|
|
33
|
+
constructor(sql, holder, onLost) {
|
|
34
|
+
this.#sql = sql;
|
|
35
|
+
this.holder = holder;
|
|
36
|
+
this.#heartbeat = setInterval(() => {
|
|
37
|
+
this.#renewal = renewLease(sql, holder).then(({ renewed, lease }) => {
|
|
38
|
+
if (renewed)
|
|
39
|
+
return;
|
|
40
|
+
clearInterval(this.#heartbeat);
|
|
41
|
+
onLost(new ExpectedError(lease
|
|
42
|
+
? `Lost the deploy lease to ${describeLease(lease)}.`
|
|
43
|
+
: "Lost the deploy lease: it lapsed or was broken."));
|
|
44
|
+
}, (e) => log.warn("deploy_release.lease_renewal_failed", "deploy lease renewal failed; retrying", { exception: e }));
|
|
45
|
+
}, RENEW_MS).unref();
|
|
46
|
+
}
|
|
47
|
+
/** Takes the lease for `operation`, renewing it while held; `onLost`
|
|
48
|
+
* hears of it lapsing or passing to another holder. Throws while
|
|
49
|
+
* another holds it, naming `unlock`, the command line that breaks it. */
|
|
50
|
+
static async take(sql, operation, { onLost, unlock }) {
|
|
51
|
+
const holder = leaseHolder();
|
|
52
|
+
let row;
|
|
53
|
+
try {
|
|
54
|
+
row = await takeLease(sql, holder, operation);
|
|
55
|
+
}
|
|
56
|
+
catch (e) {
|
|
57
|
+
throw new ExpectedError(isSqlstate(e, SQLSTATE.insufficientPrivilege)
|
|
58
|
+
? "The database role may not take the deploy lease: it needs EXECUTE on the deploy.*_lease functions and UPDATE on deploy.state, both granted to app_service."
|
|
59
|
+
: "Could not take the deploy lease.", { cause: e });
|
|
60
|
+
}
|
|
61
|
+
if (!row.taken)
|
|
62
|
+
throw new ExpectedError(`The deployment is leased: ${describeLease(row)}. Wait for it, or run ${unlock} if that command died.`);
|
|
63
|
+
return new Lease(sql, holder, onLost);
|
|
64
|
+
}
|
|
65
|
+
/** Runs `fn` in a transaction locked behind the lease; throws once it is
|
|
66
|
+
* lost. */
|
|
67
|
+
fenced(fn) {
|
|
68
|
+
return this.#sql.begin(async (tx) => {
|
|
69
|
+
await tx `SELECT deploy.fence_lease(${this.holder})`;
|
|
70
|
+
return fn(tx);
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
/** Stops renewing and drops the lease. */
|
|
74
|
+
async release() {
|
|
75
|
+
clearInterval(this.#heartbeat);
|
|
76
|
+
await this.#renewal;
|
|
77
|
+
await this.#sql `SELECT deploy.drop_lease(${this.holder})`;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/** Takes the lease or renews `holder`'s, keeping its operation; reports
|
|
81
|
+
* it as it stands. */
|
|
82
|
+
async function takeLease(sql, holder, operation) {
|
|
83
|
+
const [row] = await sql `
|
|
84
|
+
SELECT * FROM deploy.take_lease(
|
|
85
|
+
${holder}, ${operation}::deploy.operation, ${LEASE_TTL}::interval)
|
|
86
|
+
`;
|
|
87
|
+
return row;
|
|
88
|
+
}
|
|
89
|
+
/** Extends `holder`'s unexpired lease; reports the lease as it stands,
|
|
90
|
+
* undefined while free. */
|
|
91
|
+
async function renewLease(sql, holder) {
|
|
92
|
+
const [{ renewed, ...row }] = await sql `
|
|
93
|
+
SELECT * FROM deploy.renew_lease(${holder}, ${LEASE_TTL}::interval)
|
|
94
|
+
`;
|
|
95
|
+
return { renewed, lease: row.holder ? row : undefined };
|
|
96
|
+
}
|
|
97
|
+
/** The lease; undefined while free. */
|
|
98
|
+
export async function readLease(sql) {
|
|
99
|
+
const [row] = await sql `
|
|
100
|
+
SELECT lease_holder AS holder, lease_operation AS operation,
|
|
101
|
+
lease_expires_at AS expires_at
|
|
102
|
+
FROM deploy.state
|
|
103
|
+
`;
|
|
104
|
+
return row.holder ? row : undefined;
|
|
105
|
+
}
|
|
106
|
+
/** Drops `holder`'s lease; false when another holds it now. */
|
|
107
|
+
export async function breakLease(sql, holder) {
|
|
108
|
+
const [{ dropped }] = await sql `
|
|
109
|
+
SELECT deploy.drop_lease(${holder}) AS dropped
|
|
110
|
+
`;
|
|
111
|
+
return dropped !== null;
|
|
112
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type ReleaseApp } from "./app.ts";
|
|
2
|
+
/** Runs `argv` (after the program name, carrying `--app <module>`)
|
|
3
|
+
* against the app `load` resolves the module path to, setting
|
|
4
|
+
* `process.exitCode`; a failure to load the app or read its targets
|
|
5
|
+
* logs like a command's. */
|
|
6
|
+
export declare function main(argv: readonly string[], load: (app: string) => ReleaseApp | Promise<ReleaseApp>): Promise<void>;
|
package/dist/src/main.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** Runs one `deploy-release` command line for an app. */
|
|
2
|
+
import { appArgs } from "@astralarium/repo-deploy-info/server";
|
|
3
|
+
import { buildApplication, ExitCode, run } from "@stricli/core";
|
|
4
|
+
import { PROGRAM } from "./app.js";
|
|
5
|
+
import { commands } from "./commands.js";
|
|
6
|
+
import { reportFailure } from "./process.js";
|
|
7
|
+
/** Exit status of a usage error. */
|
|
8
|
+
const USAGE_EXIT = 2;
|
|
9
|
+
/** What a command line lacking `--app` reports. */
|
|
10
|
+
const USAGE = `Usage: ${PROGRAM} --app <module> <command> [options]`;
|
|
11
|
+
/** Runs `argv` (after the program name, carrying `--app <module>`)
|
|
12
|
+
* against the app `load` resolves the module path to, setting
|
|
13
|
+
* `process.exitCode`; a failure to load the app or read its targets
|
|
14
|
+
* logs like a command's. */
|
|
15
|
+
export async function main(argv, load) {
|
|
16
|
+
let args;
|
|
17
|
+
let routes;
|
|
18
|
+
try {
|
|
19
|
+
args = appArgs(argv, USAGE);
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
reportFailure(error);
|
|
23
|
+
process.exitCode = USAGE_EXIT;
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
const invocation = `${PROGRAM} --app ${args.app}`;
|
|
27
|
+
try {
|
|
28
|
+
routes = commands(await load(args.app), invocation);
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
reportFailure(error);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const application = buildApplication(routes, {
|
|
35
|
+
name: invocation,
|
|
36
|
+
scanner: { caseStyle: "allow-kebab-for-camel" },
|
|
37
|
+
documentation: { caseStyle: "convert-camel-to-kebab" },
|
|
38
|
+
});
|
|
39
|
+
await run(application, args.argv, { process });
|
|
40
|
+
if (process.exitCode === ExitCode.InvalidArgument ||
|
|
41
|
+
process.exitCode === ExitCode.UnknownCommand)
|
|
42
|
+
process.exitCode = USAGE_EXIT;
|
|
43
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { ExpectedError } from "@astralarium/repo-telemetry";
|
|
2
|
+
/** Exit status after Ctrl-C. */
|
|
3
|
+
export declare const INTERRUPT_EXIT = 130;
|
|
4
|
+
/** Ctrl-C: exits `INTERRUPT_EXIT`. */
|
|
5
|
+
export declare class Interrupted extends ExpectedError {
|
|
6
|
+
constructor();
|
|
7
|
+
}
|
|
8
|
+
/** SIGKILLs every running child. */
|
|
9
|
+
export declare function killChildren(): void;
|
|
10
|
+
/** Logs `error` as the command's failure and sets the exit status:
|
|
11
|
+
* `INTERRUPT_EXIT` after Ctrl-C, else 1. */
|
|
12
|
+
export declare function reportFailure(error: unknown): void;
|
|
13
|
+
/** Why `signal` aborted, as an error. */
|
|
14
|
+
export declare const abortReason: (signal: AbortSignal) => Error;
|
|
15
|
+
/** Variables over a child's inherited environment; an undefined value
|
|
16
|
+
* removes one. */
|
|
17
|
+
export type EnvVars = Readonly<Record<string, string | undefined>>;
|
|
18
|
+
/** How `run` wires a child. */
|
|
19
|
+
export interface RunOptions {
|
|
20
|
+
cwd?: string;
|
|
21
|
+
env?: EnvVars;
|
|
22
|
+
/** Written to stdin; stdin is inherited otherwise. */
|
|
23
|
+
input?: string;
|
|
24
|
+
/** Resolve to stdout instead of passing it through. */
|
|
25
|
+
capture?: boolean;
|
|
26
|
+
/** Hold stderr, showing it only on failure. */
|
|
27
|
+
quiet?: boolean;
|
|
28
|
+
}
|
|
29
|
+
/** Runs `cmd` with `args` until it exits; resolves to its stdout when
|
|
30
|
+
* captured. Throws on a nonzero exit, and with `signal`'s reason once it
|
|
31
|
+
* aborts (killing the child). */
|
|
32
|
+
export declare function run(cmd: string, args: readonly string[], signal: AbortSignal, { cwd, env, input, capture, quiet }?: RunOptions): Promise<string>;
|
|
33
|
+
/** Resolves after `ms`; throws `signal`'s reason once it aborts. */
|
|
34
|
+
export declare const sleep: (ms: number, signal: AbortSignal) => Promise<undefined>;
|