Skip to content

Commit b790df0

Browse files
authored
Merge pull request #2009 from Automattic/proof/1974-provisioned-site-edit-lifecycle
Complete provisioned site publication lifecycle
2 parents 117e16a + 9342ec4 commit b790df0

4 files changed

Lines changed: 106 additions & 14 deletions

File tree

packages/runtime-cloudflare/src/published-reader.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export interface PublishedRevision {
1919
revision: string
2020
canonicalRevision: string
2121
canonicalVersion: number
22+
sourceJob?: string
2223
publishedAt: string
2324
routes: PublishedRoute[]
2425
}
@@ -49,7 +50,8 @@ export function validatePublishedRevision(value: unknown, site: SiteContext = DE
4950
if (![PUBLISHED_REVISION_SCHEMA, PREVIOUS_PUBLISHED_REVISION_SCHEMA, LEGACY_PUBLISHED_REVISION_SCHEMA].includes(revision.schema as string) || !isRevision(revision.revision) || !isRevision(revision.canonicalRevision)
5051
|| typeof revision.publishedAt !== "string" || !Number.isFinite(Date.parse(revision.publishedAt)) || !Array.isArray(revision.routes)
5152
|| (current && (typeof revision.canonicalVersion !== "number" || !Number.isSafeInteger(revision.canonicalVersion) || revision.canonicalVersion < 0))
52-
|| revision.routes.length === 0 || revision.routes.length > MAX_PUBLISHED_ROUTES) throw new Error("Published revision is invalid.")
53+
|| (revision.sourceJob !== undefined && (typeof revision.sourceJob !== "string" || !new RegExp(`^${escapeRegExp(siteStorageKeys(site).publicationJobPrefix)}/[0-9]{20}-[a-f0-9-]{36}\\.json$`).test(revision.sourceJob)))
54+
|| revision.routes.length > MAX_PUBLISHED_ROUTES) throw new Error("Published revision is invalid.")
5355
let previous = ""
5456
const normalizedRoutes: PublishedRoute[] = []
5557
for (const route of revision.routes) {

packages/runtime-cloudflare/src/worker.ts

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -738,6 +738,27 @@ async function readCurrentPublication(bucket: R2Bucket, site: SiteContext): Prom
738738
return { publication: validatePublishedRevision(JSON.parse(await object.text()), site), etag: object.etag }
739739
}
740740

