Skip to content

Commit e57a542

Browse files
authored
Merge pull request #2005 from Automattic/feat/1974-public-provisioning-local-gate
Add end-to-end Cloudflare provisioning gate
2 parents db1fe89 + 6e53586 commit e57a542

2 files changed

Lines changed: 93 additions & 15 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@
9090
"cloudflare:dry-run:d1": "npm exec -- wrangler deploy --dry-run --config packages/runtime-cloudflare/wrangler.d1.jsonc",
9191
"cloudflare:local-gate": "node scripts/cloudflare-local-gate.mjs",
9292
"cloudflare:local-gate:d1": "node scripts/cloudflare-local-gate.mjs --coordinator=d1",
93+
"cloudflare:local-gate:provisioning": "node scripts/cloudflare-local-gate.mjs --coordinator=d1 --public-provisioning",
9394
"postinstall": "node scripts/apply-development-patches.mjs",
9495
"generate:cloudflare-wordpress-runtime-corpus": "tsx scripts/generate-cloudflare-wordpress-runtime-corpus.ts",
9596
"provision:cloudflare-wordpress-runtime-corpus": "node scripts/provision-cloudflare-wordpress-runtime-corpus.mjs",

scripts/cloudflare-local-gate.mjs

Lines changed: 92 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { spawn } from "node:child_process"
22
import { createHash, createHmac } from "node:crypto"
3-
import { mkdtemp, rm, writeFile } from "node:fs/promises"
3+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"
44
import { tmpdir } from "node:os"
55
import { join } from "node:path"
66
import { stripVTControlCharacters } from "node:util"
@@ -11,7 +11,11 @@ const origin = `http://127.0.0.1:${port}`
1111
const password = "cloudflare-runtime-test-password"
1212
const authSecret = "cloudflare-runtime-test-auth-secret"
1313
const operatorToken = "cloudflare-runtime-test-operator-token"
14+
const apiToken = "cloudflare-runtime-test-api-token"
15+
const administratorClaimSecret = "cloudflare-runtime-test-administrator-claim-secret"
1416
const coordinator = process.argv.includes("--coordinator=d1") ? "d1" : "durable-object"
17+
const publicProvisioning = process.argv.includes("--public-provisioning")
18+
const artifactPath = process.argv.find((argument) => argument.startsWith("--artifact="))?.slice("--artifact=".length)
1519
const wranglerConfig = coordinator === "d1" ? "packages/runtime-cloudflare/wrangler.d1.jsonc" : "packages/runtime-cloudflare/wrangler.jsonc"
1620
const stateDirectory = await mkdtemp(join(tmpdir(), "wp-codebox-cloudflare-gate-"))
1721
const cookies = []
@@ -29,6 +33,10 @@ try {
2933
await run("npm", ["run", "provision:cloudflare-wordpress-runtime-corpus", "--", "--local", "--persist-to", stateDirectory])
3034
const staticArtifactImport = await provisionStaticArtifact()
3135
await startWorker()
36+
if (publicProvisioning) {
37+
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.")
39+
} else {
3240
await assertFullBootProbe()
3341
await assertWordPressCronDisabled()
3442
await assertConcurrentMutations()
@@ -113,6 +121,7 @@ try {
113121
if (conflicting.status !== "conflict" || conflictStateAfter.version !== duplicateStateBefore.version || conflictStateAfter.pointer?.revision !== duplicateStateBefore.pointer?.revision) throw new Error("Conflicting static artifact replay changed canonical state.")
114122
await assertTwoSiteIsolation()
115123
console.log(`Cloudflare local runtime gate passed with ${coordinator} coordination: canonical full-boot probe, explanatory homepage, complete block styles, coordinator-free R2 publication reads, login, dashboard, post editor, concurrent canonical mutations, authenticated REST post and media creation, plugin ZIP installation and activation, direct R2 upload serving, frontend/admin/editor assets, cold-restart persistence, and bounded durable scheduled callback execution.`)
124+
}
116125
} finally {
117126
await stopWorker()
118127
await rm(stateDirectory, { recursive: true, force: true })
@@ -127,7 +136,7 @@ async function run(command, args) {
127136
}
128137

129138
async function provisionStaticArtifact() {
130-
const artifact = {
139+
const fixture = {
131140
schema: "blocks-engine/php-transformer/site-artifact/v1",
132141
artifact_type: "website",
133142
version: 1,
@@ -149,18 +158,85 @@ async function provisionStaticArtifact() {
149158
content: "<!doctype html><html><head><meta charset=\"utf-8\"><title>About the Verified Artifact</title></head><body><main><h1>About the Verified Artifact</h1><p>A second imported page.</p></main></body></html>",
150159
}],
151160
}
152-
const serialized = JSON.stringify(artifact)
161+
const serialized = artifactPath ? await readFile(artifactPath, "utf8") : JSON.stringify(fixture)
162+
const artifact = JSON.parse(serialized)
163+
if (artifact?.schema !== "blocks-engine/php-transformer/site-artifact/v1") throw new Error(`The provisioning artifact at ${artifactPath} does not use the canonical portable schema.`)
153164
const sha256 = createHash("sha256").update(serialized).digest("hex")
154165
const key = `sites/default/import-artifacts/${sha256}.json`
155-
const path = join(stateDirectory, "static-site-artifact.json")
156-
await writeFile(path, serialized)
157-
await run("npm", ["exec", "--", "wrangler", "r2", "object", "put", `wp-codebox-runtime-chubes/${key}`, "--file", path, "--local", "--persist-to", stateDirectory])
158-
return {
166+
if (!publicProvisioning) {
167+
const path = join(stateDirectory, "static-site-artifact.json")
168+
await writeFile(path, serialized)
169+
await run("npm", ["exec", "--", "wrangler", "r2", "object", "put", `wp-codebox-runtime-chubes/${key}`, "--file", path, "--local", "--persist-to", stateDirectory])
170+
}
171+
const request = {
159172
schema: "wp-codebox/cloudflare-static-artifact-import-request/v1",
160173
idempotencyKey: `cloudflare-static-artifact-${sha256}`,
161174
artifact: { r2Key: key, sha256, size: Buffer.byteLength(serialized) },
162175
import: { slug: "verified-artifact-site", name: "Verified Artifact Site", siteTitle: "Verified Artifact Site" },
163176
}
177+
return publicProvisioning ? { ...request, serialized } : request
178+
}
179+
180+
async function assertPublicProvisioning(input) {
181+
const authorization = { authorization: `Bearer ${apiToken}` }
182+
const expectedPageCount = JSON.parse(input.serialized).files.filter((file) => file.path.endsWith(".html")).length
183+
const stagedResponse = await fetch(`${origin}/v1/artifacts/${input.artifact.sha256}`, {
184+
method: "PUT",
185+
headers: { ...authorization, "content-type": "application/json" },
186+
body: input.serialized,
187+
})
188+
const stagedBody = await stagedResponse.text()
189+
if (stagedResponse.status !== 200) throw new Error(`Public artifact staging failed: ${stagedResponse.status} ${stagedBody}.`)
190+
const staged = JSON.parse(stagedBody)
191+
if (staged.schema !== "wp-codebox/provisioning-artifact/v1" || staged.artifact?.sha256 !== input.artifact.sha256 || staged.artifact?.size !== input.artifact.size
192+
|| staged.artifact?.r2Key !== `sites/provisioning/import-artifacts/${input.artifact.sha256}.json`) {
193+
throw new Error(`Public artifact staging returned an invalid reference: ${stagedBody}.`)
194+
}
195+
196+
const idempotencyKey = `cloudflare-public-provisioning-${input.artifact.sha256}`
197+
const requestBody = JSON.stringify({ schema: "wp-codebox/provisioning-create-request/v1", idempotencyKey, artifact: staged.artifact, import: input.import })
198+
const createSite = () => fetch(`${origin}/v1/sites`, { method: "POST", headers: { ...authorization, "content-type": "application/json", "idempotency-key": idempotencyKey }, body: requestBody })
199+
const createdResponse = await createSite()
200+
const createdBody = await createdResponse.text()
201+
if (createdResponse.status !== 202) throw new Error(`Public site creation failed: ${createdResponse.status} ${createdBody}.`)
202+
const created = JSON.parse(createdBody)
203+
if (created.schema !== "wp-codebox/provisioning-site/v1" || created.site?.id !== "default" || typeof created.site.operation !== "string"
204+
|| typeof created.site.administratorClaim?.token !== "string") throw new Error(`Public site creation returned an invalid resource: ${createdBody}.`)
205+
const replayResponse = await createSite()
206+
const replayBody = await replayResponse.text()
207+
if (replayResponse.status !== 202 || replayBody !== createdBody) throw new Error(`Public site creation replay did not converge: ${replayResponse.status} ${replayBody}.`)
208+
209+
let operation
210+
for (let tick = 0; tick < 8; tick++) {
211+
const response = await fetch(new URL(created.site.operation, origin), { headers: authorization })
212+
const body = await response.text()
213+
if (!response.ok) throw new Error(`Public provisioning operation read failed: ${response.status} ${body}.`)
214+
operation = JSON.parse(body).operation
215+
if (operation?.state === "succeeded") break
216+
if (operation?.state === "failed") throw new Error(`Public provisioning operation failed: ${body}.`)
217+
await runScheduledCron()
218+
}
219+
const imported = operation?.receipt?.ssiResult
220+
if (operation?.state !== "succeeded" || imported?.status !== "imported" || imported.staticSiteImporterVersion !== "1.3.4" || !imported.themeSlug
221+
|| !imported.pages || Object.keys(imported.pages).length !== expectedPageCount || Object.values(imported.quality ?? {}).some((count) => count !== 0)
222+
|| operation.receipt?.publication?.status !== "none") {
223+
throw new Error(`Public provisioning did not produce a terminal import receipt: ${JSON.stringify(operation)}.`)
224+
}
225+
226+
await stopWorker()
227+
await startWorker()
228+
const siteResponse = await fetch(`${origin}/v1/sites/${created.site.id}`, { headers: authorization })
229+
const siteBody = await siteResponse.text()
230+
const site = JSON.parse(siteBody)
231+
if (!siteResponse.ok || site.site?.status !== "ready" || site.site?.administratorClaim?.state !== "pending" || siteBody.includes(created.site.administratorClaim.token)) {
232+
throw new Error(`Cold-restart public site read returned invalid state: ${siteResponse.status} ${siteBody}.`)
233+
}
234+
const claimResponse = await fetch(new URL(created.site.administratorClaim.url, origin), { method: "POST", headers: { authorization: `Bearer ${created.site.administratorClaim.token}` } })
235+
const claimBody = await claimResponse.text()
236+
const claim = JSON.parse(claimBody)
237+
if (!claimResponse.ok || claim.credential?.username !== "admin" || claim.credential?.password !== password) throw new Error(`Administrator claim failed: ${claimResponse.status} ${claimBody}.`)
238+
const adminHtml = await login(claim.credential.password)
239+
await assertImportedArtifactPages(adminHtml, imported)
164240
}
165241

166242
async function assertTwoSiteIsolation() {
@@ -238,7 +314,8 @@ async function assertTwoSiteIsolation() {
238314

239315
async function startWorker() {
240316
output = ""
241-
child = spawn("npm", ["exec", "--", "wrangler", "dev", "--test-scheduled", "--config", wranglerConfig, "--port", String(port), "--persist-to", stateDirectory, "--var", `WORDPRESS_ADMIN_PASSWORD:${password}`, "--var", `WORDPRESS_AUTH_SECRET:${authSecret}`, "--var", `WORDPRESS_OPERATOR_TOKEN:${operatorToken}`, "--var", `WORDPRESS_SITE_CONTEXTS:${JSON.stringify(siteContexts)}`], {
317+
const apiTokens = [{ id: "local-gate", principal: "local-gate", digest: createHash("sha256").update(apiToken).digest("hex"), scopes: ["sites:create", "sites:read", "sites:import", "operations:read"], expiresAt: "2099-01-01T00:00:00.000Z", maxSites: 1 }]
318+
child = spawn("npm", ["exec", "--", "wrangler", "dev", "--test-scheduled", "--config", wranglerConfig, "--port", String(port), "--persist-to", stateDirectory, "--var", `WORDPRESS_ADMIN_PASSWORD:${password}`, "--var", `WORDPRESS_ADMIN_CLAIM_SECRET:${administratorClaimSecret}`, "--var", `WORDPRESS_AUTH_SECRET:${authSecret}`, "--var", `WORDPRESS_OPERATOR_TOKEN:${operatorToken}`, "--var", `WORDPRESS_API_TOKENS:${JSON.stringify(apiTokens)}`, "--var", `WORDPRESS_SITE_CONTEXTS:${JSON.stringify(siteContexts)}`], {
242319
cwd: process.cwd(),
243320
// The host PAC resolves these public archive hosts through an unavailable local proxy.
244321
env: { ...process.env, NO_PROXY: "wordpress.org,github.com,codeload.github.com", no_proxy: "wordpress.org,github.com,codeload.github.com" },
@@ -277,9 +354,9 @@ async function assertLoginForm() {
277354
if (!/<form[^>]+id=["']loginform["']/i.test(html)) throw new Error("wp-login.php did not return the login form.")
278355
}
279356

280-
async function login() {
357+
async function login(candidatePassword = password) {
281358
await assertLoginForm()
282-
const form = new URLSearchParams({ log: "admin", pwd: password, redirect_to: `${origin}/wp-admin/`, testcookie: "1", "wp-submit": "Log In" })
359+
const form = new URLSearchParams({ log: "admin", pwd: candidatePassword, redirect_to: `${origin}/wp-admin/`, testcookie: "1", "wp-submit": "Log In" })
283360
const response = await request(`${origin}/wp-login.php`, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: form, redirect: "manual" })
284361
if (![301, 302].includes(response.status)) throw new Error(`Expected login redirect, received ${response.status}: ${await response.text()}`)
285362
if (response.headers.get("x-wp-codebox-publication") === "queued" || response.headers.has("x-wp-codebox-publication-revision")) throw new Error("Login must not enqueue or promote publication work.")
@@ -325,7 +402,7 @@ async function importStaticArtifact(input, expectedStatus = 201) {
325402

326403
async function assertImportedArtifactPages(adminHtml, imported) {
327404
const pageIds = Object.values(imported.pages ?? {}).filter(Number.isInteger)
328-
if (pageIds.length !== 2) throw new Error(`Static artifact import did not return two page IDs: ${JSON.stringify(imported)}.`)
405+
if (!pageIds.length) throw new Error(`Static artifact import did not return page IDs: ${JSON.stringify(imported)}.`)
329406
const pages = []
330407
for (const pageId of pageIds) {
331408
const response = await request(`${origin}/wp-json/wp/v2/pages/${pageId}?context=edit`, { headers: { "x-wp-nonce": restNonce(adminHtml) } })
@@ -337,10 +414,10 @@ async function assertImportedArtifactPages(adminHtml, imported) {
337414
const link = new URL(page.link)
338415
pages.push({ id: pageId, route: `${link.pathname}${link.search}`, raw })
339416
}
340-
const primary = pages.find(({ raw }) => raw.includes("Imported from a verified R2 artifact."))
341-
const secondary = pages.find(({ raw }) => raw.includes("A second imported page."))
342-
if (!primary || !secondary || secondary.route === "/") throw new Error(`Imported artifact routes are invalid: ${JSON.stringify(pages)}.`)
343-
return { primary, secondary }
417+
const primary = pages.find(({ route }) => route === "/")
418+
const secondary = pages.find(({ route }) => route !== "/")
419+
if (!primary || (pages.length > 1 && !secondary)) throw new Error(`Imported artifact routes are invalid: ${JSON.stringify(pages)}.`)
420+
return { primary, secondary: secondary ?? primary }
344421
}
345422

346423
async function updatePost(adminHtml, post, previousPublicationRevision) {

0 commit comments

Comments
 (0)