Skip to content

Commit 2a8b019

Browse files
authored
Merge pull request #2111 from Automattic/feat/2081-site-lifecycle
Add expiration, deletion, and storage reclamation for provisioned sites
2 parents 8b1ffb4 + 0da28ff commit 2a8b019

8 files changed

Lines changed: 353 additions & 20 deletions

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@
110110
"test:redaction": "tsx tests/redaction.test.ts",
111111
"test:browser-preview-routing": "tsx --test tests/browser-preview-routing.test.ts",
112112
"test:browser-routed-command-security": "tsx --test tests/browser-routed-command-security.test.ts",
113-
"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",
113+
"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",
114114
"test:cloudflare-administrator-claim": "tsx tests/cloudflare-provisioning-api.test.ts",
115115
"test:cloudflare-wordpress-auth": "tsx tests/cloudflare-wordpress-auth.test.ts",
116116
"test:cloudflare-wordpress-archive-corpus": "tsx tests/cloudflare-wordpress-archive-corpus.test.ts",

packages/runtime-cloudflare/src/allocation-lifecycle.ts

Lines changed: 147 additions & 0 deletions
Large diffs are not rendered by default.

packages/runtime-cloudflare/src/d1-operation-repository.ts

Lines changed: 20 additions & 10 deletions
Large diffs are not rendered by default.

