@kedge/cli 0.9.0 → 0.10.1
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/README.md +28 -0
- package/kedge-manifest.json +93 -61
- package/package.json +8 -5
- package/src/cf.ts +295 -17
- package/src/check.ts +4 -1
- package/src/cli-session.ts +57 -0
- package/src/commands.ts +339 -90
- package/src/d1-batch.ts +68 -0
- package/src/deploy.ts +91 -19
- package/src/doctor.ts +68 -3
- package/src/keys-save.ts +343 -0
- package/src/local-state.ts +122 -0
- package/src/main.ts +181 -71
- package/src/ops.ts +18 -0
- package/src/output.ts +44 -0
- package/src/preview.ts +35 -6
- package/src/smoke.ts +226 -0
- package/src/store-api.ts +36 -0
- package/src/target.ts +81 -0
package/src/d1-batch.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// The CLI's own audited batches against a store's D1 (p0.5 §3.4, D-054):
|
|
2
|
+
// `staff`, direct `keys create`, and a CLI session's row (p0.10 §6.2), each
|
|
3
|
+
// one file through `wrangler d1 execute`, locally or on Cloudflare.
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import type { CliBatch } from "@kedge/db";
|
|
9
|
+
import { wranglerBin } from "./cf";
|
|
10
|
+
import { childFailed, childStdio } from "./output";
|
|
11
|
+
|
|
12
|
+
/** Runs one CLI batch against the store's D1; tests replace it. */
|
|
13
|
+
export type Executor = (
|
|
14
|
+
b: CliBatch,
|
|
15
|
+
opts: { database: string; remote: boolean; cwd: string; config?: string; env?: Record<string, string> },
|
|
16
|
+
) => number;
|
|
17
|
+
|
|
18
|
+
/** The bootstrap Wrangler's whole environment: PATH, an empty HOME, and what Kedge resolved. */
|
|
19
|
+
export function bootstrapEnv(
|
|
20
|
+
env: NodeJS.ProcessEnv,
|
|
21
|
+
home: string,
|
|
22
|
+
resolved: Record<string, string> = {},
|
|
23
|
+
): Record<string, string> {
|
|
24
|
+
return { PATH: env.PATH ?? "", HOME: home, WRANGLER_SEND_METRICS: "false", CI: "1", ...resolved };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const wranglerExecute: Executor = (b, opts) => {
|
|
28
|
+
const dir = mkdtempSync(join(tmpdir(), "kedge-"));
|
|
29
|
+
const file = join(dir, "batch.sql");
|
|
30
|
+
// An empty dotenv file of our own: Wrangler then loads none of the store's, whose stale
|
|
31
|
+
// CLOUDFLARE_* values could redirect the resolved credentials (codex code r4).
|
|
32
|
+
const envFile = join(dir, "empty.env");
|
|
33
|
+
try {
|
|
34
|
+
writeFileSync(file, b.sql, { mode: 0o600 });
|
|
35
|
+
writeFileSync(envFile, "");
|
|
36
|
+
// The CLI's own Wrangler with only what Kedge resolved: an inherited environment could carry a
|
|
37
|
+
// global API key, which Wrangler prefers to a token and would send instead (codex code r5),
|
|
38
|
+
// and an empty HOME holds no one's login. The store directory stays the working one, for its
|
|
39
|
+
// config and its local state.
|
|
40
|
+
const r = spawnSync(
|
|
41
|
+
process.execPath,
|
|
42
|
+
[
|
|
43
|
+
wranglerBin(),
|
|
44
|
+
"d1",
|
|
45
|
+
"execute",
|
|
46
|
+
opts.database,
|
|
47
|
+
"--file",
|
|
48
|
+
file,
|
|
49
|
+
opts.remote ? "--remote" : "--local",
|
|
50
|
+
"--yes",
|
|
51
|
+
"--env-file",
|
|
52
|
+
envFile,
|
|
53
|
+
...(opts.config ? ["-c", opts.config] : []),
|
|
54
|
+
],
|
|
55
|
+
{
|
|
56
|
+
cwd: opts.cwd,
|
|
57
|
+
stdio: childStdio(),
|
|
58
|
+
env: bootstrapEnv(process.env, dir, opts.env),
|
|
59
|
+
maxBuffer: 1 << 26,
|
|
60
|
+
},
|
|
61
|
+
);
|
|
62
|
+
childFailed(r);
|
|
63
|
+
return r.status ?? 1;
|
|
64
|
+
} finally {
|
|
65
|
+
// The file holds no secret (only digests), but it needn't outlive the run.
|
|
66
|
+
rmSync(dir, { recursive: true, force: true });
|
|
67
|
+
}
|
|
68
|
+
};
|
package/src/deploy.ts
CHANGED
|
@@ -11,7 +11,14 @@ import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFi
|
|
|
11
11
|
import { join, resolve } from "node:path";
|
|
12
12
|
import { maintenanceModule } from "@kedge/runtime-cf/maintenance";
|
|
13
13
|
import { DO_STATE_FORMAT } from "@kedge/runtime-cf/release";
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
CHOOSE_JURISDICTION,
|
|
16
|
+
type Cloudflare,
|
|
17
|
+
type JurisdictionChoice,
|
|
18
|
+
NeedsPerson,
|
|
19
|
+
UnknownOutcome,
|
|
20
|
+
type WorkerSettings,
|
|
21
|
+
} from "./cf";
|
|
15
22
|
import type { Lock, LockEntry } from "./compose";
|
|
16
23
|
import { declaredClasses } from "./doctor";
|
|
17
24
|
import type { Manifest } from "./manifest";
|
|
@@ -143,6 +150,8 @@ export interface DeployDeps {
|
|
|
143
150
|
}
|
|
144
151
|
|
|
145
152
|
export interface DeployOptions {
|
|
153
|
+
/** The person's choice of where a new store's data lives (p0.10 §7); read from the record after. */
|
|
154
|
+
jurisdiction?: JurisdictionChoice;
|
|
146
155
|
vars?: Record<string, string>;
|
|
147
156
|
window?: { from: number; to: number };
|
|
148
157
|
adopt?: string[];
|
|
@@ -161,16 +170,29 @@ export class DeployRefused extends Error {
|
|
|
161
170
|
|
|
162
171
|
const lockSha = (lock: Lock) => sha256(JSON.stringify(lock.entries.map((e) => [e.file, e.sha256])));
|
|
163
172
|
|
|
164
|
-
/**
|
|
173
|
+
/**
|
|
174
|
+
* The ops database: found or created by exact name, and refused unless it's
|
|
175
|
+
* this store's (§6.1). Creating it fixes the store's jurisdiction (p0.10 §7),
|
|
176
|
+
* so only a caller given the person's choice may create it: `ask` (a first
|
|
177
|
+
* deploy or preview without one) hands off for it, and a caller passing
|
|
178
|
+
* nothing never creates, refusing a store that was never deployed. Cloudflare's record is the authority: a database
|
|
179
|
+
* found, made by a run that stopped or by another asking for something else,
|
|
180
|
+
* is read back and refused unless it matches the choice, and the record is
|
|
181
|
+
* written from its own value.
|
|
182
|
+
*/
|
|
165
183
|
export async function openOps(
|
|
166
184
|
cf: Cloudflare,
|
|
167
185
|
names: StoreNames,
|
|
168
186
|
now = Date.now,
|
|
169
|
-
|
|
187
|
+
want?: JurisdictionChoice | "ask",
|
|
188
|
+
): Promise<{ ops: OpsStore; id: string; jurisdiction: JurisdictionChoice }> {
|
|
170
189
|
let id = await cf.findD1(names.ops);
|
|
171
190
|
if (!id) {
|
|
191
|
+
if (want === "ask") throw new NeedsPerson(CHOOSE_JURISDICTION);
|
|
192
|
+
if (want === undefined)
|
|
193
|
+
throw new DeployRefused(`no ${names.ops}: this store hasn't been deployed; run kedge deploy first`);
|
|
172
194
|
try {
|
|
173
|
-
id = await cf.createD1(names.ops);
|
|
195
|
+
id = await cf.createD1(names.ops, want === "none" ? null : want);
|
|
174
196
|
} catch (e) {
|
|
175
197
|
// Two first deploys contending here both end at the same database (§6.1). Whatever the
|
|
176
198
|
// error said, the name is what decides: found, it's the other deploy's; the meta check
|
|
@@ -185,9 +207,17 @@ export async function openOps(
|
|
|
185
207
|
).map((r) => String(r.name));
|
|
186
208
|
if (tables.length && !tables.includes("kedge_ops_meta"))
|
|
187
209
|
throw new DeployRefused(`${names.ops} exists and isn't a Kedge operations database: it holds ${tables.join(", ")}`);
|
|
210
|
+
const actual = await cf.d1Jurisdiction(dbId);
|
|
211
|
+
if (want !== undefined && want !== "ask" && want !== actual)
|
|
212
|
+
throw new DeployRefused(
|
|
213
|
+
`${names.ops} exists in jurisdiction ${actual}, not ${want}: a store's jurisdiction is fixed when its first database is made`,
|
|
214
|
+
);
|
|
188
215
|
const ops = new OpsStore((sql, params) => cf.queryD1(dbId, sql, params), now);
|
|
189
216
|
await ops.init(names.worker);
|
|
190
|
-
|
|
217
|
+
const recorded = await ops.settle("jurisdiction", actual);
|
|
218
|
+
if (recorded !== actual)
|
|
219
|
+
throw new DeployRefused(`${names.ops} records jurisdiction ${recorded}, but Cloudflare says ${actual}`);
|
|
220
|
+
return { ops, id: dbId, jurisdiction: actual };
|
|
191
221
|
}
|
|
192
222
|
|
|
193
223
|
/**
|
|
@@ -259,6 +289,8 @@ export class Deployer {
|
|
|
259
289
|
readonly names: StoreNames;
|
|
260
290
|
readonly wrangler: WranglerJson;
|
|
261
291
|
private ops!: OpsStore;
|
|
292
|
+
/** The store's jurisdiction, as `<store>-ops` records it (p0.10 §7). */
|
|
293
|
+
private jurisdiction: JurisdictionChoice = "none";
|
|
262
294
|
private opId!: string;
|
|
263
295
|
private plan!: Plan;
|
|
264
296
|
private progress: Progress = {};
|
|
@@ -317,6 +349,13 @@ export class Deployer {
|
|
|
317
349
|
"wrangler.jsonc declares routes: kedge deploy manages cron and settings in P0.8, not routes or custom domains; attach a domain in the dashboard until P0.10",
|
|
318
350
|
);
|
|
319
351
|
const vars = { ...(this.wrangler.vars ?? {}), ...(o.vars ?? {}) };
|
|
352
|
+
// Every Durable Object ID is minted in the store's jurisdiction from this (p0.10 §7): another value
|
|
353
|
+
// would reach empty objects under the same names, so it's the record's, never the config's.
|
|
354
|
+
if (vars.KEDGE_JURISDICTION !== undefined && vars.KEDGE_JURISDICTION !== this.jurisdiction)
|
|
355
|
+
throw new DeployRefused(
|
|
356
|
+
`KEDGE_JURISDICTION is ${vars.KEDGE_JURISDICTION} in the config or --var, but this store's jurisdiction is ${this.jurisdiction}`,
|
|
357
|
+
);
|
|
358
|
+
vars.KEDGE_JURISDICTION = this.jurisdiction;
|
|
320
359
|
const config: WranglerJson = { ...this.wrangler, vars };
|
|
321
360
|
delete config.build;
|
|
322
361
|
const dbs = (config.d1_databases ?? []).map((d) =>
|
|
@@ -398,9 +437,13 @@ export class Deployer {
|
|
|
398
437
|
create: () => Promise<string>,
|
|
399
438
|
): Promise<string> {
|
|
400
439
|
const rec = await this.ops.resource(kind, name);
|
|
440
|
+
// A recorded database is checked like a found one: the record is ours, the jurisdiction Cloudflare's (codex code r12).
|
|
441
|
+
if (rec?.id && kind === "d1") await this.sameJurisdiction(name, rec.id);
|
|
401
442
|
if (rec?.id) return rec.id;
|
|
402
443
|
const found = await find();
|
|
403
444
|
if (found) {
|
|
445
|
+
// A database found is accepted only in the store's jurisdiction: Cloudflare can't change it after (p0.10 §7).
|
|
446
|
+
if (kind === "d1") await this.sameJurisdiction(name, found);
|
|
404
447
|
if (rec?.state === "creating" || this.plan.adopt.includes(name)) {
|
|
405
448
|
await this.ops.recordResource(kind, name, found, "ours");
|
|
406
449
|
return found;
|
|
@@ -424,6 +467,12 @@ export class Deployer {
|
|
|
424
467
|
return id;
|
|
425
468
|
}
|
|
426
469
|
|
|
470
|
+
private async sameJurisdiction(name: string, id: string): Promise<void> {
|
|
471
|
+
const j = await this.d.cf.d1Jurisdiction(id);
|
|
472
|
+
if (j !== this.jurisdiction)
|
|
473
|
+
throw new DeployRefused(`${name} is in jurisdiction ${j}, and this store's is ${this.jurisdiction}`);
|
|
474
|
+
}
|
|
475
|
+
|
|
427
476
|
private async ownWorker(): Promise<boolean> {
|
|
428
477
|
const rec = await this.ops.resource("worker", this.names.worker);
|
|
429
478
|
const exists = await this.d.cf.findWorker(this.names.worker);
|
|
@@ -487,7 +536,8 @@ export class Deployer {
|
|
|
487
536
|
const v = this.d.env?.[s];
|
|
488
537
|
if (v !== undefined && v.length < 32) throw new DeployRefused(`${s} in the environment is under 32 bytes`);
|
|
489
538
|
}
|
|
490
|
-
const { ops } = await openOps(this.d.cf, this.names, this.now);
|
|
539
|
+
const { ops, jurisdiction } = await openOps(this.d.cf, this.names, this.now, o.jurisdiction ?? "ask");
|
|
540
|
+
this.jurisdiction = jurisdiction;
|
|
491
541
|
this.ops = ops;
|
|
492
542
|
const held = await ops.current(SCOPE);
|
|
493
543
|
if (o.resume || o.takeOver) {
|
|
@@ -552,8 +602,9 @@ export class Deployer {
|
|
|
552
602
|
* a resume, with its unanswered calls unknown.
|
|
553
603
|
*/
|
|
554
604
|
private async end(e: unknown): Promise<void> {
|
|
555
|
-
|
|
556
|
-
|
|
605
|
+
// A handoff is a refusal too: nothing ran that a resume would need (p0.10 §5).
|
|
606
|
+
const refused = e instanceof DeployRefused || e instanceof NeedsPerson;
|
|
607
|
+
const clean = refused && !this.progress.deploymentId && (await this.ops.unresolved(SCOPE)).length === 0;
|
|
557
608
|
if (clean) await this.ops.finish(SCOPE, this.opId, "refused", [this.plan]).catch(() => {});
|
|
558
609
|
else await this.ops.stop(SCOPE, this.opId).catch(() => {});
|
|
559
610
|
}
|
|
@@ -717,7 +768,7 @@ export class Deployer {
|
|
|
717
768
|
"d1",
|
|
718
769
|
this.names.database,
|
|
719
770
|
() => cf.findD1(this.names.database),
|
|
720
|
-
() => cf.createD1(this.names.database),
|
|
771
|
+
() => cf.createD1(this.names.database, this.jurisdiction === "none" ? null : this.jurisdiction),
|
|
721
772
|
);
|
|
722
773
|
p.queueId = await this.own(
|
|
723
774
|
"queue",
|
|
@@ -781,9 +832,10 @@ export class Deployer {
|
|
|
781
832
|
if (!p.deploymentId) this.compatible(servingRow);
|
|
782
833
|
if (p.mode === "cutover" && !p.first && !p.deploymentId) {
|
|
783
834
|
const w = this.plan.window;
|
|
835
|
+
// Downtime on a store that has served customers is the merchant's to schedule (p0.10 §5).
|
|
784
836
|
if (!w || this.now() < w.from || this.now() > w.to)
|
|
785
|
-
|
|
786
|
-
"
|
|
837
|
+
throw new NeedsPerson(
|
|
838
|
+
"this release changes the database in a way the running store can't serve through, so the store shows a maintenance page for a few minutes while it applies: choose when, then run kedge deploy --maintenance-window <from>/<to> during that window",
|
|
787
839
|
);
|
|
788
840
|
}
|
|
789
841
|
await this.step("classified");
|
|
@@ -1100,7 +1152,8 @@ export class Deployer {
|
|
|
1100
1152
|
*/
|
|
1101
1153
|
async supersede(opId: string): Promise<void> {
|
|
1102
1154
|
if ((await this.d.checkFast()) !== 0) throw new DeployRefused("kedge check --fast failed");
|
|
1103
|
-
const { ops } = await openOps(this.d.cf, this.names, this.now);
|
|
1155
|
+
const { ops, jurisdiction } = await openOps(this.d.cf, this.names, this.now);
|
|
1156
|
+
this.jurisdiction = jurisdiction;
|
|
1104
1157
|
this.ops = ops;
|
|
1105
1158
|
const held = await ops.current(SCOPE);
|
|
1106
1159
|
if (!held || held.op_id !== opId) throw new DeployRefused(`no operation ${opId} holds this store`);
|
|
@@ -1189,7 +1242,8 @@ export class Deployer {
|
|
|
1189
1242
|
throw new DeployRefused(
|
|
1190
1243
|
"--resolve-intent needs --not-executed: it's a statement that the call never took effect",
|
|
1191
1244
|
);
|
|
1192
|
-
const { ops } = await openOps(this.d.cf, this.names, this.now);
|
|
1245
|
+
const { ops, jurisdiction } = await openOps(this.d.cf, this.names, this.now);
|
|
1246
|
+
this.jurisdiction = jurisdiction;
|
|
1193
1247
|
const i = await ops.intent(id);
|
|
1194
1248
|
// Also a migration reconcile closed for its absence: absence permits a retry, only a person can say it never ran.
|
|
1195
1249
|
const restatable = i?.kind === "migrate" && i.state === "not_executed" && !i.resolved_by?.startsWith("stated by");
|
|
@@ -1205,7 +1259,8 @@ export class Deployer {
|
|
|
1205
1259
|
|
|
1206
1260
|
async recordActive(reason: string): Promise<void> {
|
|
1207
1261
|
if (!reason) throw new DeployRefused("--record-active needs --reason");
|
|
1208
|
-
const { ops } = await openOps(this.d.cf, this.names, this.now);
|
|
1262
|
+
const { ops, jurisdiction } = await openOps(this.d.cf, this.names, this.now);
|
|
1263
|
+
this.jurisdiction = jurisdiction;
|
|
1209
1264
|
this.ops = ops;
|
|
1210
1265
|
// The operation first, then the reads it records (codex code r12).
|
|
1211
1266
|
this.opId = await ops.start(SCOPE, "record-active", { reason }, "record");
|
|
@@ -1250,7 +1305,8 @@ export class Deployer {
|
|
|
1250
1305
|
}
|
|
1251
1306
|
|
|
1252
1307
|
async abortCutover(): Promise<void> {
|
|
1253
|
-
const { ops } = await openOps(this.d.cf, this.names, this.now);
|
|
1308
|
+
const { ops, jurisdiction } = await openOps(this.d.cf, this.names, this.now);
|
|
1309
|
+
this.jurisdiction = jurisdiction;
|
|
1254
1310
|
this.ops = ops;
|
|
1255
1311
|
const held = await ops.current(SCOPE);
|
|
1256
1312
|
if (!held) throw new DeployRefused("no operation to abort");
|
|
@@ -1303,7 +1359,8 @@ export class Deployer {
|
|
|
1303
1359
|
async secret(name: string, value: string | null): Promise<void> {
|
|
1304
1360
|
if (!/^[A-Z][A-Z0-9_]*$/.test(name))
|
|
1305
1361
|
throw new DeployRefused("a secret's name is upper-case letters, digits and underscores");
|
|
1306
|
-
const { ops } = await openOps(this.d.cf, this.names, this.now);
|
|
1362
|
+
const { ops, jurisdiction } = await openOps(this.d.cf, this.names, this.now);
|
|
1363
|
+
this.jurisdiction = jurisdiction;
|
|
1307
1364
|
this.ops = ops;
|
|
1308
1365
|
const rows = await ops.deployments();
|
|
1309
1366
|
this.plan = { commit: "secret", adopt: [], vars: {} } as unknown as Plan;
|
|
@@ -1410,7 +1467,11 @@ export class Deployer {
|
|
|
1410
1467
|
/** A fresh run, or (from `kedge deploy --resume`) the held one, which this checkout's lock must match. */
|
|
1411
1468
|
async migrateRemote(held?: OperationRow): Promise<void> {
|
|
1412
1469
|
// A resumed run keeps the executor that claimed it.
|
|
1413
|
-
if (!held)
|
|
1470
|
+
if (!held) {
|
|
1471
|
+
const opened = await openOps(this.d.cf, this.names, this.now);
|
|
1472
|
+
this.ops = opened.ops;
|
|
1473
|
+
this.jurisdiction = opened.jurisdiction;
|
|
1474
|
+
}
|
|
1414
1475
|
const ops = this.ops;
|
|
1415
1476
|
const db = await ops.resource("d1", this.names.database);
|
|
1416
1477
|
if (!db?.id) throw new DeployRefused("no database is recorded for this store: kedge deploy makes it");
|
|
@@ -1481,8 +1542,19 @@ export class Deployer {
|
|
|
1481
1542
|
}
|
|
1482
1543
|
|
|
1483
1544
|
// ---- §6.1: a store deployed before P0.8 ---------------------------------------------------------------
|
|
1484
|
-
async adoptExisting(release: string, texts: (file: string) => string): Promise<void> {
|
|
1485
|
-
|
|
1545
|
+
async adoptExisting(release: string, texts: (file: string) => string, choice?: JurisdictionChoice): Promise<void> {
|
|
1546
|
+
// A store deployed before P0.10 minted its Durable Objects with no jurisdiction, so its jurisdiction is
|
|
1547
|
+
// none whatever anyone would choose now: another would address new, empty objects under the same
|
|
1548
|
+
// names. Its database says so too, read before anything is made (codex code r12).
|
|
1549
|
+
const live = await this.d.cf.findD1(this.names.database);
|
|
1550
|
+
if (!live) throw new DeployRefused(`no database named ${this.names.database}`);
|
|
1551
|
+
const actual = await this.d.cf.d1Jurisdiction(live);
|
|
1552
|
+
if (actual !== "none" || (choice !== undefined && choice !== "none"))
|
|
1553
|
+
throw new DeployRefused(
|
|
1554
|
+
`a store deployed before P0.10 keeps jurisdiction none (its Durable Objects were made without one); ${this.names.database} is ${actual}${choice ? ` and --jurisdiction says ${choice}` : ""}`,
|
|
1555
|
+
);
|
|
1556
|
+
const { ops, jurisdiction } = await openOps(this.d.cf, this.names, this.now, "none");
|
|
1557
|
+
this.jurisdiction = jurisdiction;
|
|
1486
1558
|
this.ops = ops;
|
|
1487
1559
|
// The operation first, then every read it decides on: read before, a deploy finishing in between
|
|
1488
1560
|
// would be recorded over with what it replaced (codex code r12).
|
package/src/doctor.ts
CHANGED
|
@@ -26,6 +26,8 @@ export interface CheckResult {
|
|
|
26
26
|
message?: string;
|
|
27
27
|
/** Lines under the message: each problem, or each stated exception. */
|
|
28
28
|
details?: string[];
|
|
29
|
+
/** A failure only a person can fix (p0.10 §5): doctor then exits 3 with this as its `for you:` line. */
|
|
30
|
+
handoff?: string;
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
export interface DoctorInput {
|
|
@@ -447,6 +449,52 @@ const FAMILY_SECRET: Record<string, string> = {
|
|
|
447
449
|
totp: "KEDGE_TOTP_KEY_V",
|
|
448
450
|
};
|
|
449
451
|
|
|
452
|
+
/**
|
|
453
|
+
* Whether the resolved credentials reach the store's account, claiming only
|
|
454
|
+
* what a read can show (p0.10 §4). A list succeeding proves the token answers
|
|
455
|
+
* for this account; it doesn't prove a write (Cloudflare's D1 list takes `D1
|
|
456
|
+
* Read` or `D1 Write`, creating a database only the latter:
|
|
457
|
+
* https://developers.cloudflare.com/api/resources/d1/subresources/database/methods/list/),
|
|
458
|
+
* so the writes are named as unverified, never passed. The token itself is never shown.
|
|
459
|
+
*/
|
|
460
|
+
export async function credentialsChecks(input: {
|
|
461
|
+
cf: import("./cf").Cloudflare;
|
|
462
|
+
source: string;
|
|
463
|
+
base: string;
|
|
464
|
+
}): Promise<CheckResult[]> {
|
|
465
|
+
const { NeedsPerson } = await import("./cf");
|
|
466
|
+
const where = `from ${input.source}, sent to ${input.base}, account ${input.cf.account}`;
|
|
467
|
+
const read = await input.cf.readable();
|
|
468
|
+
const refused = Object.entries(read).filter(([, status]) => status !== null);
|
|
469
|
+
const out: CheckResult[] = [];
|
|
470
|
+
if (refused.length === Object.keys(read).length && refused.every(([, s]) => s === 401 || s === 403))
|
|
471
|
+
out.push(fail("credentials", `the token doesn't answer for this account (${where})`));
|
|
472
|
+
else if (refused.length)
|
|
473
|
+
out.push(
|
|
474
|
+
fail(
|
|
475
|
+
"credentials",
|
|
476
|
+
`the token can't list everything a store uses (${where})`,
|
|
477
|
+
refused.map(([kind, s]) => (s ? `${kind}: Cloudflare answered ${s}` : `${kind}: no answer`)),
|
|
478
|
+
),
|
|
479
|
+
);
|
|
480
|
+
else
|
|
481
|
+
out.push(
|
|
482
|
+
pass(
|
|
483
|
+
"credentials",
|
|
484
|
+
`${where}; lists Workers, D1 and Queues. Writes are unverified until the first deploy: Workers Scripts Edit, D1 Edit, Queues Edit`,
|
|
485
|
+
),
|
|
486
|
+
);
|
|
487
|
+
try {
|
|
488
|
+
out.push(pass("workers.dev", `${await input.cf.workersSubdomain()}.workers.dev`));
|
|
489
|
+
} catch (e) {
|
|
490
|
+
// Any answer is a report: a token that can't read the subdomain fails this check, never the run (codex code r7).
|
|
491
|
+
// An account without one is the owner's to choose: a handoff, not a technical failure (codex code r9).
|
|
492
|
+
if (e instanceof NeedsPerson) out.push({ ...fail("workers.dev", e.handoff), handoff: e.handoff });
|
|
493
|
+
else out.push(fail("workers.dev", (e as Error).message));
|
|
494
|
+
}
|
|
495
|
+
return out;
|
|
496
|
+
}
|
|
497
|
+
|
|
450
498
|
export interface RemoteDoctorInput {
|
|
451
499
|
cwd: string;
|
|
452
500
|
cf: import("./cf").Cloudflare;
|
|
@@ -465,9 +513,19 @@ export async function doctorRemote(input: RemoteDoctorInput): Promise<CheckResul
|
|
|
465
513
|
const names = storeNames(input.cwd).names;
|
|
466
514
|
const opsId = await input.cf.findD1(names.ops);
|
|
467
515
|
if (!opsId) return [skip("remote", `no ${names.ops}: this store hasn't been deployed with kedge deploy`)];
|
|
468
|
-
const { ops } = await openOps(input.cf, names);
|
|
516
|
+
const { ops, jurisdiction } = await openOps(input.cf, names);
|
|
469
517
|
const db = await ops.resource("d1", names.database);
|
|
470
518
|
|
|
519
|
+
// jurisdiction: the store's database is where <store>-ops records the store's data lives (p0.10 §7).
|
|
520
|
+
if (db?.id) {
|
|
521
|
+
const actual = await input.cf.d1Jurisdiction(db.id);
|
|
522
|
+
out.push(
|
|
523
|
+
actual === jurisdiction
|
|
524
|
+
? pass("jurisdiction", jurisdiction)
|
|
525
|
+
: fail("jurisdiction", `${names.database} is in ${actual}, but the store's jurisdiction is ${jurisdiction}`),
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
|
|
471
529
|
// applied: D1's record is a prefix of the lock, and what ops recorded of each file's bytes still holds.
|
|
472
530
|
if (db?.id) {
|
|
473
531
|
const problems = appliedProblems(input.lock, await appliedNames(input.cf, db.id), await ops.appliedHashes());
|
|
@@ -559,7 +617,7 @@ export async function doctorRemote(input: RemoteDoctorInput): Promise<CheckResul
|
|
|
559
617
|
|
|
560
618
|
// previews: old ones, and lost creates whose names are re-checked for 7 days (§3.1).
|
|
561
619
|
const previews = await ops.query(
|
|
562
|
-
"SELECT identity, worker, database_name, queue_name, state, lost_create, updated_at FROM preview",
|
|
620
|
+
"SELECT identity, worker, database_name, database_id, queue_name, state, lost_create, updated_at FROM preview",
|
|
563
621
|
);
|
|
564
622
|
const old = previews.filter((p) => p.state !== "destroyed" && now - Number(p.updated_at) > 14 * 86_400_000);
|
|
565
623
|
const orphans: string[] = [];
|
|
@@ -578,7 +636,14 @@ export async function doctorRemote(input: RemoteDoctorInput): Promise<CheckResul
|
|
|
578
636
|
if (await input.cf.findD1(String(p.database_name))) orphans.push(`database ${String(p.database_name)}`);
|
|
579
637
|
if (await input.cf.findQueue(String(p.queue_name))) orphans.push(`queue ${String(p.queue_name)}`);
|
|
580
638
|
}
|
|
581
|
-
|
|
639
|
+
// A live preview's database where its store's data lives (p0.10 §7, codex code r13).
|
|
640
|
+
const misplaced: string[] = [];
|
|
641
|
+
for (const p of previews.filter((p) => p.state === "bootstrapped" && p.database_id))
|
|
642
|
+
if ((await input.cf.d1Jurisdiction(String(p.database_id))) !== jurisdiction)
|
|
643
|
+
misplaced.push(`database ${String(p.database_name)}`);
|
|
644
|
+
if (misplaced.length)
|
|
645
|
+
out.push(fail("previews", `a preview's database isn't in the store's jurisdiction, ${jurisdiction}`, misplaced));
|
|
646
|
+
else if (orphans.length)
|
|
582
647
|
out.push(
|
|
583
648
|
fail("previews", "a destroyed preview's late create left resources: kedge preview destroy --sweep", orphans),
|
|
584
649
|
);
|