741+
async function initializeProvisioningPublication(bucket: R2Bucket, lease: Lease, site: SiteContext): Promise<CurrentPublication> {
742+
const existing = await readCurrentPublication(bucket, site)
743+
if (existing) return existing
744+
if (!lease.pointer) throw new Error("Provisioning publication requires a committed canonical revision.")
745+
const publication: PublishedRevision = {
746+
schema: PUBLISHED_REVISION_SCHEMA,
747+
revision: crypto.randomUUID(),
748+
canonicalRevision: lease.pointer.revision,
749+
canonicalVersion: lease.version,
750+
publishedAt: new Date().toISOString(),
751+
routes: [],
752+
}
753+
const serialized = JSON.stringify(publication)
754+
await putImmutableJson(bucket, publishedRevisionObjectKey(publication.revision, site), serialized)
755+
const created = await bucket.put(siteStorageKeys(site).publishedCurrent, serialized, { onlyIf: { etagDoesNotMatch: "*" }, httpMetadata: { contentType: "application/json" } })
756+
if (created) return { publication, etag: created.etag }
757+
const winner = await readCurrentPublication(bucket, site)
758+
if (!winner) throw new Error("Provisioning publication did not produce a current revision.")
759+
return winner
760+
}
761+
741762
function initializePublicationChanges(php: PHP): void {
742763
php.writeFile(PUBLICATION_CHANGES_PATH, new TextEncoder().encode(JSON.stringify({ all: false, upsert: [], remove: [] })))
743764
}
@@ -930,6 +951,17 @@ async function drainNextPublicationJobWhileLeased(env: RuntimeEnv, coordinator:
930951
try {
931952
let progress = await readPublicationProgress(env.WORDPRESS_STATE_BUCKET, job, site)
932953
const current = await readCurrentPublication(env.WORDPRESS_STATE_BUCKET, site)
954+
const recoveredPromotion = current?.publication.canonicalVersion === job.coordinatorVersion
955+
&& current.publication.canonicalRevision === job.canonical.revision
956+
&& current.publication.sourceJob === job.key
957+
if (recoveredPromotion) {
958+
await renewLease()
959+
await putImmutableJson(env.WORDPRESS_STATE_BUCKET, publicationReceiptObjectKey(job, site), JSON.stringify({ status: "promoted", job: job.key, publication: current.publication.revision, recordedAt: new Date().toISOString() }))
960+
await operations?.reconcilePublication(site.id, job.key, "promoted", current.publication.revision)
961+
await renewLease()
962+
await env.WORDPRESS_STATE_BUCKET.delete([job.key, publicationProgressObjectKey(job, site)])
963+
return { schema: "wp-codebox/cloudflare-publication/v1", status: "promoted", jobKey: job.key }
964+
}
933965
if (!current || current.publication.canonicalVersion >= job.coordinatorVersion) {
934966
await renewLease()
935967
await putImmutableJson(env.WORDPRESS_STATE_BUCKET, publicationReceiptObjectKey(job, site), JSON.stringify({ status: "superseded", job: job.key, recordedAt: new Date().toISOString() }))
@@ -969,7 +1001,7 @@ async function drainNextPublicationJobWhileLeased(env: RuntimeEnv, coordinator:
9691001
const routes = new Map(current.publication.routes.map((route) => [route.route, route]))
9701002
for (const route of plan.remove) routes.delete(route)
9711003
for (const route of plan.upsert) routes.set(route, { route, objectKey: await publishedPageObjectKey(job.canonical.revision, route, site), canonicalRevision: job.canonical.revision })
972-
const publication: PublishedRevision = { schema: PUBLISHED_REVISION_SCHEMA, revision: crypto.randomUUID(), canonicalRevision: job.canonical.revision, canonicalVersion: job.coordinatorVersion, publishedAt: new Date().toISOString(), routes: [...routes.values()].sort((left, right) => left.route.localeCompare(right.route)) }
1004+
const publication: PublishedRevision = { schema: PUBLISHED_REVISION_SCHEMA, revision: crypto.randomUUID(), canonicalRevision: job.canonical.revision, canonicalVersion: job.coordinatorVersion, sourceJob: job.key, publishedAt: new Date().toISOString(), routes: [...routes.values()].sort((left, right) => left.route.localeCompare(right.route)) }
9731005
const serialized = JSON.stringify(publication)
9741006
if (new TextEncoder().encode(serialized).byteLength > MAX_PUBLISHED_REVISION_BYTES) throw new Error("Incremental publication exceeds its size budget.")
9751007
await renewLease()
@@ -1075,7 +1107,7 @@ async function runCoordinatedWordPressRequest(request: Request, env: RuntimeEnv,
10751107
const mutatesCanonicalState = isMutation(request, route)
10761108
const diagnosticsStartedAt = Date.now()
10771109
const retained = new MutationRetainedBytes()
1078-
const currentPublication = mutatesCanonicalState ? await readCurrentPublication(env.WORDPRESS_STATE_BUCKET, site) : null
1110+
let currentPublication = mutatesCanonicalState ? await readCurrentPublication(env.WORDPRESS_STATE_BUCKET, site) : null
10791111
let response: Response | undefined
10801112
let phpResponse: PHPResponseData | undefined
10811113
let responseBodyBytes = 0
@@ -1136,6 +1168,7 @@ async function runCoordinatedWordPressRequest(request: Request, env: RuntimeEnv,
11361168
await discardRuntime(runtime, site)
11371169
runtime = undefined
11381170
if (phpResponse) retained.release(responseBodyBytes)
1171+
if (route === "static-artifact-import" && !currentPublication) currentPublication = await initializeProvisioningPublication(env.WORDPRESS_STATE_BUCKET, lease, site)
11391172
const publicationJob = await enqueuePublicationJob(env.WORDPRESS_STATE_BUCKET, lease, next, currentPublication, publicationChanges, site)
11401173
response.headers.set("x-wp-codebox-publication", publicationJob ? "queued" : "unchanged")
11411174
if (publicationJob) response.headers.set("x-wp-codebox-publication-job", publicationJob.key)

scripts/cloudflare-local-gate.mjs

Lines changed: 59 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ try {
3535
await startWorker()
3636
if (publicProvisioning) {
3737
await assertPublicProvisioning(staticArtifactImport)
38-
console.log("Cloudflare public provisioning gate passed: authenticated artifact staging, idempotent site allocation, scheduled import, cold-restart site read, administrator claim, login, and editable native blocks.")
38+
console.log("Cloudflare public provisioning gate passed: authenticated artifact staging, idempotent site allocation, initial publication, cold-restart site read, administrator claim, native-block edit, automatic republication, and post-restart persistence.")
3939
} else {
4040
await assertFullBootProbe()
4141
await assertWordPressCronDisabled()
@@ -113,9 +113,9 @@ try {
113113
const importedAdmin = await login()
114114
await assertImportedArtifactPages(importedAdmin, imported)
115115
const duplicateStateBefore = await (await fetch(`${origin}/?phase=r2-state`)).json()
116-
const duplicate = await importStaticArtifact(staticArtifactImport, 200)
116+
const duplicate = await importStaticArtifact(staticArtifactImport, coordinator === "d1" ? 202 : 200)
117117
const duplicateStateAfter = await (await fetch(`${origin}/?phase=r2-state`)).json()
118-
if (duplicate.status !== "duplicate" || duplicateStateAfter.version !== duplicateStateBefore.version || duplicateStateAfter.pointer?.revision !== duplicateStateBefore.pointer?.revision) throw new Error("Idempotent static artifact import changed canonical state.")
118+
if (!(["duplicate", ...(coordinator === "d1" ? ["imported"] : [])].includes(duplicate.status)) || duplicateStateAfter.version !== duplicateStateBefore.version || duplicateStateAfter.pointer?.revision !== duplicateStateBefore.pointer?.revision) throw new Error("Idempotent static artifact import changed canonical state.")
119119
const conflicting = await importStaticArtifact({ ...staticArtifactImport, import: { ...staticArtifactImport.import, slug: "different-artifact-site" } }, 409)
120120
const conflictStateAfter = await (await fetch(`${origin}/?phase=r2-state`)).json()
121121
if (conflicting.status !== "conflict" || conflictStateAfter.version !== duplicateStateBefore.version || conflictStateAfter.pointer?.revision !== duplicateStateBefore.pointer?.revision) throw new Error("Conflicting static artifact replay changed canonical state.")
@@ -219,7 +219,7 @@ async function assertPublicProvisioning(input) {
219219
const imported = operation?.receipt?.ssiResult
220220
if (operation?.state !== "succeeded" || imported?.status !== "imported" || imported.staticSiteImporterVersion !== "1.3.4" || !imported.themeSlug
221221
|| !imported.pages || Object.keys(imported.pages).length !== expectedPageCount || Object.values(imported.quality ?? {}).some((count) => count !== 0)
222-
|| operation.receipt?.publication?.status !== "none") {
222+
|| operation.receipt?.publication?.status !== "promoted") {
223223
throw new Error(`Public provisioning did not produce a terminal import receipt: ${JSON.stringify(operation)}.`)
224224
}
225225

@@ -236,7 +236,18 @@ async function assertPublicProvisioning(input) {
236236
const claim = JSON.parse(claimBody)
237237
if (!claimResponse.ok || claim.credential?.username !== "admin" || claim.credential?.password !== password) throw new Error(`Administrator claim failed: ${claimResponse.status} ${claimBody}.`)
238238
const adminHtml = await login(claim.credential.password)
239-
await assertImportedArtifactPages(adminHtml, imported)
239+
const importedPages = await assertImportedArtifactPages(adminHtml, imported)
240+
const edited = await updateImportedPage(adminHtml, importedPages.secondary)
241+
const published = await runScheduledPublicationUntil(new URL(edited.route, origin), edited.marker, "provisioned site edit publication", 12)
242+
assertIncludes(published, edited.marker, "provisioned site edit publication")
243+
await stopWorker()
244+
await startWorker()
245+
const persisted = await assertPublishedWordPressPage(new URL(edited.route, origin), "provisioned site edit after restart", ["publication-r2", "publication-edge"])
246+
assertIncludes(persisted, edited.marker, "provisioned site edit after restart")
247+
const restartedAdmin = await assertAuthenticatedDashboard(new URL("/wp-admin/", origin))
248+
const persistedPages = await assertImportedArtifactPages(restartedAdmin, imported)
249+
const persistedEdit = [persistedPages.primary, persistedPages.secondary].find((page) => page.id === edited.id)
250+
if (!persistedEdit?.raw.includes(edited.marker)) throw new Error(`Provisioned site edit was not retained as editable block content after restart: ${JSON.stringify(persistedPages)}.`)
240251
}
241252

242253
async function assertTwoSiteIsolation() {
@@ -383,18 +394,35 @@ async function createPost(adminHtml) {
383394
return { id: post.id, slug: post.slug, route: `${link.pathname}${link.search}`, title }
384395
}
385396

386-
async function importStaticArtifact(input, expectedStatus = 201) {
397+
async function importStaticArtifact(input, expectedStatus = coordinator === "d1" ? 202 : 201) {
387398
const response = await fetch(`${origin}/?phase=operator-static-artifact-import`, {
388399
method: "POST",
389400
headers: { authorization: `Bearer ${operatorToken}`, "content-type": "application/json" },
390401
body: JSON.stringify(input),
391402
})
392403
const body = await response.text()
393404
if (response.status !== expectedStatus) throw new Error(`Static artifact import failed: ${response.status} ${body}.\nWorker output:\n${output}`)
394-
const result = JSON.parse(body)
395-
if (expectedStatus === 201 && (result.status !== "imported" || result.staticSiteImporterVersion !== "1.3.4" || !result.themeSlug
405+
let result = JSON.parse(body)
406+
if (expectedStatus === 202) {
407+
if (typeof result.operationId !== "string") throw new Error(`Queued static artifact import omitted its operation ID: ${body}.`)
408+
for (let tick = 0; tick < 10; tick++) {
409+
await runScheduledCron()
410+
const operationResponse = await fetch(`${origin}/?phase=operator-static-artifact-operation&operationId=${encodeURIComponent(result.operationId)}`, {
411+
headers: { authorization: `Bearer ${operatorToken}` },
412+
})
413+
const operationBody = await operationResponse.text()
414+
if (!operationResponse.ok) throw new Error(`Queued static artifact operation read failed: ${operationResponse.status} ${operationBody}.`)
415+
const operation = JSON.parse(operationBody)
416+
if (operation.state === "succeeded") {
417+
result = operation.receipt?.ssiResult
418+
break
419+
}
420+
if (operation.state === "failed") throw new Error(`Queued static artifact operation failed: ${operationBody}.`)
421+
}
422+
}
423+
if (expectedStatus !== 409 && (!result || typeof result !== "object" || result.status !== "imported" || result.staticSiteImporterVersion !== "1.3.4" || !result.themeSlug
396424
|| !result.pages || !Object.keys(result.pages).length || Object.values(result.quality ?? {}).some((count) => count !== 0)
397-
|| !response.headers.get("x-wp-codebox-canonical-revision") || !response.headers.get("x-wp-codebox-canonical-version"))) {
425+
|| (expectedStatus === 201 && (!response.headers.get("x-wp-codebox-canonical-revision") || !response.headers.get("x-wp-codebox-canonical-version"))))) {
398426
throw new Error(`Static artifact import returned invalid evidence: ${body}.`)
399427
}
400428
return result
@@ -420,6 +448,26 @@ async function assertImportedArtifactPages(adminHtml, imported) {
420448
return { primary, secondary: secondary ?? primary }
421449
}
422450

451+
async function updateImportedPage(adminHtml, page) {
452+
const marker = `Provisioned site edit ${Date.now()}`
453+
const content = `<!-- wp:paragraph --><p>${marker}</p><!-- /wp:paragraph -->`
454+
const response = await request(`${origin}/wp-json/wp/v2/pages/${page.id}`, {
455+
method: "POST",
456+
headers: { "content-type": "application/json", "x-wp-nonce": restNonce(adminHtml) },
457+
body: JSON.stringify({ content, status: "publish" }),
458+
})
459+
const body = await response.text()
460+
assertNoPhpDiagnostics(body, "provisioned site page edit")
461+
if (response.status !== 200 || response.headers.get("x-wp-codebox-publication") !== "queued" || !response.headers.get("x-wp-codebox-publication-job")) {
462+
throw new Error(`Provisioned site page edit did not queue publication: status=${response.status} publication=${response.headers.get("x-wp-codebox-publication")} body=${body}.`)
463+
}
464+
const updated = JSON.parse(body)
465+
const raw = updated.content?.raw
466+
if (typeof raw !== "string" || !raw.includes(marker) || /<!-- wp:(?:html|freeform)\b/.test(raw)) throw new Error(`Provisioned site page edit was not retained as native block content: ${body}.`)
467+
const link = new URL(updated.link)
468+
return { id: page.id, marker, route: `${link.pathname}${link.search}` }
469+
}
470+
423471
async function updatePost(adminHtml, post, previousPublicationRevision) {
424472
const title = `Automatically published ${Date.now()}`
425473
const response = await request(`${origin}/wp-json/wp/v2/posts/${post.id}`, {
@@ -728,10 +776,11 @@ async function assertCoordinatorAdoption() {
728776
if (!adoption.ok || !adopted.adopted || adopted.version !== before.version || adopted.pointer?.revision !== before.pointer?.revision) {
729777
throw new Error(`Exact coordinator adoption failed: status=${adoption.status} payload=${JSON.stringify(adopted)}.`)
730778
}
779+
const divergentRevision = "00000000-0000-4000-8000-000000000000"
731780
const divergent = await fetch(`${origin}/?phase=operator-adopt`, {
732781
method: "POST",
733782
headers: { authorization: `Bearer ${operatorToken}`, "content-type": "application/json" },
734-
body: JSON.stringify({ pointer: before.pointer, version: before.version + 1 }),
783+
body: JSON.stringify({ pointer: { ...before.pointer, revision: divergentRevision, manifestKey: before.pointer.manifestKey.replace(before.pointer.revision, divergentRevision) }, version: before.version }),
735784
})
736785
if (divergent.status !== 409) throw new Error(`Divergent coordinator adoption was not rejected: ${divergent.status} ${await divergent.text()}.`)
737786
await assertCoordinatorBackend()

0 commit comments

Comments
 (0)