@kedge/cli 0.0.0-stage → 0.10.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/bin/kedge.mjs +8 -0
- package/kedge-manifest.json +418 -0
- package/package.json +43 -3
- package/src/cf.ts +729 -0
- package/src/check.ts +45 -0
- package/src/cli-session.ts +57 -0
- package/src/code-lint.ts +339 -0
- package/src/commands.ts +959 -0
- package/src/compose.ts +637 -0
- package/src/d1-batch.ts +66 -0
- package/src/deploy.ts +1682 -0
- package/src/doctor.ts +701 -0
- package/src/fork.ts +67 -0
- package/src/index.ts +12 -0
- package/src/invariants.ts +168 -0
- package/src/keys-save.ts +298 -0
- package/src/local-state.ts +110 -0
- package/src/main.ts +470 -0
- package/src/manifest.ts +163 -0
- package/src/n1.ts +217 -0
- package/src/ops.ts +373 -0
- package/src/order.ts +7 -0
- package/src/output.ts +35 -0
- package/src/plugin-lint.ts +145 -0
- package/src/preview.ts +1038 -0
- package/src/schema.ts +72 -0
- package/src/smoke.ts +218 -0
- package/src/sqlite-verify.ts +333 -0
- package/src/store-api.ts +36 -0
- package/src/store.ts +132 -0
- package/src/tar.ts +125 -0
- package/src/target.ts +81 -0
- package/src/tz.ts +79 -0
- package/src/units.ts +185 -0
- package/src/upgrade.ts +235 -0
- package/src/worker-config.ts +81 -0
- package/README.md +0 -3
package/src/preview.ts
ADDED
|
@@ -0,0 +1,1038 @@
|
|
|
1
|
+
// `kedge preview` (docs/proposals/p0.8-cli-agent-kit.md §5): a separate Worker
|
|
2
|
+
// with its own D1, queue and Durable Objects, no production secrets and
|
|
3
|
+
// capture sinks for every effect. Worker Previews can't run queue consumers
|
|
4
|
+
// or cron ("Queue consumers cannot target a Preview", "Routes and Cron
|
|
5
|
+
// Triggers target production", developers.cloudflare.com/workers/previews/),
|
|
6
|
+
// and Kedge's relay and expiry need both.
|
|
7
|
+
//
|
|
8
|
+
// Two halves. `build` runs the store's code and needs no credentials: it
|
|
9
|
+
// bundles and writes a plan. `apply` holds the credentials and runs none of the
|
|
10
|
+
// store's code: it reads the plan as a tar stream of data, generates the
|
|
11
|
+
// Wrangler config itself from an allowlist, and runs Wrangler from its own
|
|
12
|
+
// directory with a sanitized environment (cf.ts). The eval depends on that split.
|
|
13
|
+
import { spawnSync } from "node:child_process";
|
|
14
|
+
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
|
|
15
|
+
import {
|
|
16
|
+
chmodSync,
|
|
17
|
+
cpSync,
|
|
18
|
+
existsSync,
|
|
19
|
+
mkdirSync,
|
|
20
|
+
mkdtempSync,
|
|
21
|
+
readdirSync,
|
|
22
|
+
readFileSync,
|
|
23
|
+
rmSync,
|
|
24
|
+
writeFileSync,
|
|
25
|
+
} from "node:fs";
|
|
26
|
+
import { tmpdir } from "node:os";
|
|
27
|
+
import { join } from "node:path";
|
|
28
|
+
import { ownerInviteSql } from "@kedge/db";
|
|
29
|
+
import { enrolOwner, type StoreCredentials } from "@kedge/testing/http";
|
|
30
|
+
import { type Cloudflare, type JurisdictionChoice, UnknownOutcome } from "./cf";
|
|
31
|
+
import { seed } from "./commands";
|
|
32
|
+
import type { Lock } from "./compose";
|
|
33
|
+
import { CORE_SECRETS, DeployRefused, openOps, stageAssets } from "./deploy";
|
|
34
|
+
import { assetsShape, declaredClasses, readWrangler } from "./doctor";
|
|
35
|
+
import { LostOwnership, OperationHeld, type OpsStore } from "./ops";
|
|
36
|
+
import { childStdio } from "./output";
|
|
37
|
+
import { hashTree, type StoreNames, sha256, type WranglerJson } from "./store";
|
|
38
|
+
import { readTar, type TarEntry, TarRefused, writeTar } from "./tar";
|
|
39
|
+
|
|
40
|
+
/** The origin the bootstrap's browser-shaped ceremonies use; the preview's ADMIN_ORIGINS includes it. */
|
|
41
|
+
export const PREVIEW_ORIGIN = "https://kedge-preview.invalid";
|
|
42
|
+
/** What a preview's secret key may do: enough for seeding and a store's scenarios. */
|
|
43
|
+
export const PREVIEW_GRANTS = [
|
|
44
|
+
"product:read",
|
|
45
|
+
"product:write",
|
|
46
|
+
"order:read",
|
|
47
|
+
"order:write",
|
|
48
|
+
"order:place",
|
|
49
|
+
"inventory:read",
|
|
50
|
+
"inventory:write",
|
|
51
|
+
"capacity:read",
|
|
52
|
+
"capacity:write",
|
|
53
|
+
"tax:read",
|
|
54
|
+
"policy:read",
|
|
55
|
+
"settings:read",
|
|
56
|
+
"customer:read",
|
|
57
|
+
"plugin:read",
|
|
58
|
+
"delivery:read",
|
|
59
|
+
"effect:read",
|
|
60
|
+
"field:pii",
|
|
61
|
+
"field:financial",
|
|
62
|
+
];
|
|
63
|
+
const LIMITS = { files: 2_000, fileBytes: 32 * 1024 * 1024, totalBytes: 64 * 1024 * 1024 };
|
|
64
|
+
const NEVER_COPIED = /^(CLOUDFLARE_|KEDGE_PREVIEW_KEY$)/;
|
|
65
|
+
|
|
66
|
+
/** Keys a preview's Worker config may carry; anything else is a refusal naming it (§5.2). */
|
|
67
|
+
const ALLOWED = new Set([
|
|
68
|
+
"$schema",
|
|
69
|
+
"name",
|
|
70
|
+
"main",
|
|
71
|
+
"compatibility_date",
|
|
72
|
+
"compatibility_flags",
|
|
73
|
+
"observability",
|
|
74
|
+
"vars",
|
|
75
|
+
"triggers",
|
|
76
|
+
"d1_databases",
|
|
77
|
+
"queues",
|
|
78
|
+
"durable_objects",
|
|
79
|
+
"migrations",
|
|
80
|
+
"workers_dev",
|
|
81
|
+
"preview_urls",
|
|
82
|
+
"keep_vars",
|
|
83
|
+
// The admin's assets (p0.9 §3.4): checked for the one supported shape, and staged from the plan.
|
|
84
|
+
"assets",
|
|
85
|
+
]);
|
|
86
|
+
|
|
87
|
+
/** A consumer's settings a preview keeps: batching and retries, nothing that names another queue. */
|
|
88
|
+
const CONSUMER_SETTINGS = new Set([
|
|
89
|
+
"max_batch_size",
|
|
90
|
+
"max_batch_timeout",
|
|
91
|
+
"max_retries",
|
|
92
|
+
"retry_delay",
|
|
93
|
+
"max_concurrency",
|
|
94
|
+
]);
|
|
95
|
+
|
|
96
|
+
export interface PreviewNames {
|
|
97
|
+
identity: string;
|
|
98
|
+
slug: string;
|
|
99
|
+
nonce: string;
|
|
100
|
+
worker: string;
|
|
101
|
+
database: string;
|
|
102
|
+
queue: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const WORKER_NAME_MAX = 54;
|
|
106
|
+
|
|
107
|
+
export function previewNames(store: string, name: string, nonce: string): PreviewNames {
|
|
108
|
+
const slug =
|
|
109
|
+
name
|
|
110
|
+
.toLowerCase()
|
|
111
|
+
.replace(/[^a-z0-9-]+/g, "-")
|
|
112
|
+
.replace(/^-+|-+$/g, "")
|
|
113
|
+
.replace(/-{2,}/g, "-") || "preview";
|
|
114
|
+
// Cloudflare's workers.dev route refuses a Worker name over 54 characters while the script's
|
|
115
|
+
// previews flag is on, which it is by default with workers_dev (100315, research/26), even
|
|
116
|
+
// though a Worker with Durable Objects gets no version URLs. The store's name gives way
|
|
117
|
+
// before the nonce, which is what keeps two previews apart.
|
|
118
|
+
const room = WORKER_NAME_MAX - "-pv-".length - 1 - nonce.length;
|
|
119
|
+
const slugPart = slug.slice(0, Math.max(1, room - store.length));
|
|
120
|
+
const worker = `${store.slice(0, room - slugPart.length)}-pv-${slugPart}-${nonce}`;
|
|
121
|
+
return { identity: `${store}/${name}`, slug, nonce, worker, database: `${worker}-db`, queue: `${worker}-q` };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** A preview's local directory: a hash of its identity, never its name (`--name ../../creds` stays inside). */
|
|
125
|
+
export function localDir(cwd: string, identity: string): string {
|
|
126
|
+
return join(cwd, ".kedge", "previews", createHash("sha256").update(identity).digest("hex").slice(0, 16));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ---- the Worker's config (§5.2) ----------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
export interface PreviewWorker {
|
|
132
|
+
worker: string;
|
|
133
|
+
main: string;
|
|
134
|
+
database: string;
|
|
135
|
+
databaseId: string;
|
|
136
|
+
migrationsDir: string;
|
|
137
|
+
queue: string;
|
|
138
|
+
vars?: Record<string, string>;
|
|
139
|
+
/** The plan's staged admin assets (p0.9 §3.4), when the store serves the admin. */
|
|
140
|
+
assetsDir?: string;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The Durable Object classes the config's migrations leave live, renames and deletions applied: doctor's reading. */
|
|
144
|
+
const classesOf = (config: WranglerJson): string[] =>
|
|
145
|
+
declaredClasses((config.migrations ?? []) as Parameters<typeof declaredClasses>[0]);
|
|
146
|
+
|
|
147
|
+
/** Built by allowlist from the store's config; a key this code doesn't know can't carry production through. */
|
|
148
|
+
export function previewConfig(source: WranglerJson, p: PreviewWorker): Record<string, unknown> {
|
|
149
|
+
const refused = Object.keys(source).filter((k) => !ALLOWED.has(k));
|
|
150
|
+
if (refused.length)
|
|
151
|
+
throw new DeployRefused(
|
|
152
|
+
`wrangler.jsonc has ${refused.map((k) => `"${k}"`).join(", ")}, which a preview can't take yet: a preview never binds production's resources or services, and a binding needs its own preview copy first`,
|
|
153
|
+
);
|
|
154
|
+
const classes = classesOf(source);
|
|
155
|
+
// Every nested binding by allowlist too: a Durable Object binding with `script_name` reaches
|
|
156
|
+
// another Worker's objects (production's carts), and a consumer's `dead_letter_queue` names a
|
|
157
|
+
// queue the preview doesn't own (codex code r1).
|
|
158
|
+
const nested: string[] = [];
|
|
159
|
+
const dos = (
|
|
160
|
+
((source.durable_objects as { bindings?: Record<string, unknown>[] } | undefined)?.bindings ?? []) as Record<
|
|
161
|
+
string,
|
|
162
|
+
unknown
|
|
163
|
+
>[]
|
|
164
|
+
).map((b) => {
|
|
165
|
+
const extra = Object.keys(b).filter((k) => k !== "name" && k !== "class_name");
|
|
166
|
+
if (extra.length || (!classes.includes(String(b.class_name)) && b.class_name !== "SyntheticProviderDO"))
|
|
167
|
+
nested.push(
|
|
168
|
+
`the Durable Object binding ${String(b.name)} (${extra.join(", ") || "a class the store doesn't declare"})`,
|
|
169
|
+
);
|
|
170
|
+
return { name: String(b.name), class_name: String(b.class_name) };
|
|
171
|
+
});
|
|
172
|
+
const consumers = (source.queues?.consumers ?? []) as Record<string, unknown>[];
|
|
173
|
+
const consumer = consumers[0];
|
|
174
|
+
const extraConsumer = Object.keys(consumer ?? {}).filter((k) => k !== "queue" && !CONSUMER_SETTINGS.has(k));
|
|
175
|
+
if (extraConsumer.length) nested.push(`the queue consumer's ${extraConsumer.join(", ")}`);
|
|
176
|
+
if (consumers.length > 1 || (source.queues?.producers ?? []).some((q) => q.binding !== "EVENTS"))
|
|
177
|
+
nested.push("a queue binding other than EVENTS");
|
|
178
|
+
if ((source.d1_databases ?? []).some((d) => d.binding !== "DB")) nested.push("a D1 binding other than DB");
|
|
179
|
+
// Only the admin's shape (p0.9 §3.4): a fallback or run_worker_first could answer API paths with HTML.
|
|
180
|
+
const a = source.assets;
|
|
181
|
+
if (a && assetsShape(a).length) nested.push(`an \`assets\` block of another shape (${assetsShape(a).join("; ")})`);
|
|
182
|
+
if (nested.length)
|
|
183
|
+
throw new DeployRefused(
|
|
184
|
+
`wrangler.jsonc has ${nested.join("; ")}, which a preview can't take: a preview binds only resources it owns`,
|
|
185
|
+
);
|
|
186
|
+
const observability = (source.observability ?? {}) as Record<string, unknown>;
|
|
187
|
+
const vars = { ...(source.vars ?? {}), ...(p.vars ?? {}) };
|
|
188
|
+
const origins = new Set(
|
|
189
|
+
String(vars.ADMIN_ORIGINS ?? "")
|
|
190
|
+
.split(",")
|
|
191
|
+
.map((s) => s.trim())
|
|
192
|
+
.filter(Boolean),
|
|
193
|
+
);
|
|
194
|
+
origins.add(PREVIEW_ORIGIN);
|
|
195
|
+
return {
|
|
196
|
+
name: p.worker,
|
|
197
|
+
main: p.main,
|
|
198
|
+
compatibility_date: source.compatibility_date,
|
|
199
|
+
compatibility_flags: source.compatibility_flags ?? [],
|
|
200
|
+
workers_dev: true,
|
|
201
|
+
observability: {
|
|
202
|
+
...observability,
|
|
203
|
+
logs: { ...((observability.logs as object | undefined) ?? {}), invocation_logs: false },
|
|
204
|
+
},
|
|
205
|
+
vars: { ...vars, ADMIN_ORIGINS: [...origins].join(","), KEDGE_ENVIRONMENT: "preview" },
|
|
206
|
+
triggers: { crons: source.triggers?.crons ?? [] },
|
|
207
|
+
d1_databases: [
|
|
208
|
+
{ binding: "DB", database_name: p.database, database_id: p.databaseId, migrations_dir: p.migrationsDir },
|
|
209
|
+
],
|
|
210
|
+
queues: {
|
|
211
|
+
producers: [{ binding: "EVENTS", queue: p.queue }],
|
|
212
|
+
consumers: [
|
|
213
|
+
{
|
|
214
|
+
...Object.fromEntries(Object.entries(consumer ?? {}).filter(([k]) => CONSUMER_SETTINGS.has(k))),
|
|
215
|
+
queue: p.queue,
|
|
216
|
+
},
|
|
217
|
+
],
|
|
218
|
+
},
|
|
219
|
+
durable_objects: {
|
|
220
|
+
bindings: [
|
|
221
|
+
...dos.filter((b) => b.name !== "SYNTHETIC"),
|
|
222
|
+
{ name: "SYNTHETIC", class_name: "SyntheticProviderDO" },
|
|
223
|
+
],
|
|
224
|
+
},
|
|
225
|
+
...(source.assets && p.assetsDir ? { assets: { directory: p.assetsDir, binding: "ASSETS" } } : {}),
|
|
226
|
+
// A new Worker has no history: one tag makes every class (§5.2).
|
|
227
|
+
migrations: [
|
|
228
|
+
{ tag: "v1", new_sqlite_classes: [...classes.filter((c) => c !== "SyntheticProviderDO"), "SyntheticProviderDO"] },
|
|
229
|
+
],
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** No production id or name may reach a preview's config. */
|
|
234
|
+
export function sharedWithProduction(production: WranglerJson, preview: Record<string, unknown>): string[] {
|
|
235
|
+
const prod = new Set<string>();
|
|
236
|
+
const walk = (v: unknown) => {
|
|
237
|
+
if (typeof v === "string" && v.length > 3) prod.add(v);
|
|
238
|
+
else if (Array.isArray(v)) v.forEach(walk);
|
|
239
|
+
else if (v && typeof v === "object") Object.values(v).forEach(walk);
|
|
240
|
+
};
|
|
241
|
+
walk(production.name);
|
|
242
|
+
for (const d of production.d1_databases ?? []) walk([d.database_name, d.database_id]);
|
|
243
|
+
for (const q of [...(production.queues?.producers ?? []), ...(production.queues?.consumers ?? [])]) walk(q.queue);
|
|
244
|
+
const ours = [
|
|
245
|
+
preview.name,
|
|
246
|
+
...((preview.d1_databases as { database_name: string; database_id: string }[]) ?? []).flatMap((d) => [
|
|
247
|
+
d.database_name,
|
|
248
|
+
d.database_id,
|
|
249
|
+
]),
|
|
250
|
+
...((preview.queues as { producers: { queue: string }[] }).producers ?? []).map((q) => q.queue),
|
|
251
|
+
].map(String);
|
|
252
|
+
return ours.filter((v) => prod.has(v) && v !== "00000000-0000-0000-0000-000000000000");
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ---- the plan (§5.3) ------------------------------------------------------------------------------------
|
|
256
|
+
|
|
257
|
+
export interface PlanFile {
|
|
258
|
+
version: 1;
|
|
259
|
+
store: string;
|
|
260
|
+
name: string;
|
|
261
|
+
/** The source config, as data; apply keeps only what the allowlist names. */
|
|
262
|
+
config: WranglerJson;
|
|
263
|
+
vars: Record<string, string>;
|
|
264
|
+
modules: Record<string, string>;
|
|
265
|
+
migrations: Record<string, string>;
|
|
266
|
+
/** The admin's assets, each file's hash (p0.9 §3.4): validated from the stream like the bundle. */
|
|
267
|
+
assets?: Record<string, string>;
|
|
268
|
+
/** Secrets the store's config asks for; only the caller of apply grants them. */
|
|
269
|
+
secrets: string[];
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** `kedge preview build`: bundles and writes the plan, with no credentials. */
|
|
273
|
+
export function buildPlan(input: {
|
|
274
|
+
cwd: string;
|
|
275
|
+
name: string;
|
|
276
|
+
lock: Lock;
|
|
277
|
+
vars: Record<string, string>;
|
|
278
|
+
secrets: readonly string[];
|
|
279
|
+
bundle: (config: string, outdir: string) => void;
|
|
280
|
+
}): { dir: string; plan: PlanFile } {
|
|
281
|
+
const w = readWrangler(input.cwd);
|
|
282
|
+
if (!w) throw new DeployRefused("no wrangler.jsonc");
|
|
283
|
+
const config = w.config as WranglerJson;
|
|
284
|
+
const dir = join(localDir(input.cwd, `${config.name}/${input.name}`), "plan");
|
|
285
|
+
rmSync(dir, { recursive: true, force: true });
|
|
286
|
+
mkdirSync(join(dir, "migrations"), { recursive: true });
|
|
287
|
+
const buildConfig = join(dir, "..", "build.wrangler.json");
|
|
288
|
+
const { build: _, ...rest } = config as WranglerJson & { build?: unknown };
|
|
289
|
+
// The admin's assets, staged into the plan, and its build in the Worker's code (p0.9 §2.4, §3.4).
|
|
290
|
+
const admin = stageAssets(input.cwd, config, join(dir, "assets"));
|
|
291
|
+
writeFileSync(
|
|
292
|
+
buildConfig,
|
|
293
|
+
JSON.stringify({
|
|
294
|
+
...rest,
|
|
295
|
+
main: join(input.cwd, String(config.main ?? "src/index.ts")),
|
|
296
|
+
// The store's own constants kept, the admin's build beside them (it wins its one name).
|
|
297
|
+
...(admin
|
|
298
|
+
? {
|
|
299
|
+
assets: { ...config.assets, directory: join(dir, "assets") },
|
|
300
|
+
define: { ...((config as { define?: Record<string, string> }).define ?? {}), ...admin.define },
|
|
301
|
+
}
|
|
302
|
+
: {}),
|
|
303
|
+
}),
|
|
304
|
+
);
|
|
305
|
+
input.bundle(buildConfig, join(dir, "bundle"));
|
|
306
|
+
rmSync(buildConfig, { force: true });
|
|
307
|
+
const migrations: Record<string, string> = {};
|
|
308
|
+
for (const e of input.lock.entries) {
|
|
309
|
+
const bytes = readFileSync(join(input.cwd, "migrations", e.file));
|
|
310
|
+
if (sha256(bytes) !== e.sha256)
|
|
311
|
+
throw new DeployRefused(`${e.file} doesn't match kedge.lock.json; run kedge doctor`);
|
|
312
|
+
writeFileSync(join(dir, "migrations", e.file), bytes);
|
|
313
|
+
migrations[e.file] = e.sha256;
|
|
314
|
+
}
|
|
315
|
+
const plan: PlanFile = {
|
|
316
|
+
version: 1,
|
|
317
|
+
store: String(config.name),
|
|
318
|
+
name: input.name,
|
|
319
|
+
config,
|
|
320
|
+
vars: input.vars,
|
|
321
|
+
modules: hashTree(join(dir, "bundle")),
|
|
322
|
+
migrations,
|
|
323
|
+
...(admin ? { assets: hashTree(join(dir, "assets")) } : {}),
|
|
324
|
+
secrets: [...input.secrets],
|
|
325
|
+
};
|
|
326
|
+
writeFileSync(join(dir, "plan.json"), JSON.stringify(plan, null, 2));
|
|
327
|
+
return { dir, plan };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** The plan directory as one tar stream: what `docker cp <c>:<plan> -` gives the eval's trusted process. */
|
|
331
|
+
export function tarPlan(dir: string): Buffer {
|
|
332
|
+
const entries: TarEntry[] = [];
|
|
333
|
+
const walk = (d: string, prefix: string) => {
|
|
334
|
+
for (const e of readdirSync(d, { withFileTypes: true })) {
|
|
335
|
+
const p = join(d, e.name);
|
|
336
|
+
const rel = prefix ? `${prefix}/${e.name}` : e.name;
|
|
337
|
+
if (e.isDirectory()) walk(p, rel);
|
|
338
|
+
else if (e.isFile()) entries.push({ path: rel, data: readFileSync(p) });
|
|
339
|
+
}
|
|
340
|
+
};
|
|
341
|
+
walk(dir, "");
|
|
342
|
+
return writeTar(entries);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Reads a plan from a tar stream into a fresh staging directory: only the
|
|
347
|
+
* regular files plan.json enumerates, each matching its hash, and nothing
|
|
348
|
+
* whose name starts with a dot (a hostile `.env` would reach Wrangler).
|
|
349
|
+
*/
|
|
350
|
+
export function stagePlan(stream: Buffer): { dir: string; plan: PlanFile } {
|
|
351
|
+
const entries = readTar(stream, LIMITS);
|
|
352
|
+
const planEntry = entries.find((e) => e.path === "plan.json");
|
|
353
|
+
if (!planEntry) throw new TarRefused("no plan.json");
|
|
354
|
+
let plan: PlanFile;
|
|
355
|
+
try {
|
|
356
|
+
plan = JSON.parse(planEntry.data.toString("utf8")) as PlanFile;
|
|
357
|
+
} catch {
|
|
358
|
+
throw new TarRefused("plan.json doesn't parse");
|
|
359
|
+
}
|
|
360
|
+
if (plan.version !== 1 || typeof plan.store !== "string" || typeof plan.name !== "string")
|
|
361
|
+
throw new TarRefused("plan.json isn't a plan");
|
|
362
|
+
const listed = new Map<string, string>([
|
|
363
|
+
...Object.entries(plan.modules ?? {}).map(([p, h]) => [`bundle/${p}`, h] as [string, string]),
|
|
364
|
+
...Object.entries(plan.migrations ?? {}).map(([p, h]) => [`migrations/${p}`, h] as [string, string]),
|
|
365
|
+
...Object.entries(plan.assets ?? {}).map(([p, h]) => [`assets/${p}`, h] as [string, string]),
|
|
366
|
+
]);
|
|
367
|
+
for (const p of listed.keys())
|
|
368
|
+
if (p.split("/").some((part) => part.startsWith(".") || part === "") || !/^(bundle|migrations|assets)\//.test(p))
|
|
369
|
+
throw new TarRefused(`${p} isn't a module, migration or asset a plan may name`);
|
|
370
|
+
for (const p of Object.keys(plan.migrations))
|
|
371
|
+
if (!/^\d{4}_[a-z0-9_]+\.sql$/.test(p)) throw new TarRefused(`${p} isn't a migration's name`);
|
|
372
|
+
for (const e of entries) {
|
|
373
|
+
if (e.path === "plan.json") continue;
|
|
374
|
+
const want = listed.get(e.path);
|
|
375
|
+
if (want === undefined) throw new TarRefused(`${e.path} isn't in plan.json`);
|
|
376
|
+
if (sha256(e.data) !== want) throw new TarRefused(`${e.path} doesn't match its hash`);
|
|
377
|
+
}
|
|
378
|
+
for (const p of listed.keys()) if (!entries.some((e) => e.path === p)) throw new TarRefused(`${p} is missing`);
|
|
379
|
+
if (!plan.modules["index.js"]) throw new TarRefused("the bundle has no index.js");
|
|
380
|
+
const dir = mkdtempSync(join(tmpdir(), "kedge-plan-"));
|
|
381
|
+
for (const e of entries) {
|
|
382
|
+
const target = join(dir, e.path);
|
|
383
|
+
mkdirSync(join(target, ".."), { recursive: true });
|
|
384
|
+
writeFileSync(target, e.data, { flag: "wx" });
|
|
385
|
+
}
|
|
386
|
+
return { dir, plan };
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ---- sealed credentials (§5.1) ------------------------------------------------------------------------
|
|
390
|
+
|
|
391
|
+
const keyOf = (secret: string) => createHash("sha256").update(secret).digest();
|
|
392
|
+
export function seal(secret: string, value: unknown): string {
|
|
393
|
+
const iv = randomBytes(12);
|
|
394
|
+
const c = createCipheriv("aes-256-gcm", keyOf(secret), iv);
|
|
395
|
+
const body = Buffer.concat([c.update(JSON.stringify(value), "utf8"), c.final()]);
|
|
396
|
+
return Buffer.concat([iv, body, c.getAuthTag()]).toString("base64url");
|
|
397
|
+
}
|
|
398
|
+
export function unseal<T>(secret: string, sealed: string): T {
|
|
399
|
+
const b = Buffer.from(sealed, "base64url");
|
|
400
|
+
const d = createDecipheriv("aes-256-gcm", keyOf(secret), b.subarray(0, 12));
|
|
401
|
+
d.setAuthTag(b.subarray(b.length - 16));
|
|
402
|
+
return JSON.parse(Buffer.concat([d.update(b.subarray(12, b.length - 16)), d.final()]).toString("utf8")) as T;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// ---- apply (§5.3) ---------------------------------------------------------------------------------------
|
|
406
|
+
|
|
407
|
+
interface PreviewRow {
|
|
408
|
+
identity: string;
|
|
409
|
+
nonce: string;
|
|
410
|
+
worker: string;
|
|
411
|
+
database_name: string;
|
|
412
|
+
database_id: string | null;
|
|
413
|
+
queue_name: string;
|
|
414
|
+
queue_id: string | null;
|
|
415
|
+
classes: string;
|
|
416
|
+
consumer_id: string | null;
|
|
417
|
+
state: "creating" | "bootstrapped" | "destroying" | "destroyed";
|
|
418
|
+
credentials: string | null;
|
|
419
|
+
applied: string;
|
|
420
|
+
lost_create: number;
|
|
421
|
+
updated_at: number;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
export interface ApplyInput {
|
|
425
|
+
cf: Cloudflare;
|
|
426
|
+
/** The plan's tar stream. */
|
|
427
|
+
stream: Buffer;
|
|
428
|
+
/** The store whose ops database records this preview; the plan's own name unless the caller says. */
|
|
429
|
+
store?: string;
|
|
430
|
+
/** The preview's name; the plan's unless the caller says. */
|
|
431
|
+
name?: string;
|
|
432
|
+
/** Secrets the caller grants from its own environment (--secret-env). */
|
|
433
|
+
secretEnv?: readonly string[];
|
|
434
|
+
/** The person's choice, when this preview is the store's first resource (p0.10 §7). */
|
|
435
|
+
jurisdiction?: JurisdictionChoice;
|
|
436
|
+
env: NodeJS.ProcessEnv;
|
|
437
|
+
fresh?: boolean;
|
|
438
|
+
takeOver?: string;
|
|
439
|
+
confirmStopped?: boolean;
|
|
440
|
+
/** HTTP for the bootstrap and seeding; tests replace it. */
|
|
441
|
+
fetch?: typeof fetch;
|
|
442
|
+
log?: (s: string) => void;
|
|
443
|
+
now?: () => number;
|
|
444
|
+
/** How long to wait for a new Worker's workers.dev route. */
|
|
445
|
+
readyMs?: number;
|
|
446
|
+
/** Waits (a TOTP step, a route coming up); tests make it instant. */
|
|
447
|
+
wait?: (ms: number) => Promise<void>;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
export interface Applied {
|
|
451
|
+
url: string;
|
|
452
|
+
credentials: StoreCredentials;
|
|
453
|
+
databaseId: string;
|
|
454
|
+
worker: string;
|
|
455
|
+
/** The operation still held; the caller releases it after its scenarios (§5.3). */
|
|
456
|
+
release: (outcome: string) => Promise<void>;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const opsNames = (store: string): StoreNames => ({
|
|
460
|
+
worker: store,
|
|
461
|
+
database: `${store}-db`,
|
|
462
|
+
databaseId: "",
|
|
463
|
+
queue: `${store}-events`,
|
|
464
|
+
ops: `${store}-ops`,
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
export async function applyPlan(input: ApplyInput): Promise<Applied> {
|
|
468
|
+
const log = input.log ?? console.log;
|
|
469
|
+
const { dir: staging, plan } = stagePlan(input.stream);
|
|
470
|
+
const store = input.store ?? plan.store;
|
|
471
|
+
const name = input.name ?? plan.name;
|
|
472
|
+
// A preview before the first deploy makes <store>-ops, so it fixes the store's jurisdiction; it takes its store's.
|
|
473
|
+
const { ops, jurisdiction } = await openOps(input.cf, opsNames(store), input.now, input.jurisdiction ?? "ask");
|
|
474
|
+
const scope = `preview:${store}/${name}`;
|
|
475
|
+
const key = input.env.KEDGE_PREVIEW_KEY;
|
|
476
|
+
if (!key) throw new DeployRefused("set KEDGE_PREVIEW_KEY: a preview's credentials are sealed under it");
|
|
477
|
+
let opId: string;
|
|
478
|
+
const held = await ops.current(scope);
|
|
479
|
+
if (held) {
|
|
480
|
+
if (input.takeOver !== held.op_id || (held.executor && !input.confirmStopped)) throw new OperationHeld(held);
|
|
481
|
+
opId = (await ops.takeOver(scope, held.op_id, held.executor)).op_id;
|
|
482
|
+
} else opId = await ops.start(scope, "preview", { modules: plan.modules, migrations: plan.migrations }, "start");
|
|
483
|
+
const release = (outcome: string) => ops.finish(scope, opId, outcome, [{ modules: plan.modules }]);
|
|
484
|
+
try {
|
|
485
|
+
const p = new PreviewRun(
|
|
486
|
+
{ ...input, jurisdiction },
|
|
487
|
+
ops,
|
|
488
|
+
scope,
|
|
489
|
+
opId,
|
|
490
|
+
staging,
|
|
491
|
+
plan,
|
|
492
|
+
`${store}/${name}`,
|
|
493
|
+
store,
|
|
494
|
+
name,
|
|
495
|
+
key,
|
|
496
|
+
log,
|
|
497
|
+
);
|
|
498
|
+
const out = await p.run();
|
|
499
|
+
return { ...out, release };
|
|
500
|
+
} catch (e) {
|
|
501
|
+
if (e instanceof LostOwnership) throw e;
|
|
502
|
+
await ops.finish(scope, opId, `failed: ${(e as Error).name}`, []).catch(() => {});
|
|
503
|
+
throw e;
|
|
504
|
+
} finally {
|
|
505
|
+
rmSync(staging, { recursive: true, force: true });
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
class PreviewRun {
|
|
510
|
+
constructor(
|
|
511
|
+
private readonly input: ApplyInput,
|
|
512
|
+
private readonly ops: OpsStore,
|
|
513
|
+
private readonly scope: string,
|
|
514
|
+
private readonly opId: string,
|
|
515
|
+
private readonly staging: string,
|
|
516
|
+
private readonly plan: PlanFile,
|
|
517
|
+
private readonly identity: string,
|
|
518
|
+
private readonly store: string,
|
|
519
|
+
private readonly name: string,
|
|
520
|
+
private readonly key: string,
|
|
521
|
+
private readonly log: (s: string) => void,
|
|
522
|
+
) {}
|
|
523
|
+
|
|
524
|
+
private get cf() {
|
|
525
|
+
return this.input.cf;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
private async row(): Promise<PreviewRow | null> {
|
|
529
|
+
return (
|
|
530
|
+
((await this.ops.query("SELECT * FROM preview WHERE identity = ?", [this.identity]))[0] as
|
|
531
|
+
| PreviewRow
|
|
532
|
+
| undefined) ?? null
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/** A row write, conditional on this process still holding the preview's operation. */
|
|
537
|
+
private async write(set: string, params: unknown[]): Promise<void> {
|
|
538
|
+
const ok = await this.ops.query(
|
|
539
|
+
`UPDATE preview SET ${set}, updated_at = ? WHERE identity = ?
|
|
540
|
+
AND EXISTS (SELECT 1 FROM operation WHERE scope = ? AND op_id = ? AND executor = ?) RETURNING identity`,
|
|
541
|
+
[...params, (this.input.now ?? Date.now)(), this.identity, this.scope, this.opId, this.ops.executor],
|
|
542
|
+
);
|
|
543
|
+
if (ok.length !== 1) throw new LostOwnership(this.scope, set);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
private get call(): Recorder {
|
|
547
|
+
return recorder(this.ops, this.scope, this.opId);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
async run(): Promise<Omit<Applied, "release">> {
|
|
551
|
+
let row = await this.row();
|
|
552
|
+
const classes =
|
|
553
|
+
(previewConfig(this.plan.config, this.dummy()).migrations as { new_sqlite_classes: string[] }[])[0]
|
|
554
|
+
?.new_sqlite_classes ?? [];
|
|
555
|
+
// A call on this generation that may still land: it's retired, not reused (codex code r7).
|
|
556
|
+
const uncertain = row
|
|
557
|
+
? (await this.ops.intents(this.scope)).filter(
|
|
558
|
+
(i) => i.tag === row?.worker && (i.state === "open" || i.state === "unknown"),
|
|
559
|
+
)
|
|
560
|
+
: [];
|
|
561
|
+
if (row && row.state !== "destroyed" && uncertain.length) await this.write("lost_create = 1", []);
|
|
562
|
+
if (row && row.state !== "destroyed") {
|
|
563
|
+
const applied = JSON.parse(row.applied) as Record<string, string>;
|
|
564
|
+
const replaced = Object.entries(applied)
|
|
565
|
+
.filter(([f, h]) => this.plan.migrations[f] !== h)
|
|
566
|
+
.map(([f]) => f);
|
|
567
|
+
// Wrangler keeps any secret a deploy doesn't name, so a grant dropped from a plan would stay on the
|
|
568
|
+
// Worker: a preview holding a secret this apply doesn't grant is made again instead (codex code r18).
|
|
569
|
+
const granted = new Set([...CORE_SECRETS, ...(this.input.secretEnv ?? [])]);
|
|
570
|
+
const ungranted =
|
|
571
|
+
row.state === "bootstrapped" ? (await this.cf.secretNames(row.worker)).filter((n) => !granted.has(n)) : [];
|
|
572
|
+
const reasons = [
|
|
573
|
+
...(this.input.fresh ? ["--fresh"] : []),
|
|
574
|
+
...(ungranted.length ? [`it holds secrets this apply doesn't grant (${ungranted.join(", ")})`] : []),
|
|
575
|
+
...(uncertain.length ? [`a call on it may still land (${uncertain.map((i) => i.kind).join(", ")})`] : []),
|
|
576
|
+
...(row.state !== "bootstrapped" ? [`it was left ${row.state}`] : []),
|
|
577
|
+
...(row.state === "bootstrapped" && JSON.stringify(JSON.parse(row.classes)) !== JSON.stringify(classes)
|
|
578
|
+
? ["its Durable Object classes changed"]
|
|
579
|
+
: []),
|
|
580
|
+
...(replaced.length ? [`applied migrations changed or vanished (${replaced.join(", ")})`] : []),
|
|
581
|
+
];
|
|
582
|
+
if (reasons.length) {
|
|
583
|
+
// A bootstrapped preview is only recreated when asked; anything less finished holds nothing of value.
|
|
584
|
+
if (row.state === "bootstrapped" && !this.input.fresh && !uncertain.length)
|
|
585
|
+
throw new DeployRefused(
|
|
586
|
+
`this preview can't be updated in place: ${reasons.join("; ")}. Run with --fresh to recreate it`,
|
|
587
|
+
);
|
|
588
|
+
this.log(`recreating ${this.identity}: ${reasons.join("; ")}`);
|
|
589
|
+
await destroyRow(this.cf, row, this.log, (s, p) => this.write(s, p), this.call);
|
|
590
|
+
row = await this.row();
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
if (!row || row.state === "destroyed") {
|
|
594
|
+
const nonce = randomBytes(4).toString("hex");
|
|
595
|
+
const n = previewNames(this.store, this.name, nonce);
|
|
596
|
+
const now = (this.input.now ?? Date.now)();
|
|
597
|
+
// The previous generation's names outlive the row's reuse while a late create could still land.
|
|
598
|
+
if (row?.lost_create)
|
|
599
|
+
await this.ops.query(
|
|
600
|
+
`INSERT INTO preview_tombstone (worker, identity, database_name, queue_name, classes, destroyed_at)
|
|
601
|
+
VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT (worker) DO NOTHING`,
|
|
602
|
+
[row.worker, row.identity, row.database_name, row.queue_name, row.classes, row.updated_at],
|
|
603
|
+
);
|
|
604
|
+
// Written before anything is created: the nonce is what makes a name ours (§5.1).
|
|
605
|
+
await this.ops.query(
|
|
606
|
+
`INSERT INTO preview (identity, nonce, worker, database_name, queue_name, state, created_at, updated_at)
|
|
607
|
+
VALUES (?, ?, ?, ?, ?, 'creating', ?, ?)
|
|
608
|
+
ON CONFLICT (identity) DO UPDATE SET nonce = excluded.nonce, worker = excluded.worker, database_name = excluded.database_name,
|
|
609
|
+
queue_name = excluded.queue_name, state = 'creating', database_id = NULL, queue_id = NULL, consumer_id = NULL,
|
|
610
|
+
credentials = NULL, applied = '{}', classes = '[]', lost_create = 0, updated_at = excluded.updated_at`,
|
|
611
|
+
[this.identity, nonce, n.worker, n.database, n.queue, now, now],
|
|
612
|
+
);
|
|
613
|
+
row = (await this.row()) as PreviewRow;
|
|
614
|
+
}
|
|
615
|
+
// Resources: created by their recorded names; a lost answer is found by that exact name.
|
|
616
|
+
const create = async (kind: "d1" | "queue", nameOf: string, have: string | null) => {
|
|
617
|
+
if (have) return have;
|
|
618
|
+
const find = () => (kind === "d1" ? this.cf.findD1(nameOf) : this.cf.findQueue(nameOf));
|
|
619
|
+
const found = await find();
|
|
620
|
+
if (found) return found;
|
|
621
|
+
const id = await this.ops.openIntent(this.scope, this.opId, "create", kind, row.worker, { name: nameOf });
|
|
622
|
+
try {
|
|
623
|
+
const j = this.input.jurisdiction ?? "none";
|
|
624
|
+
const out = await (kind === "d1"
|
|
625
|
+
? this.cf.createD1(nameOf, j === "none" ? null : j)
|
|
626
|
+
: this.cf.createQueue(nameOf));
|
|
627
|
+
await this.ops.closeIntent(id, "done", "answer");
|
|
628
|
+
return out;
|
|
629
|
+
} catch (e) {
|
|
630
|
+
if (!(e instanceof UnknownOutcome)) {
|
|
631
|
+
await this.ops.closeIntent(id, "failed", "answer");
|
|
632
|
+
throw e;
|
|
633
|
+
}
|
|
634
|
+
await this.write("lost_create = 1", []);
|
|
635
|
+
const again = await find();
|
|
636
|
+
await this.ops.closeIntent(id, again ? "done" : "unknown", again ? "an exact-name lookup found it" : "answer");
|
|
637
|
+
if (!again) throw e;
|
|
638
|
+
return again;
|
|
639
|
+
}
|
|
640
|
+
};
|
|
641
|
+
const databaseId = await create("d1", row.database_name, row.database_id);
|
|
642
|
+
// Recorded, found or made, the preview's database is accepted only where its store's data lives (p0.10 §7):
|
|
643
|
+
// one replaced outside Kedge would take the preview's writes elsewhere (codex code r13).
|
|
644
|
+
const where = await this.cf.d1Jurisdiction(databaseId);
|
|
645
|
+
if (where !== (this.input.jurisdiction ?? "none"))
|
|
646
|
+
throw new DeployRefused(
|
|
647
|
+
`${row.database_name} is in jurisdiction ${where}, and this store's is ${this.input.jurisdiction ?? "none"}`,
|
|
648
|
+
);
|
|
649
|
+
await this.write("database_id = ?", [databaseId]);
|
|
650
|
+
const queueId = await create("queue", row.queue_name, row.queue_id);
|
|
651
|
+
await this.write("queue_id = ?", [queueId]);
|
|
652
|
+
|
|
653
|
+
// The config, generated here from the validated plan: every path inside staging.
|
|
654
|
+
const config = previewConfig(this.plan.config, {
|
|
655
|
+
worker: row.worker,
|
|
656
|
+
main: join(this.staging, "bundle", "index.js"),
|
|
657
|
+
database: row.database_name,
|
|
658
|
+
databaseId,
|
|
659
|
+
migrationsDir: join(this.staging, "migrations"),
|
|
660
|
+
queue: row.queue_name,
|
|
661
|
+
// The store's jurisdiction, never the plan's: a preview's objects live where its store's do (p0.10 §7).
|
|
662
|
+
vars: { ...this.plan.vars, KEDGE_JURISDICTION: this.input.jurisdiction ?? "none" },
|
|
663
|
+
...(this.plan.assets ? { assetsDir: join(this.staging, "assets") } : {}),
|
|
664
|
+
});
|
|
665
|
+
const configFile = join(this.staging, "..", `${row.worker}.wrangler.json`);
|
|
666
|
+
writeFileSync(configFile, JSON.stringify(config));
|
|
667
|
+
try {
|
|
668
|
+
try {
|
|
669
|
+
await this.call(row.worker, "migrate", "migrate", () =>
|
|
670
|
+
this.cf.applyMigrations({ config: configFile, database: row.database_name }),
|
|
671
|
+
);
|
|
672
|
+
} catch (e) {
|
|
673
|
+
// Some files may have committed and others not, which the applied registry can't say:
|
|
674
|
+
// the generation is retired rather than reasoned about (codex code r7).
|
|
675
|
+
await this.write("state = 'destroying'", []);
|
|
676
|
+
throw e;
|
|
677
|
+
}
|
|
678
|
+
await this.write("applied = ?", [JSON.stringify(this.plan.migrations)]);
|
|
679
|
+
const first = row.state === "creating";
|
|
680
|
+
// Core's secrets are generated once, on creation; a granted secret is attached on every apply, so a
|
|
681
|
+
// new grant or a rotated sandbox key reaches an existing preview (codex code r13). Wrangler keeps any
|
|
682
|
+
// secret the file doesn't name.
|
|
683
|
+
const values: Record<string, string> = first
|
|
684
|
+
? Object.fromEntries(CORE_SECRETS.map((s) => [s, randomBytes(32).toString("base64url")]))
|
|
685
|
+
: {};
|
|
686
|
+
for (const s of this.input.secretEnv ?? []) {
|
|
687
|
+
if (NEVER_COPIED.test(s)) throw new DeployRefused(`${s} is never copied into a preview`);
|
|
688
|
+
if (!this.plan.secrets.includes(s))
|
|
689
|
+
throw new DeployRefused(`${s} isn't a secret the store's config asks a preview for`);
|
|
690
|
+
const v = this.input.env[s];
|
|
691
|
+
if (!v) throw new DeployRefused(`--secret-env ${s}, but the environment doesn't hold it`);
|
|
692
|
+
values[s] = v;
|
|
693
|
+
}
|
|
694
|
+
if (!first) {
|
|
695
|
+
const have = new Set(await this.cf.secretNames(row.worker));
|
|
696
|
+
const missing = CORE_SECRETS.filter((s) => !have.has(s));
|
|
697
|
+
if (missing.length)
|
|
698
|
+
throw new DeployRefused(
|
|
699
|
+
`the preview lacks ${missing.join(", ")}: a replacement would strand what's stored; run with --fresh`,
|
|
700
|
+
);
|
|
701
|
+
}
|
|
702
|
+
let secretsFile: string | undefined;
|
|
703
|
+
if (Object.keys(values).length) {
|
|
704
|
+
secretsFile = join(this.staging, "..", `${row.worker}.secrets.json`);
|
|
705
|
+
writeFileSync(secretsFile, JSON.stringify(values), { mode: 0o600 });
|
|
706
|
+
chmodSync(secretsFile, 0o600);
|
|
707
|
+
}
|
|
708
|
+
// What this deploy may create is recorded first, so a teardown deletes it even if the answer is lost.
|
|
709
|
+
await this.write("classes = ?", [JSON.stringify(classes)]);
|
|
710
|
+
try {
|
|
711
|
+
await this.call(row.worker, "deploy", "deploy", () =>
|
|
712
|
+
this.cf.deployWithClasses({
|
|
713
|
+
config: configFile,
|
|
714
|
+
message: `kedge preview ${this.identity}`,
|
|
715
|
+
...(secretsFile ? { secretsFile } : {}),
|
|
716
|
+
}),
|
|
717
|
+
);
|
|
718
|
+
} catch (e) {
|
|
719
|
+
// It may still land, on this generation's Worker. So the generation is retired: the next apply
|
|
720
|
+
// recreates under a new nonce, where a late landing can't replace it, and the sweep removes
|
|
721
|
+
// what lands on the old name (codex code r4).
|
|
722
|
+
if (e instanceof UnknownOutcome) await this.write("state = 'destroying', lost_create = 1", []);
|
|
723
|
+
throw e;
|
|
724
|
+
} finally {
|
|
725
|
+
if (secretsFile) rmSync(secretsFile, { force: true });
|
|
726
|
+
}
|
|
727
|
+
} finally {
|
|
728
|
+
rmSync(configFile, { force: true });
|
|
729
|
+
}
|
|
730
|
+
const consumer = (await this.cf.consumers(queueId)).find((c) => c.script === row.worker);
|
|
731
|
+
if (consumer) await this.write("consumer_id = ?", [consumer.id]);
|
|
732
|
+
const url = `https://${row.worker}.${await this.cf.workersSubdomain()}.workers.dev`;
|
|
733
|
+
|
|
734
|
+
let credentials: StoreCredentials;
|
|
735
|
+
if (row.state === "bootstrapped" && row.credentials)
|
|
736
|
+
credentials = unseal<StoreCredentials>(this.key, row.credentials);
|
|
737
|
+
else credentials = await this.bootstrap(url, row, databaseId);
|
|
738
|
+
return { url, credentials: { ...credentials, url, databaseId }, databaseId, worker: row.worker };
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
private dummy(): PreviewWorker {
|
|
742
|
+
return { worker: "x", main: "x", database: "x", databaseId: "x", migrationsDir: "x", queue: "x" };
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/** All or nothing (§5.3): the Owner, the keys, the sample data, then the sealed credentials and the state, last. */
|
|
746
|
+
private async bootstrap(url: string, row: PreviewRow, databaseId: string): Promise<StoreCredentials> {
|
|
747
|
+
const f = this.input.fetch ?? fetch;
|
|
748
|
+
const ready = Date.now() + (this.input.readyMs ?? 120_000);
|
|
749
|
+
for (;;) {
|
|
750
|
+
const res = await f(new URL("/health", url)).catch(() => null);
|
|
751
|
+
if (res?.status === 200) break;
|
|
752
|
+
// A preview whose port proves no test mode serves nothing (§5.2): say which, rather than time out.
|
|
753
|
+
const body =
|
|
754
|
+
res?.status === 503
|
|
755
|
+
? ((await res.json().catch(() => null)) as {
|
|
756
|
+
error?: { code?: string; details?: { port?: string; mode?: string } };
|
|
757
|
+
} | null)
|
|
758
|
+
: null;
|
|
759
|
+
if (body?.error?.code === "PREVIEW_LIVE_CREDENTIALS")
|
|
760
|
+
throw new DeployRefused(
|
|
761
|
+
`the preview serves nothing: port ${body.error.details?.port ?? "?"} reports ${body.error.details?.mode ?? "?"} credentials, and a preview runs only with test ones`,
|
|
762
|
+
);
|
|
763
|
+
if (Date.now() > ready) throw new DeployRefused(`${url} didn't answer /health`);
|
|
764
|
+
await (this.input.wait ?? ((ms: number) => new Promise((r) => setTimeout(r, ms))))(3_000);
|
|
765
|
+
}
|
|
766
|
+
const email = `owner@${row.worker}.preview.invalid`;
|
|
767
|
+
const invite = await ownerInviteSql(email, Date.now());
|
|
768
|
+
await this.cf.executeD1File({ database: row.database_name, databaseId, sql: invite.sql });
|
|
769
|
+
const { owner, session } = await enrolOwner(url, invite.token, email, PREVIEW_ORIGIN, f, this.input.wait);
|
|
770
|
+
const bearer = { authorization: `Bearer ${session}`, "content-type": "application/json" };
|
|
771
|
+
const post = async (path: string, body: unknown) => {
|
|
772
|
+
const r = await f(new URL(path, url), {
|
|
773
|
+
method: "POST",
|
|
774
|
+
headers: { ...bearer, "idempotency-key": `kedge-preview-${randomBytes(8).toString("hex")}` },
|
|
775
|
+
body: JSON.stringify(body),
|
|
776
|
+
});
|
|
777
|
+
const j = (await r.json()) as Record<string, unknown>;
|
|
778
|
+
if (!r.ok) throw new DeployRefused(`bootstrap: ${path} answered ${r.status} ${JSON.stringify(j.error ?? j)}`);
|
|
779
|
+
return j;
|
|
780
|
+
};
|
|
781
|
+
// The preview origin, so a scenario can sign a customer in: that ceremony needs an allowed Origin.
|
|
782
|
+
const sf = await post("/admin/storefronts", { name: "Preview", allowed_origins: [PREVIEW_ORIGIN] });
|
|
783
|
+
const pk = await post(`/admin/storefronts/${String(sf.id)}/keys`, { name: "Preview" });
|
|
784
|
+
const sk = await post("/admin/api-keys", {
|
|
785
|
+
name: "Preview scenarios",
|
|
786
|
+
grants: PREVIEW_GRANTS.map((permission) => ({ permission, row_scope: "all" })),
|
|
787
|
+
});
|
|
788
|
+
await seed(url, { authorization: `Bearer ${String(sk.key)}` }, f, this.log);
|
|
789
|
+
const credentials: StoreCredentials = {
|
|
790
|
+
url,
|
|
791
|
+
publishableKey: String(pk.key),
|
|
792
|
+
secretKey: String(sk.key),
|
|
793
|
+
owner,
|
|
794
|
+
adminOrigin: PREVIEW_ORIGIN,
|
|
795
|
+
storefrontOrigin: PREVIEW_ORIGIN,
|
|
796
|
+
databaseId,
|
|
797
|
+
};
|
|
798
|
+
await this.write("credentials = ?, state = 'bootstrapped'", [seal(this.key, credentials)]);
|
|
799
|
+
this.log(`bootstrapped ${this.identity}: an Owner, a storefront key, a secret key and sample products`);
|
|
800
|
+
return credentials;
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/** A mutating call on a generation, as an intent recorded before it's sent and closed by its answer. */
|
|
805
|
+
type Recorder = <T>(worker: string, step: string, kind: string, fn: () => Promise<T>) => Promise<T>;
|
|
806
|
+
|
|
807
|
+
/**
|
|
808
|
+
* Every mutating preview call goes through this, like a production deploy's (§3.1): tagged with the
|
|
809
|
+
* generation's Worker name, so a generation left with an open or unknown call is never reused and is
|
|
810
|
+
* re-checked by the sweep, whatever killed the run (codex code r7, r14).
|
|
811
|
+
*/
|
|
812
|
+
function recorder(ops: OpsStore, scope: string, opId: string): Recorder {
|
|
813
|
+
return async (worker, step, kind, fn) => {
|
|
814
|
+
const id = await ops.openIntent(scope, opId, step, kind, worker, {});
|
|
815
|
+
try {
|
|
816
|
+
const out = await fn();
|
|
817
|
+
await ops.closeIntent(id, "done", "answer");
|
|
818
|
+
return out;
|
|
819
|
+
} catch (e) {
|
|
820
|
+
await ops.closeIntent(id, e instanceof UnknownOutcome ? "unknown" : "failed", "answer");
|
|
821
|
+
throw e;
|
|
822
|
+
}
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/** One generation's resources, by recorded id or exact name: what teardown and the sweep remove. */
|
|
827
|
+
interface Generation {
|
|
828
|
+
identity: string;
|
|
829
|
+
worker: string;
|
|
830
|
+
database_name: string;
|
|
831
|
+
queue_name: string;
|
|
832
|
+
database_id?: string | null;
|
|
833
|
+
queue_id?: string | null;
|
|
834
|
+
classes: string;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/**
|
|
838
|
+
* Removes one generation in the order Cloudflare needs, and returns what it found: its Durable
|
|
839
|
+
* Object classes deleted (which removes their storage), its consumer detached (a Worker that
|
|
840
|
+
* consumes a queue can't be deleted, 10064), then the Worker, the queue and the database. The
|
|
841
|
+
* sweep uses it too, since a late deploy lands with its bindings (codex code r6).
|
|
842
|
+
*/
|
|
843
|
+
async function removeGeneration(
|
|
844
|
+
cf: Cloudflare,
|
|
845
|
+
g: Generation,
|
|
846
|
+
call: Recorder = (_w, _s, _k, fn) => fn(),
|
|
847
|
+
): Promise<string[]> {
|
|
848
|
+
const removed: string[] = [];
|
|
849
|
+
const classes = JSON.parse(g.classes) as string[];
|
|
850
|
+
if (classes.length && (await cf.findWorker(g.worker))) {
|
|
851
|
+
const dir = mkdtempSync(join(tmpdir(), "kedge-stub-"));
|
|
852
|
+
try {
|
|
853
|
+
// Cloudflare refuses a queue consumer's Worker without queue and scheduled handlers (code 11001).
|
|
854
|
+
writeFileSync(
|
|
855
|
+
join(dir, "stub.js"),
|
|
856
|
+
"export default { fetch() { return new Response('removed', { status: 410 }); }, queue() {}, scheduled() {} };\n",
|
|
857
|
+
);
|
|
858
|
+
writeFileSync(
|
|
859
|
+
join(dir, "wrangler.json"),
|
|
860
|
+
JSON.stringify({
|
|
861
|
+
name: g.worker,
|
|
862
|
+
main: join(dir, "stub.js"),
|
|
863
|
+
compatibility_date: "2026-10-01",
|
|
864
|
+
migrations: [
|
|
865
|
+
{ tag: "v1", new_sqlite_classes: classes },
|
|
866
|
+
{ tag: "v2", deleted_classes: classes },
|
|
867
|
+
],
|
|
868
|
+
}),
|
|
869
|
+
);
|
|
870
|
+
// A stub that loses its answer could land after teardown and bring the Worker back: recorded (codex code r14).
|
|
871
|
+
await call(g.worker, "destroy", "deploy", () =>
|
|
872
|
+
cf.deployWithClasses({ config: join(dir, "wrangler.json"), message: `kedge preview destroy ${g.identity}` }),
|
|
873
|
+
);
|
|
874
|
+
} finally {
|
|
875
|
+
rmSync(dir, { recursive: true, force: true });
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
// Only a queue still listed: a recorded one a lost answer already deleted would fail its consumer
|
|
879
|
+
// listing on every retry, and the teardown would never reach the database (codex code r27).
|
|
880
|
+
const live = await cf.findQueue(g.queue_name);
|
|
881
|
+
const queue = g.queue_id && g.queue_id !== live ? null : live;
|
|
882
|
+
if (queue)
|
|
883
|
+
for (const c of (await cf.consumers(queue)).filter((c) => c.script === g.worker))
|
|
884
|
+
await cf.deleteConsumer(queue, c.id);
|
|
885
|
+
if (await cf.findWorker(g.worker)) {
|
|
886
|
+
await cf.deleteWorker(g.worker);
|
|
887
|
+
removed.push(`Worker ${g.worker}`);
|
|
888
|
+
}
|
|
889
|
+
if (queue) {
|
|
890
|
+
await cf.deleteQueue(queue);
|
|
891
|
+
removed.push(`queue ${g.queue_name}`);
|
|
892
|
+
}
|
|
893
|
+
const db = g.database_id ?? (await cf.findD1(g.database_name));
|
|
894
|
+
if (db) {
|
|
895
|
+
await cf.deleteD1(db);
|
|
896
|
+
removed.push(`database ${g.database_name}`);
|
|
897
|
+
}
|
|
898
|
+
return removed;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
/**
|
|
902
|
+
* Tears a preview down by recorded ids, in the order Cloudflare needs: its
|
|
903
|
+
* Durable Object classes deleted (which removes their storage), its consumer
|
|
904
|
+
* detached, then the Worker, the queue and the database; each confirmed gone
|
|
905
|
+
* by name before the row says so (§5.5). The row stays, `destroyed`.
|
|
906
|
+
*/
|
|
907
|
+
export async function destroyRow(
|
|
908
|
+
cf: Cloudflare,
|
|
909
|
+
row: PreviewRow,
|
|
910
|
+
log: (s: string) => void,
|
|
911
|
+
write: (set: string, params: unknown[]) => Promise<void>,
|
|
912
|
+
call?: Recorder,
|
|
913
|
+
): Promise<void> {
|
|
914
|
+
await write("state = 'destroying'", []);
|
|
915
|
+
// The class list stays on the row: a tombstone written from it later is what lets the sweep delete
|
|
916
|
+
// the classes a late deploy makes again (codex code r11).
|
|
917
|
+
await removeGeneration(cf, row, call);
|
|
918
|
+
const left = [
|
|
919
|
+
...((await cf.findWorker(row.worker)) ? [`Worker ${row.worker}`] : []),
|
|
920
|
+
...((await cf.findQueue(row.queue_name)) ? [`queue ${row.queue_name}`] : []),
|
|
921
|
+
...((await cf.findD1(row.database_name)) ? [`database ${row.database_name}`] : []),
|
|
922
|
+
];
|
|
923
|
+
if (left.length)
|
|
924
|
+
throw new DeployRefused(`${row.identity} isn't gone yet: ${left.join(", ")}; run kedge preview destroy again`);
|
|
925
|
+
await write("state = 'destroyed', database_id = NULL, queue_id = NULL, consumer_id = NULL, credentials = NULL", []);
|
|
926
|
+
log(`destroyed ${row.identity}`);
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
export async function destroyPreview(input: {
|
|
930
|
+
cf: Cloudflare;
|
|
931
|
+
store: string;
|
|
932
|
+
name: string;
|
|
933
|
+
log?: (s: string) => void;
|
|
934
|
+
takeOver?: string;
|
|
935
|
+
confirmStopped?: boolean;
|
|
936
|
+
}): Promise<void> {
|
|
937
|
+
const log = input.log ?? console.log;
|
|
938
|
+
const { ops } = await openOps(input.cf, opsNames(input.store));
|
|
939
|
+
const identity = `${input.store}/${input.name}`;
|
|
940
|
+
const scope = `preview:${identity}`;
|
|
941
|
+
const held = await ops.current(scope);
|
|
942
|
+
let opId: string;
|
|
943
|
+
if (held) {
|
|
944
|
+
if (input.takeOver !== held.op_id || (held.executor && !input.confirmStopped)) throw new OperationHeld(held);
|
|
945
|
+
opId = (await ops.takeOver(scope, held.op_id, held.executor)).op_id;
|
|
946
|
+
} else opId = await ops.start(scope, "destroy", { identity }, "destroy");
|
|
947
|
+
const row = (await ops.query("SELECT * FROM preview WHERE identity = ?", [identity]))[0] as PreviewRow | undefined;
|
|
948
|
+
try {
|
|
949
|
+
// A call on it that may still land keeps its names re-checked by the sweep for 7 days.
|
|
950
|
+
if (
|
|
951
|
+
row &&
|
|
952
|
+
row.state !== "destroyed" &&
|
|
953
|
+
(await ops.intents(scope)).some((i) => i.tag === row.worker && (i.state === "open" || i.state === "unknown"))
|
|
954
|
+
) {
|
|
955
|
+
await ops.query("UPDATE preview SET lost_create = 1 WHERE identity = ?", [identity]);
|
|
956
|
+
row.lost_create = 1;
|
|
957
|
+
}
|
|
958
|
+
if (!row || row.state === "destroyed") log(`${identity} has nothing to destroy`);
|
|
959
|
+
else
|
|
960
|
+
await destroyRow(
|
|
961
|
+
input.cf,
|
|
962
|
+
row,
|
|
963
|
+
log,
|
|
964
|
+
async (set, params) => {
|
|
965
|
+
await ops.query(`UPDATE preview SET ${set}, updated_at = ? WHERE identity = ?`, [
|
|
966
|
+
...params,
|
|
967
|
+
Date.now(),
|
|
968
|
+
identity,
|
|
969
|
+
]);
|
|
970
|
+
},
|
|
971
|
+
recorder(ops, scope, opId),
|
|
972
|
+
);
|
|
973
|
+
await ops.finish(scope, opId, "destroyed", []);
|
|
974
|
+
} catch (e) {
|
|
975
|
+
await ops.stop(scope, opId).catch(() => {});
|
|
976
|
+
throw e;
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
/** `preview destroy --sweep`: what a late create left after a destroy, for tombstones of the last 7 days (§3.1). */
|
|
981
|
+
export async function sweepPreviews(input: {
|
|
982
|
+
cf: Cloudflare;
|
|
983
|
+
store: string;
|
|
984
|
+
log?: (s: string) => void;
|
|
985
|
+
now?: number;
|
|
986
|
+
}): Promise<string[]> {
|
|
987
|
+
const { ops } = await openOps(input.cf, opsNames(input.store));
|
|
988
|
+
const now = input.now ?? Date.now();
|
|
989
|
+
const swept: string[] = [];
|
|
990
|
+
for (const r of await ops.query(
|
|
991
|
+
`SELECT identity, worker, database_name, queue_name, classes FROM preview
|
|
992
|
+
WHERE state = 'destroyed' AND lost_create = 1 AND updated_at > ?1
|
|
993
|
+
UNION SELECT identity, worker, database_name, queue_name, classes FROM preview_tombstone WHERE destroyed_at > ?1`,
|
|
994
|
+
[now - 7 * 86_400_000],
|
|
995
|
+
))
|
|
996
|
+
swept.push(...(await removeGeneration(input.cf, r as unknown as Generation)));
|
|
997
|
+
for (const s of swept) (input.log ?? console.log)(`swept ${s}`);
|
|
998
|
+
return swept;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
/** Whether the store has HTTP scenarios, as deep as the config's `**` looks: one skipped would read as a pass (codex code r27). */
|
|
1002
|
+
export function hasScenarios(cwd: string): boolean {
|
|
1003
|
+
const dir = join(cwd, "test", "scenarios");
|
|
1004
|
+
return existsSync(dir) && readdirSync(dir, { recursive: true }).some((f) => String(f).endsWith(".http.ts"));
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
/** Runs the store's own HTTP scenarios against the preview, under the held operation (§5.3). */
|
|
1008
|
+
export function runScenarios(cwd: string, credentials: StoreCredentials, env: NodeJS.ProcessEnv): number {
|
|
1009
|
+
if (!hasScenarios(cwd)) return 0;
|
|
1010
|
+
const tmp = mkdtempSync(join(tmpdir(), "kedge-scn-"));
|
|
1011
|
+
try {
|
|
1012
|
+
const config = join(tmp, "vitest.scenarios.mjs");
|
|
1013
|
+
writeFileSync(
|
|
1014
|
+
config,
|
|
1015
|
+
`export default { test: { root: ${JSON.stringify(cwd)}, include: ["test/scenarios/**/*.http.ts"], environment: "node", testTimeout: 600000, hookTimeout: 600000 } };\n`,
|
|
1016
|
+
);
|
|
1017
|
+
const r = spawnSync(join(cwd, "node_modules", ".bin", "vitest"), ["run", "--config", config], {
|
|
1018
|
+
cwd,
|
|
1019
|
+
stdio: childStdio(),
|
|
1020
|
+
env: { ...env, KEDGE_PREVIEW_CREDENTIALS: JSON.stringify(credentials) },
|
|
1021
|
+
});
|
|
1022
|
+
return r.status ?? 1;
|
|
1023
|
+
} finally {
|
|
1024
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
/** The local cache of a preview's credentials, mode 0600, in its hashed directory (gitignored, §5.1). */
|
|
1029
|
+
export function cacheCredentials(cwd: string, identity: string, credentials: StoreCredentials): string {
|
|
1030
|
+
const dir = localDir(cwd, identity);
|
|
1031
|
+
mkdirSync(dir, { recursive: true });
|
|
1032
|
+
const file = join(dir, "credentials.json");
|
|
1033
|
+
writeFileSync(file, JSON.stringify({ identity, ...credentials }, null, 2), { mode: 0o600 });
|
|
1034
|
+
chmodSync(file, 0o600);
|
|
1035
|
+
return file;
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
export const copyTree = (from: string, to: string) => cpSync(from, to, { recursive: true });
|