packages/runtime-cloudflare/src/provisioning-api.ts

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { D1OperationRepository, OperationConflict, type StaticArtifactOperation,
22
import { MAX_STATIC_ARTIFACT_BYTES, readBoundedRequestBytes, readStaticArtifactImport, StaticArtifactImportError, validateStaticArtifact } from "./static-artifact-import.js"
33
import { allocatePreviewSiteContext, parseSiteContexts, previewDomain, siteStorageKeys, type SiteContext } from "./site-context.js"
44
import { deriveSiteCredential } from "./wordpress-auth.js"
5+
import { allocationIdentity, CloudflareAllocationLifecycle, type AllocationLifecycle } from "./allocation-lifecycle.js"
56

67
export const PROVISIONING_API_SCHEMA = "wp-codebox/provisioning-api/v1"
78
export const PROVISIONING_CREATE_REQUEST_SCHEMA = "wp-codebox/provisioning-create-request/v1"
@@ -29,11 +30,35 @@ export async function routeProvisioningApi(request: Request, env: ProvisioningEn
2930
// This capability endpoint must never fall through to API bearer authentication or WordPress.
3031
if (parts.length === 4 && parts[3] === "administrator-claim") return method === "POST" ? claimAdministrator(request, env, operations, siteId) : methodNotAllowed("POST")
3132
if (parts.length === 3) return method === "GET" ? readSite(request, env, operations, siteId) : methodNotAllowed("GET")
33+
if (parts.length === 4 && parts[3] === "lifecycle" && method === "GET") return lifecycleStatus(request, env, siteId)
34+
if (parts.length === 5 && parts[3] === "lifecycle" && parts[4] === "renew") return method === "POST" ? renewLifecycle(request, env, siteId) : methodNotAllowed("POST")
35+
if (parts.length === 4 && parts[3] === "lifecycle") return method === "DELETE" ? deleteLifecycle(request, env, siteId) : methodNotAllowed("GET, DELETE")
3236
if (parts.length === 4 && parts[3] === "imports") return method === "POST" ? importSite(request, env, operations, siteId) : methodNotAllowed("POST")
3337
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")
3438
return notFound()
3539
}
3640

41+
async function lifecycleStatus(request: Request, env: ProvisioningEnv, siteId: string): Promise<Response> {
42+
const token = await authenticate(request, env, "sites:read"); if (token instanceof Response) return token
43+
const allocation = await new AllocationStore(env.WORDPRESS_STATE_DATABASE).bySite(siteId)
44+
if (!allocation || allocation.principal !== token.principal || !allowed(token, siteId)) return notFound()
45+
const lifecycle = await new CloudflareAllocationLifecycle(env.WORDPRESS_STATE_DATABASE).get(allocationIdentity(siteId))
46+
return lifecycle ? Response.json({ schema: PROVISIONING_API_SCHEMA, lifecycle: lifecycleResource(lifecycle) }) : notFound()
47+
}
48+
async function renewLifecycle(request: Request, env: ProvisioningEnv, siteId: string): Promise<Response> {
49+
const token = await authenticate(request, env, "sites:import"); if (token instanceof Response) return token
50+
const allocation = await new AllocationStore(env.WORDPRESS_STATE_DATABASE).bySite(siteId)
51+
if (!allocation || allocation.principal !== token.principal || !allowed(token, siteId)) return notFound()
52+
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.") }
53+
}
54+
async function deleteLifecycle(request: Request, env: ProvisioningEnv, siteId: string): Promise<Response> {
55+
const token = await authenticate(request, env, "sites:import"); if (token instanceof Response) return token
56+
const allocation = await new AllocationStore(env.WORDPRESS_STATE_DATABASE).bySite(siteId)
57+
if (!allocation || allocation.principal !== token.principal || !allowed(token, siteId)) return notFound()
58+
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.") }
59+
}
60+
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 } }
61+
3762
async function stageArtifact(request: Request, env: ProvisioningEnv, expectedSha256: string): Promise<Response> {
3863
const token = await authenticate(request, env, "sites:create"); if (token instanceof Response) return token
3964
const declaredLength = request.headers.get("content-length")
@@ -121,6 +146,7 @@ async function importSite(request: Request, env: ProvisioningEnv, operations: D1
121146
const key = idempotencyKey(request); if (key instanceof Response) return key
122147
const store = new AllocationStore(env.WORDPRESS_STATE_DATABASE); const allocation = await store.bySite(siteId); const site = await store.context(siteId)
123148
if (!allocation || !site || allocation.principal !== token.principal || !allowed(token, siteId)) return notFound()
149+
if (!await allocationActive(env.WORDPRESS_STATE_DATABASE, siteId)) return apiError(409, "allocation_inactive", "The allocation is no longer active.")
124150
const provision = allocation.operationId ? await operations.get(siteId, allocation.operationId) : null
125151
if (provision?.state !== "succeeded") return apiError(409, "site_not_ready", "The site is not ready for imports.")
126152
try {
@@ -145,6 +171,7 @@ export async function resumeProvisioningAllocation(env: ProvisioningEnv, site: S
145171
const store = new AllocationStore(env.WORDPRESS_STATE_DATABASE)
146172
const allocation = await store.bySite(site.id)
147173
if (!allocation) return null
174+
if (!await allocationActive(env.WORDPRESS_STATE_DATABASE, site.id)) throw new OperationConflict("The allocation is no longer active.")
148175
if (!claimConfiguration(env)) throw new AdministratorClaimError()
149176
const administratorClaim = await new AdministratorClaimStore(env.WORDPRESS_STATE_DATABASE).issue(allocation, env)
150177
if (allocation.operationId) {
@@ -255,7 +282,7 @@ class AllocationError extends Error { constructor(readonly code: "quota_exceeded
255282
class AllocationStore {
256283
constructor(private readonly db: D1Database) {}
257284
async allocate(token: Token, input: CreateInput, domain: ReturnType<typeof previewDomain>, configured: SiteContext[]): Promise<ProvisioningAllocation> {
258-
await this.schema(); const existing = await this.byRequest(token.principal, input.key)
285+
await this.schema(); const lifecycle = new CloudflareAllocationLifecycle(this.db); await lifecycle.initialize(); const existing = await this.byRequest(token.principal, input.key)
259286
if (existing) { if (existing.fingerprint !== input.fingerprint) throw new OperationConflict("The idempotency key is already bound to a different immutable input."); return existing }
260287
if (domain && token.sites) throw new AllocationError("quota_exceeded")
261288
const attempts = domain ? 8 : configured.length
@@ -264,17 +291,20 @@ class AllocationStore {
264291
const now = Date.now()
265292
try {
266293
const [allocation, reservation] = await this.db.batch([
267-
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),
268-
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),
294+
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),
295+
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),
269296
])
270-
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 }
297+
if (allocation.meta.changes === 1 && reservation.meta.changes === 1) {
298+
await lifecycle.create(site, token.principal, now)
299+
return { siteId: site.id, principal: token.principal, key: input.key, fingerprint: input.fingerprint, operationId: null, artifactSha256: input.artifactSha256, artifactSize: input.artifactSize, options: input.options }
300+
}
271301
} catch (error) {
272302
if (!(error instanceof Error) || !/unique|constraint/i.test(error.message)) throw error
273303
}
274304
const raced = await this.byRequest(token.principal, input.key)
275305
if (raced) { if (raced.fingerprint !== input.fingerprint) throw new OperationConflict("The idempotency key is already bound to a different immutable input."); return raced }
276306
}
277-
const count = await this.db.prepare("SELECT COUNT(*) AS count FROM wp_codebox_api_sites WHERE principal = ?").bind(token.principal).first<{ count: number }>()
307+
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 }>()
278308
throw new AllocationError((count?.count ?? 0) >= token.maxSites ? "quota_exceeded" : "capacity_exhausted")
279309
}
280310
async context(siteId: string): Promise<SiteContext | null> { 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 }
@@ -308,6 +338,7 @@ class AdministratorClaimStore {
308338
constructor(private readonly db: D1Database) {}
309339
async issue(allocation: ProvisioningAllocation, env: ProvisioningEnv): Promise<AdministratorClaimIssued | AdministratorClaimMetadata> {
310340
await this.schema()
341+
if (!await allocationActive(this.db, allocation.siteId)) throw new AdministratorClaimError()
311342
const token = await claimCapability(env.WORDPRESS_ADMIN_CLAIM_SECRET!, allocation)
312343
const digest = await shaText(token)
313344
const credential = await deriveSiteCredential(env.WORDPRESS_ADMIN_PASSWORD!, allocation.siteId, "admin-password")
@@ -326,9 +357,14 @@ class AdministratorClaimStore {
326357
async metadata(siteId: string): Promise<AdministratorClaimMetadata | null> { 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 } }
327358
async bySite(siteId: string): Promise<AdministratorClaimRecord | null> { 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 }
328359
async expire(siteId: string): Promise<void> { 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() }
329-
async consume(siteId: string, digest: string): Promise<boolean> { 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 }
360+
async consume(siteId: string, digest: string): Promise<boolean> { 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 }
330361
private async schema(): Promise<void> { 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() }
331362
}
363+
async function allocationActive(db: D1Database, siteId: string): Promise<boolean> {
364+
const lifecycle = new CloudflareAllocationLifecycle(db)
365+
const current = await lifecycle.get(allocationIdentity(siteId))
366+
return !current || (current.state === "active" && current.expiresAt > Date.now())
367+
}
332368
async function claimCapability(root: string, allocation: ProvisioningAllocation): Promise<string> {
333369
const encoder = new TextEncoder()
334370
const key = await crypto.subtle.importKey("raw", encoder.encode(root), { name: "HMAC", hash: "SHA-256" }, false, ["sign"])

0 commit comments

Comments
 (0)