From b6661f3a55e65cca521e6dcc5a1a8bf72e373348 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sun, 26 Jul 2026 14:17:57 -0400 Subject: [PATCH 1/4] feat: add elastic Cloudflare site allocation --- docs/cloudflare-provisioning-api.md | 2 +- packages/runtime-cloudflare/README.md | 2 +- .../src/d1-operation-repository.ts | 11 +++- .../src/provisioning-api.ts | 26 ++++++---- .../runtime-cloudflare/src/site-context.ts | 50 +++++++++++++++++++ packages/runtime-cloudflare/src/worker.ts | 16 ++++-- tests/cloudflare-provisioning-api.test.ts | 17 +++++++ tests/cloudflare-runtime.test.ts | 3 ++ tests/cloudflare-site-context.test.ts | 18 ++++++- 9 files changed, 127 insertions(+), 18 deletions(-) diff --git a/docs/cloudflare-provisioning-api.md b/docs/cloudflare-provisioning-api.md index 8bc41f883..9b1f06fc7 100644 --- a/docs/cloudflare-provisioning-api.md +++ b/docs/cloudflare-provisioning-api.md @@ -21,6 +21,6 @@ The Worker compares SHA-256 token digests and never accepts plaintext API creden - `GET /v1/sites/{siteId}/operations/{operationId}` requires `operations:read`. - `POST /v1/sites/{siteId}/administrator-claim` exchanges its one-time bearer capability for the persistent site-scoped `admin` credential after provisioning succeeds. It does not accept an API bearer token. -All responses use versioned WP Codebox provisioning schemas. Site IDs are allocated only from `WORDPRESS_SITE_CONTEXTS`; caller hostnames, DNS, and Cloudflare control APIs are not inputs. The allocation transaction reserves the same shipped D1 site identity used by legacy/operator operations, so both interfaces contend on one hostname and an existing site cannot be taken over. D1 stores allocation ownership, immutable artifact identity and import options, and API operation links. The scheduler resumes incomplete allocations for its selected site before it runs an operation; it verifies staged bytes and converges the conditional destination copy, operation, and API link without blocking publication or cron when recovery fails. +All responses use versioned WP Codebox provisioning schemas. With `WORDPRESS_PREVIEW_DOMAIN` and the secret `WORDPRESS_PREVIEW_HOST_SECRET` configured, `POST /v1/sites` allocates a collision-resistant signed site ID and returns the canonical `https://{siteId}.{preview-domain}` origin. Configure the suffix as a Worker wildcard route before serving those origins; no per-site Worker configuration is needed. The Worker validates that wildcard hostname and derives the site ID before any D1, coordinator, PHP, or SQLite work; anonymous published reads therefore remain R2/cache-only. D1 transactionally stores allocation ownership, lifecycle timestamps, the canonical hostname, immutable artifact identity, import options, and API operation links. `wp_codebox_site_aliases` is reserved for custom-domain aliases and is never consulted by wildcard routing. Without a preview domain, the legacy finite `WORDPRESS_SITE_CONTEXTS` allocator remains available for existing configured deployments. `POST /v1/sites` validates `WORDPRESS_ADMIN_CLAIM_SECRET` and `WORDPRESS_ADMIN_PASSWORD` before allocation. Its create/replay response includes the deterministic pending capability while the configured root still derives the stored digest. Site reads expose only claim state, expiry, and the fixed endpoint. D1 stores capability and derived-credential digests, never either plaintext value. Scheduled allocation recovery issues a missing claim before provisioning work can run. Redemption requires the current site-scoped credential to match the digest pinned before bootstrap, then atomically consumes the capability exactly once and transfers that persistent administrator credential; it is not a one-time browser session. Rotating either root preserves the pending record but requires restoring the matching root before replay or redemption. diff --git a/packages/runtime-cloudflare/README.md b/packages/runtime-cloudflare/README.md index cb26a2bc0..ade4d6baf 100644 --- a/packages/runtime-cloudflare/README.md +++ b/packages/runtime-cloudflare/README.md @@ -20,7 +20,7 @@ The Worker forwards browser cookies directly to Playground and disables Playgrou ## Site Identity -The Worker resolves a validated `SiteContext` from the exact request hostname before reading a cache or constructing a coordinator. `WORDPRESS_SITE_CONTEXTS` is a JSON array of `{ "id", "hostname", "origin" }` records. Site IDs use lowercase letters, digits, and single hyphens; hostnames must be canonical lowercase DNS names or loopback addresses; origins must match the configured hostname and contain no path, query, or credentials. HTTPS is required except for loopback and `*.localhost` development origins. Unknown hostnames fail with `421` and never fall back to another site. +The Worker resolves a validated `SiteContext` from the exact request hostname before reading a cache or constructing a coordinator. `WORDPRESS_SITE_CONTEXTS` is a JSON array of `{ "id", "hostname", "origin" }` records and retains the production `default` mapping. Configure `WORDPRESS_PREVIEW_DOMAIN` plus the 32-byte-or-longer secret `WORDPRESS_PREVIEW_HOST_SECRET` to allocate elastic preview sites at signed `https://{siteId}.{preview-domain}` wildcard origins. Their hostname proves the random identity and generation without D1, so malformed, suffix-confused, forged, and stale-generation preview names fail with `421` before state access. D1 records canonical allocations and optional aliases, but aliases never participate in preview-host resolution. Site IDs use lowercase letters, digits, and single hyphens; hostnames must be canonical lowercase DNS names or loopback addresses; origins must match the configured hostname and contain no path, query, or credentials. HTTPS is required except for loopback and `*.localhost` development origins. Unknown hostnames fail with `421` and never fall back to another site. Every mutable R2 key is rooted below `sites/{siteId}/`, D1 tables partition rows by site ID, Durable Object names include the site ID, and runtime and publication caches include it in their identity. The existing production hostname remains explicitly mapped to `default`, preserving its `sites/default/...` objects and credentials. Non-default sites derive distinct operator tokens and bootstrap admin passwords from the corresponding root Worker secrets with a versioned HMAC domain separator; the mapping itself contains no credentials. WordPress auth keys and salts are independently site-derived from `WORDPRESS_AUTH_SECRET`. diff --git a/packages/runtime-cloudflare/src/d1-operation-repository.ts b/packages/runtime-cloudflare/src/d1-operation-repository.ts index 17cdb15e0..feba88936 100644 --- a/packages/runtime-cloudflare/src/d1-operation-repository.ts +++ b/packages/runtime-cloudflare/src/d1-operation-repository.ts @@ -70,6 +70,12 @@ export class D1OperationRepository { return row ? this.hydrate(row) : null } + async activeSites(): Promise { + await ensureSchema(this.database) + const rows = await this.database.prepare("SELECT site_id, hostname, origin FROM wp_codebox_sites WHERE state = 'active' ORDER BY site_id").all<{ site_id: string; hostname: string; origin: string }>() + return rows.results.map((row) => ({ id: row.site_id, hostname: row.hostname, origin: row.origin })) + } + async claimNext(siteId: string, now = Date.now()): Promise<(StaticArtifactOperation & { claimToken: string }) | null> { await ensureSchema(this.database) const candidate = await this.database.prepare(`SELECT operation_id FROM wp_codebox_operations WHERE site_id = ? AND (state = 'queued' OR (state = 'retryable' AND retry_at <= ?) OR (state = 'running' AND claim_expires_at <= ?)) ORDER BY created_at LIMIT 1`).bind(siteId, now, now).first<{ operation_id: string }>() @@ -180,7 +186,10 @@ async function ensureSchema(database: D1Database): Promise { const existing = schemaReady.get(database as object) if (!existing) { const pending = (async () => { - await database.prepare(`CREATE TABLE IF NOT EXISTS wp_codebox_sites (site_id TEXT PRIMARY KEY, hostname TEXT NOT NULL, origin TEXT NOT NULL, state TEXT NOT NULL CHECK (state IN ('active')), created_at INTEGER NOT NULL, activated_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)`).run() + await database.prepare(`CREATE TABLE IF NOT EXISTS wp_codebox_sites (site_id TEXT PRIMARY KEY, hostname TEXT NOT NULL, origin TEXT NOT NULL, generation INTEGER NOT NULL DEFAULT 1, state TEXT NOT NULL CHECK (state IN ('active')), created_at INTEGER NOT NULL, activated_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)`).run() + try { await database.prepare("ALTER TABLE wp_codebox_sites ADD COLUMN generation INTEGER NOT NULL DEFAULT 1").run() } catch (error) { if (!(error instanceof Error) || !/duplicate column/i.test(error.message)) throw error } + await database.prepare(`CREATE UNIQUE INDEX IF NOT EXISTS wp_codebox_site_hostname ON wp_codebox_sites(hostname)`).run() + await database.prepare(`CREATE TABLE IF NOT EXISTS wp_codebox_site_aliases (hostname TEXT PRIMARY KEY, site_id TEXT NOT NULL, created_at INTEGER NOT NULL, FOREIGN KEY (site_id) REFERENCES wp_codebox_sites(site_id))`).run() await database.prepare(`CREATE TABLE IF NOT EXISTS wp_codebox_operations (site_id TEXT NOT NULL, operation_id TEXT NOT NULL, idempotency_key TEXT NOT NULL, fingerprint TEXT NOT NULL, artifact_key TEXT NOT NULL, artifact_sha256 TEXT NOT NULL, artifact_size INTEGER NOT NULL, slug TEXT NOT NULL, name TEXT NOT NULL, site_title TEXT NOT NULL, state TEXT NOT NULL CHECK (state IN ('queued','running','retryable','publication-pending','succeeded','failed')), stage TEXT NOT NULL, progress INTEGER NOT NULL CHECK (progress BETWEEN 0 AND 100), attempts INTEGER NOT NULL, retry_at INTEGER, claim_token TEXT, claim_expires_at INTEGER, prepared_version INTEGER, prepared_revision TEXT, prepared_manifest_key TEXT, prepared_persisted_at TEXT, prepared_result_json TEXT, prepared_publication_job TEXT, receipt_json TEXT, error_code TEXT, error_message TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, completed_at TEXT, PRIMARY KEY (site_id, operation_id), UNIQUE (site_id, idempotency_key), FOREIGN KEY (site_id) REFERENCES wp_codebox_sites(site_id))`).run() await database.prepare(`CREATE TABLE IF NOT EXISTS wp_codebox_operation_attempts (site_id TEXT NOT NULL, operation_id TEXT NOT NULL, attempt_number INTEGER NOT NULL, claim_token TEXT NOT NULL, started_at TEXT NOT NULL, completed_at TEXT, state TEXT NOT NULL, stage TEXT NOT NULL, error_code TEXT, error_message TEXT, PRIMARY KEY (site_id, operation_id, attempt_number), FOREIGN KEY (site_id, operation_id) REFERENCES wp_codebox_operations(site_id, operation_id))`).run() await database.prepare(`CREATE UNIQUE INDEX IF NOT EXISTS wp_codebox_one_active_operation ON wp_codebox_operations(site_id) WHERE state IN ('queued','running','retryable')`).run() diff --git a/packages/runtime-cloudflare/src/provisioning-api.ts b/packages/runtime-cloudflare/src/provisioning-api.ts index 1e9d81cbc..80e54b72c 100644 --- a/packages/runtime-cloudflare/src/provisioning-api.ts +++ b/packages/runtime-cloudflare/src/provisioning-api.ts @@ -1,6 +1,6 @@ import { D1OperationRepository, OperationConflict, type StaticArtifactOperation, type StaticArtifactOperationInput } from "./d1-operation-repository.js" import { MAX_STATIC_ARTIFACT_BYTES, readBoundedRequestBytes, readStaticArtifactImport, StaticArtifactImportError, validateStaticArtifact } from "./static-artifact-import.js" -import { parseSiteContexts, siteStorageKeys, type SiteContext } from "./site-context.js" +import { allocatePreviewSiteContext, parseSiteContexts, previewDomain, siteStorageKeys, type SiteContext } from "./site-context.js" import { deriveSiteCredential } from "./wordpress-auth.js" export const PROVISIONING_API_SCHEMA = "wp-codebox/provisioning-api/v1" @@ -16,7 +16,7 @@ export interface ProvisioningAllocation { artifactSha256: string; artifactSize: number; options: StaticArtifactOperationInput["options"] } interface CreateInput { key: string; fingerprint: string; artifactSha256: string; artifactSize: number; options: StaticArtifactOperationInput["options"] } -export interface ProvisioningEnv { WORDPRESS_STATE_DATABASE: D1Database; WORDPRESS_STATE_BUCKET: R2Bucket; WORDPRESS_SITE_CONTEXTS?: string; WORDPRESS_API_TOKENS?: string; WORDPRESS_ADMIN_CLAIM_SECRET?: string; WORDPRESS_ADMIN_PASSWORD?: string } +export interface ProvisioningEnv { WORDPRESS_STATE_DATABASE: D1Database; WORDPRESS_STATE_BUCKET: R2Bucket; WORDPRESS_SITE_CONTEXTS?: string; WORDPRESS_PREVIEW_DOMAIN?: string; WORDPRESS_PREVIEW_HOST_SECRET?: string; WORDPRESS_API_TOKENS?: string; WORDPRESS_ADMIN_CLAIM_SECRET?: string; WORDPRESS_ADMIN_PASSWORD?: string } export async function routeProvisioningApi(request: Request, env: ProvisioningEnv, operations: D1OperationRepository): Promise { const parts = new URL(request.url).pathname.split("/").filter(Boolean) @@ -73,13 +73,14 @@ async function create(request: Request, env: ProvisioningEnv, operations: D1Oper const store = new AllocationStore(env.WORDPRESS_STATE_DATABASE) let allocation: ProvisioningAllocation try { + const domain = previewDomain(env.WORDPRESS_PREVIEW_DOMAIN, env.WORDPRESS_PREVIEW_HOST_SECRET) const active = await env.WORDPRESS_STATE_DATABASE.prepare("SELECT site_id FROM wp_codebox_sites WHERE state = 'active'").all<{ site_id: string }>() const occupied = new Set(active.results.map((row) => row.site_id)) - const candidates = parseSiteContexts(env.WORDPRESS_SITE_CONTEXTS).filter((site) => allowed(token, site.id) && !occupied.has(site.id)) - allocation = await store.allocate(token, input, candidates) + const configured = domain ? [] : parseSiteContexts(env.WORDPRESS_SITE_CONTEXTS).filter((site) => allowed(token, site.id) && !occupied.has(site.id)) + allocation = await store.allocate(token, input, domain, configured) } catch (error) { return error instanceof OperationConflict ? apiError(409, "idempotency_conflict", error.message) : allocationError(error) } if (!allowed(token, allocation.siteId)) return notFound() - const site = context(env, allocation.siteId) + const site = await store.context(allocation.siteId) if (!site) return notFound() try { const operation = await resumeProvisioningAllocation(env, site, operations) @@ -91,7 +92,7 @@ async function create(request: Request, env: ProvisioningEnv, operations: D1Oper async function readSite(request: Request, env: ProvisioningEnv, operations: D1OperationRepository, siteId: string): Promise { const token = await authenticate(request, env, "sites:read"); if (token instanceof Response) return token const allocation = await new AllocationStore(env.WORDPRESS_STATE_DATABASE).bySite(siteId) - const site = context(env, siteId) + const site = await new AllocationStore(env.WORDPRESS_STATE_DATABASE).context(siteId) if (!allocation || !site || allocation.principal !== token.principal || !allowed(token, siteId)) return notFound() const claim = await new AdministratorClaimStore(env.WORDPRESS_STATE_DATABASE).metadata(siteId) return siteResource(site, allocation.operationId ? await operations.get(siteId, allocation.operationId) : null, 200, claim) @@ -118,7 +119,7 @@ async function claimAdministrator(request: Request, env: ProvisioningEnv, operat async function importSite(request: Request, env: ProvisioningEnv, operations: D1OperationRepository, siteId: string): Promise { const token = await authenticate(request, env, "sites:import"); if (token instanceof Response) return token const key = idempotencyKey(request); if (key instanceof Response) return key - const store = new AllocationStore(env.WORDPRESS_STATE_DATABASE); const allocation = await store.bySite(siteId); const site = context(env, siteId) + const store = new AllocationStore(env.WORDPRESS_STATE_DATABASE); const allocation = await store.bySite(siteId); const site = await store.context(siteId) if (!allocation || !site || allocation.principal !== token.principal || !allowed(token, siteId)) return notFound() const provision = allocation.operationId ? await operations.get(siteId, allocation.operationId) : null if (provision?.state !== "succeeded") return apiError(409, "site_not_ready", "The site is not ready for imports.") @@ -134,7 +135,7 @@ async function importSite(request: Request, env: ProvisioningEnv, operations: D1 async function readOperation(request: Request, env: ProvisioningEnv, operations: D1OperationRepository, siteId: string, operationId: string): Promise { const token = await authenticate(request, env, "operations:read"); if (token instanceof Response) return token const store = new AllocationStore(env.WORDPRESS_STATE_DATABASE); const allocation = await store.bySite(siteId) - if (!allocation || !context(env, siteId) || allocation.principal !== token.principal || !allowed(token, siteId) || !await store.ownsOperation(token.principal, siteId, operationId)) return notFound() + if (!allocation || !await store.context(siteId) || allocation.principal !== token.principal || !allowed(token, siteId) || !await store.ownsOperation(token.principal, siteId, operationId)) return notFound() const operation = await operations.get(siteId, operationId) return operation ? operationResource(siteId, operation) : notFound() } @@ -242,7 +243,6 @@ function parseTokens(value: string | undefined): Token[] { function optionsOf(value: Record): StaticArtifactOperationInput["options"] { const slug = value.slug; const name = typeof value.name === "string" ? value.name.trim() : ""; const siteTitle = typeof value.siteTitle === "string" ? value.siteTitle.trim() : ""; if (typeof slug !== "string" || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug) || slug.length > 64 || !name || name.length > 120 || !siteTitle || siteTitle.length > 120) throw new StaticArtifactImportError("Provisioning import options are invalid.", 400); return { slug, name, siteTitle } } function stagedKey(sha256: string): string { return `sites/provisioning/import-artifacts/${sha256}.json` } function destinationKey(site: SiteContext, sha256: string): string { return `${siteStorageKeys(site).staticArtifactPrefix}/${sha256}.json` } -function context(env: ProvisioningEnv, id: string): SiteContext | undefined { return parseSiteContexts(env.WORDPRESS_SITE_CONTEXTS).find((site) => site.id === id) } function allowed(token: Token, siteId: string): boolean { return !token.sites || token.sites.includes(siteId) } function validSiteId(value: string): boolean { return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value) && value.length <= 63 } function record(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value) } @@ -254,10 +254,13 @@ function claimConfiguration(env: ProvisioningEnv): boolean { return typeof env.W class AllocationError extends Error { constructor(readonly code: "quota_exceeded" | "capacity_exhausted") { super(code) } } class AllocationStore { constructor(private readonly db: D1Database) {} - async allocate(token: Token, input: CreateInput, sites: SiteContext[]): Promise { + async allocate(token: Token, input: CreateInput, domain: ReturnType, configured: SiteContext[]): Promise { await this.schema(); const existing = await this.byRequest(token.principal, input.key) if (existing) { if (existing.fingerprint !== input.fingerprint) throw new OperationConflict("The idempotency key is already bound to a different immutable input."); return existing } - for (const site of sites) { + if (domain && token.sites) throw new AllocationError("quota_exceeded") + const attempts = domain ? 8 : configured.length + for (let attempt = 0; attempt < attempts; attempt++) { + const site = domain ? await allocatePreviewSiteContext(domain) : configured[attempt] const now = Date.now() try { const [allocation, reservation] = await this.db.batch([ @@ -274,6 +277,7 @@ class AllocationStore { const count = await this.db.prepare("SELECT COUNT(*) AS count FROM wp_codebox_api_sites WHERE principal = ?").bind(token.principal).first<{ count: number }>() throw new AllocationError((count?.count ?? 0) >= token.maxSites ? "quota_exceeded" : "capacity_exhausted") } + async context(siteId: string): Promise { await this.schema(); const row = await this.db.prepare("SELECT site_id, hostname, origin FROM wp_codebox_sites WHERE site_id = ? AND state = 'active'").bind(siteId).first<{ site_id: string; hostname: string; origin: string }>(); return row ? { id: row.site_id, hostname: row.hostname, origin: row.origin } : null } async bySite(siteId: string): Promise { await this.schema(); const row = await this.db.prepare("SELECT site_id, principal, idempotency_key, fingerprint, artifact_sha256, artifact_size, slug, name, site_title, operation_id FROM wp_codebox_api_sites WHERE site_id = ?").bind(siteId).first>(); return row ? allocation(row) : null } async bindOperation(value: ProvisioningAllocation, operationId: string): Promise { await this.schema() diff --git a/packages/runtime-cloudflare/src/site-context.ts b/packages/runtime-cloudflare/src/site-context.ts index 98561def0..eb2d8dc82 100644 --- a/packages/runtime-cloudflare/src/site-context.ts +++ b/packages/runtime-cloudflare/src/site-context.ts @@ -4,6 +4,11 @@ export interface SiteContext { origin: string } +export interface PreviewDomain { + suffix: string + hostSecret: string +} + export interface SiteStorageKeys { root: string markdownCurrent: string @@ -61,6 +66,41 @@ export function resolveSiteContextFromRequest(request: Request | URL, contexts: return resolveSiteContext(normalizeHostname(request instanceof Request ? new URL(request.url).hostname : request.hostname), contexts) } +/** + * Dynamic preview names are self-authenticating. This lets the public reader + * reject invented and stale generation names without consulting D1. + */ +export function previewDomain(suffix: string | undefined, hostSecret: string | undefined): PreviewDomain | undefined { + if (suffix === undefined && hostSecret === undefined) return undefined + if (typeof suffix !== "string" || typeof hostSecret !== "string" || hostSecret.length < 32) throw new Error("Preview hostname configuration is invalid.") + const normalized = normalizeHostname(suffix) + if (suffix !== normalized || !isDnsHostname(normalized) || normalized.split(".").length < 2) throw new Error("Preview hostname configuration is invalid.") + return { suffix: normalized, hostSecret } +} + +export async function allocatePreviewSiteContext(domain: PreviewDomain, generation = 1): Promise { + const nonce = crypto.randomUUID().replaceAll("-", "").slice(0, 24) + const signature = await previewSignature(domain, nonce, generation) + return previewSiteContext(`s${nonce}-g${generation}-${signature}`, domain) +} + +export async function resolvePreviewSiteContextFromRequest(request: Request | URL, domain: PreviewDomain | undefined): Promise { + if (!domain) throw new Error("Unknown site hostname.") + const hostname = normalizeHostname(request instanceof Request ? new URL(request.url).hostname : request.hostname) + const suffix = `.${domain.suffix}` + if (!hostname.endsWith(suffix)) throw new Error("Unknown site hostname.") + const siteId = hostname.slice(0, -suffix.length) + if (!siteId || siteId.includes(".")) throw new Error("Unknown site hostname.") + return previewSiteContext(siteId, domain) +} + +export async function previewSiteContext(siteId: string, domain: PreviewDomain): Promise { + const match = /^s([a-f0-9]{24})-g([1-9][0-9]*)-([a-f0-9]{16})$/.exec(siteId) + if (!match || match[3] !== await previewSignature(domain, match[1], Number(match[2]))) throw new Error("Unknown site hostname.") + const hostname = `${siteId}.${domain.suffix}` + return { id: siteId, hostname, origin: `https://${hostname}` } +} + export function siteStorageKeys(site: SiteContext): SiteStorageKeys { const prefix = `sites/${site.id}` return { @@ -106,3 +146,13 @@ function normalizeHostname(hostname: string): string { function isLocalHostname(hostname: string): boolean { return hostname === "localhost" || hostname.endsWith(".localhost") || hostname === "127.0.0.1" || hostname === "::1" } + +function isDnsHostname(value: string): boolean { + return value.length <= 253 && value.split(".").every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label)) +} + +async function previewSignature(domain: PreviewDomain, nonce: string, generation: number): Promise { + const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(domain.hostSecret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]) + const bytes = new Uint8Array(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(`wp-codebox-preview-host/v1:${domain.suffix}:${nonce}:${generation}`))) + return Array.from(bytes.slice(0, 8), (byte) => byte.toString(16).padStart(2, "0")).join("") +} diff --git a/packages/runtime-cloudflare/src/worker.ts b/packages/runtime-cloudflare/src/worker.ts index 33fd9819f..0ddb686b5 100644 --- a/packages/runtime-cloudflare/src/worker.ts +++ b/packages/runtime-cloudflare/src/worker.ts @@ -16,7 +16,7 @@ import { readStaticArtifactImport, STATIC_ARTIFACT_IMPORT_RESULT_SCHEMA, StaticA import { D1OperationRepository, OperationConflict, STATIC_ARTIFACT_OPERATION_SCHEMA, shouldRecoverPreparedCommit } from "./d1-operation-repository.js" import { resumeProvisioningAllocation, routeProvisioningApi } from "./provisioning-api.js" import { toFetchResponse, toPHPRequest } from "./request-translation.js" -import { DEFAULT_SITE_CONTEXT, parseSiteContexts, resolveSiteContextFromRequest, siteStorageKeys, type SiteContext } from "./site-context.js" +import { DEFAULT_SITE_CONTEXT, parseSiteContexts, previewDomain, resolvePreviewSiteContextFromRequest, resolveSiteContextFromRequest, siteStorageKeys, type SiteContext } from "./site-context.js" import { validateUploadManifestFiles, validateUploadMetadata } from "./upload-persistence.js" import { deriveSiteCredential, deriveWordPressAuthConstants, type WordPressAuthConstant } from "./wordpress-auth.js" import { isWordPressRuntimeFile, wordpressStaticArchivePath, wordpressStaticContentType } from "./wordpress-runtime-corpus.js" @@ -208,6 +208,8 @@ echo wp_json_encode(array_merge(array('status' => 'imported'), $record), JSON_UN export interface RuntimeEnv { WORDPRESS_STATE_BUCKET: R2Bucket WORDPRESS_SITE_CONTEXTS?: string + WORDPRESS_PREVIEW_DOMAIN?: string + WORDPRESS_PREVIEW_HOST_SECRET?: string WORDPRESS_ADMIN_PASSWORD?: string WORDPRESS_AUTH_SECRET?: string WORDPRESS_OPERATOR_TOKEN?: string @@ -230,7 +232,13 @@ export function createCloudflareRuntime( } let site: SiteContext try { - site = resolveSiteContextFromRequest(request, parseSiteContexts(env.WORDPRESS_SITE_CONTEXTS)) + const contexts = parseSiteContexts(env.WORDPRESS_SITE_CONTEXTS) + try { + site = resolveSiteContextFromRequest(request, contexts) + } catch (error) { + if (!(error instanceof Error) || error.message !== "Unknown site hostname.") throw error + site = await resolvePreviewSiteContextFromRequest(request, previewDomain(env.WORDPRESS_PREVIEW_DOMAIN, env.WORDPRESS_PREVIEW_HOST_SECRET)) + } } catch (error) { if (error instanceof Error && error.message === "Unknown site hostname.") return new Response(error.message, { status: 421 }) throw error @@ -271,7 +279,9 @@ export function createCloudflareRuntime( } }, async scheduled(controller: ScheduledController, env: Env): Promise { - const sites = parseSiteContexts(env.WORDPRESS_SITE_CONTEXTS).sort((left, right) => left.id.localeCompare(right.id)) + const configured = parseSiteContexts(env.WORDPRESS_SITE_CONTEXTS) + const registered = env.WORDPRESS_STATE_DATABASE && resolveOperations?.(env) ? await resolveOperations(env)!.activeSites() : [] + const sites = [...new Map([...configured, ...registered].map((site) => [site.id, site])).values()].sort((left, right) => left.id.localeCompare(right.id)) const site = sites[Math.floor(controller.scheduledTime / 60_000) % sites.length] const coordinator = resolveCoordinator(env, site) const operations = resolveOperations?.(env) diff --git a/tests/cloudflare-provisioning-api.test.ts b/tests/cloudflare-provisioning-api.test.ts index 48a5b5d7a..2b2306396 100644 --- a/tests/cloudflare-provisioning-api.test.ts +++ b/tests/cloudflare-provisioning-api.test.ts @@ -24,6 +24,12 @@ function runtime(db = database(), bucket = new Bucket(), tokens?: unknown, maxSi const records = tokens ?? [{ id: "a", principal: "a", digest: hash("good"), scopes: ["sites:create", "sites:read", "sites:import", "operations:read"], expiresAt: "2099-01-01T00:00:00.000Z", maxSites }] return { env: { WORDPRESS_STATE_DATABASE: db, WORDPRESS_STATE_BUCKET: bucket as unknown as R2Bucket, WORDPRESS_SITE_CONTEXTS: JSON.stringify([{ id: "alpha", hostname: "alpha.example", origin: "https://alpha.example" }, { id: "beta", hostname: "beta.example", origin: "https://beta.example" }]), WORDPRESS_API_TOKENS: JSON.stringify(records), WORDPRESS_ADMIN_CLAIM_SECRET: "claim-root-secret-with-at-least-thirty-two-bytes", WORDPRESS_ADMIN_PASSWORD: "admin-root-secret" }, db, bucket, operations: new D1OperationRepository(db) } } +function dynamicRuntime(db = database(), bucket = new Bucket(), maxSites = 128) { + const value = runtime(db, bucket, undefined, maxSites) + value.env.WORDPRESS_PREVIEW_DOMAIN = "preview.example" + value.env.WORDPRESS_PREVIEW_HOST_SECRET = "preview-host-secret-with-at-least-thirty-two-bytes" + return value +} function createRequest(key = "create-1", change: Partial<{ sha256: string; size: number; r2Key: string; title: string }> = {}, token = "good") { const sha256 = change.sha256 ?? digest; const size = change.size ?? artifact.byteLength return new Request("https://control.invalid/v1/sites", { method: "POST", headers: { authorization: `Bearer ${token}`, "idempotency-key": key }, body: JSON.stringify({ schema: PROVISIONING_CREATE_REQUEST_SCHEMA, idempotencyKey: key, artifact: { sha256, size, r2Key: change.r2Key ?? `sites/provisioning/import-artifacts/${sha256}.json` }, import: { slug: "site", name: "Site", siteTitle: change.title ?? "Site" } }) }) @@ -73,6 +79,17 @@ test("changed fingerprints conflict without another allocation", async () => { c test("create replay honors current site restrictions", async () => { const r = runtime(); await create(r); const records = JSON.parse(r.env.WORDPRESS_API_TOKENS) as Array>; records[0].sites = ["beta"]; r.env.WORDPRESS_API_TOKENS = JSON.stringify(records); assert.equal((await create(r)).status, 404); assert.equal(count(r.db, "wp_codebox_api_sites"), 1) }) test("concurrent distinct keys allocate unique contexts", async () => { const r = runtime(); await Promise.all([create(r, "one"), create(r, "two")]); const ids = r.db.sqlite.prepare("SELECT site_id FROM wp_codebox_api_sites ORDER BY site_id").all() as Array<{ site_id: string }>; assert.deepEqual(ids.map((row) => row.site_id), ["alpha", "beta"]) }) test("maxSites concurrency distinguishes quota from pool exhaustion", async () => { const quota = runtime(database(), new Bucket(), undefined, 1); const responses = await Promise.all([create(quota, "one"), create(quota, "two")]); assert.equal(responses.filter((response) => response.status === 202).length, 1); const rejected = responses.find((response) => response.status !== 202)!; assert.equal((await rejected.json() as { error: { code: string } }).error.code, "quota_exceeded"); assert.equal(count(quota.db, "wp_codebox_api_sites"), 1); const pool = runtime(database(), new Bucket(), undefined, 3); await Promise.all([create(pool, "one"), create(pool, "two")]); const pr = await create(pool, "three"); assert.equal((await pr.json() as { error: { code: string } }).error.code, "capacity_exhausted") }) +test("dynamic preview allocation is unique, idempotent, and not limited by configured contexts", async () => { + const r = dynamicRuntime() + const responses = await Promise.all(Array.from({ length: 100 }, (_, index) => create(r, `dynamic-${index}`))) + assert.equal(responses.filter((response) => response.status === 202).length, 100) + const sites = await Promise.all(responses.map(async (response) => (await response.json() as { site: { id: string; url: string } }).site)) + assert.equal(new Set(sites.map((site) => site.id)).size, 100) + assert.ok(sites.every((site) => site.url === `https://${site.id}.preview.example`)) + assert.equal(count(r.db, "wp_codebox_api_sites"), 100) + const replay = await create(r, "dynamic-0") + assert.deepEqual((await replay.json() as { site: { id: string } }).site.id, sites[0].id) +}) test("conditional R2 winner is re-read and verified, while missing or conflicting winners block operation", async () => { const r = runtime(); r.bucket.race = artifact; const ok = await create(r); assert.equal(ok.status, 202); const bad = runtime(); bad.bucket.race = new TextEncoder().encode("bad"); assert.equal((await create(bad)).status, 409); assert.equal(count(bad.db, "wp_codebox_operations"), 0); const missing = runtime(); missing.bucket.dropSuccessfulPut = true; assert.equal((await create(missing)).status, 409); assert.equal(count(missing.db, "wp_codebox_operations"), 0) }) test("scheduler recovery helper converges allocation without operation, API link, and destination copy", async () => { const r = runtime(); r.db.sqlite.exec("CREATE TABLE wp_codebox_api_sites (site_id TEXT PRIMARY KEY, principal TEXT NOT NULL, idempotency_key TEXT NOT NULL, fingerprint TEXT NOT NULL, artifact_sha256 TEXT NOT NULL, artifact_size INTEGER NOT NULL, slug TEXT NOT NULL, name TEXT NOT NULL, site_title TEXT NOT NULL, operation_id TEXT, created_at INTEGER NOT NULL, UNIQUE(principal, idempotency_key)); CREATE TABLE wp_codebox_api_operation_links (principal TEXT NOT NULL, site_id TEXT NOT NULL, operation_id TEXT NOT NULL, kind TEXT NOT NULL, idempotency_key TEXT NOT NULL, PRIMARY KEY (principal,site_id,operation_id), UNIQUE(principal,site_id,kind,idempotency_key));"); r.db.sqlite.prepare("INSERT INTO wp_codebox_api_sites VALUES ('alpha','a','one','fingerprint',?,?, 'site','Site','Site',NULL,1)").run(digest, artifact.byteLength); const site = { id: "alpha", hostname: "alpha.example", origin: "https://alpha.example" }; const operation = await resumeProvisioningAllocation(r.env, site, r.operations); assert.ok(operation); r.db.sqlite.prepare("DELETE FROM wp_codebox_api_operation_links").run(); r.db.sqlite.prepare("UPDATE wp_codebox_api_sites SET operation_id = ? WHERE site_id = 'alpha'").run(operation!.operationId); await resumeProvisioningAllocation(r.env, site, r.operations); assert.equal(count(r.db, "wp_codebox_api_operation_links"), 1); assert.ok(r.bucket.objects.has(`sites/alpha/import-artifacts/${digest}.json`)) }) test("cross-principal and site-restricted reads and imports fail closed", async () => { const tokens = [{ id: "a", principal: "a", digest: hash("good"), scopes: ["sites:create", "sites:read", "sites:import", "operations:read"], expiresAt: "2099-01-01T00:00:00.000Z", maxSites: 2 }, { id: "b", principal: "b", digest: hash("other"), scopes: ["sites:read", "sites:import"], expiresAt: "2099-01-01T00:00:00.000Z", maxSites: 2, sites: ["beta"] }]; const r = runtime(database(), new Bucket(), tokens); await create(r); for (const url of ["/v1/sites/alpha", "/v1/sites/alpha/imports"]) { const response = await routeProvisioningApi(new Request(`https://control.invalid${url}`, { method: url.endsWith("imports") ? "POST" : "GET", headers: { authorization: "Bearer other", "idempotency-key": "x" }, body: url.endsWith("imports") ? "{}" : undefined }), r.env, r.operations); assert.equal(response.status, 404) } }) diff --git a/tests/cloudflare-runtime.test.ts b/tests/cloudflare-runtime.test.ts index 0ded8aab0..4289ae13d 100644 --- a/tests/cloudflare-runtime.test.ts +++ b/tests/cloudflare-runtime.test.ts @@ -396,6 +396,9 @@ test("Cloudflare runtime injects composable coordinators without moving PHP out assert.match(worker, /const wpContentResponse = await serveWordPressWpContent\(request, env\.WORDPRESS_STATE_BUCKET, coordinator, site\)/) assert.match(worker, /const publishedResponse = await servePublishedWordPressPage\(request, env\.WORDPRESS_STATE_BUCKET, site\)/) assert.ok(worker.indexOf("resolveSiteContextFromRequest") < worker.indexOf("servePublishedWordPressPage(request, env.WORDPRESS_STATE_BUCKET, site)")) + assert.match(worker, /site = await resolvePreviewSiteContextFromRequest\(request, previewDomain\(env\.WORDPRESS_PREVIEW_DOMAIN, env\.WORDPRESS_PREVIEW_HOST_SECRET\)\)/) + const anonymousReader = worker.slice(worker.indexOf("const publishedResponse = await servePublishedWordPressPage"), worker.indexOf("const route = routeWorkerRequest")) + assert.doesNotMatch(anonymousReader, /WORDPRESS_STATE_DATABASE|resolveCoordinator|new PHP|bootWordPress/) assert.match(worker, /const staticResponse = await serveWordPressStaticAsset\(request, env\.WORDPRESS_STATE_BUCKET\)/) assert.ok(worker.indexOf("const wpContentResponse = await serveWordPressWpContent(request, env.WORDPRESS_STATE_BUCKET, coordinator, site)") < worker.indexOf("const staticResponse = await serveWordPressStaticAsset(request, env.WORDPRESS_STATE_BUCKET)")) assert.ok(worker.indexOf("const route = routeWorkerRequest(request)") < worker.indexOf("selectOperatorCoordinator(")) diff --git a/tests/cloudflare-site-context.test.ts b/tests/cloudflare-site-context.test.ts index b3bf82206..c67106b17 100644 --- a/tests/cloudflare-site-context.test.ts +++ b/tests/cloudflare-site-context.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict" import test from "node:test" -import { DEFAULT_SITE_CONTEXT, parseSiteContexts, resolveSiteContext, resolveSiteContextFromRequest, siteStorageKeys } from "../packages/runtime-cloudflare/src/site-context.js" +import { allocatePreviewSiteContext, DEFAULT_SITE_CONTEXT, parseSiteContexts, previewDomain, resolvePreviewSiteContextFromRequest, resolveSiteContext, resolveSiteContextFromRequest, siteStorageKeys } from "../packages/runtime-cloudflare/src/site-context.js" test("site contexts preserve default storage paths and isolate configured sites", () => { assert.deepEqual(parseSiteContexts(undefined), [DEFAULT_SITE_CONTEXT]) @@ -41,3 +41,19 @@ test("site context resolution only accepts exact configured hosts", () => { assert.throws(() => resolveSiteContext(hostname, contexts), /Unknown site hostname/) } }) + +test("signed wildcard preview hostnames resolve stably without a registry lookup", async () => { + const domain = previewDomain("preview.example.test", "preview-host-secret-with-at-least-thirty-two-bytes")! + const first = await allocatePreviewSiteContext(domain) + const second = await allocatePreviewSiteContext(domain) + assert.notEqual(first.id, second.id) + assert.notEqual(siteStorageKeys(first).root, siteStorageKeys(second).root) + assert.deepEqual(await resolvePreviewSiteContextFromRequest(new Request(`${first.origin}/`), domain), first) + for (const hostname of [ + `unknown.preview.example.test`, + `s${"0".repeat(24)}-g1-${"0".repeat(16)}.preview.example.test`, + `s${"0".repeat(24)}-g2-${"0".repeat(16)}.preview.example.test`, + `${first.id}.preview.example.test.evil.test`, + `nested.${first.id}.preview.example.test`, + ]) await assert.rejects(() => resolvePreviewSiteContextFromRequest(new Request(`https://${hostname}/`), domain), /Unknown site hostname/) +}) From a8119823a02d8c8914ed1350fe0b96eda0c4207c Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sun, 26 Jul 2026 16:48:54 -0400 Subject: [PATCH 2/4] feat(runtime-cloudflare): add allocation lifecycle foundation --- package.json | 2 +- .../src/allocation-lifecycle.ts | 110 ++++++++++++++++++ .../src/provisioning-api.ts | 12 +- packages/runtime-cloudflare/src/worker.ts | 7 ++ tests/cloudflare-allocation-lifecycle.test.ts | 57 +++++++++ tests/cloudflare-provisioning-api.test.ts | 1 + 6 files changed, 184 insertions(+), 5 deletions(-) create mode 100644 packages/runtime-cloudflare/src/allocation-lifecycle.ts create mode 100644 tests/cloudflare-allocation-lifecycle.test.ts diff --git a/package.json b/package.json index 711960a1c..317f5ceae 100644 --- a/package.json +++ b/package.json @@ -110,7 +110,7 @@ "test:redaction": "tsx tests/redaction.test.ts", "test:browser-preview-routing": "tsx --test tests/browser-preview-routing.test.ts", "test:browser-routed-command-security": "tsx --test tests/browser-routed-command-security.test.ts", - "test:cloudflare-runtime": "node --test tests/cloudflare-d1-provisioner.test.mjs && tsx tests/cloudflare-site-context.test.ts && tsx tests/cloudflare-coordinator-site-partitioning.test.ts && tsx tests/cloudflare-d1-operation-repository.test.ts && tsx tests/cloudflare-provisioning-api.test.ts && tsx tests/cloudflare-runtime.test.ts && node ./node_modules/typescript/bin/tsc -p packages/runtime-cloudflare --noEmit", + "test:cloudflare-runtime": "node --test tests/cloudflare-d1-provisioner.test.mjs && tsx tests/cloudflare-site-context.test.ts && tsx tests/cloudflare-coordinator-site-partitioning.test.ts && tsx tests/cloudflare-d1-operation-repository.test.ts && tsx tests/cloudflare-allocation-lifecycle.test.ts && tsx tests/cloudflare-provisioning-api.test.ts && tsx tests/cloudflare-runtime.test.ts && node ./node_modules/typescript/bin/tsc -p packages/runtime-cloudflare --noEmit", "test:cloudflare-administrator-claim": "tsx tests/cloudflare-provisioning-api.test.ts", "test:cloudflare-wordpress-auth": "tsx tests/cloudflare-wordpress-auth.test.ts", "test:cloudflare-wordpress-archive-corpus": "tsx tests/cloudflare-wordpress-archive-corpus.test.ts", diff --git a/packages/runtime-cloudflare/src/allocation-lifecycle.ts b/packages/runtime-cloudflare/src/allocation-lifecycle.ts new file mode 100644 index 000000000..ccf61ab5e --- /dev/null +++ b/packages/runtime-cloudflare/src/allocation-lifecycle.ts @@ -0,0 +1,110 @@ +import type { SiteContext } from "./site-context.js" + +export type AllocationLifecycleState = "active" | "deleting" | "tombstoned" +export interface AllocationIdentity { siteId: string; generation: number } +export interface AllocationRetentionPolicy { ttlMs: number; retainMs: number } +export interface AllocationDeletionReceipt { schema: "wp-codebox/cloudflare-allocation-deletion-receipt/v1"; siteId: string; generation: number; deletedObjects: number; completedAt: string } +export interface AllocationLifecycle { identity: AllocationIdentity; principal: string; state: AllocationLifecycleState; expiresAt: number; retainUntil: number; mutationFence: number; operationFence: number; cleanupCursor: string | null; deletedObjects: number; receipt: AllocationDeletionReceipt | null } + +const RECEIPT_SCHEMA = "wp-codebox/cloudflare-allocation-deletion-receipt/v1" as const +const schemaReady = new WeakMap>() + +/** Durable lifecycle policy for elastic Cloudflare site allocations. */ +export class CloudflareAllocationLifecycle { + constructor(private readonly database: D1Database, private readonly policy: AllocationRetentionPolicy = { ttlMs: 24 * 60 * 60 * 1000, retainMs: 60 * 60 * 1000 }) { + if (!Number.isSafeInteger(policy.ttlMs) || !Number.isSafeInteger(policy.retainMs) || policy.ttlMs < 1 || policy.retainMs < 0) throw new Error("Allocation lifecycle policy is invalid.") + } + + async initialize(): Promise { await ensureSchema(this.database) } + async create(site: SiteContext, principal: string, now = Date.now()): Promise { + await this.initialize() + const identity = allocationIdentity(site.id) + await this.database.prepare("INSERT OR IGNORE INTO wp_codebox_site_lifecycles (site_id, generation, principal, state, expires_at, retain_until, mutation_fence, operation_fence, cleanup_cursor, deleted_objects, created_at, updated_at) VALUES (?, ?, ?, 'active', ?, ?, 1, 1, NULL, 0, ?, ?)").bind(site.id, identity.generation, principal, now + this.policy.ttlMs, now + this.policy.ttlMs + this.policy.retainMs, now, now).run() + const lifecycle = await this.get(identity) + if (!lifecycle || lifecycle.principal !== principal) throw new AllocationLifecycleConflict("Allocation identity conflicts with an existing owner.") + return lifecycle + } + async get(identity: AllocationIdentity): Promise { + await this.initialize() + const row = await this.database.prepare("SELECT site_id, generation, principal, state, expires_at, retain_until, mutation_fence, operation_fence, cleanup_cursor, deleted_objects, receipt_json FROM wp_codebox_site_lifecycles WHERE site_id = ? AND generation = ?").bind(identity.siteId, identity.generation).first() + return row ? hydrate(row) : null + } + async renew(identity: AllocationIdentity, principal: string, now = Date.now()): Promise { + await this.initialize() + const result = await this.database.prepare("UPDATE wp_codebox_site_lifecycles SET expires_at = ?, retain_until = ?, updated_at = ? WHERE site_id = ? AND generation = ? AND principal = ? AND state = 'active' AND expires_at > ?").bind(now + this.policy.ttlMs, now + this.policy.ttlMs + this.policy.retainMs, now, identity.siteId, identity.generation, principal, now).run() + if (result.meta.changes !== 1) throw new AllocationLifecycleConflict("Allocation is not renewable by this owner.") + return (await this.get(identity))! + } + async beginMutation(identity: AllocationIdentity, principal: string, now = Date.now()): Promise { + await this.initialize() + const result = await this.database.prepare("UPDATE wp_codebox_site_lifecycles SET mutation_fence = mutation_fence + 1, updated_at = ? WHERE site_id = ? AND generation = ? AND principal = ? AND state = 'active' AND expires_at > ?").bind(now, identity.siteId, identity.generation, principal, now).run() + if (result.meta.changes !== 1) throw new AllocationLifecycleConflict("Allocation is not mutable by this owner.") + return (await this.get(identity))!.mutationFence + } + async assertMutation(identity: AllocationIdentity, fence: number, now = Date.now()): Promise { + const lifecycle = await this.get(identity) + if (!lifecycle || lifecycle.state !== "active" || lifecycle.expiresAt <= now || lifecycle.mutationFence !== fence) throw new AllocationLifecycleConflict("Allocation mutation fence changed.") + } + async beginDeletion(identity: AllocationIdentity, principal: string | null, now = Date.now()): Promise { + await this.initialize() + const owner = principal === null ? "" : " AND principal = ?" + const values: unknown[] = [now, identity.siteId, identity.generation] + if (principal !== null) values.push(principal) + const result = await this.database.prepare(`UPDATE wp_codebox_site_lifecycles SET state = 'deleting', mutation_fence = mutation_fence + 1, operation_fence = operation_fence + 1, cleanup_cursor = NULL, updated_at = ? WHERE site_id = ? AND generation = ?${owner} AND state = 'active'`).bind(...values).run() + if (result.meta.changes !== 1) throw new AllocationLifecycleConflict("Allocation is not deletable by this owner.") + return (await this.get(identity))!.operationFence + } + async expire(now = Date.now(), limit = 8): Promise { + await this.initialize() + const rows = await this.database.prepare("SELECT site_id, generation FROM wp_codebox_site_lifecycles WHERE state = 'active' AND expires_at <= ? ORDER BY expires_at LIMIT ?").bind(now, limit).all>() + const expired: AllocationIdentity[] = [] + for (const row of rows.results) { + try { await this.beginDeletion({ siteId: row.site_id, generation: row.generation }, null, now); expired.push({ siteId: row.site_id, generation: row.generation }) } catch { /* A competing owner or sweeper already fenced it. */ } + } + return expired + } + async pendingDeletions(limit = 8): Promise> { + await this.initialize() + const rows = await this.database.prepare("SELECT site_id, generation, principal, state, expires_at, retain_until, mutation_fence, operation_fence, cleanup_cursor, deleted_objects, receipt_json FROM wp_codebox_site_lifecycles WHERE state = 'deleting' ORDER BY updated_at LIMIT ?").bind(limit).all() + return rows.results.map(hydrate) + } + async reclaim(bucket: Pick, identity: AllocationIdentity, operationFence: number, limit = 100, now = Date.now()): Promise { + if (!Number.isInteger(limit) || limit < 1 || limit > 1000) throw new Error("Allocation reclamation limit is invalid.") + const lifecycle = await this.get(identity) + if (!lifecycle || lifecycle.state !== "deleting" || lifecycle.operationFence !== operationFence) throw new AllocationLifecycleConflict("Allocation deletion fence changed.") + const page = await bucket.list({ prefix: `sites/${identity.siteId}/`, cursor: lifecycle.cleanupCursor ?? undefined, limit }) + if (page.objects.length) await bucket.delete(page.objects.map((object) => object.key)) + const cursor = page.truncated ? page.cursor : null + const updated = await this.database.prepare("UPDATE wp_codebox_site_lifecycles SET cleanup_cursor = ?, deleted_objects = deleted_objects + ?, updated_at = ? WHERE site_id = ? AND generation = ? AND state = 'deleting' AND operation_fence = ?").bind(cursor, page.objects.length, now, identity.siteId, identity.generation, operationFence).run() + if (updated.meta.changes !== 1) throw new AllocationLifecycleConflict("Allocation deletion fence changed.") + if (cursor) return (await this.get(identity))! + const receipt: AllocationDeletionReceipt = { schema: RECEIPT_SCHEMA, siteId: identity.siteId, generation: identity.generation, deletedObjects: lifecycle.deletedObjects + page.objects.length, completedAt: new Date(now).toISOString() } + await this.database.batch([ + this.database.prepare("INSERT OR IGNORE INTO wp_codebox_site_deletion_receipts (site_id, generation, receipt_json, created_at) VALUES (?, ?, ?, ?)").bind(identity.siteId, identity.generation, JSON.stringify(receipt), now), + this.database.prepare("UPDATE wp_codebox_site_lifecycles SET state = 'tombstoned', cleanup_cursor = NULL, receipt_json = ?, terminal_at = ?, updated_at = ? WHERE site_id = ? AND generation = ? AND state = 'deleting' AND operation_fence = ?").bind(JSON.stringify(receipt), now, now, identity.siteId, identity.generation, operationFence), + ]) + return (await this.get(identity))! + } +} + +export class AllocationLifecycleConflict extends Error {} +export function allocationIdentity(siteId: string): AllocationIdentity { + const match = /-g([1-9][0-9]*)-[a-f0-9]{16}$/.exec(siteId) + return { siteId, generation: match ? Number(match[1]) : 1 } +} + +interface LifecycleRow { site_id: string; generation: number; principal: string; state: AllocationLifecycleState; expires_at: number; retain_until: number; mutation_fence: number; operation_fence: number; cleanup_cursor: string | null; deleted_objects: number; receipt_json: string | null } +function hydrate(row: LifecycleRow): AllocationLifecycle { return { identity: { siteId: row.site_id, generation: row.generation }, principal: row.principal, state: row.state, expiresAt: row.expires_at, retainUntil: row.retain_until, mutationFence: row.mutation_fence, operationFence: row.operation_fence, cleanupCursor: row.cleanup_cursor, deletedObjects: row.deleted_objects, receipt: row.receipt_json ? JSON.parse(row.receipt_json) as AllocationDeletionReceipt : null } } +async function ensureSchema(database: D1Database): Promise { + let pending = schemaReady.get(database as object) + if (!pending) { + pending = (async () => { + await database.prepare("CREATE TABLE IF NOT EXISTS wp_codebox_site_lifecycles (site_id TEXT NOT NULL, generation INTEGER NOT NULL, principal TEXT NOT NULL, state TEXT NOT NULL CHECK (state IN ('active','deleting','tombstoned')), expires_at INTEGER NOT NULL, retain_until INTEGER NOT NULL, mutation_fence INTEGER NOT NULL, operation_fence INTEGER NOT NULL, cleanup_cursor TEXT, deleted_objects INTEGER NOT NULL, receipt_json TEXT, terminal_at INTEGER, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, PRIMARY KEY (site_id, generation))").run() + await database.prepare("CREATE TABLE IF NOT EXISTS wp_codebox_site_deletion_receipts (site_id TEXT NOT NULL, generation INTEGER NOT NULL, receipt_json TEXT NOT NULL, created_at INTEGER NOT NULL, PRIMARY KEY (site_id, generation))").run() + await database.prepare("CREATE INDEX IF NOT EXISTS wp_codebox_site_lifecycle_expiry ON wp_codebox_site_lifecycles(state, expires_at)").run() + })() + schemaReady.set(database as object, pending) + pending.catch(() => schemaReady.delete(database as object)) + } + await pending +} diff --git a/packages/runtime-cloudflare/src/provisioning-api.ts b/packages/runtime-cloudflare/src/provisioning-api.ts index 80e54b72c..5d17e016d 100644 --- a/packages/runtime-cloudflare/src/provisioning-api.ts +++ b/packages/runtime-cloudflare/src/provisioning-api.ts @@ -2,6 +2,7 @@ import { D1OperationRepository, OperationConflict, type StaticArtifactOperation, import { MAX_STATIC_ARTIFACT_BYTES, readBoundedRequestBytes, readStaticArtifactImport, StaticArtifactImportError, validateStaticArtifact } from "./static-artifact-import.js" import { allocatePreviewSiteContext, parseSiteContexts, previewDomain, siteStorageKeys, type SiteContext } from "./site-context.js" import { deriveSiteCredential } from "./wordpress-auth.js" +import { CloudflareAllocationLifecycle } from "./allocation-lifecycle.js" export const PROVISIONING_API_SCHEMA = "wp-codebox/provisioning-api/v1" export const PROVISIONING_CREATE_REQUEST_SCHEMA = "wp-codebox/provisioning-create-request/v1" @@ -255,7 +256,7 @@ class AllocationError extends Error { constructor(readonly code: "quota_exceeded class AllocationStore { constructor(private readonly db: D1Database) {} async allocate(token: Token, input: CreateInput, domain: ReturnType, configured: SiteContext[]): Promise { - await this.schema(); const existing = await this.byRequest(token.principal, input.key) + await this.schema(); const lifecycle = new CloudflareAllocationLifecycle(this.db); await lifecycle.initialize(); const existing = await this.byRequest(token.principal, input.key) if (existing) { if (existing.fingerprint !== input.fingerprint) throw new OperationConflict("The idempotency key is already bound to a different immutable input."); return existing } if (domain && token.sites) throw new AllocationError("quota_exceeded") const attempts = domain ? 8 : configured.length @@ -264,17 +265,20 @@ class AllocationStore { const now = Date.now() try { const [allocation, reservation] = await this.db.batch([ - this.db.prepare("INSERT OR IGNORE INTO wp_codebox_api_sites (site_id, principal, idempotency_key, fingerprint, artifact_sha256, artifact_size, slug, name, site_title, operation_id, created_at) SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ? WHERE (SELECT COUNT(*) FROM wp_codebox_api_sites WHERE principal = ?) < ? AND NOT EXISTS (SELECT 1 FROM wp_codebox_sites WHERE site_id = ?)").bind(site.id, token.principal, input.key, input.fingerprint, input.artifactSha256, input.artifactSize, input.options.slug, input.options.name, input.options.siteTitle, now, token.principal, token.maxSites, site.id), + this.db.prepare("INSERT OR IGNORE INTO wp_codebox_api_sites (site_id, principal, idempotency_key, fingerprint, artifact_sha256, artifact_size, slug, name, site_title, operation_id, created_at) SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ? WHERE (SELECT COUNT(*) FROM wp_codebox_api_sites a LEFT JOIN wp_codebox_site_lifecycles l ON l.site_id = a.site_id WHERE a.principal = ? AND (l.site_id IS NULL OR l.state != 'tombstoned')) < ? AND NOT EXISTS (SELECT 1 FROM wp_codebox_sites WHERE site_id = ?)").bind(site.id, token.principal, input.key, input.fingerprint, input.artifactSha256, input.artifactSize, input.options.slug, input.options.name, input.options.siteTitle, now, token.principal, token.maxSites, site.id), this.db.prepare("INSERT INTO wp_codebox_sites (site_id, hostname, origin, state, created_at, activated_at, updated_at) SELECT ?, ?, ?, 'active', ?, ?, ? WHERE EXISTS (SELECT 1 FROM wp_codebox_api_sites WHERE site_id = ? AND principal = ? AND idempotency_key = ? AND fingerprint = ?)").bind(site.id, site.hostname, site.origin, now, now, now, site.id, token.principal, input.key, input.fingerprint), ]) - if (allocation.meta.changes === 1 && reservation.meta.changes === 1) return { siteId: site.id, principal: token.principal, key: input.key, fingerprint: input.fingerprint, operationId: null, artifactSha256: input.artifactSha256, artifactSize: input.artifactSize, options: input.options } + if (allocation.meta.changes === 1 && reservation.meta.changes === 1) { + await lifecycle.create(site, token.principal, now) + return { siteId: site.id, principal: token.principal, key: input.key, fingerprint: input.fingerprint, operationId: null, artifactSha256: input.artifactSha256, artifactSize: input.artifactSize, options: input.options } + } } catch (error) { if (!(error instanceof Error) || !/unique|constraint/i.test(error.message)) throw error } const raced = await this.byRequest(token.principal, input.key) if (raced) { if (raced.fingerprint !== input.fingerprint) throw new OperationConflict("The idempotency key is already bound to a different immutable input."); return raced } } - const count = await this.db.prepare("SELECT COUNT(*) AS count FROM wp_codebox_api_sites WHERE principal = ?").bind(token.principal).first<{ count: number }>() + const count = await this.db.prepare("SELECT COUNT(*) AS count FROM wp_codebox_api_sites a LEFT JOIN wp_codebox_site_lifecycles l ON l.site_id = a.site_id WHERE a.principal = ? AND (l.site_id IS NULL OR l.state != 'tombstoned')").bind(token.principal).first<{ count: number }>() throw new AllocationError((count?.count ?? 0) >= token.maxSites ? "quota_exceeded" : "capacity_exhausted") } async context(siteId: string): Promise { await this.schema(); const row = await this.db.prepare("SELECT site_id, hostname, origin FROM wp_codebox_sites WHERE site_id = ? AND state = 'active'").bind(siteId).first<{ site_id: string; hostname: string; origin: string }>(); return row ? { id: row.site_id, hostname: row.hostname, origin: row.origin } : null } diff --git a/packages/runtime-cloudflare/src/worker.ts b/packages/runtime-cloudflare/src/worker.ts index 0ddb686b5..7fe15391d 100644 --- a/packages/runtime-cloudflare/src/worker.ts +++ b/packages/runtime-cloudflare/src/worker.ts @@ -15,6 +15,7 @@ import { routeWorkerRequest } from "./request-routing.js" import { readStaticArtifactImport, STATIC_ARTIFACT_IMPORT_RESULT_SCHEMA, StaticArtifactImportError, type StaticArtifactImport } from "./static-artifact-import.js" import { D1OperationRepository, OperationConflict, STATIC_ARTIFACT_OPERATION_SCHEMA, shouldRecoverPreparedCommit } from "./d1-operation-repository.js" import { resumeProvisioningAllocation, routeProvisioningApi } from "./provisioning-api.js" +import { CloudflareAllocationLifecycle } from "./allocation-lifecycle.js" import { toFetchResponse, toPHPRequest } from "./request-translation.js" import { DEFAULT_SITE_CONTEXT, parseSiteContexts, previewDomain, resolvePreviewSiteContextFromRequest, resolveSiteContextFromRequest, siteStorageKeys, type SiteContext } from "./site-context.js" import { validateUploadManifestFiles, validateUploadMetadata } from "./upload-persistence.js" @@ -279,6 +280,12 @@ export function createCloudflareRuntime( } }, async scheduled(controller: ScheduledController, env: Env): Promise { + if (env.WORDPRESS_STATE_DATABASE) { + const lifecycle = new CloudflareAllocationLifecycle(env.WORDPRESS_STATE_DATABASE) + await lifecycle.expire(controller.scheduledTime, 1) + const deleting = (await lifecycle.pendingDeletions(1))[0] + if (deleting) await lifecycle.reclaim(env.WORDPRESS_STATE_BUCKET, deleting.identity, deleting.operationFence, 100, controller.scheduledTime) + } const configured = parseSiteContexts(env.WORDPRESS_SITE_CONTEXTS) const registered = env.WORDPRESS_STATE_DATABASE && resolveOperations?.(env) ? await resolveOperations(env)!.activeSites() : [] const sites = [...new Map([...configured, ...registered].map((site) => [site.id, site])).values()].sort((left, right) => left.id.localeCompare(right.id)) diff --git a/tests/cloudflare-allocation-lifecycle.test.ts b/tests/cloudflare-allocation-lifecycle.test.ts new file mode 100644 index 000000000..658941c6e --- /dev/null +++ b/tests/cloudflare-allocation-lifecycle.test.ts @@ -0,0 +1,57 @@ +import assert from "node:assert/strict" +import { DatabaseSync } from "node:sqlite" +import test from "node:test" +import { AllocationLifecycleConflict, CloudflareAllocationLifecycle } from "../packages/runtime-cloudflare/src/allocation-lifecycle.js" + +function database(): D1Database { + const sqlite = new DatabaseSync(":memory:") + let queued = Promise.resolve() + return { prepare(query: string) { const statement = sqlite.prepare(query); let values: unknown[] = []; return { bind(...next: unknown[]) { values = next; return this }, async run() { return { meta: { changes: statement.run(...values).changes } } }, async first() { return statement.get(...values) as T | null }, async all() { return { results: statement.all(...values) as T[] } } } }, async batch(statements: Array<{ run: () => Promise<{ meta: { changes: number } }> }>) { let release!: () => void; const previous = queued; queued = new Promise((resolve) => { release = resolve }); await previous; sqlite.exec("BEGIN"); try { const results = []; for (const statement of statements) results.push(await statement.run()); sqlite.exec("COMMIT"); return results } catch (error) { sqlite.exec("ROLLBACK"); throw error } finally { release() } } } as unknown as D1Database +} +class Bucket { + objects = new Map() + failOnce = false + async list(options: { prefix?: string; cursor?: string; limit?: number }) { + const keys = [...this.objects.keys()].filter((key) => key.startsWith(options.prefix ?? "")).sort() + const available = options.cursor ? keys.filter((key) => key > options.cursor!) : keys; const page = available.slice(0, options.limit ?? 100) + return { objects: page.map((key) => ({ key })), truncated: page.length < available.length, cursor: page.at(-1) ?? "" } + } + async delete(keys: string[]) { if (this.failOnce) { this.failOnce = false; throw new Error("transient R2 failure") } for (const key of keys) this.objects.delete(key) } +} +const site = (id = "saaaaaaaaaaaaaaaaaaaaaaaa-g2-0123456789abcdef") => ({ id, hostname: `${id}.preview.example`, origin: `https://${id}.preview.example` }) + +test("expiration fences mutations, retains quota until terminal reclamation, and then releases it", async () => { + const lifecycle = new CloudflareAllocationLifecycle(database(), { ttlMs: 10, retainMs: 20 }); const bucket = new Bucket(); const now = 1_000 + const active = await lifecycle.create(site(), "owner", now); assert.equal(active.retainUntil, now + 30); const mutation = await lifecycle.beginMutation(active.identity, "owner", now + 1) + assert.deepEqual(await lifecycle.expire(now + 10), [active.identity]) + await assert.rejects(() => lifecycle.assertMutation(active.identity, mutation, now + 10), AllocationLifecycleConflict) + assert.equal((await lifecycle.get(active.identity))!.state, "deleting") + bucket.objects.set(`sites/${site().id}/one`, new Uint8Array([1])) + const deleting = (await lifecycle.get(active.identity))!; const tombstone = await lifecycle.reclaim(bucket as unknown as Pick, active.identity, deleting.operationFence, 10, now + 11) + assert.equal(tombstone.state, "tombstoned"); assert.equal(tombstone.receipt?.deletedObjects, 1); assert.equal(tombstone.receipt?.completedAt, new Date(now + 11).toISOString()) +}) + +test("owner authorization, concurrent deletion, and stale generations fail closed", async () => { + const lifecycle = new CloudflareAllocationLifecycle(database()); const created = await lifecycle.create(site(), "owner", 1_000) + await assert.rejects(() => lifecycle.renew(created.identity, "other", 1_001), AllocationLifecycleConflict) + await assert.rejects(() => lifecycle.beginDeletion(created.identity, "other", 1_001), AllocationLifecycleConflict) + const [one, two] = await Promise.allSettled([lifecycle.beginDeletion(created.identity, "owner", 1_001), lifecycle.beginDeletion(created.identity, "owner", 1_001)]) + assert.equal([one, two].filter((result) => result.status === "fulfilled").length, 1) + assert.equal(await lifecycle.get({ ...created.identity, generation: 1 }), null, "a prior generation is never an authorized identity") +}) + +test("cleanup checkpoints resume after partial R2 failure and tombstone receipts are immutable", async () => { + const lifecycle = new CloudflareAllocationLifecycle(database()); const bucket = new Bucket(); const created = await lifecycle.create(site(), "owner", 1_000) + for (const key of ["a", "b", "c"]) bucket.objects.set(`sites/${site().id}/${key}`, new Uint8Array([1])) + const fence = await lifecycle.beginDeletion(created.identity, "owner", 1_001) + const first = await lifecycle.reclaim(bucket as unknown as Pick, created.identity, fence, 1, 1_002) + assert.ok(first.cleanupCursor?.endsWith("/a")); assert.equal(first.deletedObjects, 1) + bucket.failOnce = true + await assert.rejects(() => lifecycle.reclaim(bucket as unknown as Pick, created.identity, fence, 1, 1_003)) + let current = await lifecycle.reclaim(bucket as unknown as Pick, created.identity, fence, 1, 1_004) + current = await lifecycle.reclaim(bucket as unknown as Pick, created.identity, fence, 1, 1_005) + assert.equal(current.state, "tombstoned"); assert.equal(bucket.objects.size, 0) + const receipt = JSON.stringify(current.receipt) + await assert.rejects(() => lifecycle.reclaim(bucket as unknown as Pick, created.identity, fence, 1, 1_006), AllocationLifecycleConflict) + assert.equal(JSON.stringify((await lifecycle.get(created.identity))!.receipt), receipt) +}) diff --git a/tests/cloudflare-provisioning-api.test.ts b/tests/cloudflare-provisioning-api.test.ts index 2b2306396..443cd903c 100644 --- a/tests/cloudflare-provisioning-api.test.ts +++ b/tests/cloudflare-provisioning-api.test.ts @@ -79,6 +79,7 @@ test("changed fingerprints conflict without another allocation", async () => { c test("create replay honors current site restrictions", async () => { const r = runtime(); await create(r); const records = JSON.parse(r.env.WORDPRESS_API_TOKENS) as Array>; records[0].sites = ["beta"]; r.env.WORDPRESS_API_TOKENS = JSON.stringify(records); assert.equal((await create(r)).status, 404); assert.equal(count(r.db, "wp_codebox_api_sites"), 1) }) test("concurrent distinct keys allocate unique contexts", async () => { const r = runtime(); await Promise.all([create(r, "one"), create(r, "two")]); const ids = r.db.sqlite.prepare("SELECT site_id FROM wp_codebox_api_sites ORDER BY site_id").all() as Array<{ site_id: string }>; assert.deepEqual(ids.map((row) => row.site_id), ["alpha", "beta"]) }) test("maxSites concurrency distinguishes quota from pool exhaustion", async () => { const quota = runtime(database(), new Bucket(), undefined, 1); const responses = await Promise.all([create(quota, "one"), create(quota, "two")]); assert.equal(responses.filter((response) => response.status === 202).length, 1); const rejected = responses.find((response) => response.status !== 202)!; assert.equal((await rejected.json() as { error: { code: string } }).error.code, "quota_exceeded"); assert.equal(count(quota.db, "wp_codebox_api_sites"), 1); const pool = runtime(database(), new Bucket(), undefined, 3); await Promise.all([create(pool, "one"), create(pool, "two")]); const pr = await create(pool, "three"); assert.equal((await pr.json() as { error: { code: string } }).error.code, "capacity_exhausted") }) +test("quota is released only after the lifecycle reaches its tombstone", async () => { const r = dynamicRuntime(database(), new Bucket(), 1); const first = await (await create(r, "one")).json() as { site: { id: string } }; assert.equal((await create(r, "two")).status, 429); r.db.sqlite.prepare("UPDATE wp_codebox_site_lifecycles SET state = 'tombstoned' WHERE site_id = ?").run(first.site.id); assert.equal((await create(r, "two")).status, 202) }) test("dynamic preview allocation is unique, idempotent, and not limited by configured contexts", async () => { const r = dynamicRuntime() const responses = await Promise.all(Array.from({ length: 100 }, (_, index) => create(r, `dynamic-${index}`))) From 4b0a5f726511b6ffde99bc388bc31c11f672d582 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sun, 26 Jul 2026 17:05:28 -0400 Subject: [PATCH 3/4] feat(runtime-cloudflare): expose allocation lifecycle controls --- .../src/allocation-lifecycle.ts | 29 ++++++++++--------- .../src/d1-operation-repository.ts | 9 ++++-- .../src/provisioning-api.ts | 28 ++++++++++++++++-- packages/runtime-cloudflare/src/worker.ts | 2 +- tests/cloudflare-allocation-lifecycle.test.ts | 7 +++-- tests/cloudflare-provisioning-api.test.ts | 13 ++++++++- 6 files changed, 66 insertions(+), 22 deletions(-) diff --git a/packages/runtime-cloudflare/src/allocation-lifecycle.ts b/packages/runtime-cloudflare/src/allocation-lifecycle.ts index ccf61ab5e..cb7991120 100644 --- a/packages/runtime-cloudflare/src/allocation-lifecycle.ts +++ b/packages/runtime-cloudflare/src/allocation-lifecycle.ts @@ -3,8 +3,8 @@ import type { SiteContext } from "./site-context.js" export type AllocationLifecycleState = "active" | "deleting" | "tombstoned" export interface AllocationIdentity { siteId: string; generation: number } export interface AllocationRetentionPolicy { ttlMs: number; retainMs: number } -export interface AllocationDeletionReceipt { schema: "wp-codebox/cloudflare-allocation-deletion-receipt/v1"; siteId: string; generation: number; deletedObjects: number; completedAt: string } -export interface AllocationLifecycle { identity: AllocationIdentity; principal: string; state: AllocationLifecycleState; expiresAt: number; retainUntil: number; mutationFence: number; operationFence: number; cleanupCursor: string | null; deletedObjects: number; receipt: AllocationDeletionReceipt | null } +export interface AllocationDeletionReceipt { schema: "wp-codebox/cloudflare-allocation-deletion-receipt/v1"; siteId: string; generation: number; deletedObjects: number; deletedBytes: number; retainedObjects: number; retainedBytes: number; unresolvedObjects: number; startedAt: string; completedAt: string; terminalState: "tombstoned" } +export interface AllocationLifecycle { identity: AllocationIdentity; principal: string; state: AllocationLifecycleState; expiresAt: number; retainUntil: number; mutationFence: number; operationFence: number; cleanupCursor: string | null; deletedObjects: number; deletedBytes: number; receipt: AllocationDeletionReceipt | null } const RECEIPT_SCHEMA = "wp-codebox/cloudflare-allocation-deletion-receipt/v1" as const const schemaReady = new WeakMap>() @@ -19,14 +19,14 @@ export class CloudflareAllocationLifecycle { async create(site: SiteContext, principal: string, now = Date.now()): Promise { await this.initialize() const identity = allocationIdentity(site.id) - await this.database.prepare("INSERT OR IGNORE INTO wp_codebox_site_lifecycles (site_id, generation, principal, state, expires_at, retain_until, mutation_fence, operation_fence, cleanup_cursor, deleted_objects, created_at, updated_at) VALUES (?, ?, ?, 'active', ?, ?, 1, 1, NULL, 0, ?, ?)").bind(site.id, identity.generation, principal, now + this.policy.ttlMs, now + this.policy.ttlMs + this.policy.retainMs, now, now).run() + await this.database.prepare("INSERT OR IGNORE INTO wp_codebox_site_lifecycles (site_id, generation, principal, state, expires_at, retain_until, mutation_fence, operation_fence, cleanup_cursor, deleted_objects, deleted_bytes, created_at, updated_at) VALUES (?, ?, ?, 'active', ?, ?, 1, 1, NULL, 0, 0, ?, ?)").bind(site.id, identity.generation, principal, now + this.policy.ttlMs, now + this.policy.ttlMs + this.policy.retainMs, now, now).run() const lifecycle = await this.get(identity) if (!lifecycle || lifecycle.principal !== principal) throw new AllocationLifecycleConflict("Allocation identity conflicts with an existing owner.") return lifecycle } async get(identity: AllocationIdentity): Promise { await this.initialize() - const row = await this.database.prepare("SELECT site_id, generation, principal, state, expires_at, retain_until, mutation_fence, operation_fence, cleanup_cursor, deleted_objects, receipt_json FROM wp_codebox_site_lifecycles WHERE site_id = ? AND generation = ?").bind(identity.siteId, identity.generation).first() + const row = await this.database.prepare("SELECT site_id, generation, principal, state, expires_at, retain_until, mutation_fence, operation_fence, cleanup_cursor, deleted_objects, deleted_bytes, receipt_json FROM wp_codebox_site_lifecycles WHERE site_id = ? AND generation = ?").bind(identity.siteId, identity.generation).first() return row ? hydrate(row) : null } async renew(identity: AllocationIdentity, principal: string, now = Date.now()): Promise { @@ -48,9 +48,9 @@ export class CloudflareAllocationLifecycle { async beginDeletion(identity: AllocationIdentity, principal: string | null, now = Date.now()): Promise { await this.initialize() const owner = principal === null ? "" : " AND principal = ?" - const values: unknown[] = [now, identity.siteId, identity.generation] + const values: unknown[] = [now + this.policy.retainMs, now, identity.siteId, identity.generation] if (principal !== null) values.push(principal) - const result = await this.database.prepare(`UPDATE wp_codebox_site_lifecycles SET state = 'deleting', mutation_fence = mutation_fence + 1, operation_fence = operation_fence + 1, cleanup_cursor = NULL, updated_at = ? WHERE site_id = ? AND generation = ?${owner} AND state = 'active'`).bind(...values).run() + const result = await this.database.prepare(`UPDATE wp_codebox_site_lifecycles SET state = 'deleting', mutation_fence = mutation_fence + 1, operation_fence = operation_fence + 1, retain_until = MIN(retain_until, ?), cleanup_cursor = NULL, updated_at = ? WHERE site_id = ? AND generation = ?${owner} AND state = 'active'`).bind(...values).run() if (result.meta.changes !== 1) throw new AllocationLifecycleConflict("Allocation is not deletable by this owner.") return (await this.get(identity))!.operationFence } @@ -63,22 +63,24 @@ export class CloudflareAllocationLifecycle { } return expired } - async pendingDeletions(limit = 8): Promise> { + async pendingDeletions(limit = 8, now = Date.now()): Promise> { await this.initialize() - const rows = await this.database.prepare("SELECT site_id, generation, principal, state, expires_at, retain_until, mutation_fence, operation_fence, cleanup_cursor, deleted_objects, receipt_json FROM wp_codebox_site_lifecycles WHERE state = 'deleting' ORDER BY updated_at LIMIT ?").bind(limit).all() + const rows = await this.database.prepare("SELECT site_id, generation, principal, state, expires_at, retain_until, mutation_fence, operation_fence, cleanup_cursor, deleted_objects, deleted_bytes, receipt_json FROM wp_codebox_site_lifecycles WHERE state = 'deleting' AND retain_until <= ? ORDER BY updated_at LIMIT ?").bind(now, limit).all() return rows.results.map(hydrate) } async reclaim(bucket: Pick, identity: AllocationIdentity, operationFence: number, limit = 100, now = Date.now()): Promise { if (!Number.isInteger(limit) || limit < 1 || limit > 1000) throw new Error("Allocation reclamation limit is invalid.") const lifecycle = await this.get(identity) if (!lifecycle || lifecycle.state !== "deleting" || lifecycle.operationFence !== operationFence) throw new AllocationLifecycleConflict("Allocation deletion fence changed.") + if (lifecycle.retainUntil > now) throw new AllocationLifecycleConflict("Allocation retention period is still active.") const page = await bucket.list({ prefix: `sites/${identity.siteId}/`, cursor: lifecycle.cleanupCursor ?? undefined, limit }) if (page.objects.length) await bucket.delete(page.objects.map((object) => object.key)) const cursor = page.truncated ? page.cursor : null - const updated = await this.database.prepare("UPDATE wp_codebox_site_lifecycles SET cleanup_cursor = ?, deleted_objects = deleted_objects + ?, updated_at = ? WHERE site_id = ? AND generation = ? AND state = 'deleting' AND operation_fence = ?").bind(cursor, page.objects.length, now, identity.siteId, identity.generation, operationFence).run() + const deletedBytes = page.objects.reduce((total, object) => total + (object.size ?? 0), 0) + const updated = await this.database.prepare("UPDATE wp_codebox_site_lifecycles SET cleanup_cursor = ?, deleted_objects = deleted_objects + ?, deleted_bytes = deleted_bytes + ?, updated_at = ? WHERE site_id = ? AND generation = ? AND state = 'deleting' AND operation_fence = ?").bind(cursor, page.objects.length, deletedBytes, now, identity.siteId, identity.generation, operationFence).run() if (updated.meta.changes !== 1) throw new AllocationLifecycleConflict("Allocation deletion fence changed.") if (cursor) return (await this.get(identity))! - const receipt: AllocationDeletionReceipt = { schema: RECEIPT_SCHEMA, siteId: identity.siteId, generation: identity.generation, deletedObjects: lifecycle.deletedObjects + page.objects.length, completedAt: new Date(now).toISOString() } + const receipt: AllocationDeletionReceipt = { schema: RECEIPT_SCHEMA, siteId: identity.siteId, generation: identity.generation, deletedObjects: lifecycle.deletedObjects + page.objects.length, deletedBytes: lifecycle.deletedBytes + deletedBytes, retainedObjects: 0, retainedBytes: 0, unresolvedObjects: 0, startedAt: new Date(lifecycle.expiresAt).toISOString(), completedAt: new Date(now).toISOString(), terminalState: "tombstoned" } await this.database.batch([ this.database.prepare("INSERT OR IGNORE INTO wp_codebox_site_deletion_receipts (site_id, generation, receipt_json, created_at) VALUES (?, ?, ?, ?)").bind(identity.siteId, identity.generation, JSON.stringify(receipt), now), this.database.prepare("UPDATE wp_codebox_site_lifecycles SET state = 'tombstoned', cleanup_cursor = NULL, receipt_json = ?, terminal_at = ?, updated_at = ? WHERE site_id = ? AND generation = ? AND state = 'deleting' AND operation_fence = ?").bind(JSON.stringify(receipt), now, now, identity.siteId, identity.generation, operationFence), @@ -93,13 +95,14 @@ export function allocationIdentity(siteId: string): AllocationIdentity { return { siteId, generation: match ? Number(match[1]) : 1 } } -interface LifecycleRow { site_id: string; generation: number; principal: string; state: AllocationLifecycleState; expires_at: number; retain_until: number; mutation_fence: number; operation_fence: number; cleanup_cursor: string | null; deleted_objects: number; receipt_json: string | null } -function hydrate(row: LifecycleRow): AllocationLifecycle { return { identity: { siteId: row.site_id, generation: row.generation }, principal: row.principal, state: row.state, expiresAt: row.expires_at, retainUntil: row.retain_until, mutationFence: row.mutation_fence, operationFence: row.operation_fence, cleanupCursor: row.cleanup_cursor, deletedObjects: row.deleted_objects, receipt: row.receipt_json ? JSON.parse(row.receipt_json) as AllocationDeletionReceipt : null } } +interface LifecycleRow { site_id: string; generation: number; principal: string; state: AllocationLifecycleState; expires_at: number; retain_until: number; mutation_fence: number; operation_fence: number; cleanup_cursor: string | null; deleted_objects: number; deleted_bytes: number; receipt_json: string | null } +function hydrate(row: LifecycleRow): AllocationLifecycle { return { identity: { siteId: row.site_id, generation: row.generation }, principal: row.principal, state: row.state, expiresAt: row.expires_at, retainUntil: row.retain_until, mutationFence: row.mutation_fence, operationFence: row.operation_fence, cleanupCursor: row.cleanup_cursor, deletedObjects: row.deleted_objects, deletedBytes: row.deleted_bytes, receipt: row.receipt_json ? JSON.parse(row.receipt_json) as AllocationDeletionReceipt : null } } async function ensureSchema(database: D1Database): Promise { let pending = schemaReady.get(database as object) if (!pending) { pending = (async () => { - await database.prepare("CREATE TABLE IF NOT EXISTS wp_codebox_site_lifecycles (site_id TEXT NOT NULL, generation INTEGER NOT NULL, principal TEXT NOT NULL, state TEXT NOT NULL CHECK (state IN ('active','deleting','tombstoned')), expires_at INTEGER NOT NULL, retain_until INTEGER NOT NULL, mutation_fence INTEGER NOT NULL, operation_fence INTEGER NOT NULL, cleanup_cursor TEXT, deleted_objects INTEGER NOT NULL, receipt_json TEXT, terminal_at INTEGER, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, PRIMARY KEY (site_id, generation))").run() + await database.prepare("CREATE TABLE IF NOT EXISTS wp_codebox_site_lifecycles (site_id TEXT NOT NULL, generation INTEGER NOT NULL, principal TEXT NOT NULL, state TEXT NOT NULL CHECK (state IN ('active','deleting','tombstoned')), expires_at INTEGER NOT NULL, retain_until INTEGER NOT NULL, mutation_fence INTEGER NOT NULL, operation_fence INTEGER NOT NULL, cleanup_cursor TEXT, deleted_objects INTEGER NOT NULL, deleted_bytes INTEGER NOT NULL DEFAULT 0, receipt_json TEXT, terminal_at INTEGER, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, PRIMARY KEY (site_id, generation))").run() + try { await database.prepare("ALTER TABLE wp_codebox_site_lifecycles ADD COLUMN deleted_bytes INTEGER NOT NULL DEFAULT 0").run() } catch (error) { if (!(error instanceof Error) || !/duplicate column/i.test(error.message)) throw error } await database.prepare("CREATE TABLE IF NOT EXISTS wp_codebox_site_deletion_receipts (site_id TEXT NOT NULL, generation INTEGER NOT NULL, receipt_json TEXT NOT NULL, created_at INTEGER NOT NULL, PRIMARY KEY (site_id, generation))").run() await database.prepare("CREATE INDEX IF NOT EXISTS wp_codebox_site_lifecycle_expiry ON wp_codebox_site_lifecycles(state, expires_at)").run() })() diff --git a/packages/runtime-cloudflare/src/d1-operation-repository.ts b/packages/runtime-cloudflare/src/d1-operation-repository.ts index feba88936..6ebc5d07c 100644 --- a/packages/runtime-cloudflare/src/d1-operation-repository.ts +++ b/packages/runtime-cloudflare/src/d1-operation-repository.ts @@ -72,13 +72,15 @@ export class D1OperationRepository { async activeSites(): Promise { await ensureSchema(this.database) - const rows = await this.database.prepare("SELECT site_id, hostname, origin FROM wp_codebox_sites WHERE state = 'active' ORDER BY site_id").all<{ site_id: string; hostname: string; origin: string }>() + const rows = await this.database.prepare("SELECT s.site_id, s.hostname, s.origin FROM wp_codebox_sites s LEFT JOIN wp_codebox_site_lifecycles l ON l.site_id = s.site_id AND l.generation = s.generation WHERE s.state = 'active' AND (l.site_id IS NULL OR l.state = 'active') ORDER BY s.site_id").all<{ site_id: string; hostname: string; origin: string }>() return rows.results.map((row) => ({ id: row.site_id, hostname: row.hostname, origin: row.origin })) } async claimNext(siteId: string, now = Date.now()): Promise<(StaticArtifactOperation & { claimToken: string }) | null> { await ensureSchema(this.database) - const candidate = await this.database.prepare(`SELECT operation_id FROM wp_codebox_operations WHERE site_id = ? AND (state = 'queued' OR (state = 'retryable' AND retry_at <= ?) OR (state = 'running' AND claim_expires_at <= ?)) ORDER BY created_at LIMIT 1`).bind(siteId, now, now).first<{ operation_id: string }>() + // A dynamic allocation may be fenced between queue deliveries. Configured + // static sites have no lifecycle row and retain their existing behavior. + const candidate = await this.database.prepare(`SELECT o.operation_id FROM wp_codebox_operations o JOIN wp_codebox_sites s ON s.site_id = o.site_id LEFT JOIN wp_codebox_site_lifecycles l ON l.site_id = o.site_id AND l.generation = s.generation WHERE o.site_id = ? AND (l.site_id IS NULL OR l.state = 'active') AND (o.state = 'queued' OR (o.state = 'retryable' AND o.retry_at <= ?) OR (o.state = 'running' AND o.claim_expires_at <= ?)) ORDER BY o.created_at LIMIT 1`).bind(siteId, now, now).first<{ operation_id: string }>() if (!candidate) return null const token = crypto.randomUUID() const expiresAt = now + this.claimMs @@ -188,6 +190,9 @@ async function ensureSchema(database: D1Database): Promise { const pending = (async () => { await database.prepare(`CREATE TABLE IF NOT EXISTS wp_codebox_sites (site_id TEXT PRIMARY KEY, hostname TEXT NOT NULL, origin TEXT NOT NULL, generation INTEGER NOT NULL DEFAULT 1, state TEXT NOT NULL CHECK (state IN ('active')), created_at INTEGER NOT NULL, activated_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)`).run() try { await database.prepare("ALTER TABLE wp_codebox_sites ADD COLUMN generation INTEGER NOT NULL DEFAULT 1").run() } catch (error) { if (!(error instanceof Error) || !/duplicate column/i.test(error.message)) throw error } + // Lifecycle owns this schema; the minimal compatible creation keeps queue + // fencing available when the operation repository initializes first. + await database.prepare("CREATE TABLE IF NOT EXISTS wp_codebox_site_lifecycles (site_id TEXT NOT NULL, generation INTEGER NOT NULL, principal TEXT NOT NULL, state TEXT NOT NULL, expires_at INTEGER NOT NULL, retain_until INTEGER NOT NULL, mutation_fence INTEGER NOT NULL, operation_fence INTEGER NOT NULL, cleanup_cursor TEXT, deleted_objects INTEGER NOT NULL, receipt_json TEXT, terminal_at INTEGER, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, PRIMARY KEY (site_id, generation))").run() await database.prepare(`CREATE UNIQUE INDEX IF NOT EXISTS wp_codebox_site_hostname ON wp_codebox_sites(hostname)`).run() await database.prepare(`CREATE TABLE IF NOT EXISTS wp_codebox_site_aliases (hostname TEXT PRIMARY KEY, site_id TEXT NOT NULL, created_at INTEGER NOT NULL, FOREIGN KEY (site_id) REFERENCES wp_codebox_sites(site_id))`).run() await database.prepare(`CREATE TABLE IF NOT EXISTS wp_codebox_operations (site_id TEXT NOT NULL, operation_id TEXT NOT NULL, idempotency_key TEXT NOT NULL, fingerprint TEXT NOT NULL, artifact_key TEXT NOT NULL, artifact_sha256 TEXT NOT NULL, artifact_size INTEGER NOT NULL, slug TEXT NOT NULL, name TEXT NOT NULL, site_title TEXT NOT NULL, state TEXT NOT NULL CHECK (state IN ('queued','running','retryable','publication-pending','succeeded','failed')), stage TEXT NOT NULL, progress INTEGER NOT NULL CHECK (progress BETWEEN 0 AND 100), attempts INTEGER NOT NULL, retry_at INTEGER, claim_token TEXT, claim_expires_at INTEGER, prepared_version INTEGER, prepared_revision TEXT, prepared_manifest_key TEXT, prepared_persisted_at TEXT, prepared_result_json TEXT, prepared_publication_job TEXT, receipt_json TEXT, error_code TEXT, error_message TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, completed_at TEXT, PRIMARY KEY (site_id, operation_id), UNIQUE (site_id, idempotency_key), FOREIGN KEY (site_id) REFERENCES wp_codebox_sites(site_id))`).run() diff --git a/packages/runtime-cloudflare/src/provisioning-api.ts b/packages/runtime-cloudflare/src/provisioning-api.ts index 5d17e016d..7dc496cec 100644 --- a/packages/runtime-cloudflare/src/provisioning-api.ts +++ b/packages/runtime-cloudflare/src/provisioning-api.ts @@ -2,7 +2,7 @@ import { D1OperationRepository, OperationConflict, type StaticArtifactOperation, import { MAX_STATIC_ARTIFACT_BYTES, readBoundedRequestBytes, readStaticArtifactImport, StaticArtifactImportError, validateStaticArtifact } from "./static-artifact-import.js" import { allocatePreviewSiteContext, parseSiteContexts, previewDomain, siteStorageKeys, type SiteContext } from "./site-context.js" import { deriveSiteCredential } from "./wordpress-auth.js" -import { CloudflareAllocationLifecycle } from "./allocation-lifecycle.js" +import { allocationIdentity, CloudflareAllocationLifecycle, type AllocationLifecycle } from "./allocation-lifecycle.js" export const PROVISIONING_API_SCHEMA = "wp-codebox/provisioning-api/v1" export const PROVISIONING_CREATE_REQUEST_SCHEMA = "wp-codebox/provisioning-create-request/v1" @@ -30,11 +30,35 @@ export async function routeProvisioningApi(request: Request, env: ProvisioningEn // This capability endpoint must never fall through to API bearer authentication or WordPress. if (parts.length === 4 && parts[3] === "administrator-claim") return method === "POST" ? claimAdministrator(request, env, operations, siteId) : methodNotAllowed("POST") if (parts.length === 3) return method === "GET" ? readSite(request, env, operations, siteId) : methodNotAllowed("GET") + if (parts.length === 4 && parts[3] === "lifecycle" && method === "GET") return lifecycleStatus(request, env, siteId) + if (parts.length === 5 && parts[3] === "lifecycle" && parts[4] === "renew") return method === "POST" ? renewLifecycle(request, env, siteId) : methodNotAllowed("POST") + if (parts.length === 4 && parts[3] === "lifecycle") return method === "DELETE" ? deleteLifecycle(request, env, siteId) : methodNotAllowed("GET, DELETE") if (parts.length === 4 && parts[3] === "imports") return method === "POST" ? importSite(request, env, operations, siteId) : methodNotAllowed("POST") if (parts.length === 5 && parts[3] === "operations" && /^[0-9a-f-]{36}$/.test(parts[4])) return method === "GET" ? readOperation(request, env, operations, siteId, parts[4]) : methodNotAllowed("GET") return notFound() } +async function lifecycleStatus(request: Request, env: ProvisioningEnv, siteId: string): Promise { + const token = await authenticate(request, env, "sites:read"); if (token instanceof Response) return token + const allocation = await new AllocationStore(env.WORDPRESS_STATE_DATABASE).bySite(siteId) + if (!allocation || allocation.principal !== token.principal || !allowed(token, siteId)) return notFound() + const lifecycle = await new CloudflareAllocationLifecycle(env.WORDPRESS_STATE_DATABASE).get(allocationIdentity(siteId)) + return lifecycle ? Response.json({ schema: PROVISIONING_API_SCHEMA, lifecycle: lifecycleResource(lifecycle) }) : notFound() +} +async function renewLifecycle(request: Request, env: ProvisioningEnv, siteId: string): Promise { + const token = await authenticate(request, env, "sites:import"); if (token instanceof Response) return token + const allocation = await new AllocationStore(env.WORDPRESS_STATE_DATABASE).bySite(siteId) + if (!allocation || allocation.principal !== token.principal || !allowed(token, siteId)) return notFound() + try { const lifecycle = await new CloudflareAllocationLifecycle(env.WORDPRESS_STATE_DATABASE).renew(allocationIdentity(siteId), token.principal); return Response.json({ schema: PROVISIONING_API_SCHEMA, lifecycle: lifecycleResource(lifecycle) }) } catch { return apiError(409, "lifecycle_conflict", "The allocation lifecycle cannot be renewed.") } +} +async function deleteLifecycle(request: Request, env: ProvisioningEnv, siteId: string): Promise { + const token = await authenticate(request, env, "sites:import"); if (token instanceof Response) return token + const allocation = await new AllocationStore(env.WORDPRESS_STATE_DATABASE).bySite(siteId) + if (!allocation || allocation.principal !== token.principal || !allowed(token, siteId)) return notFound() + try { await new CloudflareAllocationLifecycle(env.WORDPRESS_STATE_DATABASE).beginDeletion(allocationIdentity(siteId), token.principal); return lifecycleStatus(new Request(request.url, { headers: request.headers }), env, siteId) } catch { return apiError(409, "lifecycle_conflict", "The allocation lifecycle cannot be deleted.") } +} +function lifecycleResource(lifecycle: AllocationLifecycle) { return { state: lifecycle.state, expiresAt: new Date(lifecycle.expiresAt).toISOString(), retainUntil: new Date(lifecycle.retainUntil).toISOString(), generation: lifecycle.identity.generation, receipt: lifecycle.receipt } } + async function stageArtifact(request: Request, env: ProvisioningEnv, expectedSha256: string): Promise { const token = await authenticate(request, env, "sites:create"); if (token instanceof Response) return token const declaredLength = request.headers.get("content-length") @@ -266,7 +290,7 @@ class AllocationStore { try { const [allocation, reservation] = await this.db.batch([ this.db.prepare("INSERT OR IGNORE INTO wp_codebox_api_sites (site_id, principal, idempotency_key, fingerprint, artifact_sha256, artifact_size, slug, name, site_title, operation_id, created_at) SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ? WHERE (SELECT COUNT(*) FROM wp_codebox_api_sites a LEFT JOIN wp_codebox_site_lifecycles l ON l.site_id = a.site_id WHERE a.principal = ? AND (l.site_id IS NULL OR l.state != 'tombstoned')) < ? AND NOT EXISTS (SELECT 1 FROM wp_codebox_sites WHERE site_id = ?)").bind(site.id, token.principal, input.key, input.fingerprint, input.artifactSha256, input.artifactSize, input.options.slug, input.options.name, input.options.siteTitle, now, token.principal, token.maxSites, site.id), - this.db.prepare("INSERT INTO wp_codebox_sites (site_id, hostname, origin, state, created_at, activated_at, updated_at) SELECT ?, ?, ?, 'active', ?, ?, ? WHERE EXISTS (SELECT 1 FROM wp_codebox_api_sites WHERE site_id = ? AND principal = ? AND idempotency_key = ? AND fingerprint = ?)").bind(site.id, site.hostname, site.origin, now, now, now, site.id, token.principal, input.key, input.fingerprint), + this.db.prepare("INSERT INTO wp_codebox_sites (site_id, hostname, origin, generation, state, created_at, activated_at, updated_at) SELECT ?, ?, ?, ?, 'active', ?, ?, ? WHERE EXISTS (SELECT 1 FROM wp_codebox_api_sites WHERE site_id = ? AND principal = ? AND idempotency_key = ? AND fingerprint = ?)").bind(site.id, site.hostname, site.origin, allocationIdentity(site.id).generation, now, now, now, site.id, token.principal, input.key, input.fingerprint), ]) if (allocation.meta.changes === 1 && reservation.meta.changes === 1) { await lifecycle.create(site, token.principal, now) diff --git a/packages/runtime-cloudflare/src/worker.ts b/packages/runtime-cloudflare/src/worker.ts index 7fe15391d..1e81a7ec0 100644 --- a/packages/runtime-cloudflare/src/worker.ts +++ b/packages/runtime-cloudflare/src/worker.ts @@ -283,7 +283,7 @@ export function createCloudflareRuntime( if (env.WORDPRESS_STATE_DATABASE) { const lifecycle = new CloudflareAllocationLifecycle(env.WORDPRESS_STATE_DATABASE) await lifecycle.expire(controller.scheduledTime, 1) - const deleting = (await lifecycle.pendingDeletions(1))[0] + const deleting = (await lifecycle.pendingDeletions(1, controller.scheduledTime))[0] if (deleting) await lifecycle.reclaim(env.WORDPRESS_STATE_BUCKET, deleting.identity, deleting.operationFence, 100, controller.scheduledTime) } const configured = parseSiteContexts(env.WORDPRESS_SITE_CONTEXTS) diff --git a/tests/cloudflare-allocation-lifecycle.test.ts b/tests/cloudflare-allocation-lifecycle.test.ts index 658941c6e..f1c59fcc1 100644 --- a/tests/cloudflare-allocation-lifecycle.test.ts +++ b/tests/cloudflare-allocation-lifecycle.test.ts @@ -27,8 +27,9 @@ test("expiration fences mutations, retains quota until terminal reclamation, and await assert.rejects(() => lifecycle.assertMutation(active.identity, mutation, now + 10), AllocationLifecycleConflict) assert.equal((await lifecycle.get(active.identity))!.state, "deleting") bucket.objects.set(`sites/${site().id}/one`, new Uint8Array([1])) - const deleting = (await lifecycle.get(active.identity))!; const tombstone = await lifecycle.reclaim(bucket as unknown as Pick, active.identity, deleting.operationFence, 10, now + 11) - assert.equal(tombstone.state, "tombstoned"); assert.equal(tombstone.receipt?.deletedObjects, 1); assert.equal(tombstone.receipt?.completedAt, new Date(now + 11).toISOString()) + assert.deepEqual(await lifecycle.pendingDeletions(1, now + 29), [], "reads remain available during the declared grace period") + const deleting = (await lifecycle.get(active.identity))!; const tombstone = await lifecycle.reclaim(bucket as unknown as Pick, active.identity, deleting.operationFence, 10, now + 30) + assert.equal(tombstone.state, "tombstoned"); assert.equal(tombstone.receipt?.deletedObjects, 1); assert.equal(tombstone.receipt?.completedAt, new Date(now + 30).toISOString()) }) test("owner authorization, concurrent deletion, and stale generations fail closed", async () => { @@ -41,7 +42,7 @@ test("owner authorization, concurrent deletion, and stale generations fail close }) test("cleanup checkpoints resume after partial R2 failure and tombstone receipts are immutable", async () => { - const lifecycle = new CloudflareAllocationLifecycle(database()); const bucket = new Bucket(); const created = await lifecycle.create(site(), "owner", 1_000) + const lifecycle = new CloudflareAllocationLifecycle(database(), { ttlMs: 10, retainMs: 0 }); const bucket = new Bucket(); const created = await lifecycle.create(site(), "owner", 1_000) for (const key of ["a", "b", "c"]) bucket.objects.set(`sites/${site().id}/${key}`, new Uint8Array([1])) const fence = await lifecycle.beginDeletion(created.identity, "owner", 1_001) const first = await lifecycle.reclaim(bucket as unknown as Pick, created.identity, fence, 1, 1_002) diff --git a/tests/cloudflare-provisioning-api.test.ts b/tests/cloudflare-provisioning-api.test.ts index 443cd903c..b9f6cdddf 100644 --- a/tests/cloudflare-provisioning-api.test.ts +++ b/tests/cloudflare-provisioning-api.test.ts @@ -3,6 +3,7 @@ import { createHash } from "node:crypto" import { DatabaseSync } from "node:sqlite" import test from "node:test" import { D1OperationRepository } from "../packages/runtime-cloudflare/src/d1-operation-repository.js" +import { allocationIdentity, CloudflareAllocationLifecycle } from "../packages/runtime-cloudflare/src/allocation-lifecycle.js" import { PROVISIONING_ARTIFACT_RESOURCE_SCHEMA, PROVISIONING_CREATE_REQUEST_SCHEMA, resumeProvisioningAllocation, routeProvisioningApi } from "../packages/runtime-cloudflare/src/provisioning-api.js" import { STATIC_ARTIFACT_IMPORT_REQUEST_SCHEMA } from "../packages/runtime-cloudflare/src/static-artifact-import.js" @@ -18,6 +19,8 @@ class Bucket { objects = new Map(); gets = 0; puts = 0; race?: Uint8Array; dropSuccessfulPut = false async get(key: string) { this.gets++; const value = this.objects.get(key); return value ? { size: value.byteLength, etag: hash(value), arrayBuffer: async () => value.slice().buffer } : null } async put(key: string, value: string | Uint8Array, options?: { onlyIf?: { etagDoesNotMatch?: string } }) { this.puts++; const bytes = typeof value === "string" ? new TextEncoder().encode(value) : value; if (options?.onlyIf?.etagDoesNotMatch === "*" && this.objects.has(key)) return null; if (this.race && options?.onlyIf) { this.objects.set(key, this.race); this.race = undefined; return null } if (!this.dropSuccessfulPut) this.objects.set(key, bytes.slice()); return { key } } + async list(options: { prefix?: string; cursor?: string; limit?: number }) { const keys = [...this.objects.keys()].filter((key) => key.startsWith(options.prefix ?? "")).sort(); const available = options.cursor ? keys.filter((key) => key > options.cursor!) : keys; const page = available.slice(0, options.limit ?? 100); return { objects: page.map((key) => ({ key, size: this.objects.get(key)!.byteLength })), truncated: page.length < available.length, cursor: page.at(-1) ?? "" } } + async delete(keys: string[]) { for (const key of keys) this.objects.delete(key) } } function runtime(db = database(), bucket = new Bucket(), tokens?: unknown, maxSites = 2) { bucket.objects.set(`sites/provisioning/import-artifacts/${digest}.json`, artifact) @@ -79,7 +82,15 @@ test("changed fingerprints conflict without another allocation", async () => { c test("create replay honors current site restrictions", async () => { const r = runtime(); await create(r); const records = JSON.parse(r.env.WORDPRESS_API_TOKENS) as Array>; records[0].sites = ["beta"]; r.env.WORDPRESS_API_TOKENS = JSON.stringify(records); assert.equal((await create(r)).status, 404); assert.equal(count(r.db, "wp_codebox_api_sites"), 1) }) test("concurrent distinct keys allocate unique contexts", async () => { const r = runtime(); await Promise.all([create(r, "one"), create(r, "two")]); const ids = r.db.sqlite.prepare("SELECT site_id FROM wp_codebox_api_sites ORDER BY site_id").all() as Array<{ site_id: string }>; assert.deepEqual(ids.map((row) => row.site_id), ["alpha", "beta"]) }) test("maxSites concurrency distinguishes quota from pool exhaustion", async () => { const quota = runtime(database(), new Bucket(), undefined, 1); const responses = await Promise.all([create(quota, "one"), create(quota, "two")]); assert.equal(responses.filter((response) => response.status === 202).length, 1); const rejected = responses.find((response) => response.status !== 202)!; assert.equal((await rejected.json() as { error: { code: string } }).error.code, "quota_exceeded"); assert.equal(count(quota.db, "wp_codebox_api_sites"), 1); const pool = runtime(database(), new Bucket(), undefined, 3); await Promise.all([create(pool, "one"), create(pool, "two")]); const pr = await create(pool, "three"); assert.equal((await pr.json() as { error: { code: string } }).error.code, "capacity_exhausted") }) -test("quota is released only after the lifecycle reaches its tombstone", async () => { const r = dynamicRuntime(database(), new Bucket(), 1); const first = await (await create(r, "one")).json() as { site: { id: string } }; assert.equal((await create(r, "two")).status, 429); r.db.sqlite.prepare("UPDATE wp_codebox_site_lifecycles SET state = 'tombstoned' WHERE site_id = ?").run(first.site.id); assert.equal((await create(r, "two")).status, 202) }) +test("quota is released only after the lifecycle reaches its tombstone", async () => { const r = dynamicRuntime(database(), new Bucket(), 1); const first = await (await create(r, "one")).json() as { site: { id: string } }; assert.equal((await create(r, "two")).status, 429); const lifecycle = new CloudflareAllocationLifecycle(r.db, { ttlMs: 1, retainMs: 0 }); const identity = allocationIdentity(first.site.id); const fence = await lifecycle.beginDeletion(identity, "a"); await lifecycle.reclaim(r.bucket as unknown as Pick, identity, fence, 100); assert.equal((await create(r, "two")).status, 202) }) +test("owners can inspect, renew, and delete their allocation lifecycle", async () => { + const r = dynamicRuntime(); const created = await (await create(r)).json() as { site: { id: string } }; const url = `https://control.invalid/v1/sites/${created.site.id}/lifecycle` + const read = await routeProvisioningApi(new Request(url, { headers: { authorization: "Bearer good" } }), r.env, r.operations) + assert.equal(read.status, 200); assert.equal((await read.json() as { lifecycle: { state: string } }).lifecycle.state, "active") + assert.equal((await routeProvisioningApi(new Request(`${url}/renew`, { method: "POST", headers: { authorization: "Bearer good" } }), r.env, r.operations)).status, 200) + const deleted = await routeProvisioningApi(new Request(url, { method: "DELETE", headers: { authorization: "Bearer good" } }), r.env, r.operations) + assert.equal(deleted.status, 200); assert.equal((await deleted.json() as { lifecycle: { state: string } }).lifecycle.state, "deleting") +}) test("dynamic preview allocation is unique, idempotent, and not limited by configured contexts", async () => { const r = dynamicRuntime() const responses = await Promise.all(Array.from({ length: 100 }, (_, index) => create(r, `dynamic-${index}`))) From 34b632501e00f4e33047da29f99c2da89c283dae Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sun, 26 Jul 2026 17:22:04 -0400 Subject: [PATCH 4/4] feat(runtime-cloudflare): fence lifecycle teardown --- .../src/allocation-lifecycle.ts | 56 +++++++++++++++---- .../src/d1-operation-repository.ts | 21 ++++--- .../src/provisioning-api.ts | 10 +++- packages/runtime-cloudflare/src/worker.ts | 38 +++++++++---- tests/cloudflare-allocation-lifecycle.test.ts | 18 ++++++ ...cloudflare-d1-operation-repository.test.ts | 17 ++++++ tests/cloudflare-provisioning-api.test.ts | 10 ++++ 7 files changed, 140 insertions(+), 30 deletions(-) diff --git a/packages/runtime-cloudflare/src/allocation-lifecycle.ts b/packages/runtime-cloudflare/src/allocation-lifecycle.ts index cb7991120..47d82af8f 100644 --- a/packages/runtime-cloudflare/src/allocation-lifecycle.ts +++ b/packages/runtime-cloudflare/src/allocation-lifecycle.ts @@ -3,8 +3,8 @@ import type { SiteContext } from "./site-context.js" export type AllocationLifecycleState = "active" | "deleting" | "tombstoned" export interface AllocationIdentity { siteId: string; generation: number } export interface AllocationRetentionPolicy { ttlMs: number; retainMs: number } -export interface AllocationDeletionReceipt { schema: "wp-codebox/cloudflare-allocation-deletion-receipt/v1"; siteId: string; generation: number; deletedObjects: number; deletedBytes: number; retainedObjects: number; retainedBytes: number; unresolvedObjects: number; startedAt: string; completedAt: string; terminalState: "tombstoned" } -export interface AllocationLifecycle { identity: AllocationIdentity; principal: string; state: AllocationLifecycleState; expiresAt: number; retainUntil: number; mutationFence: number; operationFence: number; cleanupCursor: string | null; deletedObjects: number; deletedBytes: number; receipt: AllocationDeletionReceipt | null } +export interface AllocationDeletionReceipt { schema: "wp-codebox/cloudflare-allocation-deletion-receipt/v1"; siteId: string; generation: number; deletedObjects: number; deletedBytes: number; deletedRecords: number; retainedObjects: number; retainedBytes: number; retainedRecords: number; unresolvedObjects: number; unresolvedRecords: number; startedAt: string; completedAt: string; terminalState: "tombstoned" } +export interface AllocationLifecycle { identity: AllocationIdentity; principal: string; state: AllocationLifecycleState; expiresAt: number; retainUntil: number; mutationFence: number; operationFence: number; cleanupCursor: string | null; deletedObjects: number; deletedBytes: number; deletedRecords: number; receipt: AllocationDeletionReceipt | null } const RECEIPT_SCHEMA = "wp-codebox/cloudflare-allocation-deletion-receipt/v1" as const const schemaReady = new WeakMap>() @@ -26,7 +26,7 @@ export class CloudflareAllocationLifecycle { } async get(identity: AllocationIdentity): Promise { await this.initialize() - const row = await this.database.prepare("SELECT site_id, generation, principal, state, expires_at, retain_until, mutation_fence, operation_fence, cleanup_cursor, deleted_objects, deleted_bytes, receipt_json FROM wp_codebox_site_lifecycles WHERE site_id = ? AND generation = ?").bind(identity.siteId, identity.generation).first() + const row = await this.database.prepare("SELECT site_id, generation, principal, state, expires_at, retain_until, mutation_fence, operation_fence, cleanup_cursor, deleted_objects, deleted_bytes, deleted_records, receipt_json FROM wp_codebox_site_lifecycles WHERE site_id = ? AND generation = ?").bind(identity.siteId, identity.generation).first() return row ? hydrate(row) : null } async renew(identity: AllocationIdentity, principal: string, now = Date.now()): Promise { @@ -45,6 +45,10 @@ export class CloudflareAllocationLifecycle { const lifecycle = await this.get(identity) if (!lifecycle || lifecycle.state !== "active" || lifecycle.expiresAt <= now || lifecycle.mutationFence !== fence) throw new AllocationLifecycleConflict("Allocation mutation fence changed.") } + async assertActive(identity: AllocationIdentity, now = Date.now()): Promise { + const lifecycle = await this.get(identity) + if (lifecycle && (lifecycle.state !== "active" || lifecycle.expiresAt <= now)) throw new AllocationLifecycleConflict("Allocation is no longer active.") + } async beginDeletion(identity: AllocationIdentity, principal: string | null, now = Date.now()): Promise { await this.initialize() const owner = principal === null ? "" : " AND principal = ?" @@ -52,7 +56,9 @@ export class CloudflareAllocationLifecycle { if (principal !== null) values.push(principal) const result = await this.database.prepare(`UPDATE wp_codebox_site_lifecycles SET state = 'deleting', mutation_fence = mutation_fence + 1, operation_fence = operation_fence + 1, retain_until = MIN(retain_until, ?), cleanup_cursor = NULL, updated_at = ? WHERE site_id = ? AND generation = ?${owner} AND state = 'active'`).bind(...values).run() if (result.meta.changes !== 1) throw new AllocationLifecycleConflict("Allocation is not deletable by this owner.") - return (await this.get(identity))!.operationFence + const lifecycle = (await this.get(identity))! + await this.cancelWork(identity, now) + return lifecycle.operationFence } async expire(now = Date.now(), limit = 8): Promise { await this.initialize() @@ -65,7 +71,7 @@ export class CloudflareAllocationLifecycle { } async pendingDeletions(limit = 8, now = Date.now()): Promise> { await this.initialize() - const rows = await this.database.prepare("SELECT site_id, generation, principal, state, expires_at, retain_until, mutation_fence, operation_fence, cleanup_cursor, deleted_objects, deleted_bytes, receipt_json FROM wp_codebox_site_lifecycles WHERE state = 'deleting' AND retain_until <= ? ORDER BY updated_at LIMIT ?").bind(now, limit).all() + const rows = await this.database.prepare("SELECT site_id, generation, principal, state, expires_at, retain_until, mutation_fence, operation_fence, cleanup_cursor, deleted_objects, deleted_bytes, deleted_records, receipt_json FROM wp_codebox_site_lifecycles WHERE state = 'deleting' AND retain_until <= ? ORDER BY updated_at LIMIT ?").bind(now, limit).all() return rows.results.map(hydrate) } async reclaim(bucket: Pick, identity: AllocationIdentity, operationFence: number, limit = 100, now = Date.now()): Promise { @@ -77,16 +83,42 @@ export class CloudflareAllocationLifecycle { if (page.objects.length) await bucket.delete(page.objects.map((object) => object.key)) const cursor = page.truncated ? page.cursor : null const deletedBytes = page.objects.reduce((total, object) => total + (object.size ?? 0), 0) - const updated = await this.database.prepare("UPDATE wp_codebox_site_lifecycles SET cleanup_cursor = ?, deleted_objects = deleted_objects + ?, deleted_bytes = deleted_bytes + ?, updated_at = ? WHERE site_id = ? AND generation = ? AND state = 'deleting' AND operation_fence = ?").bind(cursor, page.objects.length, deletedBytes, now, identity.siteId, identity.generation, operationFence).run() + const cleaned = cursor ? { deleted: 0, unresolved: 0 } : await this.cleanupWork(identity.siteId, limit) + const updated = await this.database.prepare("UPDATE wp_codebox_site_lifecycles SET cleanup_cursor = ?, deleted_objects = deleted_objects + ?, deleted_bytes = deleted_bytes + ?, deleted_records = deleted_records + ?, updated_at = ? WHERE site_id = ? AND generation = ? AND state = 'deleting' AND operation_fence = ?").bind(cursor, page.objects.length, deletedBytes, cleaned.deleted, now, identity.siteId, identity.generation, operationFence).run() if (updated.meta.changes !== 1) throw new AllocationLifecycleConflict("Allocation deletion fence changed.") - if (cursor) return (await this.get(identity))! - const receipt: AllocationDeletionReceipt = { schema: RECEIPT_SCHEMA, siteId: identity.siteId, generation: identity.generation, deletedObjects: lifecycle.deletedObjects + page.objects.length, deletedBytes: lifecycle.deletedBytes + deletedBytes, retainedObjects: 0, retainedBytes: 0, unresolvedObjects: 0, startedAt: new Date(lifecycle.expiresAt).toISOString(), completedAt: new Date(now).toISOString(), terminalState: "tombstoned" } + if (cursor || cleaned.unresolved) return (await this.get(identity))! + const receipt: AllocationDeletionReceipt = { schema: RECEIPT_SCHEMA, siteId: identity.siteId, generation: identity.generation, deletedObjects: lifecycle.deletedObjects + page.objects.length, deletedBytes: lifecycle.deletedBytes + deletedBytes, deletedRecords: lifecycle.deletedRecords + cleaned.deleted, retainedObjects: 0, retainedBytes: 0, retainedRecords: 0, unresolvedObjects: 0, unresolvedRecords: 0, startedAt: new Date(lifecycle.expiresAt).toISOString(), completedAt: new Date(now).toISOString(), terminalState: "tombstoned" } await this.database.batch([ this.database.prepare("INSERT OR IGNORE INTO wp_codebox_site_deletion_receipts (site_id, generation, receipt_json, created_at) VALUES (?, ?, ?, ?)").bind(identity.siteId, identity.generation, JSON.stringify(receipt), now), this.database.prepare("UPDATE wp_codebox_site_lifecycles SET state = 'tombstoned', cleanup_cursor = NULL, receipt_json = ?, terminal_at = ?, updated_at = ? WHERE site_id = ? AND generation = ? AND state = 'deleting' AND operation_fence = ?").bind(JSON.stringify(receipt), now, now, identity.siteId, identity.generation, operationFence), ]) return (await this.get(identity))! } + private async cancelWork(identity: AllocationIdentity, now: number): Promise { + if (await tableExists(this.database, "wp_codebox_operations") && await tableExists(this.database, "wp_codebox_operation_attempts")) { + await this.database.batch([ + this.database.prepare("UPDATE wp_codebox_operation_attempts SET completed_at = ?, state = 'failed', stage = 'allocation-deleted', error_code = 'allocation_deleted', error_message = 'Allocation deletion fenced this attempt.' WHERE site_id = ? AND completed_at IS NULL").bind(new Date(now).toISOString(), identity.siteId), + this.database.prepare("UPDATE wp_codebox_operations SET state = 'failed', stage = 'allocation-deleted', progress = 100, claim_token = NULL, claim_expires_at = NULL, retry_at = NULL, error_code = 'allocation_deleted', error_message = 'Allocation deletion fenced this operation.', completed_at = ?, updated_at = ? WHERE site_id = ? AND state IN ('queued','running','retryable','publication-pending')").bind(new Date(now).toISOString(), now, identity.siteId), + ]) + } + if (await tableExists(this.database, "wp_codebox_api_admin_claims")) await this.database.prepare("UPDATE wp_codebox_api_admin_claims SET state = 'expired', updated_at = ? WHERE site_id = ? AND state = 'pending'").bind(now, identity.siteId).run() + } + private async cleanupWork(siteId: string, limit: number): Promise<{ deleted: number; unresolved: number }> { + let deleted = 0 + if (await tableExists(this.database, "wp_codebox_operation_attempts")) { + const result = await this.database.prepare("DELETE FROM wp_codebox_operation_attempts WHERE rowid IN (SELECT rowid FROM wp_codebox_operation_attempts WHERE site_id = ? LIMIT ?)").bind(siteId, limit).run(); deleted += result.meta.changes + } + if (await tableExists(this.database, "wp_codebox_operations")) { + const result = await this.database.prepare("DELETE FROM wp_codebox_operations WHERE rowid IN (SELECT rowid FROM wp_codebox_operations WHERE site_id = ? LIMIT ?)").bind(siteId, limit).run(); deleted += result.meta.changes + } + if (await tableExists(this.database, "wp_codebox_api_admin_claims")) { + const result = await this.database.prepare("DELETE FROM wp_codebox_api_admin_claims WHERE site_id = ?").bind(siteId).run(); deleted += result.meta.changes + } + const tables = ["wp_codebox_operation_attempts", "wp_codebox_operations", "wp_codebox_api_admin_claims"] + let unresolved = 0 + for (const table of tables) if (await tableExists(this.database, table)) unresolved += (await this.database.prepare(`SELECT COUNT(*) AS count FROM ${table} WHERE site_id = ?`).bind(siteId).first<{ count: number }>())?.count ?? 0 + return { deleted, unresolved } + } } export class AllocationLifecycleConflict extends Error {} @@ -95,14 +127,16 @@ export function allocationIdentity(siteId: string): AllocationIdentity { return { siteId, generation: match ? Number(match[1]) : 1 } } -interface LifecycleRow { site_id: string; generation: number; principal: string; state: AllocationLifecycleState; expires_at: number; retain_until: number; mutation_fence: number; operation_fence: number; cleanup_cursor: string | null; deleted_objects: number; deleted_bytes: number; receipt_json: string | null } -function hydrate(row: LifecycleRow): AllocationLifecycle { return { identity: { siteId: row.site_id, generation: row.generation }, principal: row.principal, state: row.state, expiresAt: row.expires_at, retainUntil: row.retain_until, mutationFence: row.mutation_fence, operationFence: row.operation_fence, cleanupCursor: row.cleanup_cursor, deletedObjects: row.deleted_objects, deletedBytes: row.deleted_bytes, receipt: row.receipt_json ? JSON.parse(row.receipt_json) as AllocationDeletionReceipt : null } } +interface LifecycleRow { site_id: string; generation: number; principal: string; state: AllocationLifecycleState; expires_at: number; retain_until: number; mutation_fence: number; operation_fence: number; cleanup_cursor: string | null; deleted_objects: number; deleted_bytes: number; deleted_records: number; receipt_json: string | null } +function hydrate(row: LifecycleRow): AllocationLifecycle { return { identity: { siteId: row.site_id, generation: row.generation }, principal: row.principal, state: row.state, expiresAt: row.expires_at, retainUntil: row.retain_until, mutationFence: row.mutation_fence, operationFence: row.operation_fence, cleanupCursor: row.cleanup_cursor, deletedObjects: row.deleted_objects, deletedBytes: row.deleted_bytes, deletedRecords: row.deleted_records, receipt: row.receipt_json ? JSON.parse(row.receipt_json) as AllocationDeletionReceipt : null } } +async function tableExists(database: D1Database, name: string): Promise { return !!await database.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").bind(name).first() } async function ensureSchema(database: D1Database): Promise { let pending = schemaReady.get(database as object) if (!pending) { pending = (async () => { - await database.prepare("CREATE TABLE IF NOT EXISTS wp_codebox_site_lifecycles (site_id TEXT NOT NULL, generation INTEGER NOT NULL, principal TEXT NOT NULL, state TEXT NOT NULL CHECK (state IN ('active','deleting','tombstoned')), expires_at INTEGER NOT NULL, retain_until INTEGER NOT NULL, mutation_fence INTEGER NOT NULL, operation_fence INTEGER NOT NULL, cleanup_cursor TEXT, deleted_objects INTEGER NOT NULL, deleted_bytes INTEGER NOT NULL DEFAULT 0, receipt_json TEXT, terminal_at INTEGER, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, PRIMARY KEY (site_id, generation))").run() + await database.prepare("CREATE TABLE IF NOT EXISTS wp_codebox_site_lifecycles (site_id TEXT NOT NULL, generation INTEGER NOT NULL, principal TEXT NOT NULL, state TEXT NOT NULL CHECK (state IN ('active','deleting','tombstoned')), expires_at INTEGER NOT NULL, retain_until INTEGER NOT NULL, mutation_fence INTEGER NOT NULL, operation_fence INTEGER NOT NULL, cleanup_cursor TEXT, deleted_objects INTEGER NOT NULL, deleted_bytes INTEGER NOT NULL DEFAULT 0, deleted_records INTEGER NOT NULL DEFAULT 0, receipt_json TEXT, terminal_at INTEGER, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, PRIMARY KEY (site_id, generation))").run() try { await database.prepare("ALTER TABLE wp_codebox_site_lifecycles ADD COLUMN deleted_bytes INTEGER NOT NULL DEFAULT 0").run() } catch (error) { if (!(error instanceof Error) || !/duplicate column/i.test(error.message)) throw error } + try { await database.prepare("ALTER TABLE wp_codebox_site_lifecycles ADD COLUMN deleted_records INTEGER NOT NULL DEFAULT 0").run() } catch (error) { if (!(error instanceof Error) || !/duplicate column/i.test(error.message)) throw error } await database.prepare("CREATE TABLE IF NOT EXISTS wp_codebox_site_deletion_receipts (site_id TEXT NOT NULL, generation INTEGER NOT NULL, receipt_json TEXT NOT NULL, created_at INTEGER NOT NULL, PRIMARY KEY (site_id, generation))").run() await database.prepare("CREATE INDEX IF NOT EXISTS wp_codebox_site_lifecycle_expiry ON wp_codebox_site_lifecycles(state, expires_at)").run() })() diff --git a/packages/runtime-cloudflare/src/d1-operation-repository.ts b/packages/runtime-cloudflare/src/d1-operation-repository.ts index 6ebc5d07c..1fd33211a 100644 --- a/packages/runtime-cloudflare/src/d1-operation-repository.ts +++ b/packages/runtime-cloudflare/src/d1-operation-repository.ts @@ -49,8 +49,9 @@ export class D1OperationRepository { return { operation: existing, created: false } } try { - await this.database.prepare(`INSERT INTO wp_codebox_operations (site_id, operation_id, idempotency_key, fingerprint, artifact_key, artifact_sha256, artifact_size, slug, name, site_title, state, stage, progress, attempts, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'queued', 'created', 0, 0, ?, ?)`) - .bind(site.id, crypto.randomUUID(), input.idempotencyKey, input.fingerprint, input.artifact.r2Key, input.artifact.sha256, input.artifact.size, input.options.slug, input.options.name, input.options.siteTitle, now, now).run() + const created = await this.database.prepare(`INSERT INTO wp_codebox_operations (site_id, operation_id, idempotency_key, fingerprint, artifact_key, artifact_sha256, artifact_size, slug, name, site_title, allocation_fence, state, stage, progress, attempts, created_at, updated_at) SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, l.operation_fence, 'queued', 'created', 0, 0, ?, ? FROM (SELECT 1) LEFT JOIN wp_codebox_sites s ON s.site_id = ? LEFT JOIN wp_codebox_site_lifecycles l ON l.site_id = s.site_id AND l.generation = s.generation WHERE l.site_id IS NULL OR (l.state = 'active' AND l.expires_at > ?)`) + .bind(site.id, crypto.randomUUID(), input.idempotencyKey, input.fingerprint, input.artifact.r2Key, input.artifact.sha256, input.artifact.size, input.options.slug, input.options.name, input.options.siteTitle, now, now, site.id, now).run() + if (created.meta.changes !== 1) throw new OperationConflict("The allocation is no longer active.") } catch (error) { const raced = await this.byKey(site.id, input.idempotencyKey) if (raced?.input.fingerprint === input.fingerprint) return { operation: raced, created: false } @@ -85,7 +86,7 @@ export class D1OperationRepository { const token = crypto.randomUUID() const expiresAt = now + this.claimMs const [update, attempt] = await this.database.batch([ - this.database.prepare(`UPDATE wp_codebox_operations SET state = 'running', stage = 'claimed', claim_token = ?, claim_expires_at = ?, attempts = attempts + 1, retry_at = NULL, error_code = NULL, error_message = NULL, updated_at = ? WHERE site_id = ? AND operation_id = ? AND (state = 'queued' OR (state = 'retryable' AND retry_at <= ?) OR (state = 'running' AND claim_expires_at <= ?))`).bind(token, expiresAt, now, siteId, candidate.operation_id, now, now), + this.database.prepare(`UPDATE wp_codebox_operations SET state = 'running', stage = 'claimed', claim_token = ?, claim_expires_at = ?, attempts = attempts + 1, retry_at = NULL, error_code = NULL, error_message = NULL, updated_at = ? WHERE site_id = ? AND operation_id = ? AND (state = 'queued' OR (state = 'retryable' AND retry_at <= ?) OR (state = 'running' AND claim_expires_at <= ?)) AND EXISTS (SELECT 1 FROM wp_codebox_sites s LEFT JOIN wp_codebox_site_lifecycles l ON l.site_id = s.site_id AND l.generation = s.generation WHERE s.site_id = wp_codebox_operations.site_id AND (l.site_id IS NULL OR (l.state = 'active' AND l.expires_at > ? AND l.operation_fence = wp_codebox_operations.allocation_fence)))`).bind(token, expiresAt, now, siteId, candidate.operation_id, now, now, now), this.database.prepare(`INSERT INTO wp_codebox_operation_attempts (site_id, operation_id, attempt_number, claim_token, started_at, state, stage) SELECT site_id, operation_id, attempts, ?, ?, 'running', 'claimed' FROM wp_codebox_operations WHERE site_id = ? AND operation_id = ? AND state = 'running' AND claim_token = ? AND claim_expires_at = ?`).bind(token, new Date(now).toISOString(), siteId, candidate.operation_id, token, expiresAt), ]) if (update.meta.changes !== 1) return null @@ -133,15 +134,16 @@ export class D1OperationRepository { } private async ownedUpdate(siteId: string, operationId: string, token: string, update: string, values: unknown[]): Promise { - const result = await this.database.prepare(`${update} WHERE site_id = ? AND operation_id = ? AND state = 'running' AND claim_token = ? AND claim_expires_at > ?`).bind(...values, siteId, operationId, token, Date.now()).run() + const now = Date.now() + const result = await this.database.prepare(`${update} WHERE site_id = ? AND operation_id = ? AND state = 'running' AND claim_token = ? AND claim_expires_at > ? AND ${activeLifecycle}`).bind(...values, siteId, operationId, token, now, now).run() if (result.meta.changes !== 1) throw new OperationConflict("Operation claim expired or changed.") } private async terminalBatch(siteId: string, operationId: string, token: string, update: string, values: unknown[], state: string, stage: string, error: { code: string; message: string } | null): Promise { const now = Date.now() const [attempt, operation] = await this.database.batch([ - this.database.prepare(`UPDATE wp_codebox_operation_attempts SET completed_at = ?, state = ?, stage = ?, error_code = ?, error_message = ? WHERE site_id = ? AND operation_id = ? AND claim_token = ? AND completed_at IS NULL AND EXISTS (SELECT 1 FROM wp_codebox_operations WHERE site_id = ? AND operation_id = ? AND state = 'running' AND claim_token = ? AND claim_expires_at > ?)`).bind(new Date(now).toISOString(), state, stage, error?.code ?? null, error?.message ?? null, siteId, operationId, token, siteId, operationId, token, now), - this.database.prepare(`${update} WHERE site_id = ? AND operation_id = ? AND state = 'running' AND claim_token = ? AND claim_expires_at > ?`).bind(...values, siteId, operationId, token, now), + this.database.prepare(`UPDATE wp_codebox_operation_attempts SET completed_at = ?, state = ?, stage = ?, error_code = ?, error_message = ? WHERE site_id = ? AND operation_id = ? AND claim_token = ? AND completed_at IS NULL AND EXISTS (SELECT 1 FROM wp_codebox_operations WHERE site_id = ? AND operation_id = ? AND state = 'running' AND claim_token = ? AND claim_expires_at > ? AND ${activeLifecycle})`).bind(new Date(now).toISOString(), state, stage, error?.code ?? null, error?.message ?? null, siteId, operationId, token, siteId, operationId, token, now, now), + this.database.prepare(`${update} WHERE site_id = ? AND operation_id = ? AND state = 'running' AND claim_token = ? AND claim_expires_at > ? AND ${activeLifecycle}`).bind(...values, siteId, operationId, token, now, now), ]) if (attempt.meta.changes !== 1 || operation.meta.changes !== 1) throw new OperationConflict("Operation claim expired or changed.") } @@ -155,7 +157,8 @@ export class D1OperationRepository { const completedAt = new Date().toISOString() const receipt: OperationReceipt = { ...operation.receipt, publication: outcome === "promoted" ? { status: "promoted", jobKey, revision: revision! } : { status: outcome, jobKey }, terminalCompletedAt: completedAt } const failed = outcome !== "promoted" - await this.database.prepare(`UPDATE wp_codebox_operations SET state = ?, stage = ?, progress = 100, receipt_json = ?, error_code = ?, error_message = ?, completed_at = ?, updated_at = ? WHERE site_id = ? AND operation_id = ? AND state = 'publication-pending' AND prepared_publication_job = ?`).bind(failed ? "failed" : "succeeded", failed ? `publication-${outcome}` : "published", JSON.stringify(receipt), failed ? `publication_${outcome}` : null, failed ? `Publication ${outcome}.` : null, completedAt, Date.now(), siteId, operation.operationId, jobKey).run() + const now = Date.now() + await this.database.prepare(`UPDATE wp_codebox_operations SET state = ?, stage = ?, progress = 100, receipt_json = ?, error_code = ?, error_message = ?, completed_at = ?, updated_at = ? WHERE site_id = ? AND operation_id = ? AND state = 'publication-pending' AND prepared_publication_job = ? AND ${activeLifecycle}`).bind(failed ? "failed" : "succeeded", failed ? `publication-${outcome}` : "published", JSON.stringify(receipt), failed ? `publication_${outcome}` : null, failed ? `Publication ${outcome}.` : null, completedAt, now, siteId, operation.operationId, jobKey, now).run() } async pendingPublicationJobs(siteId: string, limit = 16): Promise { @@ -175,6 +178,7 @@ export class D1OperationRepository { } } +const activeLifecycle = `EXISTS (SELECT 1 FROM wp_codebox_sites s LEFT JOIN wp_codebox_site_lifecycles l ON l.site_id = s.site_id AND l.generation = s.generation WHERE s.site_id = wp_codebox_operations.site_id AND (l.site_id IS NULL OR (l.state = 'active' AND l.expires_at > ${"?"} AND l.operation_fence = wp_codebox_operations.allocation_fence)))` const operationSelect = `SELECT o.*, s.hostname, s.origin FROM wp_codebox_operations o JOIN wp_codebox_sites s ON s.site_id = o.site_id` interface Row { site_id: string; hostname: string; origin: string; operation_id: string; idempotency_key: string; fingerprint: string; artifact_key: string; artifact_sha256: string; artifact_size: number; slug: string; name: string; site_title: string; state: StaticArtifactOperationState; stage: string; progress: number; attempts: number; retry_at: number | null; claim_expires_at: number | null; prepared_version: number | null; prepared_revision: string | null; prepared_manifest_key: string | null; prepared_persisted_at: string | null; prepared_result_json: string | null; prepared_publication_job: string | null; error_code: string | null; error_message: string | null; receipt_json: string | null } interface AttemptRow { attempt_number: number; claim_token: string; started_at: string; completed_at: string | null; state: string; stage: string; error_code: string | null; error_message: string | null } @@ -195,7 +199,8 @@ async function ensureSchema(database: D1Database): Promise { await database.prepare("CREATE TABLE IF NOT EXISTS wp_codebox_site_lifecycles (site_id TEXT NOT NULL, generation INTEGER NOT NULL, principal TEXT NOT NULL, state TEXT NOT NULL, expires_at INTEGER NOT NULL, retain_until INTEGER NOT NULL, mutation_fence INTEGER NOT NULL, operation_fence INTEGER NOT NULL, cleanup_cursor TEXT, deleted_objects INTEGER NOT NULL, receipt_json TEXT, terminal_at INTEGER, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, PRIMARY KEY (site_id, generation))").run() await database.prepare(`CREATE UNIQUE INDEX IF NOT EXISTS wp_codebox_site_hostname ON wp_codebox_sites(hostname)`).run() await database.prepare(`CREATE TABLE IF NOT EXISTS wp_codebox_site_aliases (hostname TEXT PRIMARY KEY, site_id TEXT NOT NULL, created_at INTEGER NOT NULL, FOREIGN KEY (site_id) REFERENCES wp_codebox_sites(site_id))`).run() - await database.prepare(`CREATE TABLE IF NOT EXISTS wp_codebox_operations (site_id TEXT NOT NULL, operation_id TEXT NOT NULL, idempotency_key TEXT NOT NULL, fingerprint TEXT NOT NULL, artifact_key TEXT NOT NULL, artifact_sha256 TEXT NOT NULL, artifact_size INTEGER NOT NULL, slug TEXT NOT NULL, name TEXT NOT NULL, site_title TEXT NOT NULL, state TEXT NOT NULL CHECK (state IN ('queued','running','retryable','publication-pending','succeeded','failed')), stage TEXT NOT NULL, progress INTEGER NOT NULL CHECK (progress BETWEEN 0 AND 100), attempts INTEGER NOT NULL, retry_at INTEGER, claim_token TEXT, claim_expires_at INTEGER, prepared_version INTEGER, prepared_revision TEXT, prepared_manifest_key TEXT, prepared_persisted_at TEXT, prepared_result_json TEXT, prepared_publication_job TEXT, receipt_json TEXT, error_code TEXT, error_message TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, completed_at TEXT, PRIMARY KEY (site_id, operation_id), UNIQUE (site_id, idempotency_key), FOREIGN KEY (site_id) REFERENCES wp_codebox_sites(site_id))`).run() + await database.prepare(`CREATE TABLE IF NOT EXISTS wp_codebox_operations (site_id TEXT NOT NULL, operation_id TEXT NOT NULL, idempotency_key TEXT NOT NULL, fingerprint TEXT NOT NULL, artifact_key TEXT NOT NULL, artifact_sha256 TEXT NOT NULL, artifact_size INTEGER NOT NULL, slug TEXT NOT NULL, name TEXT NOT NULL, site_title TEXT NOT NULL, allocation_fence INTEGER, state TEXT NOT NULL CHECK (state IN ('queued','running','retryable','publication-pending','succeeded','failed')), stage TEXT NOT NULL, progress INTEGER NOT NULL CHECK (progress BETWEEN 0 AND 100), attempts INTEGER NOT NULL, retry_at INTEGER, claim_token TEXT, claim_expires_at INTEGER, prepared_version INTEGER, prepared_revision TEXT, prepared_manifest_key TEXT, prepared_persisted_at TEXT, prepared_result_json TEXT, prepared_publication_job TEXT, receipt_json TEXT, error_code TEXT, error_message TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, completed_at TEXT, PRIMARY KEY (site_id, operation_id), UNIQUE (site_id, idempotency_key), FOREIGN KEY (site_id) REFERENCES wp_codebox_sites(site_id))`).run() + try { await database.prepare("ALTER TABLE wp_codebox_operations ADD COLUMN allocation_fence INTEGER").run() } catch (error) { if (!(error instanceof Error) || !/duplicate column/i.test(error.message)) throw error } await database.prepare(`CREATE TABLE IF NOT EXISTS wp_codebox_operation_attempts (site_id TEXT NOT NULL, operation_id TEXT NOT NULL, attempt_number INTEGER NOT NULL, claim_token TEXT NOT NULL, started_at TEXT NOT NULL, completed_at TEXT, state TEXT NOT NULL, stage TEXT NOT NULL, error_code TEXT, error_message TEXT, PRIMARY KEY (site_id, operation_id, attempt_number), FOREIGN KEY (site_id, operation_id) REFERENCES wp_codebox_operations(site_id, operation_id))`).run() await database.prepare(`CREATE UNIQUE INDEX IF NOT EXISTS wp_codebox_one_active_operation ON wp_codebox_operations(site_id) WHERE state IN ('queued','running','retryable')`).run() await database.prepare(`CREATE INDEX IF NOT EXISTS wp_codebox_operation_ready ON wp_codebox_operations(site_id, state, retry_at, claim_expires_at, created_at)`).run() diff --git a/packages/runtime-cloudflare/src/provisioning-api.ts b/packages/runtime-cloudflare/src/provisioning-api.ts index 7dc496cec..36134a19c 100644 --- a/packages/runtime-cloudflare/src/provisioning-api.ts +++ b/packages/runtime-cloudflare/src/provisioning-api.ts @@ -146,6 +146,7 @@ async function importSite(request: Request, env: ProvisioningEnv, operations: D1 const key = idempotencyKey(request); if (key instanceof Response) return key const store = new AllocationStore(env.WORDPRESS_STATE_DATABASE); const allocation = await store.bySite(siteId); const site = await store.context(siteId) if (!allocation || !site || allocation.principal !== token.principal || !allowed(token, siteId)) return notFound() + if (!await allocationActive(env.WORDPRESS_STATE_DATABASE, siteId)) return apiError(409, "allocation_inactive", "The allocation is no longer active.") const provision = allocation.operationId ? await operations.get(siteId, allocation.operationId) : null if (provision?.state !== "succeeded") return apiError(409, "site_not_ready", "The site is not ready for imports.") try { @@ -170,6 +171,7 @@ export async function resumeProvisioningAllocation(env: ProvisioningEnv, site: S const store = new AllocationStore(env.WORDPRESS_STATE_DATABASE) const allocation = await store.bySite(site.id) if (!allocation) return null + if (!await allocationActive(env.WORDPRESS_STATE_DATABASE, site.id)) throw new OperationConflict("The allocation is no longer active.") if (!claimConfiguration(env)) throw new AdministratorClaimError() const administratorClaim = await new AdministratorClaimStore(env.WORDPRESS_STATE_DATABASE).issue(allocation, env) if (allocation.operationId) { @@ -336,6 +338,7 @@ class AdministratorClaimStore { constructor(private readonly db: D1Database) {} async issue(allocation: ProvisioningAllocation, env: ProvisioningEnv): Promise { await this.schema() + if (!await allocationActive(this.db, allocation.siteId)) throw new AdministratorClaimError() const token = await claimCapability(env.WORDPRESS_ADMIN_CLAIM_SECRET!, allocation) const digest = await shaText(token) const credential = await deriveSiteCredential(env.WORDPRESS_ADMIN_PASSWORD!, allocation.siteId, "admin-password") @@ -354,9 +357,14 @@ class AdministratorClaimStore { async metadata(siteId: string): Promise { const claim = await this.bySite(siteId); if (claim?.state === "pending" && claim.expiresAt <= Date.now()) { await this.expire(siteId); return { state: "expired", expiresAt: claim.expiresAt } } return claim && { state: claim.state, expiresAt: claim.expiresAt } } async bySite(siteId: string): Promise { await this.schema(); const row = await this.db.prepare("SELECT capability_digest, credential_digest, expires_at, state FROM wp_codebox_api_admin_claims WHERE site_id = ?").bind(siteId).first<{ capability_digest: string; credential_digest: string; expires_at: number; state: AdministratorClaimMetadata["state"] }>(); return row ? { capabilityDigest: row.capability_digest, credentialDigest: row.credential_digest, expiresAt: row.expires_at, state: row.state } : null } async expire(siteId: string): Promise { await this.db.prepare("UPDATE wp_codebox_api_admin_claims SET state = 'expired', updated_at = ? WHERE site_id = ? AND state = 'pending' AND expires_at <= ?").bind(Date.now(), siteId, Date.now()).run() } - async consume(siteId: string, digest: string): Promise { const result = await this.db.prepare("UPDATE wp_codebox_api_admin_claims SET state = 'consumed', updated_at = ? WHERE site_id = ? AND state = 'pending' AND capability_digest = ? AND expires_at > ?").bind(Date.now(), siteId, digest, Date.now()).run(); return result.meta.changes === 1 } + async consume(siteId: string, digest: string): Promise { const now = Date.now(); const result = await this.db.prepare("UPDATE wp_codebox_api_admin_claims SET state = 'consumed', updated_at = ? WHERE site_id = ? AND state = 'pending' AND capability_digest = ? AND expires_at > ? AND EXISTS (SELECT 1 FROM wp_codebox_sites s LEFT JOIN wp_codebox_site_lifecycles l ON l.site_id = s.site_id AND l.generation = s.generation WHERE s.site_id = wp_codebox_api_admin_claims.site_id AND (l.site_id IS NULL OR (l.state = 'active' AND l.expires_at > ?)))").bind(now, siteId, digest, now, now).run(); return result.meta.changes === 1 } private async schema(): Promise { await this.db.prepare("CREATE TABLE IF NOT EXISTS wp_codebox_api_admin_claims (site_id TEXT PRIMARY KEY, capability_digest TEXT NOT NULL, credential_digest TEXT NOT NULL, expires_at INTEGER NOT NULL, state TEXT NOT NULL CHECK (state IN ('pending','consumed','expired')), created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)").run() } } +async function allocationActive(db: D1Database, siteId: string): Promise { + const lifecycle = new CloudflareAllocationLifecycle(db) + const current = await lifecycle.get(allocationIdentity(siteId)) + return !current || (current.state === "active" && current.expiresAt > Date.now()) +} async function claimCapability(root: string, allocation: ProvisioningAllocation): Promise { const encoder = new TextEncoder() const key = await crypto.subtle.importKey("raw", encoder.encode(root), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]) diff --git a/packages/runtime-cloudflare/src/worker.ts b/packages/runtime-cloudflare/src/worker.ts index 1e81a7ec0..fb4dbba39 100644 --- a/packages/runtime-cloudflare/src/worker.ts +++ b/packages/runtime-cloudflare/src/worker.ts @@ -15,7 +15,7 @@ import { routeWorkerRequest } from "./request-routing.js" import { readStaticArtifactImport, STATIC_ARTIFACT_IMPORT_RESULT_SCHEMA, StaticArtifactImportError, type StaticArtifactImport } from "./static-artifact-import.js" import { D1OperationRepository, OperationConflict, STATIC_ARTIFACT_OPERATION_SCHEMA, shouldRecoverPreparedCommit } from "./d1-operation-repository.js" import { resumeProvisioningAllocation, routeProvisioningApi } from "./provisioning-api.js" -import { CloudflareAllocationLifecycle } from "./allocation-lifecycle.js" +import { allocationIdentity, CloudflareAllocationLifecycle } from "./allocation-lifecycle.js" import { toFetchResponse, toPHPRequest } from "./request-translation.js" import { DEFAULT_SITE_CONTEXT, parseSiteContexts, previewDomain, resolvePreviewSiteContextFromRequest, resolveSiteContextFromRequest, siteStorageKeys, type SiteContext } from "./site-context.js" import { validateUploadManifestFiles, validateUploadMetadata } from "./upload-persistence.js" @@ -258,6 +258,13 @@ export function createCloudflareRuntime( if (staticResponse) return staticResponse const uploadResponse = await serveWordPressUpload(request, env.WORDPRESS_STATE_BUCKET, coordinator, site) if (uploadResponse) return uploadResponse + if (env.WORDPRESS_STATE_DATABASE && lifecycleProtectedRoute(route.kind)) { + try { + await new CloudflareAllocationLifecycle(env.WORDPRESS_STATE_DATABASE).assertActive(allocationIdentity(site.id)) + } catch { + return new Response("The allocation is no longer active.", { status: 410 }) + } + } if (route.kind === "operator-reset") return resetCanonicalWordPress(request, env, coordinator, site) if (route.kind === "operator-restore") return restoreCanonicalWordPress(request, env, coordinator, site) if (route.kind === "operator-adopt") return adoptCanonicalWordPress(request, env, coordinator, site, selection.selected) @@ -280,15 +287,16 @@ export function createCloudflareRuntime( } }, async scheduled(controller: ScheduledController, env: Env): Promise { - if (env.WORDPRESS_STATE_DATABASE) { - const lifecycle = new CloudflareAllocationLifecycle(env.WORDPRESS_STATE_DATABASE) - await lifecycle.expire(controller.scheduledTime, 1) - const deleting = (await lifecycle.pendingDeletions(1, controller.scheduledTime))[0] - if (deleting) await lifecycle.reclaim(env.WORDPRESS_STATE_BUCKET, deleting.identity, deleting.operationFence, 100, controller.scheduledTime) - } - const configured = parseSiteContexts(env.WORDPRESS_SITE_CONTEXTS) - const registered = env.WORDPRESS_STATE_DATABASE && resolveOperations?.(env) ? await resolveOperations(env)!.activeSites() : [] - const sites = [...new Map([...configured, ...registered].map((site) => [site.id, site])).values()].sort((left, right) => left.id.localeCompare(right.id)) + if (env.WORDPRESS_STATE_DATABASE) { + const lifecycle = new CloudflareAllocationLifecycle(env.WORDPRESS_STATE_DATABASE) + await lifecycle.expire(controller.scheduledTime, 1) + const deleting = (await lifecycle.pendingDeletions(1, controller.scheduledTime))[0] + if (deleting) await lifecycle.reclaim(env.WORDPRESS_STATE_BUCKET, deleting.identity, deleting.operationFence, 100, controller.scheduledTime) + } + const configured = parseSiteContexts(env.WORDPRESS_SITE_CONTEXTS) + const registered = env.WORDPRESS_STATE_DATABASE && resolveOperations?.(env) ? await resolveOperations(env)!.activeSites() : [] + const sites = [...new Map([...configured, ...registered].map((site) => [site.id, site])).values()].sort((left, right) => left.id.localeCompare(right.id)) + if (!sites.length) return const site = sites[Math.floor(controller.scheduledTime / 60_000) % sites.length] const coordinator = resolveCoordinator(env, site) const operations = resolveOperations?.(env) @@ -942,6 +950,7 @@ async function reconcilePublicationReceipts(bucket: R2Bucket, site: SiteContext, } async function drainNextPublicationJobWhileLeased(env: RuntimeEnv, coordinator: RevisionCoordinator, site: SiteContext, renewLease: () => Promise, operations?: D1OperationRepository): Promise { + await assertActiveAllocation(env, site) const listed = await env.WORDPRESS_STATE_BUCKET.list({ prefix: `${siteStorageKeys(site).publicationJobPrefix}/`, limit: 16 }) if (!listed.objects.length) return null const state = await coordinator.state() @@ -1024,6 +1033,7 @@ async function drainNextPublicationJobWhileLeased(env: RuntimeEnv, coordinator: await renewLease() await putImmutableJson(env.WORDPRESS_STATE_BUCKET, publishedRevisionObjectKey(publication.revision, site), serialized) await renewLease() + await assertActiveAllocation(env, site) if (!await promoteIncrementalPublication(env.WORDPRESS_STATE_BUCKET, current, { serialized, invalidatedRoutes: [...new Set([...plan.upsert, ...plan.remove])].sort() }, site)) { return { schema: "wp-codebox/cloudflare-publication/v1", status: "stale", jobKey: job.key } } @@ -1049,6 +1059,14 @@ async function drainNextPublicationJobWhileLeased(env: RuntimeEnv, coordinator: } } +function lifecycleProtectedRoute(kind: string): boolean { + return ["wordpress", "r2-mutate", "operator-reset", "operator-restore", "operator-adopt", "operator-fence", "operator-static-artifact-import", "operator-publish"].includes(kind) +} + +async function assertActiveAllocation(env: RuntimeEnv, site: SiteContext): Promise { + if (env.WORDPRESS_STATE_DATABASE) await new CloudflareAllocationLifecycle(env.WORDPRESS_STATE_DATABASE).assertActive(allocationIdentity(site.id)) +} + async function compilePublicationRoutes(runtime: Runtime, routes: string[], origin: string): Promise { const compiled: CompiledPublicationRoute[] = [] for (const route of routes) { diff --git a/tests/cloudflare-allocation-lifecycle.test.ts b/tests/cloudflare-allocation-lifecycle.test.ts index f1c59fcc1..2750fcfa2 100644 --- a/tests/cloudflare-allocation-lifecycle.test.ts +++ b/tests/cloudflare-allocation-lifecycle.test.ts @@ -56,3 +56,21 @@ test("cleanup checkpoints resume after partial R2 failure and tombstone receipts await assert.rejects(() => lifecycle.reclaim(bucket as unknown as Pick, created.identity, fence, 1, 1_006), AllocationLifecycleConflict) assert.equal(JSON.stringify((await lifecycle.get(created.identity))!.receipt), receipt) }) + +test("reclamation removes bounded D1 work before issuing a zero-unresolved receipt", async () => { + const db = database(); const lifecycle = new CloudflareAllocationLifecycle(db, { ttlMs: 10, retainMs: 0 }); const created = await lifecycle.create(site(), "owner", 1_000) + await db.prepare("CREATE TABLE wp_codebox_operations (site_id TEXT, operation_id TEXT, state TEXT, stage TEXT, progress INTEGER, claim_token TEXT, claim_expires_at INTEGER, retry_at INTEGER, error_code TEXT, error_message TEXT, completed_at TEXT, updated_at INTEGER)").run() + await db.prepare("CREATE TABLE wp_codebox_operation_attempts (site_id TEXT, operation_id TEXT, completed_at TEXT, state TEXT, stage TEXT, error_code TEXT, error_message TEXT)").run() + await db.prepare("CREATE TABLE wp_codebox_api_admin_claims (site_id TEXT, state TEXT, updated_at INTEGER)").run() + await db.prepare("INSERT INTO wp_codebox_operations (site_id, operation_id, state) VALUES (?, ?, 'queued')").bind(site().id, "one").run() + await db.prepare("INSERT INTO wp_codebox_operations (site_id, operation_id, state) VALUES (?, ?, 'queued')").bind(site().id, "two").run() + await db.prepare("INSERT INTO wp_codebox_operation_attempts (site_id, operation_id) VALUES (?, ?)").bind(site().id, "one").run() + await db.prepare("INSERT INTO wp_codebox_api_admin_claims (site_id, state) VALUES (?, 'pending')").bind(site().id).run() + const fence = await lifecycle.beginDeletion(created.identity, "owner", 1_001) + const bucket = new Bucket(); const first = await lifecycle.reclaim(bucket as unknown as Pick, created.identity, fence, 1, 1_002) + assert.equal(first.state, "deleting", "a bounded cleanup pass cannot tombstone unresolved D1 work") + const final = await lifecycle.reclaim(bucket as unknown as Pick, created.identity, fence, 10, 1_003) + assert.equal(final.state, "tombstoned") + assert.equal(final.receipt?.unresolvedRecords, 0) + assert.equal(final.receipt?.deletedRecords, 4) +}) diff --git a/tests/cloudflare-d1-operation-repository.test.ts b/tests/cloudflare-d1-operation-repository.test.ts index 01d613bdf..00c92610e 100644 --- a/tests/cloudflare-d1-operation-repository.test.ts +++ b/tests/cloudflare-d1-operation-repository.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict" import { DatabaseSync } from "node:sqlite" import test from "node:test" import { D1OperationRepository, OperationConflict, shouldRecoverPreparedCommit } from "../packages/runtime-cloudflare/src/d1-operation-repository.js" +import { CloudflareAllocationLifecycle } from "../packages/runtime-cloudflare/src/allocation-lifecycle.js" function database(): D1Database { const sqlite = new DatabaseSync(":memory:") @@ -91,3 +92,19 @@ test("exact prepared-pointer recovery only skips SSI for the matching coordinato assert.equal(shouldRecoverPreparedCommit(prepared, { ...pointer, revision: "unrelated", manifestKey: "sites/alpha/markdown/revisions/unrelated.json" }), false) assert.equal(shouldRecoverPreparedCommit(null, pointer), false) }) + +test("deletion fences an in-flight claim and a publication callback", async () => { + const db = database(); const dynamic = { id: "race-g2-0123456789abcdef", hostname: "race.preview.example", origin: "https://race.preview.example" } + const lifecycle = new CloudflareAllocationLifecycle(db, { ttlMs: 60_000, retainMs: 0 }); const active = await lifecycle.create(dynamic, "owner") + const repository = new D1OperationRepository(db); const created = await repository.createOrConverge(dynamic, input("race")); const claim = await repository.claimNext(dynamic.id) + assert.ok(claim) + const fence = await lifecycle.beginDeletion(active.identity, "owner") + await assert.rejects(() => repository.renew(dynamic.id, claim.operationId, claim.claimToken), OperationConflict) + await assert.rejects(() => repository.prepareCommit(dynamic.id, claim.operationId, claim.claimToken, 1, { ...pointer, manifestKey: `sites/${dynamic.id}/markdown/revisions/revision.json` }, { imported: true }, "job-race"), OperationConflict) + await assert.rejects(() => repository.complete(dynamic.id, claim.operationId, claim.claimToken, { imported: true }, "job-race", dynamic.origin), OperationConflict) + assert.equal(await repository.claimNext(dynamic.id), null) + assert.equal((await repository.get(dynamic.id, created.operation.operationId))?.stage, "allocation-deleted") + await repository.reconcilePublication(dynamic.id, "job-race", "promoted", "revision") + assert.equal((await repository.get(dynamic.id, created.operation.operationId))?.state, "failed") + assert.ok(fence > active.operationFence) +}) diff --git a/tests/cloudflare-provisioning-api.test.ts b/tests/cloudflare-provisioning-api.test.ts index b9f6cdddf..359a9329f 100644 --- a/tests/cloudflare-provisioning-api.test.ts +++ b/tests/cloudflare-provisioning-api.test.ts @@ -145,3 +145,13 @@ test("consumed claims do not block allocation recovery after credential rotation test("administrator claim credential configuration failures do not consume a ready claim", async () => { const r = runtime(); const created = await (await create(r)).json() as { site: { id: string; administratorClaim: { token: string } } }; r.db.sqlite.prepare("UPDATE wp_codebox_operations SET state = 'succeeded' WHERE site_id = ?").run(created.site.id); delete r.env.WORDPRESS_ADMIN_PASSWORD; const response = await routeProvisioningApi(new Request(`https://control.invalid/v1/sites/${created.site.id}/administrator-claim`, { method: "POST", headers: { authorization: `Bearer ${created.site.administratorClaim.token}` } }), r.env, r.operations); assert.equal(response.status, 401); assert.equal((r.db.sqlite.prepare("SELECT state FROM wp_codebox_api_admin_claims").get() as { state: string }).state, "pending") }) + +test("deletion prevents administrator credential consumption and reclaims its claim record", async () => { + const r = runtime(); const created = await (await create(r)).json() as { site: { id: string; administratorClaim: { token: string } } } + const lifecycle = new CloudflareAllocationLifecycle(r.db, { ttlMs: 60_000, retainMs: 0 }); const identity = allocationIdentity(created.site.id); const fence = await lifecycle.beginDeletion(identity, "a") + const claim = new Request(`https://control.invalid/v1/sites/${created.site.id}/administrator-claim`, { method: "POST", headers: { authorization: `Bearer ${created.site.administratorClaim.token}` } }) + assert.equal((await routeProvisioningApi(claim, r.env, r.operations)).status, 401) + const bucket = new Bucket(); const final = await lifecycle.reclaim(bucket as unknown as Pick, identity, fence, 100) + assert.equal(final.state, "tombstoned"); assert.ok((final.receipt?.deletedRecords ?? 0) >= 2) + assert.equal(count(r.db, "wp_codebox_api_admin_claims"), 0) +})