@hearthlog/tooling 0.0.0-stage → 0.1.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 +235 -0
- package/bin/hearthlog.mjs +52 -0
- package/package.json +64 -3
- package/src/apply.ts +73 -0
- package/src/check/check-layers.ts +118 -0
- package/src/check/check-modules.ts +198 -0
- package/src/check/check-sdk-exports.ts +79 -0
- package/src/check/check-tenant-config.ts +208 -0
- package/src/cli/hearthlog.ts +212 -0
- package/src/cli/list-sites.ts +15 -0
- package/src/compose/compose-modules.ts +490 -0
- package/src/compose/module-scaffold.ts +194 -0
- package/src/deploy/artifact-compare.ts +93 -0
- package/src/deploy/artifact.ts +127 -0
- package/src/deploy/async-utils.ts +71 -0
- package/src/deploy/backup-tenants.ts +139 -0
- package/src/deploy/cloudflare-access.ts +789 -0
- package/src/deploy/copy-tenant.ts +395 -0
- package/src/deploy/dedicated-ledger.ts +77 -0
- package/src/deploy/deploy-ledger.ts +162 -0
- package/src/deploy/deploy-runner.ts +240 -0
- package/src/deploy/deploy-tenant.ts +170 -0
- package/src/deploy/deployment-security.ts +141 -0
- package/src/deploy/ensure-tenant.ts +988 -0
- package/src/deploy/event-policy.ts +23 -0
- package/src/deploy/frontend-dry-run.ts +18 -0
- package/src/deploy/ledger-credential.ts +48 -0
- package/src/deploy/ledger-scripts.ts +13 -0
- package/src/deploy/split-tenant.ts +295 -0
- package/src/dev/local-development.ts +288 -0
- package/src/dev/provision-local.ts +13 -0
- package/src/generate/build-generated.ts +166 -0
- package/src/generate/generate-brand-icons.ts +128 -0
- package/src/generate/generate-entities.ts +627 -0
- package/src/generate/generate-tenant.ts +161 -0
- package/src/host/core-routes.ts +39 -0
- package/src/host/write-host.ts +119 -0
- package/src/index.ts +8 -0
- package/src/init/check-module.ts +143 -0
- package/src/init/export-tenant.ts +124 -0
- package/src/init/init.ts +167 -0
- package/src/move.ts +189 -0
- package/src/plan.ts +160 -0
- package/src/public-workers/build-gallery-public.ts +114 -0
- package/src/public-workers/calendar-public-config.ts +27 -0
- package/src/public-workers/check-calendar-public-route.ts +41 -0
- package/src/public-workers/check-gallery-public-route.ts +43 -0
- package/src/public-workers/gallery-public-config.ts +22 -0
- package/src/receipt.ts +74 -0
- package/src/root.ts +16 -0
- package/src/security/audit-push.ts +136 -0
- package/src/security/forbidden-identities.ts +107 -0
- package/src/security/forbidden-init.ts +88 -0
- package/src/tenants/host-configs.ts +96 -0
- package/src/tenants/ledger-config.ts +57 -0
- package/src/tenants/lock-file.ts +45 -0
- package/src/tenants/module-lock.ts +58 -0
- package/src/tenants/tenant-config.ts +887 -0
- package/src/tenants/tenant-copy-plan.ts +119 -0
- package/src/tenants/tenant-schema.ts +171 -0
- package/src/tenants/tenant-wrangler.ts +37 -0
- package/src/tenants/wrangler-base.ts +50 -0
- package/src/tenants/wrangler-config.ts +266 -0
- package/src/vite/config.ts +310 -0
- package/src/vite/dev-plugins.ts +338 -0
- package/src/vite/vite-plugin-entity-gen.ts +70 -0
- package/src/vite/vite-plugin-tenant-manifest.ts +61 -0
- package/src/vite/vite-plugin-worker-assets.ts +42 -0
- package/templates/host/app.d.ts.txt +31 -0
- package/templates/host/microlighter.d.ts.txt +11 -0
- package/templates/module/ci.yml.tmpl +44 -0
- package/templates/module/gitignore.tmpl +7 -0
- package/templates/module/release.yml.tmpl +53 -0
- package/templates/tenant/README.md.tmpl +13 -0
- package/templates/tenant/generated-package.json.tmpl +16 -0
- package/templates/tenant/github-workflows/deploy.yml.tmpl +110 -0
- package/templates/tenant/gitignore.tmpl +11 -0
- package/templates/tenant/package.json.tmpl +11 -0
- package/templates/tenant/patches/sqlocal@0.18.0.patch +18 -0
- package/templates/tenant/pnpm-workspace.yaml.tmpl +8 -0
- package/templates/tenant/tenants/__id__/tenant.json.tmpl +8 -0
- package/templates/tenant/tenants.json.tmpl +17 -0
- package/templates/tenant/tsconfig.json.tmpl +21 -0
- package/templates/tenant/vite.config.ts.tmpl +5 -0
- package/README.md +0 -3
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The event policy a site's Ledger application enforces: the core's events and
|
|
3
|
+
* those of the modules the site ships, runtime entity types only when the site
|
|
4
|
+
* enables them, and rejection (not just a log line) of anything else. The
|
|
5
|
+
* Ledger stores it with the application (`PUT /admin/v1/apps/<id>/policy`).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { loadModules, modulesForFeatures } from "../compose/compose-modules.js";
|
|
9
|
+
import type { ResolvedSite } from "../tenants/tenant-config.js";
|
|
10
|
+
|
|
11
|
+
export interface EventPolicy {
|
|
12
|
+
contracts: string[];
|
|
13
|
+
dynamicEvents: boolean;
|
|
14
|
+
validation: "enforce";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function siteEventPolicy(site: Pick<ResolvedSite, "features" | "dynamicEntities">): EventPolicy {
|
|
18
|
+
return {
|
|
19
|
+
contracts: modulesForFeatures(loadModules(), site.features).map(({ manifest }) => manifest.id).sort(),
|
|
20
|
+
dynamicEvents: site.dynamicEntities,
|
|
21
|
+
validation: "enforce",
|
|
22
|
+
};
|
|
23
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#!/usr/bin/env npx tsx
|
|
2
|
+
/**
|
|
3
|
+
* Validate one site's frontend Worker deployment without uploading.
|
|
4
|
+
*
|
|
5
|
+
* Usage: pnpm frontend:worker:dry-run:tenant -- --site bravo
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { runTenantWrangler } from "../tenants/tenant-wrangler.js";
|
|
9
|
+
|
|
10
|
+
const args = process.argv.slice(2);
|
|
11
|
+
const siteIndex = args.indexOf("--site");
|
|
12
|
+
const siteId = siteIndex !== -1 ? args[siteIndex + 1]?.trim() : undefined;
|
|
13
|
+
if (!siteId) {
|
|
14
|
+
console.error("Usage: tsx packages/tooling/src/deploy/frontend-dry-run.ts --site <site-id>");
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
await runTenantWrangler(siteId, ["versions", "upload", "--dry-run"]);
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Ledger administrator token.
|
|
3
|
+
*
|
|
4
|
+
* LEDGER_ADMIN_TOKEN is the primary source; the fallback is the local copy
|
|
5
|
+
* at `.wrangler/tenants/.admin-token` (gitignored, mode 0600). The fallback
|
|
6
|
+
* exists because Cloudflare Worker secrets are write-only — once
|
|
7
|
+
* ADMIN_TOKEN has been set with `wrangler secret put`, its value cannot be
|
|
8
|
+
* read back from Cloudflare, so this file is where the token survives
|
|
9
|
+
* locally.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { readFile } from "node:fs/promises";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
|
|
15
|
+
import { REPOSITORY_ROOT } from "../tenants/tenant-config.js";
|
|
16
|
+
|
|
17
|
+
export function ledgerAdminTokenPath(): string {
|
|
18
|
+
return join(REPOSITORY_ROOT, ".wrangler", "tenants", ".admin-token");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A shared-stack site's Ledger credential (`.wrangler/tenants/production/<id>.json`; the folder name predates this one environment). */
|
|
22
|
+
export function tenantCredentialPath(siteId: string): string {
|
|
23
|
+
return join(REPOSITORY_ROOT, ".wrangler", "tenants", "production", `${siteId}.json`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function legacyTenantCredentialPath(siteId: string): string {
|
|
27
|
+
return join(REPOSITORY_ROOT, ".wrangler", "tenants", `${siteId}.json`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function resolveLedgerAdminToken(): Promise<string> {
|
|
31
|
+
const exported = process.env.LEDGER_ADMIN_TOKEN?.trim() ?? "";
|
|
32
|
+
if (exported) return exported;
|
|
33
|
+
try {
|
|
34
|
+
return (await readFile(ledgerAdminTokenPath(), "utf8")).trim();
|
|
35
|
+
} catch {
|
|
36
|
+
return "";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The deployed Ledger URL, from the Ledger Worker's name. An explicit URL remains available for a local Ledger or a
|
|
42
|
+
* custom domain.
|
|
43
|
+
*/
|
|
44
|
+
export function resolveLedgerBaseUrl(override: string | undefined, ledgerWorker: string, workersDevDomain: string): string {
|
|
45
|
+
const configured = override?.trim();
|
|
46
|
+
if (configured) return configured.replace(/\/$/, "");
|
|
47
|
+
return `https://${ledgerWorker}.${workersDevDomain}`;
|
|
48
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
import { REPOSITORY_ROOT } from "../tenants/tenant-config.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The command that runs one of `@hearthlog/ledger`'s scripts (the logical backup): the script is found from the host's
|
|
8
|
+
* dependencies, so it works in a tenant repository where `packages/ledger/` does not exist, and tsx is this package's own.
|
|
9
|
+
*/
|
|
10
|
+
export function ledgerScript(name: string, args: readonly string[], root: string = REPOSITORY_ROOT): { command: string; args: string[] } {
|
|
11
|
+
const script = createRequire(join(root, "package.json")).resolve(`@hearthlog/ledger/scripts/${name}.js`);
|
|
12
|
+
return { command: process.execPath, args: [createRequire(import.meta.url).resolve("tsx/cli"), script, ...args] };
|
|
13
|
+
}
|
|
@@ -0,0 +1,295 @@
|
|
|
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));
|