@hearthlog/tooling 0.1.3 → 0.1.5

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.
@@ -1,295 +0,0 @@
1
- #!/usr/bin/env npx tsx
2
- /**
3
- * Splits a tenant of the shared stack onto its own Ledger and buckets, keeping
4
- * every identity (docs/tenant-split-runbook.md). Each step changes nothing it
5
- * cannot repeat, and none of them switches traffic except `cutover`, which you
6
- * run yourself once `verify` passes.
7
- *
8
- * pnpm tenant:split -- declare --site bravo # write tenants/bravo/deployment.json (keep it local; see the runbook)
9
- * pnpm tenant:split -- fence --site bravo --mode tenant_migrating|moved|off
10
- * pnpm tenant:split -- transfer --site bravo # history: source Ledger -> own Ledger
11
- * pnpm tenant:split -- files --site bravo # objects: shared buckets -> own buckets
12
- * pnpm tenant:split -- verify --site bravo # checksums, file report, denied access
13
- * pnpm tenant:split -- cutover --site bravo # verify, mint the gateway token, deploy the gateway (--skip-access: leave Access alone)
14
- *
15
- * The site must already have `tenants/<id>/deployment.json` and its stack must
16
- * be provisioned empty (`pnpm hearthlog apply --site <id> --yes --stop-before ledger-app`: the import needs a
17
- * Ledger with no application, and creates the application itself under its original id).
18
- *
19
- * `verify --skip-files` skips the file copy check and is accepted only against a Ledger on localhost (a rehearsal).
20
- */
21
-
22
- import { spawn } from "node:child_process";
23
- import { randomBytes } from "node:crypto";
24
- import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
25
- import { join, relative, resolve } from "node:path";
26
- import { fileURLToPath } from "node:url";
27
-
28
- import { identityMapping, validateMapping } from "../tenants/tenant-copy-plan.js";
29
- import { moveApplication, sameChecksums, type AdminCall, type LogChecksum } from "@hearthlog/tooling";
30
-
31
- import { buildCopyWorkerConfig, copyFilesThrough, workerClient, type FilesReport } from "./copy-tenant.js";
32
- import { dedicatedLedgerBaseUrl, resolveDedicatedAdminToken } from "./dedicated-ledger.js";
33
- import { runWrangler } from "./deploy-runner.js";
34
- import { resolveLedgerAdminToken, resolveLedgerBaseUrl } from "./ledger-credential.js";
35
- import {
36
- gallerySweeperId,
37
- infrastructureFor,
38
- loadTenantConfig,
39
- REPOSITORY_ROOT,
40
- siteById,
41
- type InfrastructureDefinition,
42
- type ResolvedSite,
43
- } from "../tenants/tenant-config.js";
44
- import { WRANGLER_BASE } from "../tenants/wrangler-base.js";
45
-
46
- const COPY_WORKER_CONFIG = "wrangler.tenant-move.jsonc";
47
-
48
- export function movesDirectory(siteId: string): string {
49
- return join(REPOSITORY_ROOT, ".wrangler", "tenant-moves", siteId);
50
- }
51
-
52
- export function adminCaller(baseUrl: string, token: string): AdminCall {
53
- return (method, path, body) =>
54
- fetch(`${baseUrl.replace(/\/$/, "")}${path}`, {
55
- method,
56
- headers: { Authorization: `Bearer ${token}`, ...(body === undefined ? {} : { "Content-Type": "application/json" }) },
57
- ...(body === undefined ? {} : { body: JSON.stringify(body) }),
58
- });
59
- }
60
-
61
- interface Stacks {
62
- site: ResolvedSite;
63
- shared: InfrastructureDefinition;
64
- source: AdminCall;
65
- target: () => Promise<AdminCall>;
66
- sourceAppId: string;
67
- }
68
-
69
- async function stacks(siteId: string): Promise<Stacks> {
70
- const { infrastructure, sites } = await loadTenantConfig();
71
- const site = siteById(sites, siteId);
72
- if (!site.deployment) throw new Error(`${siteId} has no tenants/${siteId}/deployment.json: add it, then provision the stack with "pnpm hearthlog apply --site ${siteId} --yes --stop-before ledger-app"`);
73
- const sourceToken = await resolveLedgerAdminToken();
74
- if (!sourceToken) throw new Error("No administrator token for the shared Ledger (LEDGER_ADMIN_TOKEN)");
75
- const source = adminCaller(resolveLedgerBaseUrl(process.env.LEDGER_BASE_URL, infrastructure.ledgerWorker, infrastructure.workersDevDomain), sourceToken);
76
- const response = await source("GET", "/admin/v1/apps");
77
- if (!response.ok) throw new Error(`Listing the shared Ledger's applications failed with ${response.status}`);
78
- const apps = ((await response.json()) as { apps: Array<{ id: string; name: string; revokedAt: string | null }> }).apps;
79
- const app = apps.find((candidate) => candidate.name === site.ledgerAppName && candidate.revokedAt === null);
80
- if (!app) throw new Error(`The shared Ledger has no active application "${site.ledgerAppName}"`);
81
- return {
82
- site,
83
- shared: infrastructure,
84
- source,
85
- sourceAppId: app.id,
86
- target: async () => {
87
- const token = await resolveDedicatedAdminToken(site.id);
88
- if (!token) throw new Error(`No administrator token for the ${site.id} Ledger: provision it first`);
89
- return adminCaller(process.env[`LEDGER_BASE_URL_${site.id.toUpperCase().replace(/-/g, "_")}`]?.trim() || dedicatedLedgerBaseUrl(site, infrastructure), token);
90
- },
91
- };
92
- }
93
-
94
- async function newRun(siteId: string): Promise<string> {
95
- const directory = join(movesDirectory(siteId), new Date().toISOString().replace(/[:.]/g, "-"));
96
- await mkdir(directory, { recursive: true });
97
- return directory;
98
- }
99
-
100
- /** The latest run directory holding `file`, so `files` and `verify` follow `transfer`. */
101
- async function latest(siteId: string, file: string): Promise<string | null> {
102
- const runs = (await readdir(movesDirectory(siteId)).catch(() => [] as string[])).sort().reverse();
103
- for (const run of runs) {
104
- try {
105
- await readFile(join(movesDirectory(siteId), run, file));
106
- return join(movesDirectory(siteId), run);
107
- } catch {
108
- // Try the next older run.
109
- }
110
- }
111
- return null;
112
- }
113
-
114
- async function fence(siteId: string, mode: string): Promise<void> {
115
- if (!["tenant_migrating", "moved", "off"].includes(mode)) throw new Error('--mode must be tenant_migrating, moved, or off');
116
- const { site, source, sourceAppId } = await stacks(siteId);
117
- const body = mode === "moved" ? { mode, destination: new URL(site.hostname).origin } : { mode };
118
- const response = await source("PUT", `/admin/v1/apps/${sourceAppId}/fence`, body);
119
- if (!response.ok) throw new Error(`Setting the fence failed with ${response.status}: ${await response.text()}`);
120
- console.log(`Fence on ${site.ledgerAppName} (${sourceAppId}): ${mode}`);
121
- }
122
-
123
- async function transfer(siteId: string, allowUnfenced: boolean): Promise<void> {
124
- const { site, source, target, sourceAppId } = await stacks(siteId);
125
- const run = await newRun(siteId);
126
- const report = await moveApplication({ appId: sourceAppId, source, target: await target(), allowUnfenced, log: (message) => console.log(message) });
127
- await writeFile(join(run, "transfer.json"), `${JSON.stringify({ site: site.id, ...report }, null, 2)}\n`, { mode: 0o600 });
128
- console.log(`\nMoved ${report.logs} log(s), ${report.events} event(s), ${report.requests} idempotency record(s). Report: ${relative(REPOSITORY_ROOT, run)}/transfer.json`);
129
- }
130
-
131
- async function files(siteId: string): Promise<void> {
132
- const { site, shared, sourceAppId } = await stacks(siteId);
133
- const run = (await latest(siteId, "transfer.json")) ?? (await newRun(siteId));
134
- const mapping = identityMapping(sourceAppId);
135
- validateMapping(mapping);
136
- const name = `${site.id}-tenant-move`;
137
- const own = infrastructureFor(site, shared);
138
- const config = buildCopyWorkerConfig(name, shared, own, WRANGLER_BASE.compatibility_date);
139
- await writeFile(join(REPOSITORY_ROOT, COPY_WORKER_CONFIG), `${JSON.stringify({ ...config, name }, null, 2)}\n`, "utf8");
140
- const token = randomBytes(32).toString("base64url");
141
- console.log(`Deploying the short-lived copy Worker ${name}...`);
142
- await runWrangler(COPY_WORKER_CONFIG, null, ["deploy"]);
143
- try {
144
- await runWrangler(COPY_WORKER_CONFIG, null, ["secret", "bulk"], { input: `${JSON.stringify({ COPY_TOKEN: token })}\n` });
145
- const report = await copyFilesThrough(workerClient(`https://${name}.${own.workersDevDomain}`, token), mapping);
146
- await writeFile(join(run, "files-report.json"), `${JSON.stringify(report, null, 2)}\n`, { mode: 0o600 });
147
- const total = report.jobs.reduce((sum, job) => sum + job.checked, 0);
148
- console.log(`\nCopied and compared ${total} object(s), ${report.publications} publication(s). Report: ${relative(REPOSITORY_ROOT, run)}/files-report.json`);
149
- } finally {
150
- console.log(`Deleting the copy Worker ${name}...`);
151
- await runWrangler(COPY_WORKER_CONFIG, null, ["delete", "--force"]);
152
- }
153
- }
154
-
155
- export interface VerifyInput {
156
- source: LogChecksum[];
157
- target: LogChecksum[];
158
- files: FilesReport | null;
159
- /** A rehearsal against a local Ledger has no buckets to copy; a real move never skips the file check. */
160
- skipFiles?: boolean;
161
- /** What the destination answered to a request with a credential it did not issue. */
162
- foreignCredentialStatus: number;
163
- }
164
-
165
- /** Everything `verify` checks, as a list of what is wrong. */
166
- export function verificationProblems(input: VerifyInput): string[] {
167
- const problems = sameChecksums(input.source, input.target);
168
- if (!input.files && !input.skipFiles) problems.push("no files report: run the files step");
169
- if (input.foreignCredentialStatus !== 401 && input.foreignCredentialStatus !== 403) {
170
- problems.push(`the destination answered ${input.foreignCredentialStatus} to a credential it did not issue; it must refuse it`);
171
- }
172
- return problems;
173
- }
174
-
175
- /** `--skip-files` is only for a rehearsal: the destination must be a Ledger on this machine. */
176
- export function maySkipFiles(destinationUrl: string): boolean {
177
- const host = new URL(destinationUrl).hostname;
178
- return host === "localhost" || host === "127.0.0.1" || host === "[::1]" || host === "::1";
179
- }
180
-
181
- async function verificationProblemsFor(siteId: string, skipFiles: boolean): Promise<string[]> {
182
- const { source, target, sourceAppId } = await stacks(siteId);
183
- const destination = await target();
184
- const read = async (call: AdminCall) => {
185
- const response = await call("GET", `/admin/v1/apps/${sourceAppId}/checksums`);
186
- if (!response.ok) throw new Error(`Reading checksums failed with ${response.status}`);
187
- return ((await response.json()) as { logs: LogChecksum[] }).logs;
188
- };
189
- const run = await latest(siteId, "files-report.json");
190
- const files = run ? (JSON.parse(await readFile(join(run, "files-report.json"), "utf8")) as FilesReport) : null;
191
- return verificationProblems({ source: await read(source), target: await read(destination), files, skipFiles, foreignCredentialStatus: await foreignStatus(siteId) });
192
- }
193
-
194
- async function verify(siteId: string, skipFiles: boolean): Promise<void> {
195
- if (skipFiles && !maySkipFiles(await destinationUrl(siteId))) {
196
- throw new Error("--skip-files is for a rehearsal against a Ledger on localhost; the destination here is not local, so the file copy must be compared");
197
- }
198
- const problems = await verificationProblemsFor(siteId, skipFiles);
199
- if (problems.length) {
200
- console.error(`Verification failed:\n - ${problems.join("\n - ")}`);
201
- process.exit(1);
202
- }
203
- console.log(skipFiles
204
- ? "Verified (files skipped, rehearsal): event checksums match on every log, and the destination refuses a credential it did not issue."
205
- : "Verified: event checksums match on every log, the file copy was compared, and the destination refuses a credential it did not issue.");
206
- }
207
-
208
- /** The destination signs its own tokens: a credential it did not issue must be refused. */
209
- async function destinationUrl(siteId: string): Promise<string> {
210
- const { infrastructure, sites } = await loadTenantConfig();
211
- const site = siteById(sites, siteId);
212
- return (process.env[`LEDGER_BASE_URL_${site.id.toUpperCase().replace(/-/g, "_")}`]?.trim() || dedicatedLedgerBaseUrl(site, infrastructure)).replace(/\/$/, "");
213
- }
214
-
215
- async function foreignStatus(siteId: string): Promise<number> {
216
- const base = await destinationUrl(siteId);
217
- const response = await fetch(`${base}/session`, { headers: { Authorization: `Bearer ${randomBytes(48).toString("base64url")}` } });
218
- return response.status;
219
- }
220
-
221
- const BIN = fileURLToPath(new URL("../../bin/hearthlog.mjs", import.meta.url));
222
-
223
- /** Run a `hearthlog` command of this package for the site, and wait for it. */
224
- function hearthlog(args: string[]): Promise<void> {
225
- return new Promise((resolveRun, reject) => {
226
- const child = spawn(process.execPath, [BIN, ...args], { cwd: REPOSITORY_ROOT, stdio: "inherit" });
227
- child.on("error", reject);
228
- child.on("exit", (code) => (code === 0 ? resolveRun() : reject(new Error(`hearthlog ${args.join(" ")} exited with ${String(code)}`))));
229
- });
230
- }
231
-
232
- /**
233
- * Write `tenants/<id>/deployment.json` for a site that is about to leave the shared stack, pinned to the shared stack's
234
- * account so the move stays in it. This changes the site's configuration: it no longer deploys from the shared
235
- * pipeline, and the shared feed Worker loses its binding. Keep the file out of anything that is pushed until the
236
- * cutover is done (docs/tenant-split-runbook.md).
237
- */
238
- async function declare(siteId: string, accountOverride: string | undefined): Promise<void> {
239
- const { infrastructure, sites, stacks } = await loadTenantConfig();
240
- const site = siteById(sites, siteId);
241
- if (site.deployment) throw new Error(`${siteId} already has tenants/${siteId}/deployment.json`);
242
- const accountId = accountOverride ?? infrastructure.accountId;
243
- if (!accountId || !/^[0-9a-f]{32}$/.test(accountId)) {
244
- throw new Error("Name the account: set infrastructure.accountId in tenants.json (the account of the shared stack) or pass --account-id");
245
- }
246
- const path = join(REPOSITORY_ROOT, "tenants", siteId, "deployment.json");
247
- await writeFile(path, `${JSON.stringify({ schemaVersion: 1, resourcePrefix: `${siteId}-prod`, ledger: "dedicated", accountId }, null, 2)}\n`, { flag: "wx" });
248
- console.log(`Wrote ${relative(REPOSITORY_ROOT, path)} (resource prefix ${siteId}-prod, account pinned).`);
249
- console.log("Keep it local: pushing it would redeploy the shared feed Worker without this site's binding while its frontend still runs on the shared stack.");
250
- if (gallerySweeperId(stacks, infrastructure) === siteId) {
251
- console.log(`${siteId} sweeps the shared public Gallery bucket: set infrastructure.gallerySweeper in tenants.json to a site that stays on the shared stack.`);
252
- }
253
- }
254
-
255
- /**
256
- * The switch: check the move again, mint the gateway's token on the new Ledger, and deploy the gateway bound to it. The
257
- * token goes to the live gateway first, which still talks to the old Ledger until the deploy lands, so the two run
258
- * back to back. It stops before marking the old application moved (`fence --mode moved`).
259
- */
260
- async function cutover(siteId: string, skipAccess: boolean): Promise<void> {
261
- const problems = await verificationProblemsFor(siteId, false);
262
- if (problems.length) {
263
- console.error(`Not cutting over; verification failed:\n - ${problems.join("\n - ")}`);
264
- process.exit(1);
265
- }
266
- console.log("Verified. Minting the gateway token on the new Ledger, then deploying the gateway.");
267
- // The Access application is the same one (same origin), so a run without CLOUDFLARE_API_TOKEN may leave it alone;
268
- // the gateway keeps the audience secret it already holds.
269
- await hearthlog(["ensure", "--site", siteId, "--rotate-token", ...(skipAccess ? ["--skip-access"] : [])]);
270
- await hearthlog(["deploy", "--site", siteId]);
271
- console.log(`\nThe ${siteId} gateway now talks to its own Ledger. Next: pnpm tenant:split -- fence --site ${siteId} --mode moved`);
272
- }
273
-
274
- async function main(argv: string[]): Promise<void> {
275
- const args = argv.filter((argument) => argument !== "--");
276
- const [command, ...rest] = args;
277
- const value = (name: string) => {
278
- const index = rest.indexOf(name);
279
- return index === -1 ? undefined : rest[index + 1]?.trim();
280
- };
281
- const siteId = value("--site");
282
- if (!command || !siteId) {
283
- console.error("Usage: pnpm tenant:split -- <declare|fence|transfer|files|verify|cutover> --site <id> [--mode tenant_migrating|moved|off] [--allow-unfenced] [--skip-files] [--skip-access] [--account-id <id>]");
284
- process.exit(1);
285
- }
286
- if (command === "declare") await declare(siteId, value("--account-id"));
287
- else if (command === "fence") await fence(siteId, value("--mode") ?? "");
288
- else if (command === "transfer") await transfer(siteId, rest.includes("--allow-unfenced"));
289
- else if (command === "files") await files(siteId);
290
- else if (command === "verify") await verify(siteId, rest.includes("--skip-files"));
291
- else if (command === "cutover") await cutover(siteId, rest.includes("--skip-access"));
292
- else throw new Error(`Unknown command "${command}"`);
293
- }
294
-
295
- if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main(process.argv.slice(2));
@@ -1,119 +0,0 @@
1
- /**
2
- * What a tenant copy moves between buckets, as pure data.
3
- *
4
- * A copy gives the new tenant new app and log IDs, and private object keys
5
- * embed them: invoice files are `<prefix>/<appId>/<logId>/…` and Gallery
6
- * files `v1/<appId>/…`. Gallery publications are keyed by their own ID, but
7
- * their `owner.json` names the app and log. The generated-invoice cache
8
- * (`v1/<appId>/<logId>/invoices/…`) is not copied: it rebuilds on demand.
9
- * Calendar feeds are not copied: the new tenant creates its own.
10
- *
11
- * A **move** (the split of a shared tenant onto its own stack) keeps the app
12
- * and log IDs, so every key stays exactly as it is: `identity: true` copies
13
- * each prefix to the same name in the destination bucket, per app rather than
14
- * per log, and publication owner records are already correct and left alone.
15
- */
16
-
17
- export interface TenantCopyMapping {
18
- sourceAppId: string;
19
- targetAppId: string;
20
- /** Source log ID to target log ID, for every copied log. Empty for an identity move. */
21
- logs: Readonly<Record<string, string>>;
22
- /** The destination keeps the app and log IDs, so keys are copied unchanged. */
23
- identity?: true;
24
- }
25
-
26
- /** The mapping of a move that keeps every identity. */
27
- export function identityMapping(appId: string): TenantCopyMapping {
28
- return { sourceAppId: appId, targetAppId: appId, logs: {}, identity: true };
29
- }
30
-
31
- export type BucketPair = "invoice-pdfs" | "gallery-files" | "gallery-public";
32
-
33
- export interface CopyJob {
34
- pair: BucketPair;
35
- fromPrefix: string;
36
- toPrefix: string;
37
- /** Rewrite `owner.json` objects under this prefix to the target app and log. */
38
- rewriteOwner?: boolean;
39
- }
40
-
41
- const INVOICE_FILE_PREFIXES = ["received/v1", "received-isdoc/v1", "issued/v1", "issued-isdoc/v1"] as const;
42
-
43
- const ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
44
-
45
- function requireId(value: string, label: string): void {
46
- if (!ID.test(value)) throw new Error(`${label} is not a UUID: ${value}`);
47
- }
48
-
49
- export function validateMapping(mapping: TenantCopyMapping): void {
50
- requireId(mapping.sourceAppId, "source app");
51
- requireId(mapping.targetAppId, "target app");
52
- if (mapping.identity) {
53
- if (mapping.sourceAppId !== mapping.targetAppId) throw new Error("an identity move keeps the app: source and target must be the same id");
54
- if (Object.keys(mapping.logs).length) throw new Error("an identity move names no logs: every log keeps its id");
55
- return;
56
- }
57
- if (mapping.sourceAppId === mapping.targetAppId) throw new Error("source and target apps are the same");
58
- for (const [source, target] of Object.entries(mapping.logs)) {
59
- requireId(source, "source log");
60
- requireId(target, "target log");
61
- }
62
- }
63
-
64
- /** Every prefix to copy, given the publications the source app owns. */
65
- export function planFileCopies(mapping: TenantCopyMapping, ownedPublications: readonly string[]): CopyJob[] {
66
- validateMapping(mapping);
67
- const jobs: CopyJob[] = [];
68
- if (mapping.identity) {
69
- for (const prefix of INVOICE_FILE_PREFIXES) {
70
- const key = `${prefix}/${mapping.sourceAppId}/`;
71
- jobs.push({ pair: "invoice-pdfs", fromPrefix: key, toPrefix: key });
72
- }
73
- jobs.push({ pair: "gallery-files", fromPrefix: `v1/${mapping.sourceAppId}/`, toPrefix: `v1/${mapping.sourceAppId}/` });
74
- for (const publication of ownedPublications) {
75
- requireId(publication, "publication");
76
- const control = `v1/publications/${publication}/`;
77
- jobs.push({ pair: "gallery-files", fromPrefix: control, toPrefix: control });
78
- jobs.push({ pair: "gallery-public", fromPrefix: `v1/galleries/${publication}/`, toPrefix: `v1/galleries/${publication}/` });
79
- }
80
- return jobs;
81
- }
82
- for (const prefix of INVOICE_FILE_PREFIXES) {
83
- for (const [sourceLog, targetLog] of Object.entries(mapping.logs)) {
84
- jobs.push({
85
- pair: "invoice-pdfs",
86
- fromPrefix: `${prefix}/${mapping.sourceAppId}/${sourceLog}/`,
87
- toPrefix: `${prefix}/${mapping.targetAppId}/${targetLog}/`,
88
- });
89
- }
90
- }
91
- jobs.push({ pair: "gallery-files", fromPrefix: `v1/${mapping.sourceAppId}/`, toPrefix: `v1/${mapping.targetAppId}/` });
92
- for (const publication of ownedPublications) {
93
- requireId(publication, "publication");
94
- jobs.push({
95
- pair: "gallery-files",
96
- fromPrefix: `v1/publications/${publication}/`,
97
- toPrefix: `v1/publications/${publication}/`,
98
- rewriteOwner: true,
99
- });
100
- jobs.push({ pair: "gallery-public", fromPrefix: `v1/galleries/${publication}/`, toPrefix: `v1/galleries/${publication}/` });
101
- }
102
- return jobs;
103
- }
104
-
105
- export function isOwnerRecord(key: string): boolean {
106
- return /^v1\/publications\/[^/]+\/owner\.json$/.test(key);
107
- }
108
-
109
- /** The owner record for the target tenant; throws for a log the copy left out. */
110
- export function rewriteOwner(record: unknown, mapping: TenantCopyMapping): { appId: string; logId: string } {
111
- const owner = record as { appId?: unknown; logId?: unknown } | null;
112
- if (!owner || typeof owner.appId !== "string" || typeof owner.logId !== "string") {
113
- throw new Error("publication owner record is malformed");
114
- }
115
- if (owner.appId !== mapping.sourceAppId) throw new Error("publication belongs to another app");
116
- const logId = mapping.logs[owner.logId];
117
- if (!logId) throw new Error(`publication belongs to log ${owner.logId}, which was not copied`);
118
- return { appId: mapping.targetAppId, logId };
119
- }