@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,170 @@
|
|
|
1
|
+
/** Cloudflare as `deploy-release` reaches it: reads and simple writes
|
|
2
|
+
* through the API under wrangler's login; bundling and config-driven
|
|
3
|
+
* writes through wrangler. */
|
|
4
|
+
import { cfApi, CfApiError, deployScript, putScriptCrons, scriptCrons, scriptDeployments, scriptVersion, } from "@astralarium/repo-deploy-info";
|
|
5
|
+
import { ExpectedError } from "@astralarium/repo-telemetry";
|
|
6
|
+
import { abortReason, run } from "./process.js";
|
|
7
|
+
import { ownWorkspaceRoot } from "./workspace.js";
|
|
8
|
+
/** Statuses of a cutover past its sleep: it finishes or compensates. */
|
|
9
|
+
export const RUNNING = [
|
|
10
|
+
"running",
|
|
11
|
+
"paused",
|
|
12
|
+
"waitingForPause",
|
|
13
|
+
];
|
|
14
|
+
/** Statuses of a cutover waiting for its time, or retrying a step. */
|
|
15
|
+
export const WAITING = ["queued", "waiting"];
|
|
16
|
+
/** Unix seconds, for workflow instance ids. */
|
|
17
|
+
export const epoch = () => Math.floor(Date.now() / 1000);
|
|
18
|
+
/** Version id at `percentage` in `deployment`. */
|
|
19
|
+
export const idAt = (deployment, percentage) => deployment?.versions.find((v) => v.percentage === percentage)?.version_id;
|
|
20
|
+
/** The OAuth token outlives this; refetched after it. */
|
|
21
|
+
const TOKEN_REUSE_MS = 5 * 60_000;
|
|
22
|
+
/** `fallback` when `promise` rejects with a 404 (an absent script or
|
|
23
|
+
* workflow). */
|
|
24
|
+
async function orAbsent(promise, fallback) {
|
|
25
|
+
try {
|
|
26
|
+
return await promise;
|
|
27
|
+
}
|
|
28
|
+
catch (e) {
|
|
29
|
+
if (e instanceof CfApiError && e.status === 404)
|
|
30
|
+
return fallback;
|
|
31
|
+
throw e;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** The account wrangler is logged into. */
|
|
35
|
+
export class Cloudflare {
|
|
36
|
+
#signal;
|
|
37
|
+
/** Workspace package whose wrangler answers account-level queries. */
|
|
38
|
+
#pkg;
|
|
39
|
+
/** Wrangler config file within each package (`--config`). */
|
|
40
|
+
#config;
|
|
41
|
+
#account;
|
|
42
|
+
#token;
|
|
43
|
+
/** `api`, bound for the `@astralarium/repo-deploy-info` ops. */
|
|
44
|
+
#api = this.api.bind(this);
|
|
45
|
+
constructor(signal, pkg, config) {
|
|
46
|
+
this.#signal = signal;
|
|
47
|
+
this.#pkg = pkg;
|
|
48
|
+
this.#config = config;
|
|
49
|
+
}
|
|
50
|
+
/** Runs wrangler in workspace package `pkg`'s directory on its config
|
|
51
|
+
* file (`--config`): an entry path in `args` would move wrangler's
|
|
52
|
+
* lookup to the entry's directory. */
|
|
53
|
+
wrangler(pkg, args, { redirected = false, config = this.#config, ...options } = {}) {
|
|
54
|
+
return run("pnpm", [
|
|
55
|
+
"--filter",
|
|
56
|
+
pkg,
|
|
57
|
+
"exec",
|
|
58
|
+
"wrangler",
|
|
59
|
+
...args,
|
|
60
|
+
...(redirected ? [] : ["--config", config]),
|
|
61
|
+
], this.#signal, { cwd: ownWorkspaceRoot(), ...options });
|
|
62
|
+
}
|
|
63
|
+
/** Reads wrangler's JSON output of `args`. */
|
|
64
|
+
async #wranglerJson(args) {
|
|
65
|
+
const out = await this.wrangler(this.#pkg, args, {
|
|
66
|
+
capture: true,
|
|
67
|
+
quiet: true,
|
|
68
|
+
});
|
|
69
|
+
return JSON.parse(out);
|
|
70
|
+
}
|
|
71
|
+
/** The id of the one account wrangler reaches. */
|
|
72
|
+
accountId() {
|
|
73
|
+
this.#account ??= this.#wranglerJson([
|
|
74
|
+
"whoami",
|
|
75
|
+
"--json",
|
|
76
|
+
]).then(({ accounts }) => {
|
|
77
|
+
if (accounts.length !== 1)
|
|
78
|
+
throw new ExpectedError(`wrangler reaches ${accounts.length} Cloudflare accounts; log into exactly one`);
|
|
79
|
+
return accounts[0].id;
|
|
80
|
+
});
|
|
81
|
+
return this.#account;
|
|
82
|
+
}
|
|
83
|
+
/** Wrangler's OAuth token, reused `TOKEN_REUSE_MS`; a failed read is
|
|
84
|
+
* retried by the next call. */
|
|
85
|
+
token() {
|
|
86
|
+
if (!this.#token || Date.now() - this.#token.at > TOKEN_REUSE_MS) {
|
|
87
|
+
const value = this.#wranglerJson([
|
|
88
|
+
"auth",
|
|
89
|
+
"token",
|
|
90
|
+
"--json",
|
|
91
|
+
]).then((t) => t.token);
|
|
92
|
+
const token = { value, at: Date.now() };
|
|
93
|
+
value.catch(() => {
|
|
94
|
+
if (this.#token === token)
|
|
95
|
+
this.#token = undefined;
|
|
96
|
+
});
|
|
97
|
+
this.#token = token;
|
|
98
|
+
}
|
|
99
|
+
return this.#token.value;
|
|
100
|
+
}
|
|
101
|
+
/** One API call, as `CfApi`. */
|
|
102
|
+
async api(method, path, body) {
|
|
103
|
+
const signal = this.#signal;
|
|
104
|
+
const api = cfApi(await this.token(), await this.accountId(), (input, init) => fetch(input, { ...init, signal }));
|
|
105
|
+
try {
|
|
106
|
+
return await api(method, path, body);
|
|
107
|
+
}
|
|
108
|
+
catch (e) {
|
|
109
|
+
throw signal.aborted ? abortReason(signal) : e;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/** `script`'s deployments, newest first; none when it is absent. */
|
|
113
|
+
deployments(script) {
|
|
114
|
+
return orAbsent(scriptDeployments(this.#api, script), []);
|
|
115
|
+
}
|
|
116
|
+
/** Version of `script` at `percentage` in its latest deployment. */
|
|
117
|
+
async versionAt(script, percentage) {
|
|
118
|
+
const [latest] = await this.deployments(script);
|
|
119
|
+
return idAt(latest, percentage);
|
|
120
|
+
}
|
|
121
|
+
/** Version `id` of `script`. */
|
|
122
|
+
version(script, id) {
|
|
123
|
+
return scriptVersion(this.#api, script, id);
|
|
124
|
+
}
|
|
125
|
+
/** `script`'s recent deployable versions; none when it is absent. */
|
|
126
|
+
async versions(script) {
|
|
127
|
+
const { items } = await orAbsent(this.api("GET", `workers/scripts/${script}/versions?deployable=true`), { items: [] });
|
|
128
|
+
return items;
|
|
129
|
+
}
|
|
130
|
+
/** Names of `script`'s secrets; none when it is absent. */
|
|
131
|
+
async secretNames(script) {
|
|
132
|
+
const secrets = await orAbsent(this.api("GET", `workers/scripts/${script}/secrets`), []);
|
|
133
|
+
return secrets.map((s) => s.name);
|
|
134
|
+
}
|
|
135
|
+
/** `script`'s cron expressions. */
|
|
136
|
+
crons(script) {
|
|
137
|
+
return scriptCrons(this.#api, script);
|
|
138
|
+
}
|
|
139
|
+
/** Clears `script`'s crons. */
|
|
140
|
+
clearCrons(script) {
|
|
141
|
+
return putScriptCrons(this.#api, script, []);
|
|
142
|
+
}
|
|
143
|
+
/** Deploys `versions` of `script`. */
|
|
144
|
+
deploy(script, versions, message) {
|
|
145
|
+
return deployScript(this.#api, script, versions, message);
|
|
146
|
+
}
|
|
147
|
+
/** Ids of workflow `flow`'s instances in `status`; none while the
|
|
148
|
+
* workflow is absent. */
|
|
149
|
+
async instances(flow, status) {
|
|
150
|
+
const instances = await orAbsent(this.api("GET", `workflows/${flow}/instances?status=${status}&per_page=100`), []);
|
|
151
|
+
return instances.map((i) => i.id);
|
|
152
|
+
}
|
|
153
|
+
/** Starts workflow `flow` as instance `id` with `params`. */
|
|
154
|
+
async trigger(flow, id, params) {
|
|
155
|
+
await this.api("POST", `workflows/${flow}/instances`, {
|
|
156
|
+
instance_id: id,
|
|
157
|
+
params,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
/** Instance `id` of workflow `flow`. */
|
|
161
|
+
instance(flow, id) {
|
|
162
|
+
return this.api("GET", `workflows/${flow}/instances/${id}`);
|
|
163
|
+
}
|
|
164
|
+
/** Terminates instance `id` of workflow `flow`. */
|
|
165
|
+
async terminate(flow, id) {
|
|
166
|
+
await this.api("PATCH", `workflows/${flow}/instances/${id}/status`, {
|
|
167
|
+
status: "terminate",
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { type BuildEnv, type ReleaseApp } from "./app.ts";
|
|
2
|
+
import { type Session, type SlotSession } from "./session.ts";
|
|
3
|
+
/** Flags of the commands that build. */
|
|
4
|
+
interface BuildFlags {
|
|
5
|
+
/** `--build-env`: the build's `ReleaseApp.buildEnv` values. */
|
|
6
|
+
buildEnv?: BuildEnv;
|
|
7
|
+
}
|
|
8
|
+
/** Flags of the commands that stage: `stage`, `deploy`. */
|
|
9
|
+
interface StageFlags extends BuildFlags {
|
|
10
|
+
/** `--force`: ends the idle slot's ongoing work. */
|
|
11
|
+
force: boolean;
|
|
12
|
+
}
|
|
13
|
+
/** Flags of the commands that schedule a cutover: `cutover`, `deploy`. */
|
|
14
|
+
interface CutoverFlags {
|
|
15
|
+
/** `--at`: the cutover time. */
|
|
16
|
+
at: string | undefined;
|
|
17
|
+
/** `--exact-time`: keeps `at` unrounded. */
|
|
18
|
+
exactTime: boolean;
|
|
19
|
+
}
|
|
20
|
+
/** `init`: deploys the next build to blue, its web version live alone,
|
|
21
|
+
* and names it the live build. Refuses once a slot is live. */
|
|
22
|
+
export declare function runInit(s: SlotSession, { buildEnv }?: BuildFlags): Promise<void>;
|
|
23
|
+
/** `stage`: deploys the next build to the idle slot at 0%. */
|
|
24
|
+
export declare function runStage(s: SlotSession, flags: StageFlags): Promise<void>;
|
|
25
|
+
/** `cutover`: schedules the switch to the staged build at `--at`. */
|
|
26
|
+
export declare function runCutover(s: SlotSession, flags: CutoverFlags): Promise<void>;
|
|
27
|
+
/** `deploy`: `stage`, then `cutover`, unattended after the token prompt.
|
|
28
|
+
* An in-place target deploys at once (`runDeployWorkers`); its
|
|
29
|
+
* cutover flags refuse. */
|
|
30
|
+
export declare function runDeploy(session: Session, flags: StageFlags & CutoverFlags): Promise<void>;
|
|
31
|
+
/** `rollback`: switches back to the previous build now, through the
|
|
32
|
+
* cutover workflow. Previous = the newest earlier live web version on
|
|
33
|
+
* the other slot, while that slot still holds its build. */
|
|
34
|
+
export declare function runRollback(s: SlotSession): Promise<void>;
|
|
35
|
+
/** `cancel`: terminates waiting cutover runs, withdrawing their updates
|
|
36
|
+
* and one left by an ended run, and publishes the deploy state. Resolves
|
|
37
|
+
* whether an update or a waiting run was there to cancel. */
|
|
38
|
+
export declare function cancelUpdate(s: SlotSession): Promise<boolean>;
|
|
39
|
+
/** `settle`: aligns the deployment to the live web version. Ends finishing
|
|
40
|
+
* cutover runs too; the steps below redo their tail. */
|
|
41
|
+
export declare function runSettle(s: SlotSession): Promise<void>;
|
|
42
|
+
/** `status`: prints the deployment's facts and checks; resolves to the
|
|
43
|
+
* exit status, 1 on any failed check. */
|
|
44
|
+
export declare function runStatus(s: SlotSession): Promise<number>;
|
|
45
|
+
/** `unlock`: breaks the deploy lease once the operator confirms (`yes`
|
|
46
|
+
* skips the question). */
|
|
47
|
+
export declare function runUnlock(s: Session, flags: {
|
|
48
|
+
yes: boolean;
|
|
49
|
+
}): Promise<void>;
|
|
50
|
+
/** `secrets`: uploads the target's secrets file to each of its scripts. */
|
|
51
|
+
export declare function runSecrets(s: Session): Promise<void>;
|
|
52
|
+
/** `deploy-release`'s commands against `app`'s targets; `invocation`
|
|
53
|
+
* starts each printed command line. */
|
|
54
|
+
export declare function commands(app: ReleaseApp, invocation: string): import("@stricli/core").RouteMap<import("@stricli/core").CommandContext>;
|
|
55
|
+
export {};
|