void 0.10.12 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS_TEMPLATE.md +6 -0
- package/{skills/void/docs/node_modules/void/node_modules/pglite-server/LICENSE.md → LICENSE} +1 -1
- package/README.md +34 -8
- package/dist/{auth-qgMlYp7Z.d.mts → auth-DkcFflXV.d.mts} +10 -11
- package/dist/auth-W9WII-mN.mjs +630 -0
- package/dist/{auth-cmd-BqsdZJp5.mjs → auth-cmd-CYVhSNFy.mjs} +4 -3
- package/dist/{auth-migrations-BTZ-ATvQ.mjs → auth-migrations-9vif1uj8.mjs} +53 -12
- package/dist/{better-auth-shared-BQooDxbw.mjs → better-auth-shared-CYw1T3k4.mjs} +7 -10
- package/dist/{better-auth-shared-DealXecJ.d.mts → better-auth-shared-DSCeohOK.d.mts} +10 -5
- package/dist/{build-cmd-Bujrv5q-.mjs → build-cmd-CkVD1uOh.mjs} +6 -4
- package/dist/{cache-C11V8Fxq.mjs → cache-QcUfR-ff.mjs} +9 -5
- package/dist/{cancel-deploy-fwFYF04b.mjs → cancel-deploy-DsvWKFTe.mjs} +3 -2
- package/dist/cf-access-AJ1ehiFR.mjs +42 -0
- package/dist/cf-access-DsSsZUPr.mjs +67 -0
- package/dist/cli/cli.d.mts +1 -1
- package/dist/cli/cli.mjs +1592 -153
- package/dist/cli/env-schema-probe.d.mts +48 -61
- package/dist/cli/env-schema-probe.mjs +10 -58
- package/dist/{client-Gb71-XkG.mjs → client-CyCHWSO_.mjs} +61 -463
- package/dist/cloudflare-auth-DRkGe7-s.mjs +170 -0
- package/dist/cloudflare-cmd-D3ME2GAe.mjs +62 -0
- package/dist/cloudflare-connect-BivMefMA.mjs +56 -0
- package/dist/cloudflare-operations-CPTpRW6d.mjs +566 -0
- package/dist/{config-BkTvs43g.mjs → config-BQFq7QvD.mjs} +85 -41
- package/dist/{config-CutEMNGJ.mjs → config-NOG_U1aK.mjs} +10 -15
- package/dist/connect-D9Yr-T5f.mjs +79 -0
- package/dist/{create-project-DsYvl3TB.mjs → create-project-DMV-csEm.mjs} +26 -16
- package/dist/database-provider.d.mts +21 -0
- package/dist/database-provider.mjs +6 -0
- package/dist/{db-DOiJMRt2.mjs → db-BxoUWL0F.mjs} +600 -115
- package/dist/{delete-mh6p-zkQ.mjs → delete-iJOqxBz1.mjs} +9 -6
- package/dist/{deploy-u7Rv9q_q.mjs → deploy-CcDoeBIf.mjs} +3451 -1986
- package/dist/dev-inbox-DkgRWLkW.mjs +307 -0
- package/dist/{discover-CJHyvYfR.mjs → discover-xvfrgJeo.mjs} +3 -3
- package/dist/{magic-string.es-ZQjdJFFn.mjs → dist-BR1quN_w.mjs} +570 -216
- package/dist/{dist-DaKKDf8D.mjs → dist-BrsS7cai.mjs} +17 -2
- package/dist/{dist-BuiRJkTd.mjs → dist-m40_XgNh.mjs} +48 -33
- package/dist/{domain-DiaNQbrl.mjs → domain-gu_iaHmn.mjs} +41 -4
- package/dist/dotenv-VQxupEUv.mjs +181 -0
- package/dist/edge.d.mts +2 -0
- package/dist/edge.mjs +2 -0
- package/dist/email-Ce6SQq-i.mjs +182 -0
- package/dist/email-r6DHJyAB.mjs +262 -0
- package/dist/{entry-D7yy4xVH.mjs → entry-DU3oDoQ3.mjs} +2 -2
- package/dist/env-D4Emu-M_.mjs +95 -0
- package/dist/env-DmU2To0C.mjs +73 -0
- package/dist/env-helpers-CyKtOBpj.d.mts +22 -0
- package/dist/env-public-D_6u46fX.d.mts +135 -0
- package/dist/{env-types-QBj-ndax.mjs → env-types-BNPhro-M.mjs} +2 -2
- package/dist/{env-validation-Dea3v3ej.mjs → env-validation-ENpMy6Ez.mjs} +68 -122
- package/dist/{gen-o_w-8yI8.mjs → gen-BKw6qHIg.mjs} +70 -15
- package/dist/{github-cmd-DKcGUNsj.mjs → github-cmd-DJS5Ab-H.mjs} +39 -31
- package/dist/{handler-Cjh8uM3Y.d.mts → handler-D1hLsObx.d.mts} +124 -122
- package/dist/{head-nmvOgFjd.d.mts → head-Do8P4puT.d.mts} +9 -8
- package/dist/{headers-nsHIFixA.mjs → headers-D8QfRX9Y.mjs} +12 -10
- package/dist/inbound-BJ70in1n.d.mts +354 -0
- package/dist/inbound-CNKxb3FY.mjs +694 -0
- package/dist/index.d.mts +61 -34
- package/dist/index.mjs +1048 -321
- package/dist/{init-3rgHKBVi.mjs → init-Dx5cgKqK.mjs} +329 -271
- package/dist/link-D_rm5sRH.mjs +52 -0
- package/dist/{list-CPwFDZ_c.mjs → list-M8XShti7.mjs} +37 -5
- package/dist/{runner-kapo9aPs.mjs → local-d1-BE8KBbMy.mjs} +116 -6
- package/dist/{login-BT3H8PN3.mjs → login-C8-UuLnp.mjs} +17 -15
- package/dist/{logs-Bt313ax7.mjs → logs-DwFU7dMW.mjs} +16 -2
- package/dist/mime-BJD7d_qL.mjs +1216 -0
- package/dist/neon-DHwd2zvC.mjs +54 -0
- package/dist/{node-Da0UcsGA.mjs → node-BkaXcpAc.mjs} +29 -8
- package/dist/operator-cmd-DYWRbWUA.mjs +348 -0
- package/dist/{agents-CtgBYqld.mjs → output-tFQLLj26.mjs} +676 -211
- package/dist/{package-json-Cx1osYo6.mjs → package-json-CPoWX79C.mjs} +1 -1
- package/dist/pages/client.d.mts +43 -41
- package/dist/pages/client.mjs +40 -33
- package/dist/pages/head-client.d.mts +10 -12
- package/dist/pages/head.d.mts +1 -1
- package/dist/pages/index.d.mts +37 -14
- package/dist/pages/index.mjs +5 -5
- package/dist/pages/islands-plugin.d.mts +25 -27
- package/dist/pages/islands-plugin.mjs +6 -4
- package/dist/pages/prefetch.d.mts +6 -7
- package/dist/pages/protocol.d.mts +2 -2
- package/dist/pages/protocol.mjs +2 -1
- package/dist/pages/serialize.d.mts +7 -8
- package/dist/platform-cmd-BEOVv1PN.mjs +123 -0
- package/dist/platform-domain-BszUfiS8.mjs +228 -0
- package/dist/platform-lifecycle-CjSr6xf0.mjs +4450 -0
- package/dist/platform-management-W30iCaTL.mjs +698 -0
- package/dist/platform-recovery-pHHv4ZSg.mjs +99 -0
- package/dist/{plugin-inference-DMeavIJ6.mjs → plugin-inference-BDRfZngg.mjs} +35 -19
- package/dist/prepare-BfJvFUtJ.mjs +14 -0
- package/dist/{prepare-BoKHgMNx.mjs → prepare-CBetXvsN.mjs} +15 -24
- package/dist/{preset-BjyR3lzz.mjs → preset-lAy0B0BQ.mjs} +25 -212
- package/dist/{project-cmd-D_w-4w5B.mjs → project-cmd-DCpk1cDt.mjs} +25 -12
- package/dist/{project-paths-BQd7OmIo.mjs → project-paths-SK8nMHPp.mjs} +3 -1
- package/dist/{project-tsconfig-B-QtXjLQ.mjs → project-tsconfig-Ql2XsSQp.mjs} +2 -2
- package/dist/{protocol-Bnb0LFp3.d.mts → protocol-C-pqYJjE.d.mts} +3 -4
- package/dist/{provision-rShh6MKY.mjs → provision-Blnstcm2.mjs} +78 -45
- package/dist/r2-conditions-D1Wk8i7b.mjs +30 -0
- package/dist/{requests-B8sZxaFM.mjs → requests-Dn8Vheh1.mjs} +5 -3
- package/dist/{resolve-project-BBMtLLV9.mjs → resolve-project--Vxawf7z.mjs} +2 -2
- package/dist/rollback-CtlPXEBi.mjs +166 -0
- package/dist/{rolldown-runtime-DJK8HYOj.mjs → rolldown-runtime-rQ84J-ij.mjs} +1 -1
- package/dist/{route-types-COI2DsZv.mjs → route-types-Da-DpyUp.mjs} +85 -26
- package/dist/routes-stub.d.mts +22 -23
- package/dist/runner-mysql-CgRFl3s6.mjs +61 -0
- package/dist/{runner-pg-CHM76xuC.mjs → runner-pg-DCkWPsWS.mjs} +18 -6
- package/dist/runtime/ai.d.mts +21 -14
- package/dist/runtime/ai.mjs +5 -4
- package/dist/runtime/auth-client-react.d.mts +3 -5
- package/dist/runtime/auth-client-solid.d.mts +3 -5
- package/dist/runtime/auth-client-svelte.d.mts +3 -5
- package/dist/runtime/auth-client-vue.d.mts +3 -5
- package/dist/runtime/auth-client.d.mts +3 -5
- package/dist/runtime/auth.d.mts +1 -1
- package/dist/runtime/better-auth-mysql.d.mts +10 -0
- package/dist/runtime/better-auth-mysql.mjs +49 -0
- package/dist/runtime/better-auth-pg.d.mts +8 -9
- package/dist/runtime/better-auth-pg.mjs +2 -2
- package/dist/runtime/better-auth.d.mts +8 -9
- package/dist/runtime/better-auth.mjs +2 -2
- package/dist/runtime/client-react.d.mts +1 -1
- package/dist/runtime/client-solid.d.mts +1 -1
- package/dist/runtime/client-svelte.d.mts +1 -1
- package/dist/runtime/client-vue.d.mts +1 -1
- package/dist/runtime/client.d.mts +1 -1
- package/dist/runtime/db-mysql.d.mts +2 -0
- package/dist/runtime/db-mysql.mjs +1 -0
- package/dist/runtime/db.d.mts +10 -11
- package/dist/runtime/durable.d.mts +47 -0
- package/dist/runtime/durable.mjs +146 -0
- package/dist/runtime/email/testing.d.mts +112 -0
- package/dist/runtime/email/testing.mjs +283 -0
- package/dist/runtime/email.d.mts +30 -0
- package/dist/runtime/email.mjs +573 -0
- package/dist/runtime/env-helpers.d.mts +2 -2
- package/dist/runtime/env-helpers.mjs +5 -42
- package/dist/runtime/env-public-client.d.mts +10 -11
- package/dist/runtime/env-public-client.mjs +1 -1
- package/dist/runtime/env-public.d.mts +2 -2
- package/dist/runtime/env-public.mjs +104 -49
- package/dist/runtime/env.d.mts +19 -18
- package/dist/runtime/env.mjs +15 -2
- package/dist/runtime/fetch-stream.d.mts +20 -21
- package/dist/runtime/fetch.d.mts +15 -16
- package/dist/runtime/handler.d.mts +1 -1
- package/dist/runtime/isr.d.mts +21 -22
- package/dist/runtime/isr.mjs +26 -8
- package/dist/runtime/kv.d.mts +9 -10
- package/dist/runtime/live-client.d.mts +5 -7
- package/dist/runtime/live-client.mjs +9 -7
- package/dist/runtime/live-server.d.mts +4 -5
- package/dist/runtime/live.d.mts +22 -24
- package/dist/runtime/live.mjs +1 -1
- package/dist/runtime/log.d.mts +16 -17
- package/dist/runtime/migration-handler-mysql.d.mts +4 -0
- package/dist/runtime/migration-handler-mysql.mjs +81 -0
- package/dist/runtime/migration-handler-pg.d.mts +2 -4
- package/dist/runtime/migration-handler.d.mts +5 -6
- package/dist/runtime/migration-handler.mjs +4 -3
- package/dist/runtime/queues.d.mts +3 -4
- package/dist/runtime/queues.mjs +2 -1
- package/dist/runtime/remote/binding-handler.d.mts +10 -12
- package/dist/runtime/remote/binding-handler.mjs +24 -3
- package/dist/runtime/remote/index.d.mts +5 -6
- package/dist/runtime/remote/index.mjs +21 -18
- package/dist/runtime/response.d.mts +10 -11
- package/dist/runtime/sandbox.d.mts +56 -55
- package/dist/runtime/sandbox.mjs +57 -49
- package/dist/runtime/schema-mysql.d.mts +1 -0
- package/dist/runtime/schema-mysql.mjs +2 -0
- package/dist/runtime/seed.d.mts +14 -9
- package/dist/runtime/sse-client.d.mts +6 -7
- package/dist/runtime/sse.d.mts +11 -12
- package/dist/runtime/storage.d.mts +3 -4
- package/dist/runtime/validator.d.mts +1 -1
- package/dist/runtime/ws-server.d.mts +12 -12
- package/dist/runtime/ws-server.mjs +32 -4
- package/dist/runtime/ws.d.mts +19 -21
- package/dist/{scan-DYXkrasO.mjs → scan-BMH4rzlv.mjs} +53 -31
- package/dist/{scan-DEwlM_Xy.mjs → scan-CpK-57ug.mjs} +9 -5
- package/dist/{secret-Dt32J6RI.mjs → secret-BqTxGqki.mjs} +62 -5
- package/dist/{skills-CLjN0uUO.mjs → skills-Q46GZMO-.mjs} +6 -4
- package/dist/{standard-schema-DJ0HW7QP.d.mts → standard-schema-Fo_vCAZh.d.mts} +6 -6
- package/dist/{subcommand-prompt-BzV8iQZo.mjs → subcommand-prompt-WfySCQ7S.mjs} +67 -48
- package/dist/sveltekit.d.mts +12 -11
- package/dist/sveltekit.mjs +1 -1
- package/dist/types-BAp5AEBU.d.mts +79 -0
- package/dist/types-CKWnYgfy.d.mts +1 -0
- package/dist/{validate-Cw_RLeTj.mjs → validate-Bihr8WBi.mjs} +3 -3
- package/dist/wrangler--imS8n0d.mjs +1796 -0
- package/dist/{yarn-pnp-DJn3SAHF.mjs → yarn-pnp-DxSInkzL.mjs} +1 -1
- package/package.json +79 -65
- package/schema.json +35 -3
- package/skills/migrate-vite-cloudflare-to-void/SKILL.md +1 -1
- package/skills/void/SKILL.md +59 -2
- package/skills/void/docs/guide/ai.md +32 -14
- package/skills/void/docs/guide/app-types.md +6 -6
- package/skills/void/docs/guide/auth.md +14 -16
- package/skills/void/docs/guide/database/d1.md +6 -0
- package/skills/void/docs/guide/database/mysql.md +60 -0
- package/skills/void/docs/guide/database/postgresql.md +14 -9
- package/skills/void/docs/guide/database.md +39 -26
- package/skills/void/docs/guide/deployment.md +163 -22
- package/skills/void/docs/guide/durable-state.md +140 -0
- package/skills/void/docs/guide/edge/headers.md +5 -5
- package/skills/void/docs/guide/edge/prerendering.md +2 -0
- package/skills/void/docs/guide/edge/revalidation.md +21 -6
- package/skills/void/docs/guide/edge/rewrites.md +18 -14
- package/skills/void/docs/guide/edge/static-assets.md +23 -8
- package/skills/void/docs/guide/email.md +609 -0
- package/skills/void/docs/guide/env-migration.md +109 -0
- package/skills/void/docs/guide/env-vars.md +70 -248
- package/skills/void/docs/guide/index.md +15 -17
- package/skills/void/docs/guide/jobs.md +8 -5
- package/skills/void/docs/guide/live.md +7 -15
- package/skills/void/docs/guide/pages-routing/actions-and-forms.md +8 -4
- package/skills/void/docs/guide/pages-routing/islands.md +3 -3
- package/skills/void/docs/guide/pages-routing/loaders.md +6 -4
- package/skills/void/docs/guide/pages-routing/overview.md +7 -7
- package/skills/void/docs/guide/platform-administration.md +212 -0
- package/skills/void/docs/guide/platform-development.md +235 -0
- package/skills/void/docs/guide/queues.md +10 -10
- package/skills/void/docs/guide/quickstart.md +46 -67
- package/skills/void/docs/guide/remote-dev.md +8 -6
- package/skills/void/docs/guide/sandboxes.md +33 -16
- package/skills/void/docs/guide/self-hosted-platform.md +553 -0
- package/skills/void/docs/guide/server-routing.md +25 -5
- package/skills/void/docs/guide/sse.md +4 -4
- package/skills/void/docs/guide/ssg.md +5 -3
- package/skills/void/docs/guide/storage.md +2 -2
- package/skills/void/docs/guide/websockets.md +18 -7
- package/skills/void/docs/index.md +3 -3
- package/skills/void/docs/integrations/agents.md +6 -64
- package/skills/void/docs/integrations/cloudflare.md +165 -146
- package/skills/void/docs/integrations/frameworks/analog.md +4 -4
- package/skills/void/docs/integrations/frameworks/astro.md +5 -5
- package/skills/void/docs/integrations/frameworks/nuxt.md +5 -5
- package/skills/void/docs/integrations/frameworks/overview.md +3 -3
- package/skills/void/docs/integrations/frameworks/react-router.md +3 -3
- package/skills/void/docs/integrations/frameworks/sveltekit.md +3 -3
- package/skills/void/docs/integrations/frameworks/tanstack-start.md +2 -2
- package/skills/void/docs/integrations/nodejs-bun-deno.md +11 -4
- package/skills/void/docs/reference/api.md +42 -6
- package/skills/void/docs/reference/cli.md +637 -159
- package/skills/void/docs/reference/config.md +80 -25
- package/skills/void/docs/reference/resource-inference.md +13 -9
- package/skills/void/docs/reference/structure.md +10 -11
- package/AGENT_PROMPT.md +0 -19
- package/dist/cf-access-Bqw81xAf.mjs +0 -22
- package/dist/env-CZy5MorI.mjs +0 -299
- package/dist/env-helpers-z4stu8uc.d.mts +0 -52
- package/dist/env-mask-Dd47NbR6.mjs +0 -90
- package/dist/env-public-BfiLcMBk.d.mts +0 -140
- package/dist/link-CdGHSIy-.mjs +0 -45
- package/dist/mcp-DoM3_nhd.mjs +0 -377
- package/dist/project-paths-GpziKeQQ.d.mts +0 -25
- package/dist/providers-BNKRacMr.d.mts +0 -7
- package/dist/proxy-D-3_D-Gl.mjs +0 -5
- package/dist/rollback-CkvTFXx5.mjs +0 -90
- package/dist/runtime/isr-cache.d.mts +0 -207
- package/dist/runtime/isr-cache.mjs +0 -523
- package/dist/types-lLjNE9Qp.d.mts +0 -51
- package/getting-started-prompt.txt +0 -28
- package/skills/void/command/void.md +0 -7
- package/skills/void/docs/integrations/auth-providers.md +0 -0
- package/skills/void/docs/integrations/payment-processors.md +0 -0
- package/skills/void/docs/node_modules/@iconify/vue/README.md +0 -408
- package/skills/void/docs/node_modules/@iconify/vue/offline/readme.md +0 -5
- package/skills/void/docs/node_modules/@voidzero-dev/vitepress-theme/README.md +0 -103
- package/skills/void/docs/node_modules/oxc-minify/README.md +0 -78
- package/skills/void/docs/node_modules/reka-ui/README.md +0 -80
- package/skills/void/docs/node_modules/vitepress/README.md +0 -28
- package/skills/void/docs/node_modules/vitepress/template/api-examples.md +0 -49
- package/skills/void/docs/node_modules/vitepress/template/index.md +0 -28
- package/skills/void/docs/node_modules/vitepress/template/markdown-examples.md +0 -85
- package/skills/void/docs/node_modules/vitepress-plugin-group-icons/README.md +0 -101
- package/skills/void/docs/node_modules/void/AGENT_PROMPT.md +0 -19
- package/skills/void/docs/node_modules/void/CLAUDE.md +0 -219
- package/skills/void/docs/node_modules/void/README.md +0 -90
- package/skills/void/docs/node_modules/void/node_modules/@clack/prompts/CHANGELOG.md +0 -685
- package/skills/void/docs/node_modules/void/node_modules/@clack/prompts/README.md +0 -396
- package/skills/void/docs/node_modules/void/node_modules/@cloudflare/sandbox/README.md +0 -219
- package/skills/void/docs/node_modules/void/node_modules/@cloudflare/vite-plugin/README.md +0 -37
- package/skills/void/docs/node_modules/void/node_modules/@cloudflare/workers-types/README.md +0 -135
- package/skills/void/docs/node_modules/void/node_modules/@electric-sql/pglite/README.md +0 -189
- package/skills/void/docs/node_modules/void/node_modules/@hono/oauth-providers/CHANGELOG.md +0 -143
- package/skills/void/docs/node_modules/void/node_modules/@hono/oauth-providers/README.md +0 -1272
- package/skills/void/docs/node_modules/void/node_modules/@napi-rs/keyring/README.md +0 -19
- package/skills/void/docs/node_modules/void/node_modules/@types/better-sqlite3/README.md +0 -15
- package/skills/void/docs/node_modules/void/node_modules/@types/node/README.md +0 -15
- package/skills/void/docs/node_modules/void/node_modules/@types/pg/README.md +0 -15
- package/skills/void/docs/node_modules/void/node_modules/@types/proper-lockfile/README.md +0 -51
- package/skills/void/docs/node_modules/void/node_modules/@typescript/native-preview/README.md +0 -22
- package/skills/void/docs/node_modules/void/node_modules/@typescript/native-preview/vendor/vscode-jsonrpc/README.md +0 -69
- package/skills/void/docs/node_modules/void/node_modules/@void/md/README.md +0 -153
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/@shikijs/engine-javascript/README.md +0 -9
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/@shikijs/transformers/README.md +0 -9
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/@types/node/README.md +0 -15
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/gray-matter/CHANGELOG.md +0 -24
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/gray-matter/README.md +0 -565
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/markdown-exit/README.md +0 -127
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/markdown-it-anchor/README.md +0 -600
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/markdown-it-attrs/README.md +0 -386
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/markdown-it-container/README.md +0 -95
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/markdown-it-emoji/README.md +0 -101
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/markdown-it-footnote/README.md +0 -135
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/pathslash/README.md +0 -64
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/shiki/README.md +0 -15
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/tinyglobby/README.md +0 -25
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/AGENTS.md +0 -16
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/README.md +0 -220
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/config/build.md +0 -21
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/config/check.md +0 -35
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/config/create.md +0 -70
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/config/fmt.md +0 -20
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/config/index.md +0 -35
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/config/lint.md +0 -26
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/config/pack.md +0 -17
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/config/run.md +0 -364
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/config/staged.md +0 -15
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/config/test.md +0 -18
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/guide/automatic-data-tracking.md +0 -145
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/guide/build.md +0 -40
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/guide/cache.md +0 -107
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/guide/check.md +0 -60
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/guide/ci.md +0 -62
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/guide/commit-hooks.md +0 -60
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/guide/create.md +0 -341
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/guide/dev.md +0 -24
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/guide/docker.md +0 -175
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/guide/env.md +0 -167
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/guide/fmt.md +0 -41
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/guide/github-actions-cache.md +0 -165
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/guide/ide-integration.md +0 -101
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/guide/implode.md +0 -23
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/guide/index.md +0 -134
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/guide/install.md +0 -199
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/guide/lint.md +0 -50
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/guide/migrate-rules.md +0 -347
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/guide/migrate.md +0 -197
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/guide/monorepo.md +0 -176
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/guide/pack.md +0 -69
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/guide/run.md +0 -356
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/guide/test.md +0 -35
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/guide/troubleshooting.md +0 -108
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/guide/upgrade.md +0 -101
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/guide/vpx.md +0 -66
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/guide/why.md +0 -39
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/index.md +0 -12
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/team.md +0 -35
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/templates/generator/README.md +0 -35
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/templates/monorepo/README.md +0 -29
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vue/README.md +0 -58
- package/skills/void/docs/node_modules/void/node_modules/arktype/README.md +0 -165
- package/skills/void/docs/node_modules/void/node_modules/better-auth/LICENSE.md +0 -20
- package/skills/void/docs/node_modules/void/node_modules/better-auth/README.md +0 -32
- package/skills/void/docs/node_modules/void/node_modules/better-sqlite3/README.md +0 -99
- package/skills/void/docs/node_modules/void/node_modules/blake3-jit/README.md +0 -108
- package/skills/void/docs/node_modules/void/node_modules/drizzle-arktype/README.md +0 -51
- package/skills/void/docs/node_modules/void/node_modules/drizzle-kit/README.md +0 -79
- package/skills/void/docs/node_modules/void/node_modules/drizzle-orm/README.md +0 -44
- package/skills/void/docs/node_modules/void/node_modules/drizzle-valibot/README.md +0 -51
- package/skills/void/docs/node_modules/void/node_modules/drizzle-zod/README.md +0 -65
- package/skills/void/docs/node_modules/void/node_modules/es-module-lexer/README.md +0 -403
- package/skills/void/docs/node_modules/void/node_modules/estree-walker/README.md +0 -48
- package/skills/void/docs/node_modules/void/node_modules/hono/README.md +0 -85
- package/skills/void/docs/node_modules/void/node_modules/ignore/README.md +0 -452
- package/skills/void/docs/node_modules/void/node_modules/jsonc-parser/CHANGELOG.md +0 -76
- package/skills/void/docs/node_modules/void/node_modules/jsonc-parser/LICENSE.md +0 -21
- package/skills/void/docs/node_modules/void/node_modules/jsonc-parser/README.md +0 -364
- package/skills/void/docs/node_modules/void/node_modules/jsonc-parser/SECURITY.md +0 -41
- package/skills/void/docs/node_modules/void/node_modules/magic-string/README.md +0 -325
- package/skills/void/docs/node_modules/void/node_modules/ofetch/README.md +0 -398
- package/skills/void/docs/node_modules/void/node_modules/pathslash/README.md +0 -64
- package/skills/void/docs/node_modules/void/node_modules/pg/README.md +0 -96
- package/skills/void/docs/node_modules/void/node_modules/pglite-server/README.md +0 -135
- package/skills/void/docs/node_modules/void/node_modules/picocolors/README.md +0 -21
- package/skills/void/docs/node_modules/void/node_modules/proper-lockfile/CHANGELOG.md +0 -108
- package/skills/void/docs/node_modules/void/node_modules/proper-lockfile/README.md +0 -183
- package/skills/void/docs/node_modules/void/node_modules/tinyglobby/README.md +0 -25
- package/skills/void/docs/node_modules/void/node_modules/valibot/LICENSE.md +0 -9
- package/skills/void/docs/node_modules/void/node_modules/valibot/README.md +0 -94
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/AGENTS.md +0 -16
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/README.md +0 -220
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/config/build.md +0 -21
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/config/check.md +0 -35
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/config/create.md +0 -70
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/config/fmt.md +0 -20
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/config/index.md +0 -35
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/config/lint.md +0 -26
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/config/pack.md +0 -17
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/config/run.md +0 -364
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/config/staged.md +0 -15
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/config/test.md +0 -18
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/guide/automatic-data-tracking.md +0 -145
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/guide/build.md +0 -40
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/guide/cache.md +0 -107
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/guide/check.md +0 -60
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/guide/ci.md +0 -62
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/guide/commit-hooks.md +0 -60
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/guide/create.md +0 -341
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/guide/dev.md +0 -24
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/guide/docker.md +0 -175
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/guide/env.md +0 -167
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/guide/fmt.md +0 -41
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/guide/github-actions-cache.md +0 -165
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/guide/ide-integration.md +0 -101
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/guide/implode.md +0 -23
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/guide/index.md +0 -134
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/guide/install.md +0 -199
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/guide/lint.md +0 -50
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/guide/migrate-rules.md +0 -347
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/guide/migrate.md +0 -197
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/guide/monorepo.md +0 -176
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/guide/pack.md +0 -69
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/guide/run.md +0 -356
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/guide/test.md +0 -35
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/guide/troubleshooting.md +0 -108
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/guide/upgrade.md +0 -101
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/guide/vpx.md +0 -66
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/guide/why.md +0 -39
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/index.md +0 -12
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/team.md +0 -35
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/templates/generator/README.md +0 -35
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/templates/monorepo/README.md +0 -29
- package/skills/void/docs/node_modules/void/node_modules/wrangler/README.md +0 -63
- package/skills/void/docs/node_modules/void/node_modules/zod/README.md +0 -191
- package/skills/void/docs/node_modules/void/skills/migrate-vite-cloudflare-to-void/SKILL.md +0 -175
- package/skills/void/docs/node_modules/void/skills/void/SKILL.md +0 -76
- package/skills/void/docs/node_modules/void/skills/void/command/void.md +0 -7
- package/skills/void/docs/node_modules/void/test/e2e/README.md +0 -85
|
@@ -0,0 +1,4450 @@
|
|
|
1
|
+
import { r as __toESM } from "./rolldown-runtime-rQ84J-ij.mjs";
|
|
2
|
+
import { o as join } from "./dist-BrsS7cai.mjs";
|
|
3
|
+
import { $ as resolveInstallationManifest, A as password, B as assertPlatformInstallationSlugAvailable, D as log, E as intro, F as isCancel, H as latestPlatformWorkerCutoverGeneration, I as settings, J as promoteInstallationSecrets, L as u$1, M as symbol, N as text, O as note, P as CANCEL_SYMBOL, U as listInstallationManifests, X as readPendingInstallationSecrets, c as import_picocolors$1, et as savePlatformConnection, h as S_BAR_END, i as createSpinner, j as select, k as outro, m as S_BAR, n as cliCommandName, nt as writeInstallationManifest, q as platformWorkerSecretUpdateIntentState, r as cliTitle, rt as writePendingInstallationSecrets, st as require_picocolors, v as S_PASSWORD_MASK, w as confirm, z as acquirePlatformLifecycleLock } from "./output-tFQLLj26.mjs";
|
|
4
|
+
import { t as cfAccessHeaders } from "./cf-access-AJ1ehiFR.mjs";
|
|
5
|
+
import { d as openBrowser } from "./auth-W9WII-mN.mjs";
|
|
6
|
+
import { o as resolveWranglerArgv } from "./preset-lAy0B0BQ.mjs";
|
|
7
|
+
import { c as runCloudflareAuthCommand, n as announceCloudflareLogin } from "./cloudflare-auth-DRkGe7-s.mjs";
|
|
8
|
+
import { n as createDefaultRunWrangler, o as resolvePinnedApiBaseUrl } from "./provision-Blnstcm2.mjs";
|
|
9
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { execFile } from "node:child_process";
|
|
13
|
+
import { PLATFORM_DISABLED_KEY, PLATFORM_DISABLED_VALUE, PLATFORM_R2_OWNERSHIP_MARKER_KEY, PLATFORM_REMOTE_INSTALLATION_MANIFEST_KEY, PLATFORM_SANDBOX_DRAIN_KEY, createApplicationRoutes, createPlatformDeploymentManifest, createPlatformInstallationManifest, createPlatformResourceNames, createPlatformResourceOwnershipMarker, createPlatformWorkerConfigs, normalizeApplicationDomain, normalizeInstallationSlug, parsePlatformInstallationManifest, parsePlatformResourceOwnershipMarker, recordInstallationResource, recordInstallationStep, setInstallationDesiredState, setInstallationEndpoints, transitionInstallation } from "@void/platform";
|
|
14
|
+
import { encodeCloudflareVersionMessage, parseCloudflareVersionMessage, readCloudflareTriggerConfig, readCloudflareVersionMigrationTag, stabilizeCloudflareVersionTriggers } from "@void/deploy-cloudflare";
|
|
15
|
+
import { createServer, request } from "node:http";
|
|
16
|
+
import { request as request$1 } from "node:https";
|
|
17
|
+
import { platformDatabaseSchemaDigest, resolvePlatformRuntime } from "@void/platform/installer";
|
|
18
|
+
//#region src/cli/platform-auth.ts
|
|
19
|
+
function assertPlatformOperatorCredentialCanApplyPlan(credential, plan) {
|
|
20
|
+
if (credential.source !== "oauth") return;
|
|
21
|
+
const createsZone = plan.actions.some((action) => action.kind === "zone" && action.action === "create");
|
|
22
|
+
const writesDns = plan.actions.some((action) => action.kind === "dns-record" && action.action !== "reuse");
|
|
23
|
+
if (!createsZone && !writesDns) return;
|
|
24
|
+
const permissions = [...createsZone ? ["Account → Zone → Edit"] : [], ...writesDns ? ["Zone → DNS → Edit"] : []];
|
|
25
|
+
throw new Error(`platform: Cloudflare browser OAuth grants Zone Read and cannot apply the planned zone or DNS changes. Set CLOUDFLARE_API_TOKEN with ${permissions.join(" and ")}, scoped to the selected account and application zone, then rerun the command.`);
|
|
26
|
+
}
|
|
27
|
+
async function resolvePlatformCloudflareCredential(dependencies = {}) {
|
|
28
|
+
const { credential } = await resolvePlatformCloudflareCredentialWithInput(async () => void 0, dependencies);
|
|
29
|
+
return credential;
|
|
30
|
+
}
|
|
31
|
+
/** Check saved credentials during input, but never start browser login until it succeeds. */
|
|
32
|
+
async function resolvePlatformCloudflareCredentialWithInput(readInput, dependencies = {}) {
|
|
33
|
+
const controller = new AbortController();
|
|
34
|
+
const runner = dependencies.runWrangler ?? ((args) => runCloudflareAuthCommand(process.cwd(), args, controller.signal));
|
|
35
|
+
const runWrangler = (args, capture) => {
|
|
36
|
+
controller.signal.throwIfAborted();
|
|
37
|
+
return runner(args, capture);
|
|
38
|
+
};
|
|
39
|
+
const pending = readExistingCredential(dependencies, runWrangler).then((credential) => ({
|
|
40
|
+
ok: true,
|
|
41
|
+
credential
|
|
42
|
+
}), (error) => ({
|
|
43
|
+
ok: false,
|
|
44
|
+
error
|
|
45
|
+
}));
|
|
46
|
+
const cancel = () => controller.abort();
|
|
47
|
+
const assertNotCancelled = () => {
|
|
48
|
+
if (controller.signal.aborted) throw new Error("platform: Cancelled.");
|
|
49
|
+
};
|
|
50
|
+
try {
|
|
51
|
+
const input = await readInput();
|
|
52
|
+
process.once("SIGINT", cancel);
|
|
53
|
+
const result = await pending;
|
|
54
|
+
assertNotCancelled();
|
|
55
|
+
if (!result.ok) throw result.error;
|
|
56
|
+
if (result.credential) return {
|
|
57
|
+
input,
|
|
58
|
+
credential: result.credential
|
|
59
|
+
};
|
|
60
|
+
announceCloudflareLogin();
|
|
61
|
+
const login = await runWrangler(["login", "--use-keyring"], true);
|
|
62
|
+
assertNotCancelled();
|
|
63
|
+
if (login.status !== 0) throw new Error("platform: Cloudflare browser login did not complete.");
|
|
64
|
+
const credential = await readWranglerOAuthCredential(runWrangler);
|
|
65
|
+
assertNotCancelled();
|
|
66
|
+
if (!credential) throw new Error("platform: Cloudflare sign-in completed without a usable OAuth credential.");
|
|
67
|
+
return {
|
|
68
|
+
input,
|
|
69
|
+
credential
|
|
70
|
+
};
|
|
71
|
+
} finally {
|
|
72
|
+
process.removeListener("SIGINT", cancel);
|
|
73
|
+
controller.abort();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
async function readExistingCredential(dependencies, runWrangler) {
|
|
77
|
+
const environmentToken = process.env.CLOUDFLARE_API_TOKEN?.trim() || process.env.CF_API_TOKEN?.trim() || "";
|
|
78
|
+
if (environmentToken) return {
|
|
79
|
+
source: "environment",
|
|
80
|
+
token: environmentToken
|
|
81
|
+
};
|
|
82
|
+
if (!(dependencies.interactive ?? process.stdin.isTTY === true)) throw new Error("platform: Cloudflare authentication is required. Run `void cloudflare login` interactively, or set CLOUDFLARE_API_TOKEN in CI.");
|
|
83
|
+
if ((await runWrangler([
|
|
84
|
+
"auth",
|
|
85
|
+
"keyring",
|
|
86
|
+
"enable"
|
|
87
|
+
], true)).status !== 0) throw new Error("platform: Could not enable secure Cloudflare credential storage. Refusing to use a plaintext OAuth credential.");
|
|
88
|
+
return await readWranglerOAuthCredential(runWrangler);
|
|
89
|
+
}
|
|
90
|
+
async function readWranglerOAuthCredential(runWrangler = (args) => runCloudflareAuthCommand(process.cwd(), args)) {
|
|
91
|
+
const result = await runWrangler([
|
|
92
|
+
"auth",
|
|
93
|
+
"token",
|
|
94
|
+
"--json"
|
|
95
|
+
], true);
|
|
96
|
+
if (result.status !== 0) return null;
|
|
97
|
+
try {
|
|
98
|
+
const parsed = JSON.parse(result.stdout);
|
|
99
|
+
if (parsed.type !== "oauth" || typeof parsed.token !== "string" || !parsed.token.trim()) return null;
|
|
100
|
+
return {
|
|
101
|
+
source: "oauth",
|
|
102
|
+
token: parsed.token.trim()
|
|
103
|
+
};
|
|
104
|
+
} catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
//#endregion
|
|
109
|
+
//#region src/cli/platform-cloudflare.ts
|
|
110
|
+
var PlatformCloudflareApiError = class extends Error {
|
|
111
|
+
status;
|
|
112
|
+
requestPath;
|
|
113
|
+
constructor(message, status, requestPath) {
|
|
114
|
+
super(message);
|
|
115
|
+
this.name = "PlatformCloudflareApiError";
|
|
116
|
+
this.status = status;
|
|
117
|
+
this.requestPath = requestPath;
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
var PlatformWorkerCustomDomainsChangedError = class extends Error {
|
|
121
|
+
constructor(message, options) {
|
|
122
|
+
super(message, options);
|
|
123
|
+
this.name = "PlatformWorkerCustomDomainsChangedError";
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
var PlatformWorkerRoutesChangedError = class extends Error {
|
|
127
|
+
constructor(message, options) {
|
|
128
|
+
super(message, options);
|
|
129
|
+
this.name = "PlatformWorkerRoutesChangedError";
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
async function verifyPlatformRuntimeTokenPermissions(client, input) {
|
|
133
|
+
try {
|
|
134
|
+
await client.listHyperdriveConfigs(input.accountId);
|
|
135
|
+
} catch (error) {
|
|
136
|
+
throw runtimeTokenPreflightError("Account → Hyperdrive → Write", error);
|
|
137
|
+
}
|
|
138
|
+
if (!input.zoneId) return;
|
|
139
|
+
const probeUrl = `https://${input.applicationDomain}/.well-known/void/runtime-token-preflight-${randomUUID()}`;
|
|
140
|
+
try {
|
|
141
|
+
await client.request(`/zones/${encodeURIComponent(input.zoneId)}/purge_cache`, {
|
|
142
|
+
body: JSON.stringify({ files: [probeUrl] }),
|
|
143
|
+
method: "POST"
|
|
144
|
+
});
|
|
145
|
+
} catch (error) {
|
|
146
|
+
throw runtimeTokenPreflightError("Zone → Cache Purge → Purge", error);
|
|
147
|
+
}
|
|
148
|
+
if (input.customDomains) try {
|
|
149
|
+
await client.request(`/zones/${encodeURIComponent(input.zoneId)}/custom_hostnames?per_page=1`);
|
|
150
|
+
} catch (error) {
|
|
151
|
+
throw runtimeTokenPreflightError("Zone → SSL and Certificates → Edit", error);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
function runtimeTokenPreflightError(permission, cause) {
|
|
155
|
+
const status = cause instanceof PlatformCloudflareApiError ? cause.status : null;
|
|
156
|
+
const message = status === 401 || status === 403 ? `Cloudflare denied the runtime-token check (HTTP ${status}). Check that the token is valid, scoped to this account, and includes ${permission}.` : status === 429 ? `Cloudflare rate-limited the runtime-token check for ${permission} (HTTP 429). Retry shortly; this does not mean the token is missing a permission.` : `Could not complete the runtime-token check for ${permission}${status === null ? "" : ` (Cloudflare HTTP ${status})`}. Retry the check; this failure does not establish that a permission is missing.`;
|
|
157
|
+
return new Error(`platform: ${message}`, { cause });
|
|
158
|
+
}
|
|
159
|
+
var PlatformCloudflareClient = class PlatformCloudflareClient {
|
|
160
|
+
apiBaseUrl;
|
|
161
|
+
#fetcher;
|
|
162
|
+
#token;
|
|
163
|
+
#aiGatewayClient;
|
|
164
|
+
constructor(token, options) {
|
|
165
|
+
if (!token) throw new Error("platform: CLOUDFLARE_API_TOKEN is required. The installed control plane also uses this scoped token to provision app resources.");
|
|
166
|
+
this.#token = token;
|
|
167
|
+
this.apiBaseUrl = (options?.apiBase ?? resolvePinnedApiBaseUrl()).replace(/\/+$/, "");
|
|
168
|
+
this.#fetcher = options?.fetcher ?? fetch;
|
|
169
|
+
if (options?.aiGatewayToken) this.#aiGatewayClient = new PlatformCloudflareClient(options.aiGatewayToken, {
|
|
170
|
+
apiBase: this.apiBaseUrl,
|
|
171
|
+
fetcher: this.#fetcher
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
/** Keep operator auth for infrastructure; use the existing runtime credential only for AI Gateway. */
|
|
175
|
+
withAiGatewayToken(token) {
|
|
176
|
+
return new PlatformCloudflareClient(this.#token, {
|
|
177
|
+
apiBase: this.apiBaseUrl,
|
|
178
|
+
fetcher: this.#fetcher,
|
|
179
|
+
aiGatewayToken: token
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
async request(path, init) {
|
|
183
|
+
return (await this.#requestEnvelope(path, init)).result;
|
|
184
|
+
}
|
|
185
|
+
async #requestEnvelope(path, init) {
|
|
186
|
+
const response = await this.#fetcher(`${this.apiBaseUrl}${path}`, {
|
|
187
|
+
...init,
|
|
188
|
+
headers: {
|
|
189
|
+
Accept: "application/json",
|
|
190
|
+
Authorization: `Bearer ${this.#token}`,
|
|
191
|
+
...init?.body ? { "Content-Type": "application/json" } : {},
|
|
192
|
+
...init?.headers
|
|
193
|
+
},
|
|
194
|
+
signal: init?.signal ?? AbortSignal.timeout(3e4)
|
|
195
|
+
});
|
|
196
|
+
let envelope;
|
|
197
|
+
try {
|
|
198
|
+
envelope = await response.json();
|
|
199
|
+
} catch {
|
|
200
|
+
throw new PlatformCloudflareApiError(`Cloudflare returned a non-JSON response (HTTP ${response.status}).`, response.status, path.split("?")[0]);
|
|
201
|
+
}
|
|
202
|
+
if (!response.ok || envelope.success !== true || envelope.result === void 0) throw new PlatformCloudflareApiError(`Cloudflare API error: ${envelope.errors?.map((error) => error.message).filter(Boolean).join(", ") || `HTTP ${response.status}`} (HTTP ${response.status}; ${init?.method ?? "GET"} ${path.split("?")[0]}).`, response.status, path.split("?")[0]);
|
|
203
|
+
return {
|
|
204
|
+
result: envelope.result,
|
|
205
|
+
resultInfo: envelope.result_info
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
async listAccounts() {
|
|
209
|
+
const entries = [];
|
|
210
|
+
for (let page = 1;; page++) {
|
|
211
|
+
const result = await this.request(`/accounts?page=${page}&per_page=50`);
|
|
212
|
+
entries.push(...result);
|
|
213
|
+
if (result.length < 50) break;
|
|
214
|
+
}
|
|
215
|
+
return entries.map((entry) => ({
|
|
216
|
+
id: requiredString(entry.id, "Cloudflare account id"),
|
|
217
|
+
name: requiredString(entry.name, "Cloudflare account name")
|
|
218
|
+
}));
|
|
219
|
+
}
|
|
220
|
+
async getWorkersSubdomain(accountId) {
|
|
221
|
+
const setupUrl = `https://dash.cloudflare.com/${encodeURIComponent(accountId)}/workers-and-pages`;
|
|
222
|
+
let result;
|
|
223
|
+
try {
|
|
224
|
+
result = await this.request(`/accounts/${encodeURIComponent(accountId)}/workers/subdomain`);
|
|
225
|
+
} catch (error) {
|
|
226
|
+
const status = error instanceof PlatformCloudflareApiError ? error.status : null;
|
|
227
|
+
const reason = status === null ? "the request failed" : `HTTP ${status}`;
|
|
228
|
+
const guidance = status === 401 || status === 403 ? "Check your Cloudflare login or API token permissions for this account, then rerun the command." : status === 404 ? `Check ${setupUrl}. If no subdomain is shown, finish Workers onboarding and choose one; otherwise check account access and retry.` : "Rerun the command. If the dashboard already shows a subdomain, no Workers onboarding change is needed.";
|
|
229
|
+
throw new Error(`platform: Could not read the workers.dev subdomain for account ${accountId} (${reason}). ${guidance}`, { cause: error });
|
|
230
|
+
}
|
|
231
|
+
if (typeof result?.subdomain !== "string" || result.subdomain.trim() === "") throw new Error(`platform: This Cloudflare account does not have a workers.dev subdomain. Open ${setupUrl}, finish Workers onboarding, choose a subdomain, and rerun the command.`);
|
|
232
|
+
return result.subdomain.trim();
|
|
233
|
+
}
|
|
234
|
+
async listAiGateways(accountId) {
|
|
235
|
+
if (this.#aiGatewayClient) return this.#aiGatewayClient.listAiGateways(accountId);
|
|
236
|
+
const entries = [];
|
|
237
|
+
for (let page = 1;; page++) {
|
|
238
|
+
const result = await this.request(`/accounts/${encodeURIComponent(accountId)}/ai-gateway/gateways?page=${page}&per_page=50`);
|
|
239
|
+
entries.push(...result);
|
|
240
|
+
if (result.length < 50) break;
|
|
241
|
+
}
|
|
242
|
+
return entries.map((entry) => ({
|
|
243
|
+
id: requiredString(entry.id, "AI Gateway id"),
|
|
244
|
+
name: requiredString(entry.id, "AI Gateway id")
|
|
245
|
+
}));
|
|
246
|
+
}
|
|
247
|
+
async createAiGateway(accountId, id) {
|
|
248
|
+
if (this.#aiGatewayClient) return this.#aiGatewayClient.createAiGateway(accountId, id);
|
|
249
|
+
return {
|
|
250
|
+
id: requiredString((await this.request(`/accounts/${encodeURIComponent(accountId)}/ai-gateway/gateways`, {
|
|
251
|
+
method: "POST",
|
|
252
|
+
body: JSON.stringify({
|
|
253
|
+
cache_invalidate_on_update: false,
|
|
254
|
+
cache_ttl: null,
|
|
255
|
+
collect_logs: true,
|
|
256
|
+
id,
|
|
257
|
+
rate_limiting_interval: null,
|
|
258
|
+
rate_limiting_limit: null
|
|
259
|
+
})
|
|
260
|
+
})).id, "AI Gateway id"),
|
|
261
|
+
name: id
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
async findZone(accountId, name) {
|
|
265
|
+
const exact = (await this.request(`/zones?account.id=${encodeURIComponent(accountId)}&name=${encodeURIComponent(name)}&per_page=50`)).find((zone) => zone.name === name);
|
|
266
|
+
return exact ? parseZone(exact) : null;
|
|
267
|
+
}
|
|
268
|
+
async createZone(accountId, name) {
|
|
269
|
+
return parseZone(await this.request("/zones", {
|
|
270
|
+
method: "POST",
|
|
271
|
+
body: JSON.stringify({
|
|
272
|
+
account: { id: accountId },
|
|
273
|
+
name,
|
|
274
|
+
type: "full"
|
|
275
|
+
})
|
|
276
|
+
}));
|
|
277
|
+
}
|
|
278
|
+
async findZoneForHostname(accountId, hostname) {
|
|
279
|
+
const labels = hostname.split(".");
|
|
280
|
+
for (let index = 0; index <= labels.length - 2; index++) {
|
|
281
|
+
const zone = await this.findZone(accountId, labels.slice(index).join("."));
|
|
282
|
+
if (zone) return zone;
|
|
283
|
+
}
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
async listWorkerDomains(accountId) {
|
|
287
|
+
return (await this.#listWorkerDomainRecords(accountId)).map((entry) => ({
|
|
288
|
+
hostname: requiredString(entry.hostname, "Worker custom domain hostname"),
|
|
289
|
+
id: requiredString(entry.id, "Worker custom domain id"),
|
|
290
|
+
service: requiredString(entry.service, "Worker custom domain service"),
|
|
291
|
+
...typeof entry.zone_name === "string" ? { zoneName: entry.zone_name } : {}
|
|
292
|
+
}));
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Add missing Custom Domains without replacing the Worker's existing set or
|
|
296
|
+
* authorizing takeover of another Worker origin or DNS record. Wrangler
|
|
297
|
+
* enables destructive overrides automatically without a TTY, so the
|
|
298
|
+
* installer deliberately owns this mutation through the API instead.
|
|
299
|
+
*/
|
|
300
|
+
async publishWorkerCustomDomainsSafely(accountId, workerName, origins) {
|
|
301
|
+
const before = await this.listWorkerDomains(accountId);
|
|
302
|
+
const pending = origins.filter((origin) => {
|
|
303
|
+
const matches = before.filter((domain) => domain.hostname === origin.hostname);
|
|
304
|
+
if (matches.length > 1 || matches.some((domain) => domain.service !== workerName)) throw new PlatformWorkerCustomDomainsChangedError(`platform: Custom Domain ${JSON.stringify(origin.hostname)} changed concurrently before publication; refusing to replace its traffic state.`);
|
|
305
|
+
return matches.length === 0;
|
|
306
|
+
});
|
|
307
|
+
if (pending.length === 0) return;
|
|
308
|
+
try {
|
|
309
|
+
await this.request(`/accounts/${encodeURIComponent(accountId)}/workers/scripts/${encodeURIComponent(workerName)}/domains/records`, {
|
|
310
|
+
method: "PUT",
|
|
311
|
+
body: JSON.stringify({
|
|
312
|
+
override_scope: false,
|
|
313
|
+
override_existing_origin: false,
|
|
314
|
+
override_existing_dns_record: false,
|
|
315
|
+
origins: pending.map((origin) => ({
|
|
316
|
+
hostname: origin.hostname,
|
|
317
|
+
...origin.zoneId ? { zone_id: origin.zoneId } : {},
|
|
318
|
+
...origin.zoneName ? { zone_name: origin.zoneName } : {},
|
|
319
|
+
...origin.enabled === void 0 ? {} : { enabled: origin.enabled },
|
|
320
|
+
...origin.previewsEnabled === void 0 ? {} : { previews_enabled: origin.previewsEnabled }
|
|
321
|
+
}))
|
|
322
|
+
})
|
|
323
|
+
});
|
|
324
|
+
} catch (error) {
|
|
325
|
+
throw new PlatformWorkerCustomDomainsChangedError(`platform: Custom Domain publication for Worker ${JSON.stringify(workerName)} had an ambiguous outcome; retaining live domains for safe reconciliation.`, { cause: error });
|
|
326
|
+
}
|
|
327
|
+
const expected = [...origins].map((origin) => origin.hostname).sort();
|
|
328
|
+
const inventory = await this.listWorkerDomains(accountId);
|
|
329
|
+
const actual = inventory.filter((domain) => domain.service === workerName).map((domain) => domain.hostname).sort();
|
|
330
|
+
if (!expected.every((hostname) => inventory.filter((domain) => domain.hostname === hostname && domain.service === workerName).length === 1) || expected.some((hostname) => !actual.includes(hostname))) throw new PlatformWorkerCustomDomainsChangedError(`platform: Worker ${JSON.stringify(workerName)} Custom Domains changed concurrently after publication; refusing to overwrite the newer traffic state.`);
|
|
331
|
+
}
|
|
332
|
+
async #listWorkerDomainRecords(accountId, service) {
|
|
333
|
+
const entries = [];
|
|
334
|
+
const perPage = 100;
|
|
335
|
+
for (let page = 1;; page++) {
|
|
336
|
+
const query = new URLSearchParams({
|
|
337
|
+
page: String(page),
|
|
338
|
+
per_page: String(perPage)
|
|
339
|
+
});
|
|
340
|
+
if (service) query.set("service", service);
|
|
341
|
+
const response = await this.#requestEnvelope(`/accounts/${encodeURIComponent(accountId)}/workers/domains?${query}`);
|
|
342
|
+
entries.push(...response.result);
|
|
343
|
+
const totalPages = response.resultInfo?.total_pages;
|
|
344
|
+
if (typeof totalPages === "number" && page >= totalPages || totalPages === void 0 && response.result.length < perPage) break;
|
|
345
|
+
}
|
|
346
|
+
return entries;
|
|
347
|
+
}
|
|
348
|
+
async listWorkerRoutes(zoneId) {
|
|
349
|
+
return (await this.request(`/zones/${encodeURIComponent(zoneId)}/workers/routes`)).map((entry) => ({
|
|
350
|
+
id: requiredString(entry.id, "Worker route id"),
|
|
351
|
+
pattern: requiredString(entry.pattern, "Worker route pattern"),
|
|
352
|
+
script: typeof entry.script === "string" ? entry.script : null
|
|
353
|
+
}));
|
|
354
|
+
}
|
|
355
|
+
async publishWorkerRoutesSafely(zoneId, workerName, patterns) {
|
|
356
|
+
const before = await this.listWorkerRoutes(zoneId);
|
|
357
|
+
const pending = patterns.filter((pattern) => {
|
|
358
|
+
const matches = before.filter((route) => route.pattern === pattern);
|
|
359
|
+
if (matches.length > 1 || matches.some((route) => route.script !== workerName)) throw new PlatformWorkerRoutesChangedError(`platform: Worker route ${JSON.stringify(pattern)} changed concurrently before publication; refusing to replace its traffic state.`);
|
|
360
|
+
return matches.length === 0;
|
|
361
|
+
});
|
|
362
|
+
for (const pattern of pending) try {
|
|
363
|
+
await this.request(`/zones/${encodeURIComponent(zoneId)}/workers/routes`, {
|
|
364
|
+
body: JSON.stringify({
|
|
365
|
+
pattern,
|
|
366
|
+
script: workerName
|
|
367
|
+
}),
|
|
368
|
+
method: "POST"
|
|
369
|
+
});
|
|
370
|
+
} catch (error) {
|
|
371
|
+
throw new PlatformWorkerRoutesChangedError(`platform: Worker route publication for ${JSON.stringify(pattern)} had an ambiguous outcome; retaining live routes for safe reconciliation.`, { cause: error });
|
|
372
|
+
}
|
|
373
|
+
const after = await this.listWorkerRoutes(zoneId);
|
|
374
|
+
for (const pattern of patterns) {
|
|
375
|
+
const matches = after.filter((route) => route.pattern === pattern);
|
|
376
|
+
if (matches.length !== 1 || matches[0].script !== workerName) throw new PlatformWorkerRoutesChangedError(`platform: Worker route ${JSON.stringify(pattern)} changed concurrently after publication; retaining live routes for safe reconciliation.`);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
async listWorkerServiceRoutes(accountId, workerName) {
|
|
380
|
+
return (await this.request(`/accounts/${encodeURIComponent(accountId)}/workers/services/${encodeURIComponent(workerName)}/environments/production/routes?show_zonename=true`)).map((entry) => ({
|
|
381
|
+
pattern: requiredString(entry.pattern, "Worker service route pattern"),
|
|
382
|
+
zoneName: requiredString(entry.zone_name, "Worker service route zone name")
|
|
383
|
+
}));
|
|
384
|
+
}
|
|
385
|
+
async listDnsRecords(zoneId, name) {
|
|
386
|
+
const query = new URLSearchParams({
|
|
387
|
+
name,
|
|
388
|
+
per_page: "100"
|
|
389
|
+
});
|
|
390
|
+
return (await this.request(`/zones/${encodeURIComponent(zoneId)}/dns_records?${query}`)).map(parseDnsRecord);
|
|
391
|
+
}
|
|
392
|
+
/** Read deployed edge certificates; requesting a pack is not proof of issuance. */
|
|
393
|
+
async hasActiveCertificateForHostname(zoneId, hostname) {
|
|
394
|
+
const expected = hostname.toLowerCase().replace(/\.$/, "");
|
|
395
|
+
const covers = (value) => {
|
|
396
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
397
|
+
const cert = value;
|
|
398
|
+
if (cert.status !== "active" || !Array.isArray(cert.hosts)) return false;
|
|
399
|
+
if (cert.expires_on !== void 0 && (typeof cert.expires_on !== "string" || !Number.isFinite(Date.parse(cert.expires_on)) || Date.parse(cert.expires_on) <= Date.now())) return false;
|
|
400
|
+
return cert.hosts.some((host) => typeof host === "string" && host.toLowerCase().replace(/\.$/, "") === expected);
|
|
401
|
+
};
|
|
402
|
+
for (const kind of ["ssl/certificate_packs", "custom_certificates"]) for (let page = 1; page <= 200; page++) {
|
|
403
|
+
const query = new URLSearchParams({
|
|
404
|
+
page: String(page),
|
|
405
|
+
per_page: "50"
|
|
406
|
+
});
|
|
407
|
+
if (kind === "ssl/certificate_packs") query.set("deploy", "production");
|
|
408
|
+
const { result, resultInfo } = await this.#requestEnvelope(`/zones/${encodeURIComponent(zoneId)}/${kind}?${query}`);
|
|
409
|
+
if (!Array.isArray(result)) throw new Error("platform: Cloudflare returned an invalid certificate inventory.");
|
|
410
|
+
for (const entry of result) if ((kind === "ssl/certificate_packs" ? Array.isArray(entry.certificates) ? entry.certificates : [] : [entry]).some(covers)) return true;
|
|
411
|
+
if (result.length === 0 || (resultInfo?.total_pages !== void 0 ? page >= resultInfo.total_pages : result.length < 50)) break;
|
|
412
|
+
if (page === 200) throw new Error("platform: Certificate inventory is too large to verify safely.");
|
|
413
|
+
}
|
|
414
|
+
return false;
|
|
415
|
+
}
|
|
416
|
+
async createDnsRecord(zoneId, name) {
|
|
417
|
+
return parseDnsRecord(await this.request(`/zones/${encodeURIComponent(zoneId)}/dns_records`, {
|
|
418
|
+
method: "POST",
|
|
419
|
+
body: JSON.stringify({
|
|
420
|
+
content: "100::",
|
|
421
|
+
name,
|
|
422
|
+
proxied: true,
|
|
423
|
+
ttl: 1,
|
|
424
|
+
type: "AAAA"
|
|
425
|
+
})
|
|
426
|
+
}));
|
|
427
|
+
}
|
|
428
|
+
async listKvNamespaces(accountId, title) {
|
|
429
|
+
const entries = [];
|
|
430
|
+
for (let page = 1;; page++) {
|
|
431
|
+
const query = new URLSearchParams({
|
|
432
|
+
page: String(page),
|
|
433
|
+
per_page: "100"
|
|
434
|
+
});
|
|
435
|
+
const result = await this.request(`/accounts/${encodeURIComponent(accountId)}/storage/kv/namespaces?${query}`);
|
|
436
|
+
entries.push(...result);
|
|
437
|
+
if (result.length < 100) break;
|
|
438
|
+
}
|
|
439
|
+
const namespaces = entries.map((entry) => ({
|
|
440
|
+
id: requiredString(entry.id, "KV namespace id"),
|
|
441
|
+
name: requiredString(entry.title, "KV namespace title")
|
|
442
|
+
}));
|
|
443
|
+
return title ? namespaces.filter((namespace) => namespace.name === title) : namespaces;
|
|
444
|
+
}
|
|
445
|
+
async createKvNamespace(accountId, title) {
|
|
446
|
+
const result = await this.request(`/accounts/${encodeURIComponent(accountId)}/storage/kv/namespaces`, {
|
|
447
|
+
method: "POST",
|
|
448
|
+
body: JSON.stringify({ title })
|
|
449
|
+
});
|
|
450
|
+
return {
|
|
451
|
+
id: requiredString(result.id, "KV namespace id"),
|
|
452
|
+
name: typeof result.title === "string" ? result.title : title
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
async listR2Buckets(accountId) {
|
|
456
|
+
const buckets = [];
|
|
457
|
+
let startAfter = "";
|
|
458
|
+
for (;;) {
|
|
459
|
+
const query = new URLSearchParams({
|
|
460
|
+
direction: "asc",
|
|
461
|
+
order: "name",
|
|
462
|
+
per_page: "1000"
|
|
463
|
+
});
|
|
464
|
+
if (startAfter) query.set("start_after", startAfter);
|
|
465
|
+
const result = await this.request(`/accounts/${encodeURIComponent(accountId)}/r2/buckets?${query}`);
|
|
466
|
+
const page = Array.isArray(result) ? result : "buckets" in result ? result.buckets ?? [] : [];
|
|
467
|
+
buckets.push(...page);
|
|
468
|
+
if (page.length < 1e3) break;
|
|
469
|
+
const next = requiredString(page.at(-1)?.name, "R2 bucket name");
|
|
470
|
+
if (next === startAfter) throw new Error("platform: Cloudflare returned a non-advancing R2 bucket page.");
|
|
471
|
+
startAfter = next;
|
|
472
|
+
}
|
|
473
|
+
return buckets.map((entry) => {
|
|
474
|
+
const name = requiredString(entry.name, "R2 bucket name");
|
|
475
|
+
return {
|
|
476
|
+
fingerprint: requiredString(entry.creation_date, "R2 bucket creation date"),
|
|
477
|
+
id: name,
|
|
478
|
+
name
|
|
479
|
+
};
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
async createR2Bucket(accountId, name) {
|
|
483
|
+
const result = await this.request(`/accounts/${encodeURIComponent(accountId)}/r2/buckets`, {
|
|
484
|
+
method: "POST",
|
|
485
|
+
body: JSON.stringify({ name })
|
|
486
|
+
});
|
|
487
|
+
const resultName = typeof result.name === "string" ? result.name : name;
|
|
488
|
+
const fingerprint = typeof result.creation_date === "string" && result.creation_date ? result.creation_date : (await this.listR2Buckets(accountId)).find((bucket) => bucket.name === resultName)?.fingerprint;
|
|
489
|
+
if (!fingerprint) throw new Error(`platform: Cloudflare created R2 bucket ${JSON.stringify(resultName)} without returning a verifiable creation fingerprint.`);
|
|
490
|
+
return {
|
|
491
|
+
fingerprint,
|
|
492
|
+
id: resultName,
|
|
493
|
+
name: resultName
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
async listQueues(accountId, name) {
|
|
497
|
+
return (await this.request(`/accounts/${encodeURIComponent(accountId)}/queues?name=${encodeURIComponent(name)}&per_page=100`)).map((entry) => ({
|
|
498
|
+
id: requiredString(entry.queue_id, "Queue id"),
|
|
499
|
+
name: requiredString(entry.queue_name, "Queue name")
|
|
500
|
+
}));
|
|
501
|
+
}
|
|
502
|
+
async createQueue(accountId, name) {
|
|
503
|
+
const result = await this.request(`/accounts/${encodeURIComponent(accountId)}/queues`, {
|
|
504
|
+
method: "POST",
|
|
505
|
+
body: JSON.stringify({ queue_name: name })
|
|
506
|
+
});
|
|
507
|
+
return {
|
|
508
|
+
id: requiredString(result.queue_id, "Queue id"),
|
|
509
|
+
name: typeof result.queue_name === "string" ? result.queue_name : name
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
async listDispatchNamespaces(accountId) {
|
|
513
|
+
return (await this.request(`/accounts/${encodeURIComponent(accountId)}/workers/dispatch/namespaces`)).map((entry) => ({
|
|
514
|
+
id: requiredString(entry.namespace_id, "Dispatch namespace id"),
|
|
515
|
+
name: requiredString(entry.namespace_name, "Dispatch namespace name")
|
|
516
|
+
}));
|
|
517
|
+
}
|
|
518
|
+
async createDispatchNamespace(accountId, name) {
|
|
519
|
+
const result = await this.request(`/accounts/${encodeURIComponent(accountId)}/workers/dispatch/namespaces`, {
|
|
520
|
+
method: "POST",
|
|
521
|
+
body: JSON.stringify({ name })
|
|
522
|
+
});
|
|
523
|
+
return {
|
|
524
|
+
id: requiredString(result.namespace_id, "Dispatch namespace id"),
|
|
525
|
+
name: typeof result.namespace_name === "string" ? result.namespace_name : name
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
async listHyperdriveConfigs(accountId) {
|
|
529
|
+
const entries = [];
|
|
530
|
+
for (let page = 1;; page++) {
|
|
531
|
+
const result = await this.request(`/accounts/${encodeURIComponent(accountId)}/hyperdrive/configs?page=${page}&per_page=50`);
|
|
532
|
+
entries.push(...result);
|
|
533
|
+
if (result.length < 50) break;
|
|
534
|
+
}
|
|
535
|
+
return entries.map((entry) => ({
|
|
536
|
+
id: requiredString(entry.id, "Hyperdrive id"),
|
|
537
|
+
name: requiredString(entry.name, "Hyperdrive name")
|
|
538
|
+
}));
|
|
539
|
+
}
|
|
540
|
+
async getHyperdriveConfig(accountId, id) {
|
|
541
|
+
return parseHyperdriveConfig(await this.request(`/accounts/${encodeURIComponent(accountId)}/hyperdrive/configs/${encodeURIComponent(id)}`));
|
|
542
|
+
}
|
|
543
|
+
async createHyperdrive(accountId, name, connectionString) {
|
|
544
|
+
const parsed = parsePostgresConnectionString(connectionString);
|
|
545
|
+
const result = await this.request(`/accounts/${encodeURIComponent(accountId)}/hyperdrive/configs`, {
|
|
546
|
+
method: "POST",
|
|
547
|
+
body: JSON.stringify({
|
|
548
|
+
name,
|
|
549
|
+
origin: {
|
|
550
|
+
database: parsed.database,
|
|
551
|
+
host: parsed.host,
|
|
552
|
+
password: parsed.password,
|
|
553
|
+
port: parsed.port,
|
|
554
|
+
scheme: "postgresql",
|
|
555
|
+
user: parsed.user
|
|
556
|
+
},
|
|
557
|
+
...parsed.mtls ? { mtls: parsed.mtls } : {}
|
|
558
|
+
})
|
|
559
|
+
});
|
|
560
|
+
return {
|
|
561
|
+
id: requiredString(result.id, "Hyperdrive id"),
|
|
562
|
+
name: typeof result.name === "string" ? result.name : name
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
async listWorkerNames(accountId) {
|
|
566
|
+
return (await this.request(`/accounts/${encodeURIComponent(accountId)}/workers/scripts`)).map((entry) => requiredString(entry.id, "Worker name"));
|
|
567
|
+
}
|
|
568
|
+
async putKvValue(accountId, namespaceId, key, value) {
|
|
569
|
+
await this.request(`/accounts/${encodeURIComponent(accountId)}/storage/kv/namespaces/${encodeURIComponent(namespaceId)}/values/${encodeURIComponent(key)}`, {
|
|
570
|
+
method: "PUT",
|
|
571
|
+
body: value,
|
|
572
|
+
headers: { "Content-Type": "application/json" }
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
async getKvValue(accountId, namespaceId, key) {
|
|
576
|
+
const response = await this.#fetcher(`${this.apiBaseUrl}/accounts/${encodeURIComponent(accountId)}/storage/kv/namespaces/${encodeURIComponent(namespaceId)}/values/${encodeURIComponent(key)}`, {
|
|
577
|
+
headers: { Authorization: `Bearer ${this.#token}` },
|
|
578
|
+
signal: AbortSignal.timeout(3e4)
|
|
579
|
+
});
|
|
580
|
+
if (response.status === 404) return null;
|
|
581
|
+
if (!response.ok) throw new PlatformCloudflareApiError(`Cloudflare API error reading the installation manifest (HTTP ${response.status}).`, response.status);
|
|
582
|
+
return response.text();
|
|
583
|
+
}
|
|
584
|
+
async putR2Object(accountId, bucketName, key, value) {
|
|
585
|
+
const response = await this.#fetcher(`${this.apiBaseUrl}/accounts/${encodeURIComponent(accountId)}/r2/buckets/${encodeURIComponent(bucketName)}/objects/${encodeR2ObjectKey(key)}`, {
|
|
586
|
+
body: value,
|
|
587
|
+
headers: {
|
|
588
|
+
Authorization: `Bearer ${this.#token}`,
|
|
589
|
+
"Content-Type": "application/json"
|
|
590
|
+
},
|
|
591
|
+
method: "PUT",
|
|
592
|
+
signal: AbortSignal.timeout(3e4)
|
|
593
|
+
});
|
|
594
|
+
if (!response.ok) throw new PlatformCloudflareApiError(`Cloudflare API error writing the R2 ownership marker (HTTP ${response.status}).`, response.status);
|
|
595
|
+
}
|
|
596
|
+
async getR2Object(accountId, bucketName, key) {
|
|
597
|
+
const response = await this.#fetcher(`${this.apiBaseUrl}/accounts/${encodeURIComponent(accountId)}/r2/buckets/${encodeURIComponent(bucketName)}/objects/${encodeR2ObjectKey(key)}`, {
|
|
598
|
+
headers: { Authorization: `Bearer ${this.#token}` },
|
|
599
|
+
signal: AbortSignal.timeout(3e4)
|
|
600
|
+
});
|
|
601
|
+
if (response.status === 404) return null;
|
|
602
|
+
if (!response.ok) throw new PlatformCloudflareApiError(`Cloudflare API error reading the R2 ownership marker (HTTP ${response.status}).`, response.status);
|
|
603
|
+
return response.text();
|
|
604
|
+
}
|
|
605
|
+
async deleteR2Object(accountId, bucketName, key) {
|
|
606
|
+
try {
|
|
607
|
+
await this.deleteR2Objects(accountId, bucketName, [key]);
|
|
608
|
+
} catch (error) {
|
|
609
|
+
if (error instanceof PlatformCloudflareApiError && error.status === 404) return;
|
|
610
|
+
throw error;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
async getWorkerPlainTextBindings(accountId, workerName) {
|
|
614
|
+
const settings = await this.request(`/accounts/${encodeURIComponent(accountId)}/workers/scripts/${encodeURIComponent(workerName)}/settings`);
|
|
615
|
+
const bindings = {};
|
|
616
|
+
for (const binding of settings.bindings ?? []) if (binding.type === "plain_text" && typeof binding.name === "string" && typeof binding.text === "string") bindings[binding.name] = binding.text;
|
|
617
|
+
return bindings;
|
|
618
|
+
}
|
|
619
|
+
async deleteKvValue(accountId, namespaceId, key) {
|
|
620
|
+
try {
|
|
621
|
+
await this.request(`/accounts/${encodeURIComponent(accountId)}/storage/kv/namespaces/${encodeURIComponent(namespaceId)}/values/${encodeURIComponent(key)}`, { method: "DELETE" });
|
|
622
|
+
} catch (error) {
|
|
623
|
+
if (error instanceof PlatformCloudflareApiError && error.status === 404) return;
|
|
624
|
+
throw error;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
async emptyR2Bucket(accountId, bucketName, preservedKeys = /* @__PURE__ */ new Set()) {
|
|
628
|
+
let cursor = "";
|
|
629
|
+
const observedCursors = /* @__PURE__ */ new Set();
|
|
630
|
+
for (;;) {
|
|
631
|
+
const query = new URLSearchParams({ per_page: "1000" });
|
|
632
|
+
if (cursor) query.set("cursor", cursor);
|
|
633
|
+
const response = await this.#fetcher(`${this.apiBaseUrl}/accounts/${encodeURIComponent(accountId)}/r2/buckets/${encodeURIComponent(bucketName)}/objects?${query}`, {
|
|
634
|
+
headers: {
|
|
635
|
+
Accept: "application/json",
|
|
636
|
+
Authorization: `Bearer ${this.#token}`
|
|
637
|
+
},
|
|
638
|
+
signal: AbortSignal.timeout(3e4)
|
|
639
|
+
});
|
|
640
|
+
if (response.status === 404) return;
|
|
641
|
+
const envelope = await response.json();
|
|
642
|
+
if (!response.ok || envelope.success !== true || !Array.isArray(envelope.result)) throw new PlatformCloudflareApiError(`Cloudflare API error listing R2 objects: ${envelope.errors?.map((error) => error.message).filter(Boolean).join(", ") || `HTTP ${response.status}`}`, response.status);
|
|
643
|
+
const keys = envelope.result.map((object) => requiredString(object.key, "R2 object key")).filter((key) => !preservedKeys.has(key));
|
|
644
|
+
for (let offset = 0; offset < keys.length; offset += 1e3) await this.deleteR2Objects(accountId, bucketName, keys.slice(offset, offset + 1e3));
|
|
645
|
+
const nextCursor = envelope.result_info?.cursor ?? "";
|
|
646
|
+
if (!nextCursor) return;
|
|
647
|
+
if (observedCursors.has(nextCursor)) throw new Error("platform: Cloudflare returned a repeated R2 object cursor.");
|
|
648
|
+
observedCursors.add(nextCursor);
|
|
649
|
+
cursor = nextCursor;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
async deleteR2Objects(accountId, bucketName, keys) {
|
|
653
|
+
if (keys.length === 0) return;
|
|
654
|
+
const deleted = await this.request(`/accounts/${encodeURIComponent(accountId)}/r2/buckets/${encodeURIComponent(bucketName)}/objects`, {
|
|
655
|
+
body: JSON.stringify(keys),
|
|
656
|
+
method: "DELETE"
|
|
657
|
+
});
|
|
658
|
+
const deletedKeys = new Set(deleted.map((entry) => requiredString(entry.key, "deleted R2 object key")));
|
|
659
|
+
if (keys.find((key) => !deletedKeys.has(key)) !== void 0) throw new Error("platform: Cloudflare did not confirm every requested R2 object deletion.");
|
|
660
|
+
}
|
|
661
|
+
async deleteResource(accountId, kind, id, zoneId) {
|
|
662
|
+
const account = encodeURIComponent(accountId);
|
|
663
|
+
const encoded = encodeURIComponent(id);
|
|
664
|
+
const path = kind === "kv-namespace" ? `/accounts/${account}/storage/kv/namespaces/${encoded}` : kind === "dns-record" ? zoneId ? `/zones/${encodeURIComponent(zoneId)}/dns_records/${encoded}` : null : kind === "ai-gateway" ? `/accounts/${account}/ai-gateway/gateways/${encoded}` : kind === "r2-bucket" ? `/accounts/${account}/r2/buckets/${encoded}` : kind === "queue" ? `/accounts/${account}/queues/${encoded}` : kind === "dispatch-namespace" ? `/accounts/${account}/workers/dispatch/namespaces/${encoded}` : kind === "hyperdrive" ? `/accounts/${account}/hyperdrive/configs/${encoded}` : kind === "worker" ? `/accounts/${account}/workers/scripts/${encoded}` : kind === "zone" ? `/zones/${encoded}` : null;
|
|
665
|
+
if (!path) throw new Error(`platform: Unsupported Cloudflare deletion kind ${kind}.`);
|
|
666
|
+
try {
|
|
667
|
+
await this.request(path, { method: "DELETE" });
|
|
668
|
+
} catch (error) {
|
|
669
|
+
if (error instanceof PlatformCloudflareApiError && error.status === 404) return;
|
|
670
|
+
throw error;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
async removeRoutes(_accountId, _zoneId, tracked) {
|
|
674
|
+
if (tracked.routes.length > 0 || tracked.customDomains.length > 0) throw new Error("platform: Cloudflare does not support conditional Worker traffic deletion. Retaining routes and custom domains for explicit dashboard cleanup.");
|
|
675
|
+
}
|
|
676
|
+
async setWorkerSubdomain(accountId, workerName, enabled) {
|
|
677
|
+
try {
|
|
678
|
+
await this.request(`/accounts/${encodeURIComponent(accountId)}/workers/scripts/${encodeURIComponent(workerName)}/subdomain`, {
|
|
679
|
+
body: JSON.stringify({
|
|
680
|
+
enabled,
|
|
681
|
+
previews_enabled: enabled
|
|
682
|
+
}),
|
|
683
|
+
headers: { "Cloudflare-Workers-Script-Api-Date": "2025-08-01" },
|
|
684
|
+
method: "POST"
|
|
685
|
+
});
|
|
686
|
+
} catch (error) {
|
|
687
|
+
if (!enabled && error instanceof PlatformCloudflareApiError && error.status === 404) return;
|
|
688
|
+
throw error;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
};
|
|
692
|
+
function encodeR2ObjectKey(key) {
|
|
693
|
+
return key.split("/").map(encodeURIComponent).join("/");
|
|
694
|
+
}
|
|
695
|
+
function requiredString(value, label) {
|
|
696
|
+
if (typeof value !== "string" || value === "") throw new Error(`platform: ${label} is missing from the Cloudflare response.`);
|
|
697
|
+
return value;
|
|
698
|
+
}
|
|
699
|
+
function parseZone(value) {
|
|
700
|
+
return {
|
|
701
|
+
id: requiredString(value.id, "Zone id"),
|
|
702
|
+
name: requiredString(value.name, "Zone name"),
|
|
703
|
+
nameServers: Array.isArray(value.name_servers) ? value.name_servers.filter((entry) => typeof entry === "string") : [],
|
|
704
|
+
status: requiredString(value.status, "Zone status")
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
function parseDnsRecord(value) {
|
|
708
|
+
return {
|
|
709
|
+
content: requiredString(value.content, "DNS record content"),
|
|
710
|
+
id: requiredString(value.id, "DNS record id"),
|
|
711
|
+
name: requiredString(value.name, "DNS record name"),
|
|
712
|
+
proxied: value.proxied === true,
|
|
713
|
+
type: requiredString(value.type, "DNS record type")
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
function parsePostgresConnectionString(value) {
|
|
717
|
+
let url;
|
|
718
|
+
try {
|
|
719
|
+
url = new URL(value);
|
|
720
|
+
} catch {
|
|
721
|
+
throw new Error("platform: DATABASE_URL must be a PostgreSQL connection string.");
|
|
722
|
+
}
|
|
723
|
+
if (url.protocol !== "postgres:" && url.protocol !== "postgresql:" || !url.hostname) throw new Error("platform: DATABASE_URL must use postgres:// or postgresql://.");
|
|
724
|
+
const database = decodeURIComponent(url.pathname.replace(/^\//, ""));
|
|
725
|
+
if (!url.username || !url.password || !database) throw new Error("platform: DATABASE_URL must include username, password, host, and database.");
|
|
726
|
+
const port = url.port ? Number(url.port) : 5432;
|
|
727
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) throw new Error("platform: DATABASE_URL has an invalid PostgreSQL port.");
|
|
728
|
+
const sslmode = url.searchParams.get("sslmode");
|
|
729
|
+
let mtls;
|
|
730
|
+
if (sslmode) {
|
|
731
|
+
if (sslmode !== "require" && sslmode !== "verify-ca" && sslmode !== "verify-full") throw new Error("platform: Unsupported sslmode. Cloudflare Hyperdrive supports \"require\", \"verify-ca\", or \"verify-full\".");
|
|
732
|
+
mtls = { sslmode };
|
|
733
|
+
}
|
|
734
|
+
return {
|
|
735
|
+
database,
|
|
736
|
+
host: url.hostname,
|
|
737
|
+
...mtls ? { mtls } : {},
|
|
738
|
+
password: decodeURIComponent(url.password),
|
|
739
|
+
port,
|
|
740
|
+
user: decodeURIComponent(url.username)
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
function hyperdriveOriginFromConnectionString(value) {
|
|
744
|
+
const parsed = parsePostgresConnectionString(value);
|
|
745
|
+
return {
|
|
746
|
+
database: parsed.database,
|
|
747
|
+
host: parsed.host.toLowerCase(),
|
|
748
|
+
port: parsed.port,
|
|
749
|
+
scheme: "postgresql",
|
|
750
|
+
user: parsed.user
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
function hyperdriveOriginConfigHash(origin) {
|
|
754
|
+
return createHash("sha256").update(JSON.stringify({
|
|
755
|
+
database: origin.database,
|
|
756
|
+
host: origin.host.toLowerCase(),
|
|
757
|
+
port: origin.port,
|
|
758
|
+
scheme: "postgresql",
|
|
759
|
+
user: origin.user
|
|
760
|
+
})).digest("hex");
|
|
761
|
+
}
|
|
762
|
+
function hyperdriveOriginMatches(live, expected) {
|
|
763
|
+
return live.database === expected.database && live.host.toLowerCase() === expected.host.toLowerCase() && live.port === expected.port && live.scheme === expected.scheme && (live.user === null || live.user === expected.user);
|
|
764
|
+
}
|
|
765
|
+
function parseHyperdriveConfig(value) {
|
|
766
|
+
const origin = value.origin;
|
|
767
|
+
if (!origin || typeof origin !== "object" || Array.isArray(origin)) throw new Error("platform: Cloudflare Hyperdrive config has no origin metadata.");
|
|
768
|
+
const record = origin;
|
|
769
|
+
const scheme = requiredString(record.scheme, "Hyperdrive origin scheme").toLowerCase();
|
|
770
|
+
if (scheme !== "postgres" && scheme !== "postgresql") throw new Error(`platform: Unsupported Hyperdrive origin scheme ${JSON.stringify(scheme)}.`);
|
|
771
|
+
const port = typeof record.port === "number" ? record.port : Number(record.port);
|
|
772
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) throw new Error("platform: Cloudflare Hyperdrive config has an invalid origin port.");
|
|
773
|
+
return {
|
|
774
|
+
id: requiredString(value.id, "Hyperdrive id"),
|
|
775
|
+
name: requiredString(value.name, "Hyperdrive name"),
|
|
776
|
+
origin: {
|
|
777
|
+
database: requiredString(record.database, "Hyperdrive origin database"),
|
|
778
|
+
host: requiredString(record.host, "Hyperdrive origin host").toLowerCase(),
|
|
779
|
+
port,
|
|
780
|
+
scheme: "postgresql",
|
|
781
|
+
user: typeof record.user === "string" && record.user ? record.user : null
|
|
782
|
+
}
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
//#endregion
|
|
786
|
+
//#region src/cli/platform-plan.ts
|
|
787
|
+
async function createPlatformInstallPlan(client, input, existing, options) {
|
|
788
|
+
const account = resolveAccount(await client.listAccounts(), input.accountId);
|
|
789
|
+
if (existing && existing.account.id !== account.id) throw new Error(`platform: Refusing to resume installation ${existing.id} in a different Cloudflare account.`);
|
|
790
|
+
const installationSlug = normalizeInstallationSlug(input.installationSlug);
|
|
791
|
+
const installationId = existing?.id ?? `${installationSlug}-${createHash("sha256").update(JSON.stringify({
|
|
792
|
+
accountId: account.id,
|
|
793
|
+
applicationDomain: input.applicationDomain,
|
|
794
|
+
controlPlaneDomain: input.controlPlaneDomain,
|
|
795
|
+
installationSlug,
|
|
796
|
+
zoneName: input.zoneName
|
|
797
|
+
})).digest("hex").slice(0, 12)}`;
|
|
798
|
+
const accountSubdomain = await client.getWorkersSubdomain(account.id);
|
|
799
|
+
const workersDev = existing?.deployment.application.workersDev ?? (input.workersDev ? {
|
|
800
|
+
subdomain: accountSubdomain,
|
|
801
|
+
installationId
|
|
802
|
+
} : void 0);
|
|
803
|
+
if (workersDev && workersDev.subdomain !== accountSubdomain) throw new Error("platform: The account workers.dev subdomain changed. Restore it before continuing.");
|
|
804
|
+
const testing = !!workersDev && !input.zoneName;
|
|
805
|
+
const deployment = createPlatformDeploymentManifest({
|
|
806
|
+
application: {
|
|
807
|
+
baseDomain: testing ? `${accountSubdomain}.workers.dev` : input.applicationDomain,
|
|
808
|
+
dedicatedZone: input.dedicatedZone,
|
|
809
|
+
zoneName: input.zoneName,
|
|
810
|
+
...workersDev ? { workersDev } : {},
|
|
811
|
+
...existing?.deployment.application.zoneId ? { zoneId: existing.deployment.application.zoneId } : {}
|
|
812
|
+
},
|
|
813
|
+
controlPlane: {
|
|
814
|
+
apiHostname: input.controlPlaneDomain || null,
|
|
815
|
+
dashboardHostname: null,
|
|
816
|
+
proxyHostname: null
|
|
817
|
+
},
|
|
818
|
+
displayName: input.displayName,
|
|
819
|
+
installationSlug,
|
|
820
|
+
resources: existing?.deployment.resources ?? createPlatformResourceNames(installationSlug)
|
|
821
|
+
});
|
|
822
|
+
if (existing && JSON.stringify(existing.deployment) !== JSON.stringify(deployment)) throw new Error(`platform: --resume inputs do not match installation ${existing.id}. Use its recorded configuration.`);
|
|
823
|
+
const names = deployment.resources;
|
|
824
|
+
const wildcardHostname = `*.${deployment.application.baseDomain}`;
|
|
825
|
+
const zone = testing ? null : await client.findZone(account.id, deployment.application.zoneName);
|
|
826
|
+
if (!testing && deployment.application.baseDomain !== deployment.application.zoneName) {
|
|
827
|
+
const certificateHelp = `Use the zone apex ${deployment.application.zoneName} as --application-domain, or provision an active ${wildcardHostname} edge certificate in the existing zone before installing. Cloudflare Advanced Certificate Manager is a paid add-on; an existing custom wildcard certificate also works. Manage certificates at https://dash.cloudflare.com/${account.id}/${deployment.application.zoneName}/ssl-tls/edge-certificates. Certificate inspection requires Zone > SSL and Certificates > Read (or Edit) on this zone.`;
|
|
828
|
+
if (!zone || zone.status !== "active") throw new Error(`platform: Nested application domain ${deployment.application.baseDomain} needs an active zone and wildcard certificate. ${certificateHelp}`);
|
|
829
|
+
let covered;
|
|
830
|
+
try {
|
|
831
|
+
covered = await client.hasActiveCertificateForHostname(zone.id, wildcardHostname);
|
|
832
|
+
} catch (error) {
|
|
833
|
+
throw new Error(`platform: Could not verify edge certificate coverage for ${wildcardHostname}. ${certificateHelp}`, { cause: error });
|
|
834
|
+
}
|
|
835
|
+
if (!covered) throw new Error(`platform: No active edge certificate covers ${wildcardHostname}. Universal SSL for ${deployment.application.zoneName} does not cover these deeper application hostnames. ${certificateHelp}`);
|
|
836
|
+
}
|
|
837
|
+
let needsAiGatewayCredential = false;
|
|
838
|
+
const gatewayInventory = client.listAiGateways(account.id).catch((error) => {
|
|
839
|
+
if (options?.allowUnverifiedAiGateway && error instanceof PlatformCloudflareApiError && error.status === 403) {
|
|
840
|
+
needsAiGatewayCredential = true;
|
|
841
|
+
return [];
|
|
842
|
+
}
|
|
843
|
+
throw error;
|
|
844
|
+
});
|
|
845
|
+
const [workersSubdomain, aiGateways, routingKv, isrKv, legacyKv, r2Buckets, usageQueues, cronQueues, dispatchNamespaces, hyperdrives, workers] = await Promise.all([
|
|
846
|
+
Promise.resolve(accountSubdomain),
|
|
847
|
+
gatewayInventory,
|
|
848
|
+
client.listKvNamespaces(account.id, names.routingKv),
|
|
849
|
+
client.listKvNamespaces(account.id, names.isrCacheKv),
|
|
850
|
+
client.listKvNamespaces(account.id, names.legacyAssetsKv),
|
|
851
|
+
client.listR2Buckets(account.id),
|
|
852
|
+
client.listQueues(account.id, names.usageQueue),
|
|
853
|
+
client.listQueues(account.id, names.cronQueue),
|
|
854
|
+
client.listDispatchNamespaces(account.id),
|
|
855
|
+
client.listHyperdriveConfigs(account.id),
|
|
856
|
+
client.listWorkerNames(account.id)
|
|
857
|
+
]);
|
|
858
|
+
const dnsRecords = zone ? await client.listDnsRecords(zone.id, wildcardHostname) : [];
|
|
859
|
+
const remoteOwnership = existing ? await readRemoteOwnership(client, account.id, routingKv, existing) : null;
|
|
860
|
+
const intentProofs = /* @__PURE__ */ new Set();
|
|
861
|
+
const ambiguousResources = /* @__PURE__ */ new Set();
|
|
862
|
+
if (existing) {
|
|
863
|
+
const bucket = r2Buckets.find((resource) => resource.name === names.assetsBucket);
|
|
864
|
+
const recordedBucket = existing.resources.find((resource) => resource.key === "assets-bucket" && resource.kind === "r2-bucket" && resource.ownership === "created" && resource.state === "ready");
|
|
865
|
+
if (bucket && recordedBucket?.id === bucket.id && recordedBucket.name === bucket.name) {
|
|
866
|
+
if (!matchesResourceMarker(await client.getR2Object(account.id, bucket.name, PLATFORM_R2_OWNERSHIP_MARKER_KEY), existing, "assets-bucket", "r2-bucket", bucket) || !matchesRecordedR2Fingerprint(recordedBucket.configHash, bucket)) ambiguousResources.add("assets-bucket");
|
|
867
|
+
}
|
|
868
|
+
if (bucket && hasUnrecordedProvisionIntent(existing, "assets-bucket", "r2-bucket", bucket.name)) {
|
|
869
|
+
if (matchesResourceMarker(await client.getR2Object(account.id, bucket.name, PLATFORM_R2_OWNERSHIP_MARKER_KEY), existing, "assets-bucket", "r2-bucket", bucket)) intentProofs.add("assets-bucket");
|
|
870
|
+
}
|
|
871
|
+
for (const [key, name] of [
|
|
872
|
+
["api-worker", names.apiWorker],
|
|
873
|
+
["proxy-worker", names.proxyWorker],
|
|
874
|
+
["tail-worker", names.tailWorker],
|
|
875
|
+
["dispatch-worker", names.dispatchWorker]
|
|
876
|
+
]) if (workers.includes(name) && hasUnrecordedProvisionIntent(existing, key, "worker", name)) {
|
|
877
|
+
if (matchesResourceMarker((await client.getWorkerPlainTextBindings(account.id, name)).PLATFORM_OWNERSHIP_MARKER, existing, key, "worker", {
|
|
878
|
+
id: name,
|
|
879
|
+
name
|
|
880
|
+
})) intentProofs.add(key);
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
if (zone) {
|
|
884
|
+
const expectedPatterns = new Set(createApplicationRoutes(deployment.application).map((route) => route.pattern));
|
|
885
|
+
const expectedWorker = deployment.resources.dispatchWorker;
|
|
886
|
+
const ownedWorker = intentProofs.has("dispatch-worker") || existing?.resources.some((resource) => resource.key === "dispatch-worker" && resource.kind === "worker" && resource.name === expectedWorker && resource.state === "ready");
|
|
887
|
+
const conflicts = (await client.listWorkerRoutes(zone.id)).filter((route) => expectedPatterns.has(route.pattern) && (route.script !== expectedWorker || !ownedWorker));
|
|
888
|
+
if (conflicts.length > 0) {
|
|
889
|
+
const conflict = conflicts[0];
|
|
890
|
+
throw new Error(`platform: Application route ${JSON.stringify(conflict.pattern)} is already assigned to ${JSON.stringify(conflict.script ?? "no Worker")}. Choose another application domain.`);
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
if (deployment.controlPlane.apiHostname) {
|
|
894
|
+
const apiHostname = deployment.controlPlane.apiHostname;
|
|
895
|
+
const [workerDomains, controlPlaneZone] = await Promise.all([client.listWorkerDomains(account.id), client.findZoneForHostname(account.id, apiHostname)]);
|
|
896
|
+
const matchingDomain = workerDomains.find((domain) => domain.hostname === apiHostname);
|
|
897
|
+
const expectedWorker = deployment.resources.apiWorker;
|
|
898
|
+
if (matchingDomain && matchingDomain.service !== expectedWorker) throw new Error(`platform: Custom API hostname ${JSON.stringify(apiHostname)} is already assigned to Worker ${JSON.stringify(matchingDomain.service)}. Choose another hostname.`);
|
|
899
|
+
const records = controlPlaneZone ? await client.listDnsRecords(controlPlaneZone.id, apiHostname) : [];
|
|
900
|
+
const ownedApiWorker = intentProofs.has("api-worker") || existing?.resources.some((resource) => resource.key === "api-worker" && resource.kind === "worker" && resource.name === expectedWorker && resource.state === "ready");
|
|
901
|
+
if (records.length > 0 && (!matchingDomain || !ownedApiWorker)) throw new Error(`platform: Custom API hostname ${JSON.stringify(apiHostname)} already has a DNS record that installation would replace. Choose another hostname or remove the record explicitly.`);
|
|
902
|
+
}
|
|
903
|
+
const actions = [];
|
|
904
|
+
if (!testing) {
|
|
905
|
+
const recordedZone = existing?.resources.find((resource) => resource.key === "zone");
|
|
906
|
+
const remotelyOwnedZone = remoteOwnership?.resources.some((resource) => resource.key === "zone" && resource.id === zone?.id);
|
|
907
|
+
const repairCreatedZone = options?.repairMissing && recordedZone?.ownership === "created" && !zone;
|
|
908
|
+
if (recordedZone && !repairCreatedZone && (!zone || recordedZone.id !== zone.id || recordedZone.kind !== "zone")) throw new Error(`platform: Recorded zone ${recordedZone.name} (${recordedZone.id}) no longer matches the Cloudflare account.`);
|
|
909
|
+
actions.push({
|
|
910
|
+
action: repairCreatedZone ? "create" : recordedZone || zone && remotelyOwnedZone ? "reuse" : zone ? "adopt" : "create",
|
|
911
|
+
id: zone?.id ?? null,
|
|
912
|
+
key: "zone",
|
|
913
|
+
kind: "zone",
|
|
914
|
+
name: deployment.application.zoneName,
|
|
915
|
+
reason: repairCreatedZone ? "Recreate the missing installer-owned zone." : recordedZone || remotelyOwnedZone ? "Reuse the recorded zone." : zone ? "Use the existing zone without taking ownership." : "Create the requested zone."
|
|
916
|
+
});
|
|
917
|
+
addDnsPlan(actions, existing, wildcardHostname, dnsRecords, options);
|
|
918
|
+
}
|
|
919
|
+
if (needsAiGatewayCredential) actions.push({
|
|
920
|
+
action: "inspect",
|
|
921
|
+
id: null,
|
|
922
|
+
key: "ai-gateway",
|
|
923
|
+
kind: "ai-gateway",
|
|
924
|
+
name: names.aiGateway,
|
|
925
|
+
reason: "AI Gateway access will be verified with the runtime token before provisioning (AI Gateway: Edit required)."
|
|
926
|
+
});
|
|
927
|
+
else addNamedPlan(actions, existing, "ai-gateway", "ai-gateway", names.aiGateway, aiGateways.filter((resource) => resource.id === names.aiGateway), "reuse", options, remoteOwnership, intentProofs);
|
|
928
|
+
addNamedPlan(actions, existing, "routing-kv", "kv-namespace", names.routingKv, routingKv, "reuse", options, remoteOwnership, intentProofs);
|
|
929
|
+
addNamedPlan(actions, existing, "isr-cache-kv", "kv-namespace", names.isrCacheKv, isrKv, "reuse", options, remoteOwnership);
|
|
930
|
+
addNamedPlan(actions, existing, "legacy-assets-kv", "kv-namespace", names.legacyAssetsKv, legacyKv, "reuse", options, remoteOwnership);
|
|
931
|
+
addNamedPlan(actions, existing, "assets-bucket", "r2-bucket", names.assetsBucket, r2Buckets.filter((resource) => resource.name === names.assetsBucket), "reuse", options, remoteOwnership, intentProofs, ambiguousResources);
|
|
932
|
+
addNamedPlan(actions, existing, "usage-queue", "queue", names.usageQueue, usageQueues, "reuse", options, remoteOwnership);
|
|
933
|
+
addNamedPlan(actions, existing, "cron-queue", "queue", names.cronQueue, cronQueues, "reuse", options, remoteOwnership);
|
|
934
|
+
addNamedPlan(actions, existing, "dispatch-namespace", "dispatch-namespace", names.dispatchNamespace, dispatchNamespaces.filter((resource) => resource.name === names.dispatchNamespace), "reuse", options, remoteOwnership);
|
|
935
|
+
addNamedPlan(actions, existing, "hyperdrive", "hyperdrive", names.hyperdrive, hyperdrives.filter((resource) => resource.name === names.hyperdrive), "reuse", options, remoteOwnership);
|
|
936
|
+
for (const [key, name] of [
|
|
937
|
+
["api-worker", names.apiWorker],
|
|
938
|
+
["proxy-worker", names.proxyWorker],
|
|
939
|
+
["tail-worker", names.tailWorker],
|
|
940
|
+
["dispatch-worker", names.dispatchWorker]
|
|
941
|
+
]) addNamedPlan(actions, existing, key, "worker", name, workers.includes(name) ? [{
|
|
942
|
+
id: name,
|
|
943
|
+
name
|
|
944
|
+
}] : [], "update", options, remoteOwnership, intentProofs);
|
|
945
|
+
const resolvedResources = { ...deployment.resources };
|
|
946
|
+
for (const action of actions) {
|
|
947
|
+
const resourceKey = RESOURCE_NAME_KEY_BY_ACTION[action.key];
|
|
948
|
+
if (resourceKey && action.name !== resolvedResources[resourceKey]) resolvedResources[resourceKey] = action.name;
|
|
949
|
+
}
|
|
950
|
+
const resolvedDeployment = createPlatformDeploymentManifest({
|
|
951
|
+
...deployment,
|
|
952
|
+
resources: resolvedResources
|
|
953
|
+
});
|
|
954
|
+
return {
|
|
955
|
+
account,
|
|
956
|
+
actions,
|
|
957
|
+
apiUrl: resolvedDeployment.controlPlane.apiHostname ? `https://${deployment.controlPlane.apiHostname}` : `https://${resolvedDeployment.resources.apiWorker}.${workersSubdomain}.workers.dev`,
|
|
958
|
+
deployment: resolvedDeployment,
|
|
959
|
+
installationId,
|
|
960
|
+
proxyUrl: resolvedDeployment.controlPlane.proxyHostname ? `https://${deployment.controlPlane.proxyHostname}` : `https://${resolvedDeployment.resources.proxyWorker}.${workersSubdomain}.workers.dev`,
|
|
961
|
+
workersSubdomain,
|
|
962
|
+
zone
|
|
963
|
+
};
|
|
964
|
+
}
|
|
965
|
+
async function revalidatePlatformInstallAction(client, original, manifest, action, options) {
|
|
966
|
+
const deployment = original.deployment;
|
|
967
|
+
const current = (await createPlatformInstallPlan(client, {
|
|
968
|
+
accountId: original.account.id,
|
|
969
|
+
applicationDomain: deployment.application.baseDomain,
|
|
970
|
+
controlPlaneDomain: deployment.controlPlane.apiHostname ?? "",
|
|
971
|
+
dedicatedZone: deployment.application.dedicatedZone,
|
|
972
|
+
displayName: deployment.displayName,
|
|
973
|
+
installationSlug: deployment.installationSlug,
|
|
974
|
+
zoneName: deployment.application.zoneName
|
|
975
|
+
}, manifest, options)).actions.find((entry) => entry.key === action.key);
|
|
976
|
+
if (!current || current.kind !== action.kind || current.name !== action.name || current.action !== action.action) throw new Error(`platform: Cloudflare changed after the installation plan was approved for ${action.kind} ${JSON.stringify(action.name)}. Review a new plan before retrying.`);
|
|
977
|
+
if (action.kind === "worker" && action.action === "update") {
|
|
978
|
+
if (!matchesResourceMarker((await client.getWorkerPlainTextBindings(original.account.id, action.name)).PLATFORM_OWNERSHIP_MARKER, manifest, action.key, "worker", {
|
|
979
|
+
id: action.name,
|
|
980
|
+
name: action.name
|
|
981
|
+
})) throw new Error(`platform: Cloudflare Worker ${JSON.stringify(action.name)} no longer has this installation's ownership marker. Review a new plan before retrying.`);
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
function resourceSlugForManifest(manifest) {
|
|
985
|
+
const apiWorker = manifest.deployment.resources.apiWorker;
|
|
986
|
+
if (!apiWorker.startsWith("void-") || !apiWorker.endsWith("-api")) return manifest.deployment.installationSlug;
|
|
987
|
+
const resourceSlug = apiWorker.slice(5, -4);
|
|
988
|
+
const suffix = resourceSlug.slice(resourceSlug.lastIndexOf("-") + 1);
|
|
989
|
+
if (!/^(?:[a-f0-9]{12}|[a-f0-9]{32})$/.test(suffix)) return manifest.deployment.installationSlug;
|
|
990
|
+
return resourceSlug;
|
|
991
|
+
}
|
|
992
|
+
function addDnsPlan(actions, existing, name, records, options) {
|
|
993
|
+
const compatible = records.filter((record) => record.proxied);
|
|
994
|
+
if (records.some((record) => !record.proxied)) throw new Error(`platform: DNS name ${JSON.stringify(name)} contains an unproxied record. Proxy every record at this wildcard name through Cloudflare or choose another application domain.`);
|
|
995
|
+
const recorded = existing?.resources.find((resource) => resource.key === "application-dns");
|
|
996
|
+
const remote = recorded ? compatible.find((record) => record.id === recorded.id) : compatible.sort((left, right) => left.id.localeCompare(right.id))[0];
|
|
997
|
+
if (recorded) {
|
|
998
|
+
if (remote && recorded.id === remote.id && recorded.kind === "dns-record") {
|
|
999
|
+
actions.push({
|
|
1000
|
+
action: "reuse",
|
|
1001
|
+
id: remote.id,
|
|
1002
|
+
key: "application-dns",
|
|
1003
|
+
kind: "dns-record",
|
|
1004
|
+
name,
|
|
1005
|
+
reason: "Reuse the recorded wildcard application DNS record."
|
|
1006
|
+
});
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
if (!remote && options?.repairMissing && recorded.ownership === "created") {
|
|
1010
|
+
actions.push({
|
|
1011
|
+
action: "create",
|
|
1012
|
+
id: null,
|
|
1013
|
+
key: "application-dns",
|
|
1014
|
+
kind: "dns-record",
|
|
1015
|
+
name,
|
|
1016
|
+
reason: "Recreate the missing installer-owned wildcard application DNS record."
|
|
1017
|
+
});
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1020
|
+
throw new Error(`platform: Recorded DNS record ${JSON.stringify(recorded.name)} (${recorded.id}) no longer matches the Cloudflare zone.`);
|
|
1021
|
+
}
|
|
1022
|
+
actions.push({
|
|
1023
|
+
action: remote ? "adopt" : "create",
|
|
1024
|
+
id: remote?.id ?? null,
|
|
1025
|
+
key: "application-dns",
|
|
1026
|
+
kind: "dns-record",
|
|
1027
|
+
name,
|
|
1028
|
+
reason: remote ? "Use the existing compatible wildcard DNS record without taking ownership." : "Create an installer-owned proxied wildcard DNS record for application traffic."
|
|
1029
|
+
});
|
|
1030
|
+
}
|
|
1031
|
+
function resolveAccount(accounts, selector) {
|
|
1032
|
+
const matches = selector ? accounts.filter((account) => account.id === selector || account.name.toLowerCase() === selector.toLowerCase()) : accounts;
|
|
1033
|
+
if (matches.length !== 1) throw new Error(matches.length === 0 ? `platform: Cloudflare account ${JSON.stringify(selector)} is not available to this token.` : "platform: Select a Cloudflare account with --account <id>.");
|
|
1034
|
+
return matches[0];
|
|
1035
|
+
}
|
|
1036
|
+
function addNamedPlan(actions, existing, key, kind, name, matches, existingAction = "reuse", options, remoteOwnership, intentProofs = /* @__PURE__ */ new Set(), ambiguousResources = /* @__PURE__ */ new Set()) {
|
|
1037
|
+
if (matches.length > 1) throw new Error(`platform: More than one ${kind} named ${JSON.stringify(name)} exists.`);
|
|
1038
|
+
const remote = matches[0];
|
|
1039
|
+
const recorded = existing?.resources.find((resource) => resource.key === key);
|
|
1040
|
+
if (remote) {
|
|
1041
|
+
if (existing && ambiguousResources.has(key)) {
|
|
1042
|
+
if (!isLegacyUniqueProvisionAttempt(existing, key, kind, name)) throw new Error(`platform: Ownership of ${kind} ${JSON.stringify(name)} could not be verified. The resource was left untouched. Restore this installation's ownership record before resuming, or choose another installation name for a new platform.`);
|
|
1043
|
+
actions.push({
|
|
1044
|
+
action: "create",
|
|
1045
|
+
id: null,
|
|
1046
|
+
key,
|
|
1047
|
+
kind,
|
|
1048
|
+
name: interruptedProvisionRetryName(existing, key, kind, name),
|
|
1049
|
+
reason: "Retain the ambiguously owned resource and create a fresh installer-scoped replacement."
|
|
1050
|
+
});
|
|
1051
|
+
return;
|
|
1052
|
+
}
|
|
1053
|
+
const remotelyOwned = remoteOwnership?.resources.some((resource) => resource.key === key && resource.kind === kind && resource.id === remote.id && resource.name === name && resource.ownership === "created");
|
|
1054
|
+
const markedProvision = existing !== null && hasUnrecordedProvisionIntent(existing, key, kind, name) && intentProofs.has(key);
|
|
1055
|
+
const ambiguousProvision = existing !== null && options?.authoritativeProvisionIntents === true && isLegacyUniqueProvisionAttempt(existing, key, kind, name) && hasAmbiguousProvisionIntent(existing, key, kind, remote.id, name);
|
|
1056
|
+
if ((!recorded || recorded.id !== remote.id || recorded.kind !== kind) && !remotelyOwned && !markedProvision && !ambiguousProvision) throw new Error(`platform: ${kind} ${JSON.stringify(name)} already exists but is not owned by this installation. Resume the owning installation, or choose another installation name. Nothing was overwritten.`);
|
|
1057
|
+
if (ambiguousProvision && !markedProvision && !remotelyOwned) {
|
|
1058
|
+
actions.push({
|
|
1059
|
+
action: "create",
|
|
1060
|
+
id: null,
|
|
1061
|
+
key,
|
|
1062
|
+
kind,
|
|
1063
|
+
name: interruptedProvisionRetryName(existing, key, kind, name),
|
|
1064
|
+
reason: "Leave the ambiguous same-name resource untouched and create a fresh installer-scoped replacement."
|
|
1065
|
+
});
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
1068
|
+
actions.push({
|
|
1069
|
+
action: existingAction,
|
|
1070
|
+
id: remote.id,
|
|
1071
|
+
key,
|
|
1072
|
+
kind,
|
|
1073
|
+
name,
|
|
1074
|
+
reason: markedProvision ? "Recover the resource with its matching ownership marker from an interrupted provisioning attempt." : existingAction === "update" ? "Deploy the current platform version." : "Reuse the recorded resource."
|
|
1075
|
+
});
|
|
1076
|
+
return;
|
|
1077
|
+
}
|
|
1078
|
+
if (recorded && recorded.state === "ready") {
|
|
1079
|
+
if (options?.repairMissing && recorded.ownership === "created") {
|
|
1080
|
+
actions.push({
|
|
1081
|
+
action: "create",
|
|
1082
|
+
id: null,
|
|
1083
|
+
key,
|
|
1084
|
+
kind,
|
|
1085
|
+
name,
|
|
1086
|
+
reason: `Recreate the missing installer-owned ${kind}.`
|
|
1087
|
+
});
|
|
1088
|
+
return;
|
|
1089
|
+
}
|
|
1090
|
+
if (options?.repairMissing && recorded.ownership !== "created") throw new Error(`platform: Recorded ${recorded.ownership} ${kind} ${JSON.stringify(name)} (${recorded.id}) is missing. Repair will not recreate a resource the installer does not own.`);
|
|
1091
|
+
throw new Error(`platform: Recorded ${kind} ${JSON.stringify(name)} (${recorded.id}) is missing. Run \`void platform repair ${existing.id}\` to reconcile it.`);
|
|
1092
|
+
}
|
|
1093
|
+
actions.push({
|
|
1094
|
+
action: "create",
|
|
1095
|
+
id: null,
|
|
1096
|
+
key,
|
|
1097
|
+
kind,
|
|
1098
|
+
name,
|
|
1099
|
+
reason: `Create an installer-owned ${kind}.`
|
|
1100
|
+
});
|
|
1101
|
+
}
|
|
1102
|
+
function hasUnrecordedProvisionIntent(manifest, key, kind, name) {
|
|
1103
|
+
return !manifest.resources.some((resource) => resource.key === key && resource.state === "ready") && manifest.steps.some((step) => isProvisionIntentStep(step.key, key) && step.resultHash === platformProvisionIntentHash(manifest, key, kind, name));
|
|
1104
|
+
}
|
|
1105
|
+
function hasAmbiguousProvisionIntent(manifest, key, kind, remoteId, name) {
|
|
1106
|
+
const recorded = manifest.resources.find((resource) => resource.key === key);
|
|
1107
|
+
if (recorded?.id === remoteId && recorded.kind === kind && recorded.name === name) return false;
|
|
1108
|
+
return manifest.steps.some((step) => isProvisionIntentStep(step.key, key) && step.resultHash === platformProvisionIntentHash(manifest, key, kind, name, recorded?.id ?? null));
|
|
1109
|
+
}
|
|
1110
|
+
function isProvisionIntentStep(stepKey, resourceKey) {
|
|
1111
|
+
return stepKey === `provision-intent:${resourceKey}` || stepKey.startsWith(`provision-intent:${resourceKey}:`);
|
|
1112
|
+
}
|
|
1113
|
+
function interruptedProvisionRetryName(manifest, key, kind, name) {
|
|
1114
|
+
const suffix = `-retry-${createHash("sha256").update(JSON.stringify({
|
|
1115
|
+
key,
|
|
1116
|
+
kind,
|
|
1117
|
+
name,
|
|
1118
|
+
ownershipNonce: manifest.ownershipNonce
|
|
1119
|
+
})).digest("hex").slice(0, 32)}`;
|
|
1120
|
+
return `${name.slice(0, 63 - suffix.length).replace(/-+$/, "")}${suffix}`;
|
|
1121
|
+
}
|
|
1122
|
+
function matchesRecordedR2Fingerprint(configHash, resource) {
|
|
1123
|
+
if (!resource.fingerprint) return false;
|
|
1124
|
+
const liveHash = createHash("sha256").update(JSON.stringify({
|
|
1125
|
+
fingerprint: resource.fingerprint,
|
|
1126
|
+
kind: "r2-bucket",
|
|
1127
|
+
name: resource.name
|
|
1128
|
+
})).digest("hex");
|
|
1129
|
+
const legacyHash = createHash("sha256").update(JSON.stringify({
|
|
1130
|
+
kind: "r2-bucket",
|
|
1131
|
+
name: resource.name
|
|
1132
|
+
})).digest("hex");
|
|
1133
|
+
return configHash === liveHash || configHash === legacyHash;
|
|
1134
|
+
}
|
|
1135
|
+
function matchesResourceMarker(value, manifest, key, kind, resource) {
|
|
1136
|
+
if (!value) return false;
|
|
1137
|
+
try {
|
|
1138
|
+
const actual = parsePlatformResourceOwnershipMarker(JSON.parse(value));
|
|
1139
|
+
const expected = createPlatformResourceOwnershipMarker({
|
|
1140
|
+
accountId: manifest.account.id,
|
|
1141
|
+
installationId: manifest.id,
|
|
1142
|
+
ownershipNonce: manifest.ownershipNonce,
|
|
1143
|
+
resourceId: resource.id,
|
|
1144
|
+
resourceKey: key,
|
|
1145
|
+
resourceKind: kind,
|
|
1146
|
+
resourceName: resource.name
|
|
1147
|
+
});
|
|
1148
|
+
return JSON.stringify(actual) === JSON.stringify(expected);
|
|
1149
|
+
} catch {
|
|
1150
|
+
return false;
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
function isLegacyUniqueProvisionAttempt(manifest, key, kind, name) {
|
|
1154
|
+
const resourceSlug = resourceSlugForManifest(manifest);
|
|
1155
|
+
if (resourceSlug === manifest.deployment.installationSlug) return false;
|
|
1156
|
+
const expectedNames = createPlatformResourceNames(resourceSlug);
|
|
1157
|
+
const resourceKey = RESOURCE_NAME_KEY_BY_ACTION[key];
|
|
1158
|
+
if (!resourceKey) return false;
|
|
1159
|
+
const expectedName = expectedNames[resourceKey];
|
|
1160
|
+
const uniquePrefix = expectedName.slice(0, expectedName.lastIndexOf("-") + 1);
|
|
1161
|
+
return manifest.deployment.resources[resourceKey] === name && (name === expectedName || name.startsWith(uniquePrefix)) && kind !== "zone" && kind !== "dns-record";
|
|
1162
|
+
}
|
|
1163
|
+
const RESOURCE_NAME_KEY_BY_ACTION = {
|
|
1164
|
+
"ai-gateway": "aiGateway",
|
|
1165
|
+
"api-worker": "apiWorker",
|
|
1166
|
+
"assets-bucket": "assetsBucket",
|
|
1167
|
+
"cron-queue": "cronQueue",
|
|
1168
|
+
"dispatch-namespace": "dispatchNamespace",
|
|
1169
|
+
"dispatch-worker": "dispatchWorker",
|
|
1170
|
+
hyperdrive: "hyperdrive",
|
|
1171
|
+
"isr-cache-kv": "isrCacheKv",
|
|
1172
|
+
"legacy-assets-kv": "legacyAssetsKv",
|
|
1173
|
+
"proxy-worker": "proxyWorker",
|
|
1174
|
+
"routing-kv": "routingKv",
|
|
1175
|
+
"tail-worker": "tailWorker",
|
|
1176
|
+
"usage-queue": "usageQueue"
|
|
1177
|
+
};
|
|
1178
|
+
function platformProvisionIntentHash(manifest, key, kind, name, previousResourceId = null) {
|
|
1179
|
+
return createHash("sha256").update(JSON.stringify({
|
|
1180
|
+
key,
|
|
1181
|
+
kind,
|
|
1182
|
+
name,
|
|
1183
|
+
ownershipNonce: manifest.ownershipNonce,
|
|
1184
|
+
...previousResourceId ? { previousResourceId } : {}
|
|
1185
|
+
})).digest("hex");
|
|
1186
|
+
}
|
|
1187
|
+
function platformAdoptionIntentHash(manifest, key, kind, id, name) {
|
|
1188
|
+
return createHash("sha256").update(JSON.stringify({
|
|
1189
|
+
id,
|
|
1190
|
+
key,
|
|
1191
|
+
kind,
|
|
1192
|
+
name,
|
|
1193
|
+
ownershipNonce: manifest.ownershipNonce
|
|
1194
|
+
})).digest("hex");
|
|
1195
|
+
}
|
|
1196
|
+
async function readRemoteOwnership(client, accountId, routingNamespaces, existing) {
|
|
1197
|
+
for (const namespace of routingNamespaces) {
|
|
1198
|
+
const value = await client.getKvValue(accountId, namespace.id, PLATFORM_REMOTE_INSTALLATION_MANIFEST_KEY);
|
|
1199
|
+
if (value === null) continue;
|
|
1200
|
+
let remote;
|
|
1201
|
+
try {
|
|
1202
|
+
remote = parsePlatformInstallationManifest(JSON.parse(value));
|
|
1203
|
+
} catch {
|
|
1204
|
+
throw new Error(`platform: Routing namespace ${JSON.stringify(namespace.name)} contains invalid ownership metadata.`);
|
|
1205
|
+
}
|
|
1206
|
+
const routing = remote.resources.find((resource) => resource.key === "routing-kv");
|
|
1207
|
+
if (remote.id === existing.id && remote.ownershipNonce === existing.ownershipNonce && remote.account.id === accountId && routing?.id === namespace.id && routing.name === namespace.name && routing.ownership === "created") return remote;
|
|
1208
|
+
}
|
|
1209
|
+
return null;
|
|
1210
|
+
}
|
|
1211
|
+
function formatPlatformInstallPlan(plan) {
|
|
1212
|
+
const lines = [
|
|
1213
|
+
`${import_picocolors$1.default.bold(`${plan.deployment.displayName} in ${plan.account.name}`)} (${plan.account.id})`,
|
|
1214
|
+
plan.deployment.application.zoneName ? `${import_picocolors$1.default.bold("Applications:")} *.${plan.deployment.application.baseDomain}` : `${import_picocolors$1.default.bold("Applications:")} workers.dev (testing); add a domain with void platform domain set <domain>`,
|
|
1215
|
+
`${import_picocolors$1.default.bold("Control plane:")} ${plan.apiUrl}`,
|
|
1216
|
+
`${import_picocolors$1.default.bold("Resource proxy:")} ${plan.proxyUrl}`,
|
|
1217
|
+
""
|
|
1218
|
+
];
|
|
1219
|
+
for (const action of plan.actions) lines.push(`${action.action.padEnd(6)} ${action.kind.padEnd(19)} ${action.name}`);
|
|
1220
|
+
return lines.join("\n");
|
|
1221
|
+
}
|
|
1222
|
+
//#endregion
|
|
1223
|
+
//#region src/cli/platform-secret-update-authority.ts
|
|
1224
|
+
const COMPLETE_WORKER_SECRET_UPDATE_NAMES = [
|
|
1225
|
+
"CF_API_TOKEN",
|
|
1226
|
+
"GITHUB_CLIENT_ID",
|
|
1227
|
+
"GITHUB_CLIENT_SECRET",
|
|
1228
|
+
"R2_ACCESS_KEY_ID",
|
|
1229
|
+
"R2_SECRET_ACCESS_KEY"
|
|
1230
|
+
];
|
|
1231
|
+
/**
|
|
1232
|
+
* Prevents a host without the encrypted values for an interrupted credential
|
|
1233
|
+
* update from carrying an opaque, possibly partial update into another cutover.
|
|
1234
|
+
*/
|
|
1235
|
+
function assertPlatformWorkerSecretUpdateAuthority(input) {
|
|
1236
|
+
const unresolved = unresolvedWorkerSecretUpdate(input.manifest);
|
|
1237
|
+
if (!unresolved) return;
|
|
1238
|
+
const latestWorkerGeneration = readLatestWorkerGeneration(input.manifest);
|
|
1239
|
+
if (unresolved.generation === latestWorkerGeneration && input.localIntent?.id === unresolved.id && (input.localIntent.generation === null || input.localIntent.generation === unresolved.generation) || hasCompleteWorkerSecretReplacement(input.explicitUpdates)) return;
|
|
1240
|
+
throw new Error("platform: An unresolved Worker credential update is owned by another encrypted recovery checkpoint. Resume on the host that owns that checkpoint, restore its encrypted pending recovery material, or provide all supported Worker credential values together as a complete replacement.");
|
|
1241
|
+
}
|
|
1242
|
+
function unresolvedWorkerSecretUpdate(manifest) {
|
|
1243
|
+
const markers = manifest.steps.flatMap((step) => {
|
|
1244
|
+
const match = /^worker-secret-updates:(\d+):(pending|committed)$/.exec(step.key);
|
|
1245
|
+
return match ? [{
|
|
1246
|
+
generation: Number(match[1]),
|
|
1247
|
+
id: step.resultHash,
|
|
1248
|
+
state: match[2]
|
|
1249
|
+
}] : [];
|
|
1250
|
+
});
|
|
1251
|
+
const latestGeneration = Math.max(0, ...markers.map((marker) => marker.generation));
|
|
1252
|
+
const latest = markers.filter((marker) => marker.generation === latestGeneration);
|
|
1253
|
+
const committed = new Set(latest.filter((marker) => marker.state === "committed").map((marker) => marker.id));
|
|
1254
|
+
const pending = latest.filter((marker) => marker.state === "pending" && !committed.has(marker.id));
|
|
1255
|
+
if (pending.length === 0) return null;
|
|
1256
|
+
return {
|
|
1257
|
+
generation: latestGeneration,
|
|
1258
|
+
id: pending[0].id
|
|
1259
|
+
};
|
|
1260
|
+
}
|
|
1261
|
+
function readLatestWorkerGeneration(manifest) {
|
|
1262
|
+
return Math.max(0, ...manifest.steps.flatMap((step) => {
|
|
1263
|
+
const match = /^worker-(?:cutover|secret-updates|traffic-state):(\d+)/.exec(step.key);
|
|
1264
|
+
return match ? [Number(match[1])] : [];
|
|
1265
|
+
}));
|
|
1266
|
+
}
|
|
1267
|
+
function hasCompleteWorkerSecretReplacement(updates) {
|
|
1268
|
+
return COMPLETE_WORKER_SECRET_UPDATE_NAMES.every((name) => typeof updates[name] === "string" && updates[name] !== "");
|
|
1269
|
+
}
|
|
1270
|
+
//#endregion
|
|
1271
|
+
//#region src/cli/platform-traffic.ts
|
|
1272
|
+
function platformManagedWorkerTraffic(deployment, workers) {
|
|
1273
|
+
const includes = (worker) => workers === void 0 || workers.has(worker);
|
|
1274
|
+
const names = deployment.resources;
|
|
1275
|
+
const customDomains = [];
|
|
1276
|
+
if (deployment.controlPlane.apiHostname && includes(names.apiWorker)) customDomains.push({
|
|
1277
|
+
hostname: deployment.controlPlane.apiHostname,
|
|
1278
|
+
service: names.apiWorker
|
|
1279
|
+
});
|
|
1280
|
+
if (deployment.controlPlane.proxyHostname && includes(names.proxyWorker)) customDomains.push({
|
|
1281
|
+
hostname: deployment.controlPlane.proxyHostname,
|
|
1282
|
+
service: names.proxyWorker
|
|
1283
|
+
});
|
|
1284
|
+
return {
|
|
1285
|
+
customDomains,
|
|
1286
|
+
routes: includes(names.dispatchWorker) ? createApplicationRoutes(deployment.application).map((route) => ({
|
|
1287
|
+
pattern: route.pattern,
|
|
1288
|
+
script: names.dispatchWorker
|
|
1289
|
+
})) : []
|
|
1290
|
+
};
|
|
1291
|
+
}
|
|
1292
|
+
function trafficConfigHash(kind, value) {
|
|
1293
|
+
return createHash("sha256").update(JSON.stringify({
|
|
1294
|
+
kind,
|
|
1295
|
+
value
|
|
1296
|
+
})).digest("hex");
|
|
1297
|
+
}
|
|
1298
|
+
function trackedPlatformWorkerTraffic(_manifest, _workers) {
|
|
1299
|
+
return {
|
|
1300
|
+
customDomains: [],
|
|
1301
|
+
routes: []
|
|
1302
|
+
};
|
|
1303
|
+
}
|
|
1304
|
+
function recordPlatformWorkerTraffic(manifest, workerName, inventory, updatedAt) {
|
|
1305
|
+
const desired = platformManagedWorkerTraffic(manifest.deployment, /* @__PURE__ */ new Set([workerName]));
|
|
1306
|
+
let next = manifest;
|
|
1307
|
+
for (const target of desired.routes) {
|
|
1308
|
+
const matches = inventory.routes.filter((route) => route.pattern === target.pattern && route.script === target.script);
|
|
1309
|
+
if (matches.length !== 1) throw new Error(`platform: Expected exactly one published Worker route for ${JSON.stringify(target.pattern)} and ${JSON.stringify(target.script)}.`);
|
|
1310
|
+
const route = matches[0];
|
|
1311
|
+
next = recordInstallationResource(next, {
|
|
1312
|
+
configHash: trafficConfigHash("worker-route", target),
|
|
1313
|
+
id: route.id,
|
|
1314
|
+
key: `worker-route:${route.id}`,
|
|
1315
|
+
kind: "worker-route",
|
|
1316
|
+
name: route.pattern,
|
|
1317
|
+
ownership: "external",
|
|
1318
|
+
state: "retained"
|
|
1319
|
+
}, updatedAt);
|
|
1320
|
+
}
|
|
1321
|
+
for (const target of desired.customDomains) {
|
|
1322
|
+
const matches = inventory.customDomains.filter((domain) => domain.hostname === target.hostname && domain.service === target.service);
|
|
1323
|
+
if (matches.length !== 1) throw new Error(`platform: Expected exactly one published Worker custom domain for ${JSON.stringify(target.hostname)} and ${JSON.stringify(target.service)}.`);
|
|
1324
|
+
const domain = matches[0];
|
|
1325
|
+
next = recordInstallationResource(next, {
|
|
1326
|
+
configHash: trafficConfigHash("worker-custom-domain", target),
|
|
1327
|
+
id: domain.id,
|
|
1328
|
+
key: `worker-custom-domain:${domain.id}`,
|
|
1329
|
+
kind: "worker-custom-domain",
|
|
1330
|
+
name: domain.hostname,
|
|
1331
|
+
ownership: "external",
|
|
1332
|
+
state: "retained"
|
|
1333
|
+
}, updatedAt);
|
|
1334
|
+
}
|
|
1335
|
+
return next;
|
|
1336
|
+
}
|
|
1337
|
+
//#endregion
|
|
1338
|
+
//#region src/cli/platform-installer.ts
|
|
1339
|
+
const REMOTE_INSTALLATION_MANIFEST_KEY = PLATFORM_REMOTE_INSTALLATION_MANIFEST_KEY;
|
|
1340
|
+
const PLATFORM_WORKER_SECRET_UPDATE_NAMES = [
|
|
1341
|
+
"CF_API_TOKEN",
|
|
1342
|
+
"GITHUB_CLIENT_ID",
|
|
1343
|
+
"GITHUB_CLIENT_SECRET",
|
|
1344
|
+
"R2_ACCESS_KEY_ID",
|
|
1345
|
+
"R2_SECRET_ACCESS_KEY"
|
|
1346
|
+
];
|
|
1347
|
+
var PlatformWorkerRecoveryIncompleteError = class extends Error {
|
|
1348
|
+
constructor(message, options) {
|
|
1349
|
+
super(message, options);
|
|
1350
|
+
this.name = "PlatformWorkerRecoveryIncompleteError";
|
|
1351
|
+
}
|
|
1352
|
+
};
|
|
1353
|
+
function loadPlatformRuntime(customRuntimeDirectory) {
|
|
1354
|
+
const runtime = resolvePlatformRuntime(customRuntimeDirectory || void 0);
|
|
1355
|
+
verifyRuntimeArtifacts(runtime.directory, runtime.manifest);
|
|
1356
|
+
return runtime;
|
|
1357
|
+
}
|
|
1358
|
+
function createPlatformInstallationSecrets(input) {
|
|
1359
|
+
const checkpoint = input.checkpoint ?? {};
|
|
1360
|
+
const required = (value, name) => {
|
|
1361
|
+
if (!value) throw new Error(`platform: ${name} is required to install the platform.`);
|
|
1362
|
+
return value;
|
|
1363
|
+
};
|
|
1364
|
+
const existingInstallation = input.existingInstallation === true;
|
|
1365
|
+
const restoredIdentity = (explicit, name) => existingInstallation ? checkpoint[name] || explicit || "" : explicit || checkpoint[name] || "";
|
|
1366
|
+
const configured = (explicit, name) => explicit || checkpoint[name] || "";
|
|
1367
|
+
const jwtSecret = restoredIdentity(input.jwtSecret || "", "jwtSecret");
|
|
1368
|
+
const resolvedJwtSecret = input.existingInstallation ? jwtSecret : required(jwtSecret, "VOID_PLATFORM_JWT_SECRET");
|
|
1369
|
+
if (!existingInstallation && Buffer.byteLength(resolvedJwtSecret, "utf8") < 32) throw new Error("platform: VOID_PLATFORM_JWT_SECRET must contain at least 32 bytes of secret material unique to this installation.");
|
|
1370
|
+
const projectSecretKeyring = resolveProjectSecretKeyring({
|
|
1371
|
+
checkpoint,
|
|
1372
|
+
existingInstallation,
|
|
1373
|
+
projectSecretActiveKeyVersion: input.projectSecretActiveKeyVersion || "",
|
|
1374
|
+
projectSecretKey: input.projectSecretKey || "",
|
|
1375
|
+
projectSecretKeysJson: input.projectSecretKeysJson || "",
|
|
1376
|
+
required
|
|
1377
|
+
});
|
|
1378
|
+
const pendingWorkerSecretUpdateIntent = parseWorkerSecretUpdateIntent(checkpoint.workerSecretUpdatesJson);
|
|
1379
|
+
const explicitWorkerSecretUpdates = withoutEmptySecrets({
|
|
1380
|
+
CF_API_TOKEN: input.cloudflareApiToken,
|
|
1381
|
+
GITHUB_CLIENT_ID: input.githubClientId,
|
|
1382
|
+
GITHUB_CLIENT_SECRET: input.githubClientSecret,
|
|
1383
|
+
R2_ACCESS_KEY_ID: input.r2AccessKeyId,
|
|
1384
|
+
R2_SECRET_ACCESS_KEY: input.r2SecretAccessKey
|
|
1385
|
+
});
|
|
1386
|
+
return {
|
|
1387
|
+
workerSecretUpdates: input.updateExistingWorkers ? explicitWorkerSecretUpdates : {},
|
|
1388
|
+
pendingWorkerSecretUpdateIntent: input.updateExistingWorkers ? pendingWorkerSecretUpdateIntent : void 0,
|
|
1389
|
+
bootstrapGitHubId: existingInstallation ? configured(input.bootstrapGitHubId, "bootstrapGitHubId") : required(configured(input.bootstrapGitHubId, "bootstrapGitHubId"), "resolved administrator GitHub account id"),
|
|
1390
|
+
bootstrapGitHubLogin: existingInstallation ? configured(input.bootstrapGitHubLogin, "bootstrapGitHubLogin") : required(configured(input.bootstrapGitHubLogin, "bootstrapGitHubLogin"), "VOID_PLATFORM_ADMIN_GITHUB_LOGIN"),
|
|
1391
|
+
cloudflareApiToken: existingInstallation ? configured(input.cloudflareApiToken, "cloudflareApiToken") : required(configured(input.cloudflareApiToken, "cloudflareApiToken"), "VOID_PLATFORM_RUNTIME_CLOUDFLARE_API_TOKEN"),
|
|
1392
|
+
databaseUrl: required(input.databaseUrl || checkpoint.databaseUrl || "", "DATABASE_URL"),
|
|
1393
|
+
githubClientId: existingInstallation ? configured(input.githubClientId, "githubClientId") : required(configured(input.githubClientId, "githubClientId"), "VOID_PLATFORM_GITHUB_CLIENT_ID"),
|
|
1394
|
+
githubClientSecret: existingInstallation ? configured(input.githubClientSecret, "githubClientSecret") : required(configured(input.githubClientSecret, "githubClientSecret"), "VOID_PLATFORM_GITHUB_CLIENT_SECRET"),
|
|
1395
|
+
jwtSecret: resolvedJwtSecret,
|
|
1396
|
+
projectSecretActiveKeyVersion: projectSecretKeyring.activeVersion,
|
|
1397
|
+
projectSecretKey: projectSecretKeyring.v1Key,
|
|
1398
|
+
projectSecretKeysJson: projectSecretKeyring.keysJson,
|
|
1399
|
+
r2AccessKeyId: existingInstallation ? configured(input.r2AccessKeyId, "r2AccessKeyId") : required(configured(input.r2AccessKeyId, "r2AccessKeyId"), "VOID_PLATFORM_R2_ACCESS_KEY_ID"),
|
|
1400
|
+
r2SecretAccessKey: existingInstallation ? configured(input.r2SecretAccessKey, "r2SecretAccessKey") : required(configured(input.r2SecretAccessKey, "r2SecretAccessKey"), "VOID_PLATFORM_R2_SECRET_ACCESS_KEY")
|
|
1401
|
+
};
|
|
1402
|
+
}
|
|
1403
|
+
function resolveProjectSecretKeyring(input) {
|
|
1404
|
+
const explicitFullKeyring = input.projectSecretKeysJson !== "" || input.projectSecretActiveKeyVersion !== "";
|
|
1405
|
+
if (explicitFullKeyring && (!input.projectSecretKeysJson || !input.projectSecretActiveKeyVersion)) throw new Error("platform: VOID_PLATFORM_PROJECT_SECRET_KEYS_JSON and VOID_PLATFORM_PROJECT_SECRET_ACTIVE_KEY_VERSION must be supplied together.");
|
|
1406
|
+
if (explicitFullKeyring && !input.existingInstallation) throw new Error("platform: Fresh installations use VOID_PLATFORM_PROJECT_SECRET_KEY to initialize key version v1. The full project-secret keyring inputs are for restoring an existing installation.");
|
|
1407
|
+
const checkpointKeysJson = input.checkpoint.projectSecretKeysJson || "";
|
|
1408
|
+
const checkpointActiveVersion = input.checkpoint.projectSecretActiveKeyVersion || "";
|
|
1409
|
+
if (!explicitFullKeyring && (checkpointKeysJson !== "" || checkpointActiveVersion !== "") && (!checkpointKeysJson || !checkpointActiveVersion)) throw new Error("platform: Encrypted recovery material must contain both the project-secret keyring and its active version.");
|
|
1410
|
+
if (explicitFullKeyring) return normalizeProjectSecretKeyring(input.projectSecretKeysJson, input.projectSecretActiveKeyVersion, "VOID_PLATFORM_PROJECT_SECRET_KEYS_JSON", "VOID_PLATFORM_PROJECT_SECRET_ACTIVE_KEY_VERSION");
|
|
1411
|
+
if (checkpointKeysJson) return normalizeProjectSecretKeyring(checkpointKeysJson, checkpointActiveVersion, "encrypted recovery project-secret keyring", "encrypted recovery project-secret active version");
|
|
1412
|
+
const v1Key = input.existingInstallation ? input.checkpoint.projectSecretKey || input.projectSecretKey || "" : input.required(input.projectSecretKey || input.checkpoint.projectSecretKey || "", "VOID_PLATFORM_PROJECT_SECRET_KEY");
|
|
1413
|
+
if (!v1Key) return {
|
|
1414
|
+
activeVersion: "",
|
|
1415
|
+
keysJson: "",
|
|
1416
|
+
v1Key: ""
|
|
1417
|
+
};
|
|
1418
|
+
assertCanonicalProjectSecretKey(v1Key, "VOID_PLATFORM_PROJECT_SECRET_KEY", true);
|
|
1419
|
+
return {
|
|
1420
|
+
activeVersion: "v1",
|
|
1421
|
+
keysJson: JSON.stringify({ v1: v1Key }),
|
|
1422
|
+
v1Key
|
|
1423
|
+
};
|
|
1424
|
+
}
|
|
1425
|
+
function normalizeProjectSecretKeyring(keysJson, activeVersion, keysName, activeVersionName) {
|
|
1426
|
+
let parsed;
|
|
1427
|
+
try {
|
|
1428
|
+
parsed = JSON.parse(keysJson);
|
|
1429
|
+
} catch {
|
|
1430
|
+
throw new Error(`platform: ${keysName} must be valid JSON.`);
|
|
1431
|
+
}
|
|
1432
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error(`platform: ${keysName} must be a JSON object.`);
|
|
1433
|
+
const entries = Object.entries(parsed);
|
|
1434
|
+
if (entries.length === 0) throw new Error(`platform: ${keysName} must define at least one key.`);
|
|
1435
|
+
for (const [version, key] of entries) {
|
|
1436
|
+
if (!version || typeof key !== "string" || !key) throw new Error(`platform: ${keysName} must map non-empty versions to keys.`);
|
|
1437
|
+
assertCanonicalProjectSecretKey(key, `${keysName} entry ${JSON.stringify(version)}`);
|
|
1438
|
+
}
|
|
1439
|
+
if (!Object.hasOwn(parsed, activeVersion)) throw new Error(`platform: ${activeVersionName} must identify a key in ${keysName}.`);
|
|
1440
|
+
const keys = Object.fromEntries(entries.sort(([left], [right]) => left.localeCompare(right)));
|
|
1441
|
+
return {
|
|
1442
|
+
activeVersion,
|
|
1443
|
+
keysJson: JSON.stringify(keys),
|
|
1444
|
+
v1Key: keys.v1 || ""
|
|
1445
|
+
};
|
|
1446
|
+
}
|
|
1447
|
+
function assertCanonicalProjectSecretKey(value, name, uniqueToInstallation = false) {
|
|
1448
|
+
const decoded = Buffer.from(value, "base64");
|
|
1449
|
+
if (decoded.byteLength !== 32 || decoded.toString("base64") !== value) throw new Error(`platform: ${name} must be the canonical base64 encoding of a 32-byte key${uniqueToInstallation ? " unique to this installation" : ""}.`);
|
|
1450
|
+
}
|
|
1451
|
+
function secretCheckpoint(secrets, workerSecretUpdateIntent) {
|
|
1452
|
+
return {
|
|
1453
|
+
bootstrapGitHubId: secrets.bootstrapGitHubId,
|
|
1454
|
+
bootstrapGitHubLogin: secrets.bootstrapGitHubLogin,
|
|
1455
|
+
cloudflareApiToken: secrets.cloudflareApiToken,
|
|
1456
|
+
databaseUrl: secrets.databaseUrl,
|
|
1457
|
+
githubClientId: secrets.githubClientId,
|
|
1458
|
+
githubClientSecret: secrets.githubClientSecret,
|
|
1459
|
+
jwtSecret: secrets.jwtSecret,
|
|
1460
|
+
projectSecretActiveKeyVersion: secrets.projectSecretActiveKeyVersion,
|
|
1461
|
+
projectSecretKey: secrets.projectSecretKey,
|
|
1462
|
+
projectSecretKeysJson: secrets.projectSecretKeysJson,
|
|
1463
|
+
r2AccessKeyId: secrets.r2AccessKeyId,
|
|
1464
|
+
r2SecretAccessKey: secrets.r2SecretAccessKey,
|
|
1465
|
+
...workerSecretUpdateIntent ? { workerSecretUpdatesJson: JSON.stringify(workerSecretUpdateIntent) } : {}
|
|
1466
|
+
};
|
|
1467
|
+
}
|
|
1468
|
+
function parseWorkerSecretUpdateIntent(value) {
|
|
1469
|
+
if (!value) return;
|
|
1470
|
+
let parsed;
|
|
1471
|
+
try {
|
|
1472
|
+
parsed = JSON.parse(value);
|
|
1473
|
+
} catch {
|
|
1474
|
+
throw new Error("platform: Encrypted pending Worker secret updates must be valid JSON.");
|
|
1475
|
+
}
|
|
1476
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || !("schema" in parsed) || parsed.schema !== 1 || !("id" in parsed) || typeof parsed.id !== "string" || parsed.id === "" || !("baselineGeneration" in parsed) || !Number.isSafeInteger(parsed.baselineGeneration) || Number(parsed.baselineGeneration) < 0 || !("generation" in parsed) || parsed.generation !== null && (!Number.isSafeInteger(parsed.generation) || Number(parsed.generation) <= 0) || !("updates" in parsed) || !parsed.updates || typeof parsed.updates !== "object" || Array.isArray(parsed.updates)) throw new Error("platform: Encrypted pending Worker secret update intent is invalid.");
|
|
1477
|
+
const updates = {};
|
|
1478
|
+
for (const [name, entry] of Object.entries(parsed.updates)) {
|
|
1479
|
+
if (!PLATFORM_WORKER_SECRET_UPDATE_NAMES.includes(name) || typeof entry !== "string" || entry === "") throw new Error("platform: Encrypted pending Worker secret updates are invalid.");
|
|
1480
|
+
updates[name] = entry;
|
|
1481
|
+
}
|
|
1482
|
+
if (Object.keys(updates).length === 0) throw new Error("platform: Encrypted pending Worker secret update intent is empty.");
|
|
1483
|
+
return {
|
|
1484
|
+
baselineGeneration: Number(parsed.baselineGeneration),
|
|
1485
|
+
generation: parsed.generation === null ? null : Number(parsed.generation),
|
|
1486
|
+
id: parsed.id,
|
|
1487
|
+
schema: 1,
|
|
1488
|
+
updates
|
|
1489
|
+
};
|
|
1490
|
+
}
|
|
1491
|
+
function sanitizeWorkerSecretUpdates(updates) {
|
|
1492
|
+
return Object.fromEntries(Object.entries(updates ?? {}).filter(([name, value]) => PLATFORM_WORKER_SECRET_UPDATE_NAMES.includes(name) && typeof value === "string" && value !== ""));
|
|
1493
|
+
}
|
|
1494
|
+
async function installPlatform(plan, secrets, existing, dependencies) {
|
|
1495
|
+
if (plan.actions.some((action) => action.action === "inspect")) throw new Error("platform: AI Gateway access must be verified before provisioning. Provide AI Gateway: Edit on the runtime token, then retry.");
|
|
1496
|
+
const now = dependencies.now ?? (() => (/* @__PURE__ */ new Date()).toISOString());
|
|
1497
|
+
const cloudflareApiBaseUrl = dependencies.client.apiBaseUrl ?? resolvePinnedApiBaseUrl();
|
|
1498
|
+
const runPinnedPlatformWrangler = (args, accountId, cwd, capture) => runPlatformWranglerAtApiBase(args, accountId, cwd, capture, cloudflareApiBaseUrl);
|
|
1499
|
+
const resolvedRuntime = dependencies.runtime ?? resolvePlatformRuntime();
|
|
1500
|
+
const runtimeDirectory = resolvedRuntime.directory;
|
|
1501
|
+
const runtime = resolvedRuntime.manifest;
|
|
1502
|
+
if (plan.deployment.application.workersDev && runtime.applicationRoutingVersion !== 1) throw new Error("platform: This runtime does not support workers.dev application routing. Use a current platform runtime.");
|
|
1503
|
+
verifyRuntimeArtifacts(runtimeDirectory, runtime);
|
|
1504
|
+
const runtimeProvenance = {
|
|
1505
|
+
manifestSha256: resolvedRuntime.manifestSha256,
|
|
1506
|
+
source: resolvedRuntime.source,
|
|
1507
|
+
sourceRevision: runtime.sourceRevision
|
|
1508
|
+
};
|
|
1509
|
+
const version = runtime.platformVersion;
|
|
1510
|
+
const targetUsageProtocolVersion = runtime.usageProtocolVersion;
|
|
1511
|
+
const sourcePlatformVersion = existing?.platformVersion ?? null;
|
|
1512
|
+
const hadPriorWorkers = existing !== null && (existing.resources.some((resource) => resource.kind === "worker" && resource.state === "ready") || plan.actions.some((action) => action.kind === "worker" && action.action !== "create"));
|
|
1513
|
+
const wasDisabled = existing?.status === "disabled";
|
|
1514
|
+
const preserveDisabled = existing?.desiredState === "disabled";
|
|
1515
|
+
const previousPublicEndpoints = stablePublicEndpointState(existing);
|
|
1516
|
+
let workerCutoverGeneration = 0;
|
|
1517
|
+
let workerRollbacks = [];
|
|
1518
|
+
let workerDeploymentCommitted = false;
|
|
1519
|
+
let workerTrafficGuarded = false;
|
|
1520
|
+
let manifest = existing;
|
|
1521
|
+
if (!manifest) {
|
|
1522
|
+
const createdAt = now();
|
|
1523
|
+
manifest = createPlatformInstallationManifest({
|
|
1524
|
+
account: plan.account,
|
|
1525
|
+
createdAt,
|
|
1526
|
+
deployment: plan.deployment,
|
|
1527
|
+
id: plan.installationId,
|
|
1528
|
+
ownershipNonce: randomBytes(32).toString("base64url"),
|
|
1529
|
+
platformVersion: version
|
|
1530
|
+
});
|
|
1531
|
+
manifest = transitionInstallation(manifest, "planned", now());
|
|
1532
|
+
writeInstallationManifest(manifest);
|
|
1533
|
+
} else if (manifest.status === "uninstalled") throw new Error(`platform: Installation ${manifest.id} has already been uninstalled.`);
|
|
1534
|
+
const explicitWorkerSecretUpdates = sanitizeWorkerSecretUpdates(secrets.workerSecretUpdates);
|
|
1535
|
+
const hasExplicitWorkerSecretUpdates = Object.keys(explicitWorkerSecretUpdates).length > 0;
|
|
1536
|
+
const resumedWorkerSecretUpdateIntent = secrets.pendingWorkerSecretUpdateIntent && platformWorkerSecretUpdateIntentState(manifest, secrets.pendingWorkerSecretUpdateIntent) === "active" ? secrets.pendingWorkerSecretUpdateIntent : null;
|
|
1537
|
+
let workerSecretUpdateIntent = hasExplicitWorkerSecretUpdates ? {
|
|
1538
|
+
baselineGeneration: latestPlatformWorkerCutoverGeneration(manifest),
|
|
1539
|
+
generation: null,
|
|
1540
|
+
id: randomUUID(),
|
|
1541
|
+
schema: 1,
|
|
1542
|
+
updates: {
|
|
1543
|
+
...resumedWorkerSecretUpdateIntent?.updates,
|
|
1544
|
+
...explicitWorkerSecretUpdates
|
|
1545
|
+
}
|
|
1546
|
+
} : resumedWorkerSecretUpdateIntent;
|
|
1547
|
+
if (preserveDisabled && manifest.desiredState !== "disabled") {
|
|
1548
|
+
manifest = setInstallationDesiredState(manifest, "disabled", now());
|
|
1549
|
+
writeInstallationManifest(manifest);
|
|
1550
|
+
}
|
|
1551
|
+
assertRequiredPlatformSecrets(plan, manifest, secrets);
|
|
1552
|
+
const pendingSecrets = secretCheckpoint(secrets, workerSecretUpdateIntent);
|
|
1553
|
+
writePendingInstallationSecrets(manifest, {
|
|
1554
|
+
...pendingSecrets,
|
|
1555
|
+
databaseClaimState: "claiming"
|
|
1556
|
+
});
|
|
1557
|
+
dependencies.onProgress?.("Preparing the platform database");
|
|
1558
|
+
try {
|
|
1559
|
+
await (dependencies.claimDatabase ?? claimPlatformDatabase)(secrets.databaseUrl, manifest.id, manifest.ownershipNonce);
|
|
1560
|
+
} catch (error) {
|
|
1561
|
+
writePendingInstallationSecrets(manifest, {
|
|
1562
|
+
...pendingSecrets,
|
|
1563
|
+
databaseClaimState: "failed"
|
|
1564
|
+
});
|
|
1565
|
+
throw error;
|
|
1566
|
+
}
|
|
1567
|
+
writePendingInstallationSecrets(manifest, {
|
|
1568
|
+
...pendingSecrets,
|
|
1569
|
+
databaseClaimState: "claimed"
|
|
1570
|
+
});
|
|
1571
|
+
dependencies.onProgress?.("Acquiring the database lock");
|
|
1572
|
+
const releaseDatabaseLock = await dependencies.acquireDatabaseLifecycleLock(secrets.databaseUrl, manifest.id, manifest.ownershipNonce);
|
|
1573
|
+
const writeRemoteInstallationCheckpoint = (client, checkpointPlan, checkpointManifest) => writeRemoteInstallationCheckpointWithoutDatabase(client, checkpointPlan, checkpointManifest, releaseDatabaseLock);
|
|
1574
|
+
const authoritative = releaseDatabaseLock.authoritativeManifest;
|
|
1575
|
+
if (authoritative && JSON.stringify(authoritative) !== JSON.stringify(manifest)) {
|
|
1576
|
+
writeInstallationManifest(authoritative);
|
|
1577
|
+
await releaseDatabaseLock();
|
|
1578
|
+
throw new Error("platform: This installation changed on another host. Local state was refreshed; rerun the command to review a current plan.");
|
|
1579
|
+
}
|
|
1580
|
+
try {
|
|
1581
|
+
assertPlatformWorkerSecretUpdateAuthority({
|
|
1582
|
+
explicitUpdates: explicitWorkerSecretUpdates,
|
|
1583
|
+
localIntent: secrets.pendingWorkerSecretUpdateIntent,
|
|
1584
|
+
manifest
|
|
1585
|
+
});
|
|
1586
|
+
} catch (error) {
|
|
1587
|
+
await releaseDatabaseLock();
|
|
1588
|
+
throw error;
|
|
1589
|
+
}
|
|
1590
|
+
if (workerSecretUpdateIntent && !hasExplicitWorkerSecretUpdates && platformWorkerSecretUpdateIntentState(manifest, workerSecretUpdateIntent) !== "active") workerSecretUpdateIntent = null;
|
|
1591
|
+
const workerDeploymentSecrets = {
|
|
1592
|
+
...secrets,
|
|
1593
|
+
workerSecretUpdates: workerSecretUpdateIntent?.updates ?? {}
|
|
1594
|
+
};
|
|
1595
|
+
try {
|
|
1596
|
+
assertPlatformRuntimeSafety(runtime, manifest.safetyProtocols, releaseDatabaseLock.requiredSafetyProtocols);
|
|
1597
|
+
if (existing) await dependencies.verifyExistingOwnership?.(manifest, authoritative !== null && authoritative !== void 0);
|
|
1598
|
+
dependencies.onProgress?.("Checking and creating Cloudflare resources");
|
|
1599
|
+
if (JSON.stringify(manifest.deployment) !== JSON.stringify(plan.deployment)) {
|
|
1600
|
+
manifest = {
|
|
1601
|
+
...manifest,
|
|
1602
|
+
deployment: plan.deployment,
|
|
1603
|
+
updatedAt: now()
|
|
1604
|
+
};
|
|
1605
|
+
writeInstallationManifest(manifest);
|
|
1606
|
+
await releaseDatabaseLock.checkpoint?.(manifest);
|
|
1607
|
+
}
|
|
1608
|
+
await releaseDatabaseLock.checkpoint?.(manifest);
|
|
1609
|
+
if (manifest.status !== "provisioning") {
|
|
1610
|
+
manifest = transitionInstallation(manifest, "provisioning", now());
|
|
1611
|
+
writeInstallationManifest(manifest);
|
|
1612
|
+
}
|
|
1613
|
+
const routingAction = plan.actions.find((action) => action.key === "routing-kv");
|
|
1614
|
+
if (routingAction.action === "adopt") throw new Error("platform: The routing checkpoint cannot be adopted.");
|
|
1615
|
+
if (routingAction.action === "reuse") {
|
|
1616
|
+
if (!manifest.resources.some((resource) => resource.key === routingAction.key) && routingAction.id) {
|
|
1617
|
+
manifest = recordResource(manifest, routingAction, routingAction.id, "created", now());
|
|
1618
|
+
writeInstallationManifest(manifest);
|
|
1619
|
+
}
|
|
1620
|
+
} else {
|
|
1621
|
+
manifest = recordProvisionIntent(manifest, routingAction, now());
|
|
1622
|
+
writeInstallationManifest(manifest);
|
|
1623
|
+
await releaseDatabaseLock.checkpoint?.(manifest);
|
|
1624
|
+
const routing = await createResource(dependencies.client, plan, routingAction, secrets.databaseUrl, "");
|
|
1625
|
+
manifest = recordResource(manifest, routingAction, routing.id, "created", now());
|
|
1626
|
+
writeInstallationManifest(manifest);
|
|
1627
|
+
}
|
|
1628
|
+
await writeRemoteInstallationCheckpoint(dependencies.client, plan, manifest);
|
|
1629
|
+
const zoneAction = plan.actions.find((action) => action.key === "zone");
|
|
1630
|
+
let zone = plan.zone;
|
|
1631
|
+
if (zoneAction) {
|
|
1632
|
+
if (zoneAction.action === "create") {
|
|
1633
|
+
manifest = recordProvisionIntent(manifest, zoneAction, now());
|
|
1634
|
+
writeInstallationManifest(manifest);
|
|
1635
|
+
await writeRemoteInstallationCheckpoint(dependencies.client, plan, manifest);
|
|
1636
|
+
zone = await dependencies.client.createZone(plan.account.id, zoneAction.name);
|
|
1637
|
+
}
|
|
1638
|
+
if (!zone) throw new Error("platform: Cloudflare did not return the platform zone.");
|
|
1639
|
+
if (zoneAction.action === "adopt") {
|
|
1640
|
+
manifest = recordAdoptionIntent(manifest, zoneAction, zone.id, now());
|
|
1641
|
+
writeInstallationManifest(manifest);
|
|
1642
|
+
await writeRemoteInstallationCheckpoint(dependencies.client, plan, manifest);
|
|
1643
|
+
}
|
|
1644
|
+
manifest = recordResource(manifest, zoneAction, zone.id, zoneAction.action === "adopt" ? "adopted" : zoneAction.action === "create" || hasProvisionIntent(manifest, zoneAction) ? "created" : void 0, now());
|
|
1645
|
+
writeInstallationManifest(manifest);
|
|
1646
|
+
await writeRemoteInstallationCheckpoint(dependencies.client, plan, manifest);
|
|
1647
|
+
if (zone.status !== "active") {
|
|
1648
|
+
manifest = transitionInstallation(manifest, "waiting-for-dns", now());
|
|
1649
|
+
writeInstallationManifest(manifest);
|
|
1650
|
+
await writeRemoteInstallationCheckpoint(dependencies.client, plan, manifest);
|
|
1651
|
+
return {
|
|
1652
|
+
manifest,
|
|
1653
|
+
waitingForDns: true,
|
|
1654
|
+
waitingForSandboxDrain: false,
|
|
1655
|
+
zoneNameServers: zone.nameServers
|
|
1656
|
+
};
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
const zoneId = zone?.id ?? "";
|
|
1660
|
+
for (const action of plan.actions.filter((entry) => entry.key !== "routing-kv" && entry.kind !== "zone" && entry.kind !== "worker")) {
|
|
1661
|
+
const retainedManifest = retainReplacedInstallationResource(manifest, action, now());
|
|
1662
|
+
if (retainedManifest !== manifest) {
|
|
1663
|
+
manifest = retainedManifest;
|
|
1664
|
+
writeInstallationManifest(manifest);
|
|
1665
|
+
await writeRemoteInstallationCheckpoint(dependencies.client, plan, manifest);
|
|
1666
|
+
}
|
|
1667
|
+
if (action.action === "adopt") {
|
|
1668
|
+
if (!action.id) throw new Error(`platform: Adopted ${action.kind} ${action.name} has no id.`);
|
|
1669
|
+
manifest = recordAdoptionIntent(manifest, action, action.id, now());
|
|
1670
|
+
writeInstallationManifest(manifest);
|
|
1671
|
+
await writeRemoteInstallationCheckpoint(dependencies.client, plan, manifest);
|
|
1672
|
+
manifest = recordResource(manifest, action, action.id, "adopted", now(), secrets.databaseUrl);
|
|
1673
|
+
writeInstallationManifest(manifest);
|
|
1674
|
+
await writeRemoteInstallationCheckpoint(dependencies.client, plan, manifest);
|
|
1675
|
+
continue;
|
|
1676
|
+
}
|
|
1677
|
+
if (action.action === "reuse") {
|
|
1678
|
+
if (action.id && (action.kind === "hyperdrive" || !manifest.resources.some((resource) => resource.key === action.key))) {
|
|
1679
|
+
manifest = recordResource(manifest, action, action.id, void 0, now(), secrets.databaseUrl);
|
|
1680
|
+
writeInstallationManifest(manifest);
|
|
1681
|
+
await writeR2OwnershipMarkerIfNeeded(dependencies.client, plan, manifest, action);
|
|
1682
|
+
await writeRemoteInstallationCheckpoint(dependencies.client, plan, manifest);
|
|
1683
|
+
}
|
|
1684
|
+
continue;
|
|
1685
|
+
}
|
|
1686
|
+
manifest = recordProvisionIntent(manifest, action, now());
|
|
1687
|
+
writeInstallationManifest(manifest);
|
|
1688
|
+
await writeRemoteInstallationCheckpoint(dependencies.client, plan, manifest);
|
|
1689
|
+
const resource = await createResource(dependencies.client, plan, action, secrets.databaseUrl, zoneId);
|
|
1690
|
+
manifest = recordResource(manifest, action, resource.id, "created", now(), secrets.databaseUrl, resource.fingerprint);
|
|
1691
|
+
writeInstallationManifest(manifest);
|
|
1692
|
+
await writeR2OwnershipMarkerIfNeeded(dependencies.client, plan, manifest, action);
|
|
1693
|
+
await writeRemoteInstallationCheckpoint(dependencies.client, plan, manifest);
|
|
1694
|
+
}
|
|
1695
|
+
dependencies.onProgress?.("Checking the platform database schema");
|
|
1696
|
+
const rollbackDatabase = dependencies.databaseRollbackSource;
|
|
1697
|
+
const migrationStep = `database-schema:${runtime.database.schemaDigest}`;
|
|
1698
|
+
if (rollbackDatabase) {
|
|
1699
|
+
const verifyDatabase = releaseDatabaseLock.verifyDatabase;
|
|
1700
|
+
if (!verifyDatabase) throw new Error("platform: The PostgreSQL lifecycle lock cannot verify the forward-only rollback schema.");
|
|
1701
|
+
await verifyDatabase(rollbackDatabase.directory, rollbackDatabase.manifest.database);
|
|
1702
|
+
manifest = {
|
|
1703
|
+
...manifest,
|
|
1704
|
+
databaseSchemaDigest: rollbackDatabase.manifest.database.schemaDigest,
|
|
1705
|
+
updatedAt: now()
|
|
1706
|
+
};
|
|
1707
|
+
writeInstallationManifest(manifest);
|
|
1708
|
+
await writeRemoteInstallationCheckpoint(dependencies.client, plan, manifest);
|
|
1709
|
+
} else if (!manifest.steps.some((step) => step.key === migrationStep)) {
|
|
1710
|
+
dependencies.onProgress?.("Applying platform database migrations");
|
|
1711
|
+
const legacyMigrationStep = `database-migrations:${runtime.database.journalSha256}`;
|
|
1712
|
+
if (dependencies.migrateDatabase) {
|
|
1713
|
+
if (!manifest.steps.some((step) => step.key === legacyMigrationStep)) await dependencies.migrateDatabase(secrets.databaseUrl, join(runtimeDirectory, "drizzle"));
|
|
1714
|
+
} else {
|
|
1715
|
+
const prepareDatabase = dependencies.prepareDatabase ?? releaseDatabaseLock.prepareDatabase;
|
|
1716
|
+
if (!prepareDatabase) throw new Error("platform: The PostgreSQL lifecycle lock cannot verify or migrate this database.");
|
|
1717
|
+
await prepareDatabase(runtimeDirectory, runtime.database, sourcePlatformVersion, hadPriorWorkers);
|
|
1718
|
+
}
|
|
1719
|
+
manifest = recordInstallationStep(manifest, {
|
|
1720
|
+
completedAt: now(),
|
|
1721
|
+
key: migrationStep,
|
|
1722
|
+
resultHash: runtime.database.schemaDigest
|
|
1723
|
+
}, now());
|
|
1724
|
+
manifest = {
|
|
1725
|
+
...manifest,
|
|
1726
|
+
databaseSchemaDigest: runtime.database.schemaDigest,
|
|
1727
|
+
updatedAt: now()
|
|
1728
|
+
};
|
|
1729
|
+
writeInstallationManifest(manifest);
|
|
1730
|
+
await writeRemoteInstallationCheckpoint(dependencies.client, plan, manifest);
|
|
1731
|
+
} else if (releaseDatabaseLock.verifyDatabase) {
|
|
1732
|
+
await releaseDatabaseLock.verifyDatabase(runtimeDirectory, runtime.database);
|
|
1733
|
+
if (manifest.databaseSchemaDigest !== runtime.database.schemaDigest) {
|
|
1734
|
+
manifest = {
|
|
1735
|
+
...manifest,
|
|
1736
|
+
databaseSchemaDigest: runtime.database.schemaDigest,
|
|
1737
|
+
updatedAt: now()
|
|
1738
|
+
};
|
|
1739
|
+
writeInstallationManifest(manifest);
|
|
1740
|
+
await writeRemoteInstallationCheckpoint(dependencies.client, plan, manifest);
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1743
|
+
dependencies.onProgress?.("Checking platform safety");
|
|
1744
|
+
const targetSandboxDrainProtocol = runtime.safetyProtocols?.sandboxDrain ?? 0;
|
|
1745
|
+
const prepareSandboxDrain = dependencies.prepareSandboxDrain ?? releaseDatabaseLock.prepareSandboxDrain;
|
|
1746
|
+
if (targetSandboxDrainProtocol >= 1 && !prepareSandboxDrain) throw new Error("platform: The PostgreSQL lifecycle lock cannot establish the sandbox-drain safety checkpoint.");
|
|
1747
|
+
const sandboxDrainPreparation = targetSandboxDrainProtocol >= 1 ? await prepareSandboxDrain(targetSandboxDrainProtocol >= 2 ? 2 : 0) : null;
|
|
1748
|
+
const sandboxDrainRoutingKvId = requireId(Object.fromEntries(manifest.resources.map((resource) => [resource.key, resource.id])), "routing-kv");
|
|
1749
|
+
const writeSandboxDrainMirror = async (preparation) => {
|
|
1750
|
+
await dependencies.client.putKvValue(plan.account.id, sandboxDrainRoutingKvId, PLATFORM_SANDBOX_DRAIN_KEY, preparation.mirrorValue);
|
|
1751
|
+
};
|
|
1752
|
+
if (sandboxDrainPreparation) await writeSandboxDrainMirror(sandboxDrainPreparation);
|
|
1753
|
+
const sandboxDrainState = sandboxDrainPreparation?.state ?? "complete";
|
|
1754
|
+
if (targetSandboxDrainProtocol >= 1 && (manifest.safetyProtocols?.sandboxDrain ?? 0) < targetSandboxDrainProtocol) {
|
|
1755
|
+
manifest = {
|
|
1756
|
+
...manifest,
|
|
1757
|
+
safetyProtocols: { sandboxDrain: targetSandboxDrainProtocol },
|
|
1758
|
+
updatedAt: now()
|
|
1759
|
+
};
|
|
1760
|
+
writeInstallationManifest(manifest);
|
|
1761
|
+
await writeRemoteInstallationCheckpoint(dependencies.client, plan, manifest);
|
|
1762
|
+
}
|
|
1763
|
+
manifest = transitionInstallation(manifest, "deploying", now());
|
|
1764
|
+
writeInstallationManifest(manifest);
|
|
1765
|
+
const ids = Object.fromEntries(manifest.resources.map((resource) => [resource.key, resource.id]));
|
|
1766
|
+
const bootstrapStep = "bootstrap-access:v1";
|
|
1767
|
+
if (!manifest.steps.some((step) => step.key === bootstrapStep)) {
|
|
1768
|
+
const routingKv = requireId(ids, "routing-kv");
|
|
1769
|
+
await dependencies.client.putKvValue(plan.account.id, routingKv, "signup:enabled", "true");
|
|
1770
|
+
await dependencies.client.putKvValue(plan.account.id, routingKv, "signup:allowlist", JSON.stringify([]));
|
|
1771
|
+
manifest = recordInstallationStep(manifest, {
|
|
1772
|
+
completedAt: now(),
|
|
1773
|
+
key: bootstrapStep,
|
|
1774
|
+
resultHash: hash(secrets.bootstrapGitHubId)
|
|
1775
|
+
}, now());
|
|
1776
|
+
writeInstallationManifest(manifest);
|
|
1777
|
+
}
|
|
1778
|
+
const entrypoints = Object.fromEntries(Object.entries(runtime.artifacts).map(([key, artifact]) => [key, join(runtimeDirectory, artifact.path)]));
|
|
1779
|
+
const interruptedCutover = interruptedWorkerCutover(manifest);
|
|
1780
|
+
const recoveringUnsafeTraffic = (interruptedCutover?.unsafeTraffic.size ?? 0) > 0;
|
|
1781
|
+
const createsWorker = plan.actions.some((action) => action.kind === "worker" && action.action === "create");
|
|
1782
|
+
const recoveringCreatedWorker = interruptedCutover !== null && [...interruptedCutover.staged.values()].some((snapshot) => snapshot.previousVersionId === null);
|
|
1783
|
+
const requiresSandboxDrain = !preserveDisabled && targetSandboxDrainProtocol >= 1 && sandboxDrainState === "pending";
|
|
1784
|
+
const requiresTrafficGuard = !previousPublicEndpoints || recoveringUnsafeTraffic || createsWorker || recoveringCreatedWorker || manifest.usageProtocolVersion !== targetUsageProtocolVersion || requiresSandboxDrain;
|
|
1785
|
+
if (requiresTrafficGuard) {
|
|
1786
|
+
await dependencies.client.putKvValue(plan.account.id, requireId(ids, "routing-kv"), PLATFORM_DISABLED_KEY, PLATFORM_DISABLED_VALUE);
|
|
1787
|
+
workerTrafficGuarded = true;
|
|
1788
|
+
}
|
|
1789
|
+
if (!preserveDisabled) {
|
|
1790
|
+
const trafficGeneration = nextTrafficGeneration(manifest);
|
|
1791
|
+
manifest = recordInstallationStep(manifest, {
|
|
1792
|
+
completedAt: now(),
|
|
1793
|
+
key: `traffic-enabled:${trafficGeneration}`,
|
|
1794
|
+
resultHash: String(trafficGeneration)
|
|
1795
|
+
}, now());
|
|
1796
|
+
writeInstallationManifest(manifest);
|
|
1797
|
+
await writeRemoteInstallationCheckpoint(dependencies.client, plan, manifest);
|
|
1798
|
+
}
|
|
1799
|
+
const configInput = {
|
|
1800
|
+
accountId: plan.account.id,
|
|
1801
|
+
apiUrl: plan.apiUrl,
|
|
1802
|
+
deployment: plan.deployment,
|
|
1803
|
+
healthProbeToken: createPlatformHealthProbeToken(manifest.ownershipNonce),
|
|
1804
|
+
installationId: manifest.id,
|
|
1805
|
+
ownershipNonce: manifest.ownershipNonce,
|
|
1806
|
+
platformVersion: version,
|
|
1807
|
+
proxyUrl: plan.proxyUrl,
|
|
1808
|
+
resources: {
|
|
1809
|
+
hyperdrive: requireId(ids, "hyperdrive"),
|
|
1810
|
+
isrCacheKv: requireId(ids, "isr-cache-kv"),
|
|
1811
|
+
legacyAssetsKv: requireId(ids, "legacy-assets-kv"),
|
|
1812
|
+
routingKv: requireId(ids, "routing-kv"),
|
|
1813
|
+
zone: zoneId
|
|
1814
|
+
},
|
|
1815
|
+
runtimeEntrypoints: entrypoints
|
|
1816
|
+
};
|
|
1817
|
+
const requiresStagedCommit = !preserveDisabled && requiresTrafficGuard;
|
|
1818
|
+
const enabledConfigs = createPlatformWorkerConfigs({
|
|
1819
|
+
...configInput,
|
|
1820
|
+
publicEndpoints: !preserveDisabled,
|
|
1821
|
+
trafficState: preserveDisabled ? "staging" : "enabled"
|
|
1822
|
+
});
|
|
1823
|
+
const configs = requiresStagedCommit ? createPlatformWorkerConfigs({
|
|
1824
|
+
...configInput,
|
|
1825
|
+
publicEndpoints: true,
|
|
1826
|
+
trafficState: requiresSandboxDrain ? "sandbox-drain" : "staging"
|
|
1827
|
+
}) : enabledConfigs;
|
|
1828
|
+
const previousConfigs = createPlatformWorkerConfigs({
|
|
1829
|
+
...configInput,
|
|
1830
|
+
publicEndpoints: previousPublicEndpoints,
|
|
1831
|
+
trafficState: previousPublicEndpoints ? "enabled" : "staging"
|
|
1832
|
+
});
|
|
1833
|
+
const managedRoutes = platformManagedWorkerTraffic(plan.deployment).routes;
|
|
1834
|
+
let workerActions = plan.actions.filter((action) => action.kind === "worker");
|
|
1835
|
+
if (interruptedCutover) {
|
|
1836
|
+
for (const key of [...interruptedCutover.staged.keys()].reverse()) {
|
|
1837
|
+
const snapshot = interruptedCutover.staged.get(key);
|
|
1838
|
+
const preservedRoutes = dependencies.recoverWorkerCutover ? [] : await inventoryUnmanagedPlatformWorkerTraffic(dependencies.client, plan, snapshot.workerName);
|
|
1839
|
+
const recoveryConfig = mergePlatformWorkerTrafficConfig({
|
|
1840
|
+
...configs[key],
|
|
1841
|
+
name: snapshot.workerName
|
|
1842
|
+
}, preservedRoutes);
|
|
1843
|
+
const previousRecoveryConfig = mergePlatformWorkerTrafficConfig({
|
|
1844
|
+
...previousConfigs[key],
|
|
1845
|
+
name: snapshot.workerName
|
|
1846
|
+
}, preservedRoutes);
|
|
1847
|
+
if (dependencies.recoverWorkerCutover) await dependencies.recoverWorkerCutover({
|
|
1848
|
+
key,
|
|
1849
|
+
preserveCustomDomains: interruptedCutover.unsafeTraffic.has(key),
|
|
1850
|
+
previousVersionId: snapshot.previousVersionId,
|
|
1851
|
+
stagedVersionId: snapshot.stagedVersionId,
|
|
1852
|
+
workerName: snapshot.workerName
|
|
1853
|
+
});
|
|
1854
|
+
else {
|
|
1855
|
+
const recoveryTraffic = isolatePlatformWorkerCustomDomains(recoveryConfig);
|
|
1856
|
+
const previousRecoveryTraffic = isolatePlatformWorkerCustomDomains(previousRecoveryConfig);
|
|
1857
|
+
await rollbackPlatformWorker({
|
|
1858
|
+
accountId: plan.account.id,
|
|
1859
|
+
activatedDeploymentId: null,
|
|
1860
|
+
activatedVersionId: snapshot.stagedVersionId,
|
|
1861
|
+
config: recoveryConfig,
|
|
1862
|
+
nextTriggers: readTriggerConfigFromObject(recoveryTraffic.wranglerConfig),
|
|
1863
|
+
previousConfig: previousRecoveryConfig,
|
|
1864
|
+
previousCustomDomains: previousRecoveryTraffic.customDomains,
|
|
1865
|
+
previousDeployment: null,
|
|
1866
|
+
previousTriggers: mergePreservedTriggerRoutes(withoutPlatformCustomDomainTriggers(snapshot.previousTriggers), preservedRoutes),
|
|
1867
|
+
previousVersionId: snapshot.previousVersionId,
|
|
1868
|
+
preserveCustomDomains: interruptedCutover.unsafeTraffic.has(key),
|
|
1869
|
+
inspectNewWorker: () => inspectNewPlatformWorker(dependencies.client, plan.account.id, manifest, `${key}-worker`, snapshot.workerName),
|
|
1870
|
+
removeCustomDomains: async () => {
|
|
1871
|
+
if (!dependencies.client.removeRoutes) throw new Error("platform: Cloudflare route removal is unavailable.");
|
|
1872
|
+
await dependencies.client.removeRoutes(plan.account.id, zoneId, trackedPlatformWorkerTraffic(manifest, /* @__PURE__ */ new Set([snapshot.workerName])));
|
|
1873
|
+
},
|
|
1874
|
+
publishCustomDomains: (workerName, origins) => publishPlatformWorkerCustomDomains(dependencies.client, plan.account.id, workerName, origins),
|
|
1875
|
+
retainNewWorker: async () => {
|
|
1876
|
+
await dependencies.client.putKvValue(plan.account.id, requireId(ids, "routing-kv"), PLATFORM_DISABLED_KEY, PLATFORM_DISABLED_VALUE);
|
|
1877
|
+
},
|
|
1878
|
+
runner: runPinnedPlatformWrangler,
|
|
1879
|
+
workerExisted: snapshot.previousVersionId !== null,
|
|
1880
|
+
workerName: snapshot.workerName
|
|
1881
|
+
});
|
|
1882
|
+
}
|
|
1883
|
+
}
|
|
1884
|
+
manifest = recordWorkerCutoverResolution(manifest, interruptedCutover.generation, "restored", now());
|
|
1885
|
+
manifest = recordWorkerTrafficState(manifest, interruptedCutover.generation, previousPublicEndpoints && interruptedCutover.unsafeTraffic.size === 0, now());
|
|
1886
|
+
let retainedNewWorker = false;
|
|
1887
|
+
for (const [, snapshot] of interruptedCutover.staged) {
|
|
1888
|
+
if (snapshot.previousVersionId !== null) continue;
|
|
1889
|
+
retainedNewWorker = true;
|
|
1890
|
+
}
|
|
1891
|
+
writeInstallationManifest(manifest);
|
|
1892
|
+
await writeRemoteInstallationCheckpoint(dependencies.client, plan, manifest);
|
|
1893
|
+
if (retainedNewWorker) {
|
|
1894
|
+
if (!dependencies.replanWorkers) throw new Error("platform: Recovering retained Workers requires a fresh Cloudflare installation plan.");
|
|
1895
|
+
workerActions = validateReplannedWorkerActions(plan, manifest, await dependencies.replanWorkers(manifest));
|
|
1896
|
+
}
|
|
1897
|
+
}
|
|
1898
|
+
workerCutoverGeneration = nextWorkerCutoverGeneration(manifest);
|
|
1899
|
+
if (workerSecretUpdateIntent) {
|
|
1900
|
+
manifest = recordWorkerSecretUpdateState(manifest, workerCutoverGeneration, "pending", workerSecretUpdateIntent.id, now());
|
|
1901
|
+
writeInstallationManifest(manifest);
|
|
1902
|
+
await writeRemoteInstallationCheckpoint(dependencies.client, plan, manifest);
|
|
1903
|
+
workerSecretUpdateIntent = {
|
|
1904
|
+
...workerSecretUpdateIntent,
|
|
1905
|
+
generation: workerCutoverGeneration
|
|
1906
|
+
};
|
|
1907
|
+
writePendingInstallationSecrets(manifest, secretCheckpoint(secrets, workerSecretUpdateIntent));
|
|
1908
|
+
}
|
|
1909
|
+
for (const key of [
|
|
1910
|
+
"api",
|
|
1911
|
+
"proxy",
|
|
1912
|
+
"tail",
|
|
1913
|
+
"dispatch"
|
|
1914
|
+
]) {
|
|
1915
|
+
dependencies.onProgress?.(`Deploying the ${key} service`);
|
|
1916
|
+
const action = workerActions.find((entry) => entry.key === `${key}-worker`);
|
|
1917
|
+
const workerSecrets = platformWorkerSecretPayload(key, workerDeploymentSecrets, plan.account.id, zoneId, action.action !== "create");
|
|
1918
|
+
manifest = recordProvisionIntent(manifest, action, now());
|
|
1919
|
+
writeInstallationManifest(manifest);
|
|
1920
|
+
await writeRemoteInstallationCheckpoint(dependencies.client, plan, manifest);
|
|
1921
|
+
await dependencies.revalidateBeforeWorkerDeploy(manifest, action);
|
|
1922
|
+
const preservedRoutes = dependencies.deployWorker ? [] : action.action === "create" ? [] : await inventoryUnmanagedPlatformWorkerTraffic(dependencies.client, plan, action.name);
|
|
1923
|
+
const workerConfig = mergePlatformWorkerTrafficConfig(configs[key], preservedRoutes);
|
|
1924
|
+
const previousWorkerConfig = mergePlatformWorkerTrafficConfig(previousConfigs[key], preservedRoutes);
|
|
1925
|
+
const rollback = dependencies.deployWorker ? await dependencies.deployWorker(workerConfig, workerSecrets, previousWorkerConfig) : await deployPlatformWorker(workerConfig, workerSecrets, {
|
|
1926
|
+
apiBaseUrl: cloudflareApiBaseUrl,
|
|
1927
|
+
onStaged: async (previousVersionId, stagedVersionId, previousTriggers) => {
|
|
1928
|
+
manifest = recordWorkerCutoverStage(manifest, workerCutoverGeneration, key, previousVersionId, stagedVersionId, action.name, previousTriggers, now());
|
|
1929
|
+
writeInstallationManifest(manifest);
|
|
1930
|
+
await writeRemoteInstallationCheckpoint(dependencies.client, plan, manifest);
|
|
1931
|
+
},
|
|
1932
|
+
onTrafficPublished: async () => {
|
|
1933
|
+
if (!dependencies.client.listWorkerDomains || !dependencies.client.listWorkerRoutes) throw new Error("platform: Cloudflare traffic inventory is unavailable.");
|
|
1934
|
+
const [customDomains, routes] = await Promise.all([dependencies.client.listWorkerDomains(plan.account.id), zoneId ? dependencies.client.listWorkerRoutes(zoneId) : Promise.resolve([])]);
|
|
1935
|
+
manifest = recordPlatformWorkerTraffic(manifest, action.name, {
|
|
1936
|
+
customDomains,
|
|
1937
|
+
routes
|
|
1938
|
+
}, now());
|
|
1939
|
+
writeInstallationManifest(manifest);
|
|
1940
|
+
await writeRemoteInstallationCheckpoint(dependencies.client, plan, manifest);
|
|
1941
|
+
},
|
|
1942
|
+
onUnsafeTraffic: async () => {
|
|
1943
|
+
manifest = recordWorkerUnsafeTraffic(manifest, workerCutoverGeneration, key, action.name, now());
|
|
1944
|
+
writeInstallationManifest(manifest);
|
|
1945
|
+
await writeRemoteInstallationCheckpoint(dependencies.client, plan, manifest);
|
|
1946
|
+
},
|
|
1947
|
+
managedRoutes: preserveDisabled ? [] : managedRoutes.filter((route) => route.script === action.name).map((route) => route.pattern),
|
|
1948
|
+
preservedRoutes,
|
|
1949
|
+
publishCustomDomains: (workerName, origins) => publishPlatformWorkerCustomDomains(dependencies.client, plan.account.id, workerName, origins),
|
|
1950
|
+
publishRoutes: async (workerName, patterns) => {
|
|
1951
|
+
if (!dependencies.client.publishWorkerRoutesSafely) throw new Error("platform: Safe Cloudflare Worker route publication is unavailable.");
|
|
1952
|
+
if (zoneId) await dependencies.client.publishWorkerRoutesSafely(zoneId, workerName, patterns);
|
|
1953
|
+
else if (patterns.length > 0) throw new Error("platform: Cannot publish zone routes in workers.dev mode.");
|
|
1954
|
+
},
|
|
1955
|
+
previousConfig: previousWorkerConfig,
|
|
1956
|
+
previousPublicEndpoints,
|
|
1957
|
+
inspectNewWorker: () => inspectNewPlatformWorker(dependencies.client, plan.account.id, manifest, action.key, action.name),
|
|
1958
|
+
removeCustomDomains: async () => {
|
|
1959
|
+
if (!dependencies.client.removeRoutes) throw new Error("platform: Cloudflare route removal is unavailable.");
|
|
1960
|
+
await dependencies.client.removeRoutes(plan.account.id, zoneId, trackedPlatformWorkerTraffic(manifest, /* @__PURE__ */ new Set([action.name])));
|
|
1961
|
+
},
|
|
1962
|
+
retainNewWorker: async () => {
|
|
1963
|
+
await dependencies.client.putKvValue(plan.account.id, requireId(ids, "routing-kv"), PLATFORM_DISABLED_KEY, PLATFORM_DISABLED_VALUE);
|
|
1964
|
+
},
|
|
1965
|
+
runner: runPinnedPlatformWrangler,
|
|
1966
|
+
workerExisted: action.action !== "create"
|
|
1967
|
+
});
|
|
1968
|
+
if (rollback) workerRollbacks.push(rollback);
|
|
1969
|
+
manifest = recordResource(manifest, action, action.name, "created", now());
|
|
1970
|
+
writeInstallationManifest(manifest);
|
|
1971
|
+
await writeRemoteInstallationCheckpoint(dependencies.client, plan, manifest);
|
|
1972
|
+
}
|
|
1973
|
+
manifest = setInstallationEndpoints(manifest, {
|
|
1974
|
+
apiUrl: plan.apiUrl,
|
|
1975
|
+
dashboardUrl: null,
|
|
1976
|
+
proxyUrl: plan.proxyUrl
|
|
1977
|
+
}, now());
|
|
1978
|
+
if (preserveDisabled) {
|
|
1979
|
+
workerDeploymentCommitted = true;
|
|
1980
|
+
manifest = recordWorkerCutoverResolution(manifest, workerCutoverGeneration, "committed", now());
|
|
1981
|
+
manifest = recordWorkerTrafficState(manifest, workerCutoverGeneration, false, now());
|
|
1982
|
+
if (workerSecretUpdateIntent) manifest = recordWorkerSecretUpdateState(manifest, workerCutoverGeneration, "committed", workerSecretUpdateIntent.id, now());
|
|
1983
|
+
manifest = {
|
|
1984
|
+
...manifest,
|
|
1985
|
+
platformVersion: version,
|
|
1986
|
+
runtime: runtimeProvenance,
|
|
1987
|
+
updatedAt: now(),
|
|
1988
|
+
usageProtocolVersion: targetUsageProtocolVersion
|
|
1989
|
+
};
|
|
1990
|
+
manifest = transitionInstallation(manifest, "disabled", now());
|
|
1991
|
+
writeInstallationManifest(manifest);
|
|
1992
|
+
await writeRemoteInstallationCheckpoint(dependencies.client, plan, manifest);
|
|
1993
|
+
writePendingInstallationSecrets(manifest, secretCheckpoint(secrets));
|
|
1994
|
+
promoteInstallationSecrets(manifest);
|
|
1995
|
+
saveInstalledPlatformConnection(plan, manifest);
|
|
1996
|
+
return {
|
|
1997
|
+
manifest,
|
|
1998
|
+
waitingForDns: false,
|
|
1999
|
+
waitingForSandboxDrain: false,
|
|
2000
|
+
zoneNameServers: zone?.nameServers ?? []
|
|
2001
|
+
};
|
|
2002
|
+
}
|
|
2003
|
+
manifest = transitionInstallation(manifest, "verifying", now());
|
|
2004
|
+
writeInstallationManifest(manifest);
|
|
2005
|
+
dependencies.onProgress?.("Verifying platform health");
|
|
2006
|
+
const healthCheckContext = {
|
|
2007
|
+
accountName: plan.account.name,
|
|
2008
|
+
workers: [
|
|
2009
|
+
plan.deployment.resources.apiWorker,
|
|
2010
|
+
plan.deployment.resources.proxyWorker,
|
|
2011
|
+
plan.deployment.resources.dispatchWorker
|
|
2012
|
+
],
|
|
2013
|
+
resumeCommand: dependencies.healthCheckResumeCommand
|
|
2014
|
+
};
|
|
2015
|
+
await (dependencies.verifyHealth ?? verifyPlatformHealth)(plan.apiUrl, manifest.id, plan.proxyUrl, plan.deployment.application.baseDomain, version, targetUsageProtocolVersion, createPlatformHealthProbeToken(manifest.ownershipNonce), void 0, !plan.deployment.application.zoneName ? `https://${plan.deployment.resources.dispatchWorker}.${plan.workersSubdomain}.workers.dev` : void 0, healthCheckContext);
|
|
2016
|
+
if (requiresSandboxDrain) {
|
|
2017
|
+
if (!await (dependencies.verifySandboxDrain ?? verifyPlatformSandboxDrain)(plan.apiUrl, createPlatformHealthProbeToken(manifest.ownershipNonce))) {
|
|
2018
|
+
manifest = recordWorkerCutoverResolution(manifest, workerCutoverGeneration, "committed", now());
|
|
2019
|
+
manifest = recordWorkerTrafficState(manifest, workerCutoverGeneration, false, now());
|
|
2020
|
+
manifest = recordInstallationStep(manifest, {
|
|
2021
|
+
completedAt: now(),
|
|
2022
|
+
key: "sandbox-drain:maintenance-runtime:v1",
|
|
2023
|
+
resultHash: resolvedRuntime.manifestSha256
|
|
2024
|
+
}, now());
|
|
2025
|
+
writeInstallationManifest(manifest);
|
|
2026
|
+
await writeRemoteInstallationCheckpoint(dependencies.client, plan, manifest);
|
|
2027
|
+
workerRollbacks = [];
|
|
2028
|
+
workerDeploymentCommitted = true;
|
|
2029
|
+
saveInstalledPlatformConnection(plan, manifest);
|
|
2030
|
+
return {
|
|
2031
|
+
manifest,
|
|
2032
|
+
waitingForDns: false,
|
|
2033
|
+
waitingForSandboxDrain: true,
|
|
2034
|
+
zoneNameServers: zone?.nameServers ?? []
|
|
2035
|
+
};
|
|
2036
|
+
}
|
|
2037
|
+
const completedDrain = await prepareSandboxDrain();
|
|
2038
|
+
if (completedDrain.state !== "complete") throw new Error("platform: Sandbox drain completion changed while it was being verified. Traffic remains disabled.");
|
|
2039
|
+
await writeSandboxDrainMirror(completedDrain);
|
|
2040
|
+
}
|
|
2041
|
+
if (requiresStagedCommit) {
|
|
2042
|
+
manifest = recordWorkerCutoverResolution(manifest, workerCutoverGeneration, "committed", now());
|
|
2043
|
+
manifest = recordWorkerTrafficState(manifest, workerCutoverGeneration, false, now());
|
|
2044
|
+
writeInstallationManifest(manifest);
|
|
2045
|
+
await writeRemoteInstallationCheckpoint(dependencies.client, plan, manifest);
|
|
2046
|
+
workerRollbacks = [];
|
|
2047
|
+
workerCutoverGeneration = nextWorkerCutoverGeneration(manifest);
|
|
2048
|
+
if (workerSecretUpdateIntent) {
|
|
2049
|
+
manifest = recordWorkerSecretUpdateState(manifest, workerCutoverGeneration, "pending", workerSecretUpdateIntent.id, now());
|
|
2050
|
+
writeInstallationManifest(manifest);
|
|
2051
|
+
await writeRemoteInstallationCheckpoint(dependencies.client, plan, manifest);
|
|
2052
|
+
workerSecretUpdateIntent = {
|
|
2053
|
+
...workerSecretUpdateIntent,
|
|
2054
|
+
generation: workerCutoverGeneration
|
|
2055
|
+
};
|
|
2056
|
+
writePendingInstallationSecrets(manifest, secretCheckpoint(secrets, workerSecretUpdateIntent));
|
|
2057
|
+
}
|
|
2058
|
+
for (const key of [
|
|
2059
|
+
"api",
|
|
2060
|
+
"proxy",
|
|
2061
|
+
"tail",
|
|
2062
|
+
"dispatch"
|
|
2063
|
+
]) {
|
|
2064
|
+
dependencies.onProgress?.(`Enabling the ${key} service`);
|
|
2065
|
+
const action = workerActions.find((entry) => entry.key === `${key}-worker`);
|
|
2066
|
+
const workerSecrets = platformWorkerSecretPayload(key, workerDeploymentSecrets, plan.account.id, zoneId, action.action !== "create");
|
|
2067
|
+
const preservedRoutes = dependencies.deployWorker ? [] : await inventoryUnmanagedPlatformWorkerTraffic(dependencies.client, plan, action.name);
|
|
2068
|
+
const workerConfig = mergePlatformWorkerTrafficConfig(enabledConfigs[key], preservedRoutes);
|
|
2069
|
+
const previousWorkerConfig = mergePlatformWorkerTrafficConfig(configs[key], preservedRoutes);
|
|
2070
|
+
const rollback = dependencies.deployWorker ? await dependencies.deployWorker(workerConfig, workerSecrets, previousWorkerConfig) : await deployPlatformWorker(workerConfig, workerSecrets, {
|
|
2071
|
+
apiBaseUrl: cloudflareApiBaseUrl,
|
|
2072
|
+
onStaged: async (previousVersionId, stagedVersionId, previousTriggers) => {
|
|
2073
|
+
manifest = recordWorkerCutoverStage(manifest, workerCutoverGeneration, key, previousVersionId, stagedVersionId, action.name, previousTriggers, now());
|
|
2074
|
+
writeInstallationManifest(manifest);
|
|
2075
|
+
await writeRemoteInstallationCheckpoint(dependencies.client, plan, manifest);
|
|
2076
|
+
},
|
|
2077
|
+
onTrafficPublished: async () => {
|
|
2078
|
+
if (!dependencies.client.listWorkerDomains || !dependencies.client.listWorkerRoutes) throw new Error("platform: Cloudflare traffic inventory is unavailable.");
|
|
2079
|
+
const [customDomains, routes] = await Promise.all([dependencies.client.listWorkerDomains(plan.account.id), zoneId ? dependencies.client.listWorkerRoutes(zoneId) : Promise.resolve([])]);
|
|
2080
|
+
manifest = recordPlatformWorkerTraffic(manifest, action.name, {
|
|
2081
|
+
customDomains,
|
|
2082
|
+
routes
|
|
2083
|
+
}, now());
|
|
2084
|
+
writeInstallationManifest(manifest);
|
|
2085
|
+
await writeRemoteInstallationCheckpoint(dependencies.client, plan, manifest);
|
|
2086
|
+
},
|
|
2087
|
+
onUnsafeTraffic: async () => {
|
|
2088
|
+
manifest = recordWorkerUnsafeTraffic(manifest, workerCutoverGeneration, key, action.name, now());
|
|
2089
|
+
writeInstallationManifest(manifest);
|
|
2090
|
+
await writeRemoteInstallationCheckpoint(dependencies.client, plan, manifest);
|
|
2091
|
+
},
|
|
2092
|
+
managedRoutes: managedRoutes.filter((route) => route.script === action.name).map((route) => route.pattern),
|
|
2093
|
+
preservedRoutes,
|
|
2094
|
+
publishCustomDomains: (workerName, origins) => publishPlatformWorkerCustomDomains(dependencies.client, plan.account.id, workerName, origins),
|
|
2095
|
+
publishRoutes: async (workerName, patterns) => {
|
|
2096
|
+
if (!dependencies.client.publishWorkerRoutesSafely) throw new Error("platform: Safe Cloudflare Worker route publication is unavailable.");
|
|
2097
|
+
if (zoneId) await dependencies.client.publishWorkerRoutesSafely(zoneId, workerName, patterns);
|
|
2098
|
+
else if (patterns.length > 0) throw new Error("platform: Cannot publish zone routes in workers.dev mode.");
|
|
2099
|
+
},
|
|
2100
|
+
previousConfig: previousWorkerConfig,
|
|
2101
|
+
previousPublicEndpoints: true,
|
|
2102
|
+
inspectNewWorker: () => inspectNewPlatformWorker(dependencies.client, plan.account.id, manifest, action.key, action.name),
|
|
2103
|
+
removeCustomDomains: async () => {
|
|
2104
|
+
if (!dependencies.client.removeRoutes) throw new Error("platform: Cloudflare route removal is unavailable.");
|
|
2105
|
+
await dependencies.client.removeRoutes(plan.account.id, zoneId, trackedPlatformWorkerTraffic(manifest, /* @__PURE__ */ new Set([action.name])));
|
|
2106
|
+
},
|
|
2107
|
+
retainNewWorker: async () => {
|
|
2108
|
+
await dependencies.client.putKvValue(plan.account.id, requireId(ids, "routing-kv"), PLATFORM_DISABLED_KEY, PLATFORM_DISABLED_VALUE);
|
|
2109
|
+
},
|
|
2110
|
+
runner: runPinnedPlatformWrangler,
|
|
2111
|
+
workerExisted: true
|
|
2112
|
+
});
|
|
2113
|
+
if (rollback) workerRollbacks.push(rollback);
|
|
2114
|
+
}
|
|
2115
|
+
}
|
|
2116
|
+
const verifyCurrentWorkerCutover = async () => {
|
|
2117
|
+
if (!requiresStagedCommit) return;
|
|
2118
|
+
if (dependencies.verifyWorkerCutover) await dependencies.verifyWorkerCutover(manifest, workerCutoverGeneration);
|
|
2119
|
+
else if (!dependencies.deployWorker) await verifyPlatformWorkerCutoverVersions(manifest, workerCutoverGeneration, enabledConfigs, plan.account.id, runPinnedPlatformWrangler);
|
|
2120
|
+
};
|
|
2121
|
+
await verifyCurrentWorkerCutover();
|
|
2122
|
+
if (dependencies.client.deleteKvValue) await dependencies.client.deleteKvValue(plan.account.id, requireId(ids, "routing-kv"), PLATFORM_DISABLED_KEY);
|
|
2123
|
+
else if (wasDisabled || !previousPublicEndpoints) throw new Error("platform: Routing disablement removal is unavailable.");
|
|
2124
|
+
if (requiresStagedCommit) {
|
|
2125
|
+
dependencies.onProgress?.("Verifying the enabled platform");
|
|
2126
|
+
await (dependencies.verifyHealth ?? verifyPlatformHealth)(plan.apiUrl, manifest.id, plan.proxyUrl, plan.deployment.application.baseDomain, version, targetUsageProtocolVersion, createPlatformHealthProbeToken(manifest.ownershipNonce), void 0, !plan.deployment.application.zoneName ? `https://${plan.deployment.resources.dispatchWorker}.${plan.workersSubdomain}.workers.dev` : void 0, healthCheckContext);
|
|
2127
|
+
await verifyCurrentWorkerCutover();
|
|
2128
|
+
}
|
|
2129
|
+
workerDeploymentCommitted = true;
|
|
2130
|
+
manifest = recordWorkerCutoverResolution(manifest, workerCutoverGeneration, "committed", now());
|
|
2131
|
+
manifest = recordWorkerTrafficState(manifest, workerCutoverGeneration, true, now());
|
|
2132
|
+
if (workerSecretUpdateIntent) manifest = recordWorkerSecretUpdateState(manifest, workerCutoverGeneration, "committed", workerSecretUpdateIntent.id, now());
|
|
2133
|
+
manifest = {
|
|
2134
|
+
...manifest,
|
|
2135
|
+
platformVersion: version,
|
|
2136
|
+
runtime: runtimeProvenance,
|
|
2137
|
+
safetyProtocols: runtime.safetyProtocols ?? { sandboxDrain: 0 },
|
|
2138
|
+
updatedAt: now(),
|
|
2139
|
+
usageProtocolVersion: targetUsageProtocolVersion
|
|
2140
|
+
};
|
|
2141
|
+
manifest = transitionInstallation(manifest, "installed", now());
|
|
2142
|
+
writeInstallationManifest(manifest);
|
|
2143
|
+
await writeRemoteInstallationCheckpoint(dependencies.client, plan, manifest);
|
|
2144
|
+
workerTrafficGuarded = false;
|
|
2145
|
+
writePendingInstallationSecrets(manifest, secretCheckpoint(secrets));
|
|
2146
|
+
promoteInstallationSecrets(manifest);
|
|
2147
|
+
saveInstalledPlatformConnection(plan, manifest);
|
|
2148
|
+
return {
|
|
2149
|
+
manifest,
|
|
2150
|
+
waitingForDns: false,
|
|
2151
|
+
waitingForSandboxDrain: false,
|
|
2152
|
+
zoneNameServers: zone?.nameServers ?? []
|
|
2153
|
+
};
|
|
2154
|
+
} catch (error) {
|
|
2155
|
+
let failure = error;
|
|
2156
|
+
const currentWorkerRecoveryIncomplete = error instanceof PlatformWorkerRecoveryIncompleteError;
|
|
2157
|
+
if (workerTrafficGuarded) try {
|
|
2158
|
+
await dependencies.client.putKvValue(plan.account.id, requireId(Object.fromEntries(manifest.resources.map((resource) => [resource.key, resource.id])), "routing-kv"), PLATFORM_DISABLED_KEY, PLATFORM_DISABLED_VALUE);
|
|
2159
|
+
} catch (guardError) {
|
|
2160
|
+
failure = new Error(`platform: ${errorMessage(error)} The installation also failed to retain its routing safety gate: ${errorMessage(guardError)}`, { cause: error });
|
|
2161
|
+
}
|
|
2162
|
+
if (!workerDeploymentCommitted && workerRollbacks.length > 0) {
|
|
2163
|
+
const rollbackFailures = [];
|
|
2164
|
+
for (const rollback of workerRollbacks.reverse()) try {
|
|
2165
|
+
await rollback();
|
|
2166
|
+
} catch (rollbackError) {
|
|
2167
|
+
rollbackFailures.push(errorMessage(rollbackError));
|
|
2168
|
+
}
|
|
2169
|
+
workerRollbacks = [];
|
|
2170
|
+
if (rollbackFailures.length > 0 || currentWorkerRecoveryIncomplete) failure = new Error(rollbackFailures.length > 0 ? `platform: ${errorMessage(error)} Automatic Worker rollback was incomplete: ${rollbackFailures.join("; ")} Inspect the installation before retrying.` : `platform: ${errorMessage(error)} The current Worker rollback was incomplete. The cutover remains unresolved and will be recovered on retry.`, { cause: error });
|
|
2171
|
+
else if (workerCutoverGeneration > 0) {
|
|
2172
|
+
manifest = recordWorkerCutoverResolution(manifest, workerCutoverGeneration, "restored", now());
|
|
2173
|
+
manifest = recordWorkerTrafficState(manifest, workerCutoverGeneration, previousPublicEndpoints, now());
|
|
2174
|
+
writeInstallationManifest(manifest);
|
|
2175
|
+
}
|
|
2176
|
+
}
|
|
2177
|
+
if (manifest.status !== "failed" && manifest.status !== "uninstalled") try {
|
|
2178
|
+
manifest = transitionInstallation(manifest, "failed", now());
|
|
2179
|
+
writeInstallationManifest(manifest);
|
|
2180
|
+
await writeRemoteInstallationCheckpoint(dependencies.client, plan, manifest);
|
|
2181
|
+
} catch {}
|
|
2182
|
+
throw failure;
|
|
2183
|
+
} finally {
|
|
2184
|
+
await releaseDatabaseLock();
|
|
2185
|
+
}
|
|
2186
|
+
}
|
|
2187
|
+
async function writeR2OwnershipMarkerIfNeeded(client, plan, manifest, action) {
|
|
2188
|
+
if (action.kind !== "r2-bucket") return;
|
|
2189
|
+
const bucket = manifest.resources.find((resource) => resource.key === action.key && resource.state === "ready");
|
|
2190
|
+
if (!bucket || bucket.ownership !== "created") return;
|
|
2191
|
+
await client.putR2Object(plan.account.id, bucket.name, PLATFORM_R2_OWNERSHIP_MARKER_KEY, JSON.stringify(createPlatformResourceOwnershipMarker({
|
|
2192
|
+
accountId: plan.account.id,
|
|
2193
|
+
installationId: manifest.id,
|
|
2194
|
+
ownershipNonce: manifest.ownershipNonce,
|
|
2195
|
+
resourceId: bucket.id,
|
|
2196
|
+
resourceKey: bucket.key,
|
|
2197
|
+
resourceKind: bucket.kind,
|
|
2198
|
+
resourceName: bucket.name
|
|
2199
|
+
})));
|
|
2200
|
+
}
|
|
2201
|
+
function saveInstalledPlatformConnection(plan, manifest) {
|
|
2202
|
+
savePlatformConnection({
|
|
2203
|
+
accountId: plan.account.id,
|
|
2204
|
+
apiUrl: plan.apiUrl,
|
|
2205
|
+
applicationDomain: plan.deployment.application.baseDomain,
|
|
2206
|
+
displayName: plan.deployment.displayName,
|
|
2207
|
+
installationId: manifest.id,
|
|
2208
|
+
kind: "installed",
|
|
2209
|
+
proxyUrl: plan.proxyUrl
|
|
2210
|
+
});
|
|
2211
|
+
}
|
|
2212
|
+
async function writeRemoteInstallationCheckpointWithoutDatabase(client, plan, manifest, databaseLock) {
|
|
2213
|
+
await databaseLock?.checkpoint?.(manifest);
|
|
2214
|
+
const routing = manifest.resources.find((resource) => resource.key === "routing-kv" && resource.kind === "kv-namespace" && resource.ownership === "created" && resource.state === "ready");
|
|
2215
|
+
if (!routing) return;
|
|
2216
|
+
await client.putKvValue(plan.account.id, routing.id, REMOTE_INSTALLATION_MANIFEST_KEY, JSON.stringify(manifest));
|
|
2217
|
+
}
|
|
2218
|
+
function recordProvisionIntent(manifest, action, now) {
|
|
2219
|
+
const previousResourceId = manifest.resources.find((resource) => resource.key === action.key)?.id ?? null;
|
|
2220
|
+
const resultHash = platformProvisionIntentHash(manifest, action.key, action.kind, action.name, previousResourceId);
|
|
2221
|
+
if (manifest.steps.find((step) => (step.key === `provision-intent:${action.key}` || step.key.startsWith(`provision-intent:${action.key}:`)) && step.resultHash === resultHash)) return manifest;
|
|
2222
|
+
return recordInstallationStep(manifest, {
|
|
2223
|
+
completedAt: now,
|
|
2224
|
+
key: `provision-intent:${action.key}:${resultHash.slice(0, 16)}`,
|
|
2225
|
+
resultHash
|
|
2226
|
+
}, now);
|
|
2227
|
+
}
|
|
2228
|
+
function recordAdoptionIntent(manifest, action, id, now) {
|
|
2229
|
+
return recordInstallationStep(manifest, {
|
|
2230
|
+
completedAt: now,
|
|
2231
|
+
key: `adoption-intent:${action.key}`,
|
|
2232
|
+
resultHash: platformAdoptionIntentHash(manifest, action.key, action.kind, id, action.name)
|
|
2233
|
+
}, now);
|
|
2234
|
+
}
|
|
2235
|
+
function hasProvisionIntent(manifest, action) {
|
|
2236
|
+
const previousResourceId = manifest.resources.find((resource) => resource.key === action.key)?.id ?? null;
|
|
2237
|
+
return manifest.steps.some((step) => (step.key === `provision-intent:${action.key}` || step.key.startsWith(`provision-intent:${action.key}:`)) && step.resultHash === platformProvisionIntentHash(manifest, action.key, action.kind, action.name, previousResourceId));
|
|
2238
|
+
}
|
|
2239
|
+
async function createResource(client, plan, action, databaseUrl, zoneId) {
|
|
2240
|
+
switch (action.kind) {
|
|
2241
|
+
case "ai-gateway": return client.createAiGateway(plan.account.id, action.name);
|
|
2242
|
+
case "dns-record": return client.createDnsRecord(zoneId, action.name);
|
|
2243
|
+
case "kv-namespace": return client.createKvNamespace(plan.account.id, action.name);
|
|
2244
|
+
case "r2-bucket": return client.createR2Bucket(plan.account.id, action.name);
|
|
2245
|
+
case "queue": return client.createQueue(plan.account.id, action.name);
|
|
2246
|
+
case "dispatch-namespace": return client.createDispatchNamespace(plan.account.id, action.name);
|
|
2247
|
+
case "hyperdrive": return client.createHyperdrive(plan.account.id, action.name, databaseUrl);
|
|
2248
|
+
default: throw new Error(`platform: Cannot provision ${action.kind} in the resource phase.`);
|
|
2249
|
+
}
|
|
2250
|
+
}
|
|
2251
|
+
function recordResource(manifest, action, id, ownership, now, databaseUrl, resourceFingerprint) {
|
|
2252
|
+
const previous = manifest.resources.find((resource) => resource.key === action.key);
|
|
2253
|
+
return recordInstallationResource(manifest, {
|
|
2254
|
+
configHash: action.kind === "dns-record" ? dnsRecordConfigHash(action.name, "AAAA", "100::", true) : action.kind === "hyperdrive" && databaseUrl ? hyperdriveOriginConfigHash(hyperdriveOriginFromConnectionString(databaseUrl)) : action.kind === "r2-bucket" && resourceFingerprint ? r2BucketConfigHash(action.name, resourceFingerprint) : hash(JSON.stringify({
|
|
2255
|
+
kind: action.kind,
|
|
2256
|
+
name: action.name
|
|
2257
|
+
})),
|
|
2258
|
+
id,
|
|
2259
|
+
key: action.key,
|
|
2260
|
+
kind: action.kind,
|
|
2261
|
+
name: action.name,
|
|
2262
|
+
ownership: ownership ?? previous?.ownership ?? "created",
|
|
2263
|
+
state: "ready"
|
|
2264
|
+
}, now);
|
|
2265
|
+
}
|
|
2266
|
+
function r2BucketConfigHash(name, fingerprint) {
|
|
2267
|
+
return hash(JSON.stringify({
|
|
2268
|
+
fingerprint,
|
|
2269
|
+
kind: "r2-bucket",
|
|
2270
|
+
name
|
|
2271
|
+
}));
|
|
2272
|
+
}
|
|
2273
|
+
function retainReplacedInstallationResource(manifest, action, now) {
|
|
2274
|
+
const previous = manifest.resources.find((resource) => resource.key === action.key);
|
|
2275
|
+
if (action.action !== "create" || !previous || previous.state !== "ready" || previous.name === action.name) return manifest;
|
|
2276
|
+
const retainedKey = `retained:${action.key}:${hash(previous.id).slice(0, 16)}`;
|
|
2277
|
+
const resources = manifest.resources.filter((resource) => resource.key !== action.key && resource.key !== retainedKey).concat({
|
|
2278
|
+
...previous,
|
|
2279
|
+
key: retainedKey,
|
|
2280
|
+
state: "retained"
|
|
2281
|
+
}).sort((left, right) => left.key.localeCompare(right.key));
|
|
2282
|
+
return {
|
|
2283
|
+
...manifest,
|
|
2284
|
+
resources,
|
|
2285
|
+
updatedAt: now
|
|
2286
|
+
};
|
|
2287
|
+
}
|
|
2288
|
+
function validateReplannedWorkerActions(original, manifest, actions) {
|
|
2289
|
+
const expected = original.actions.filter((action) => action.kind === "worker");
|
|
2290
|
+
if (actions.length !== expected.length) throw new Error("platform: Fresh Cloudflare planning did not return every platform Worker.");
|
|
2291
|
+
for (const previous of expected) {
|
|
2292
|
+
const matches = actions.filter((action) => action.key === previous.key);
|
|
2293
|
+
const current = matches[0];
|
|
2294
|
+
if (matches.length !== 1 || !current || current.kind !== "worker" || current.name !== previous.name) throw new Error(`platform: Fresh Cloudflare planning changed the identity of Worker ${JSON.stringify(previous.name)}.`);
|
|
2295
|
+
if (manifest.resources.find((entry) => entry.key === current.key)?.state === "deleted" && current.action !== "create") throw new Error(`platform: Fresh Cloudflare planning did not recreate deleted Worker ${JSON.stringify(current.name)}.`);
|
|
2296
|
+
}
|
|
2297
|
+
return actions;
|
|
2298
|
+
}
|
|
2299
|
+
async function inventoryUnmanagedPlatformWorkerTraffic(client, plan, workerName) {
|
|
2300
|
+
if (!client.listWorkerDomains || !client.listWorkerServiceRoutes) throw new Error(`platform: Cloudflare traffic inventory is required before updating Worker ${JSON.stringify(workerName)}.`);
|
|
2301
|
+
const [routes, domains] = await Promise.all([client.listWorkerServiceRoutes(plan.account.id, workerName), client.listWorkerDomains(plan.account.id)]);
|
|
2302
|
+
const managed = platformManagedWorkerTraffic(plan.deployment, /* @__PURE__ */ new Set([workerName]));
|
|
2303
|
+
const managedRoutes = new Set(managed.routes.map((route) => route.pattern));
|
|
2304
|
+
const managedDomains = new Set(managed.customDomains.map((domain) => domain.hostname));
|
|
2305
|
+
return uniqueTriggerRoutes([...routes.filter((route) => !managedRoutes.has(route.pattern)).map((route) => ({
|
|
2306
|
+
pattern: route.pattern,
|
|
2307
|
+
zone_name: route.zoneName
|
|
2308
|
+
})), ...domains.filter((domain) => domain.service === workerName && !managedDomains.has(domain.hostname)).map((domain) => ({
|
|
2309
|
+
pattern: domain.hostname,
|
|
2310
|
+
custom_domain: true,
|
|
2311
|
+
...domain.zoneName ? { zone_name: domain.zoneName } : {}
|
|
2312
|
+
}))]);
|
|
2313
|
+
}
|
|
2314
|
+
function mergePlatformWorkerTrafficConfig(config, preservedRoutes) {
|
|
2315
|
+
if (preservedRoutes.length === 0) return config;
|
|
2316
|
+
return {
|
|
2317
|
+
...config,
|
|
2318
|
+
routes: uniqueTriggerRoutes([...Array.isArray(config.routes) ? config.routes : [], ...preservedRoutes])
|
|
2319
|
+
};
|
|
2320
|
+
}
|
|
2321
|
+
function mergePreservedTriggerRoutes(config, preservedRoutes) {
|
|
2322
|
+
return preservedRoutes.length === 0 ? config : {
|
|
2323
|
+
...config,
|
|
2324
|
+
routes: uniqueTriggerRoutes([...config.routes, ...preservedRoutes])
|
|
2325
|
+
};
|
|
2326
|
+
}
|
|
2327
|
+
function isolatePlatformWorkerCustomDomains(config) {
|
|
2328
|
+
if (config.routes === void 0) return {
|
|
2329
|
+
customDomains: [],
|
|
2330
|
+
wranglerConfig: config
|
|
2331
|
+
};
|
|
2332
|
+
if (!Array.isArray(config.routes)) throw new Error("platform: Generated Worker routes must be an array.");
|
|
2333
|
+
const customDomains = [];
|
|
2334
|
+
const wranglerRoutes = [];
|
|
2335
|
+
for (const route of config.routes) {
|
|
2336
|
+
if (typeof route !== "object" || route === null || route.custom_domain !== true) {
|
|
2337
|
+
wranglerRoutes.push(route);
|
|
2338
|
+
continue;
|
|
2339
|
+
}
|
|
2340
|
+
const entry = route;
|
|
2341
|
+
const hostname = requiredNonEmptyString(entry.pattern, "Worker Custom Domain hostname");
|
|
2342
|
+
customDomains.push({
|
|
2343
|
+
hostname,
|
|
2344
|
+
...entry.zone_id === void 0 ? {} : { zoneId: requiredNonEmptyString(entry.zone_id, `zone id for ${hostname}`) },
|
|
2345
|
+
...entry.zone_name === void 0 ? {} : { zoneName: requiredNonEmptyString(entry.zone_name, `zone name for ${hostname}`) },
|
|
2346
|
+
...entry.enabled === void 0 ? {} : { enabled: requiredBoolean(entry.enabled, `enabled for ${hostname}`) },
|
|
2347
|
+
...entry.previews_enabled === void 0 ? {} : { previewsEnabled: requiredBoolean(entry.previews_enabled, `previews_enabled for ${hostname}`) }
|
|
2348
|
+
});
|
|
2349
|
+
}
|
|
2350
|
+
return {
|
|
2351
|
+
customDomains,
|
|
2352
|
+
wranglerConfig: {
|
|
2353
|
+
...config,
|
|
2354
|
+
routes: wranglerRoutes
|
|
2355
|
+
}
|
|
2356
|
+
};
|
|
2357
|
+
}
|
|
2358
|
+
function withoutPlatformWorkerTrafficAttachments(config) {
|
|
2359
|
+
const isolated = { ...config };
|
|
2360
|
+
delete isolated.route;
|
|
2361
|
+
delete isolated.routes;
|
|
2362
|
+
return isolated;
|
|
2363
|
+
}
|
|
2364
|
+
function withoutPlatformCustomDomainTriggers(config) {
|
|
2365
|
+
return {
|
|
2366
|
+
...config,
|
|
2367
|
+
routes: config.routes.filter((route) => typeof route !== "object" || route === null || route.custom_domain !== true)
|
|
2368
|
+
};
|
|
2369
|
+
}
|
|
2370
|
+
function requiredNonEmptyString(value, field) {
|
|
2371
|
+
if (typeof value !== "string" || value.length === 0) throw new Error(`platform: ${field} must be a non-empty string.`);
|
|
2372
|
+
return value;
|
|
2373
|
+
}
|
|
2374
|
+
function requiredBoolean(value, field) {
|
|
2375
|
+
if (typeof value !== "boolean") throw new Error(`platform: ${field} must be a boolean.`);
|
|
2376
|
+
return value;
|
|
2377
|
+
}
|
|
2378
|
+
async function publishPlatformWorkerCustomDomains(client, accountId, workerName, origins) {
|
|
2379
|
+
if (!client.publishWorkerCustomDomainsSafely) throw new Error("platform: Safe Cloudflare Custom Domain publication is unavailable.");
|
|
2380
|
+
await client.publishWorkerCustomDomainsSafely(accountId, workerName, origins);
|
|
2381
|
+
}
|
|
2382
|
+
function uniqueTriggerRoutes(routes) {
|
|
2383
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2384
|
+
return routes.filter((route) => {
|
|
2385
|
+
const key = JSON.stringify(route);
|
|
2386
|
+
if (seen.has(key)) return false;
|
|
2387
|
+
seen.add(key);
|
|
2388
|
+
return true;
|
|
2389
|
+
});
|
|
2390
|
+
}
|
|
2391
|
+
function dnsRecordConfigHash(name, type, content, proxied) {
|
|
2392
|
+
return hash(JSON.stringify({
|
|
2393
|
+
content,
|
|
2394
|
+
kind: "dns-record",
|
|
2395
|
+
name,
|
|
2396
|
+
proxied,
|
|
2397
|
+
type
|
|
2398
|
+
}));
|
|
2399
|
+
}
|
|
2400
|
+
function hash(value) {
|
|
2401
|
+
return createHash("sha256").update(value).digest("hex");
|
|
2402
|
+
}
|
|
2403
|
+
function databaseInteger(value) {
|
|
2404
|
+
if (typeof value === "number" && Number.isSafeInteger(value)) return value;
|
|
2405
|
+
if (typeof value === "bigint") {
|
|
2406
|
+
const number = Number(value);
|
|
2407
|
+
return Number.isSafeInteger(number) ? number : null;
|
|
2408
|
+
}
|
|
2409
|
+
if (typeof value === "string" && /^[0-9]+$/.test(value)) {
|
|
2410
|
+
const number = Number(value);
|
|
2411
|
+
return Number.isSafeInteger(number) ? number : null;
|
|
2412
|
+
}
|
|
2413
|
+
return null;
|
|
2414
|
+
}
|
|
2415
|
+
function requireId(ids, key) {
|
|
2416
|
+
const id = ids[key];
|
|
2417
|
+
if (!id) throw new Error(`platform: Installation manifest is missing ${key}.`);
|
|
2418
|
+
return id;
|
|
2419
|
+
}
|
|
2420
|
+
function apiWorkerSecrets(secrets, accountId, zoneId) {
|
|
2421
|
+
return withoutEmptySecrets({
|
|
2422
|
+
CF_ACCOUNT_ID: accountId,
|
|
2423
|
+
CF_API_TOKEN: secrets.cloudflareApiToken,
|
|
2424
|
+
CF_ZONE_ID: zoneId,
|
|
2425
|
+
GITHUB_CLIENT_ID: secrets.githubClientId,
|
|
2426
|
+
GITHUB_CLIENT_SECRET: secrets.githubClientSecret,
|
|
2427
|
+
JWT_SECRET: secrets.jwtSecret,
|
|
2428
|
+
PLATFORM_BOOTSTRAP_GITHUB_ID: secrets.bootstrapGitHubId,
|
|
2429
|
+
...secrets.projectSecretKeysJson && secrets.projectSecretActiveKeyVersion ? {
|
|
2430
|
+
PROJECT_SECRET_ACTIVE_KEY_VERSION: secrets.projectSecretActiveKeyVersion,
|
|
2431
|
+
PROJECT_SECRET_KEYS_JSON: secrets.projectSecretKeysJson
|
|
2432
|
+
} : {},
|
|
2433
|
+
R2_ACCESS_KEY_ID: secrets.r2AccessKeyId,
|
|
2434
|
+
R2_SECRET_ACCESS_KEY: secrets.r2SecretAccessKey
|
|
2435
|
+
});
|
|
2436
|
+
}
|
|
2437
|
+
/** Existing Workers inherit live secrets, including complete encryption keyrings. */
|
|
2438
|
+
function platformWorkerSecretPayload(worker, secrets, accountId, zoneId, existingWorker) {
|
|
2439
|
+
if (worker !== "api" && worker !== "proxy") return null;
|
|
2440
|
+
if (!existingWorker) return worker === "api" ? apiWorkerSecrets(secrets, accountId, zoneId) : proxyWorkerSecrets(secrets, accountId);
|
|
2441
|
+
const allowed = worker === "api" ? [
|
|
2442
|
+
"CF_API_TOKEN",
|
|
2443
|
+
"GITHUB_CLIENT_ID",
|
|
2444
|
+
"GITHUB_CLIENT_SECRET",
|
|
2445
|
+
"R2_ACCESS_KEY_ID",
|
|
2446
|
+
"R2_SECRET_ACCESS_KEY"
|
|
2447
|
+
] : ["CF_API_TOKEN"];
|
|
2448
|
+
const updates = Object.fromEntries(Object.entries(secrets.workerSecretUpdates ?? {}).filter(([name, value]) => allowed.includes(name) && typeof value === "string" && value !== ""));
|
|
2449
|
+
return Object.keys(updates).length > 0 ? updates : null;
|
|
2450
|
+
}
|
|
2451
|
+
async function resolveBootstrapGitHubId(login, fetcher = fetch) {
|
|
2452
|
+
const normalizedLogin = login.trim();
|
|
2453
|
+
if (!normalizedLogin) throw new Error("platform: VOID_PLATFORM_ADMIN_GITHUB_LOGIN is required.");
|
|
2454
|
+
const response = await fetcher(`https://api.github.com/users/${encodeURIComponent(normalizedLogin)}`, {
|
|
2455
|
+
headers: {
|
|
2456
|
+
Accept: "application/vnd.github+json",
|
|
2457
|
+
"User-Agent": "void-platform-installer"
|
|
2458
|
+
},
|
|
2459
|
+
redirect: "follow",
|
|
2460
|
+
signal: AbortSignal.timeout(15e3)
|
|
2461
|
+
});
|
|
2462
|
+
if (!response.ok) throw new Error(`platform: Could not resolve GitHub user ${JSON.stringify(normalizedLogin)} (HTTP ${response.status}). Verify the username and retry.`);
|
|
2463
|
+
const body = await response.json();
|
|
2464
|
+
const id = typeof body.id === "number" && Number.isSafeInteger(body.id) ? String(body.id) : "";
|
|
2465
|
+
if (!/^[1-9][0-9]*$/.test(id)) throw new Error("platform: GitHub returned an invalid account id for the initial administrator.");
|
|
2466
|
+
return id;
|
|
2467
|
+
}
|
|
2468
|
+
function proxyWorkerSecrets(secrets, accountId) {
|
|
2469
|
+
return withoutEmptySecrets({
|
|
2470
|
+
CF_ACCOUNT_ID: accountId,
|
|
2471
|
+
CF_API_TOKEN: secrets.cloudflareApiToken,
|
|
2472
|
+
JWT_SECRET: secrets.jwtSecret
|
|
2473
|
+
});
|
|
2474
|
+
}
|
|
2475
|
+
function withoutEmptySecrets(secrets) {
|
|
2476
|
+
return Object.fromEntries(Object.entries(secrets).filter(([, value]) => value !== ""));
|
|
2477
|
+
}
|
|
2478
|
+
function assertRequiredPlatformSecrets(plan, manifest, secrets) {
|
|
2479
|
+
const missing = /* @__PURE__ */ new Set();
|
|
2480
|
+
const requireSecret = (value, name) => {
|
|
2481
|
+
if (!value) missing.add(name);
|
|
2482
|
+
};
|
|
2483
|
+
if (!manifest.steps.some((step) => step.key === "bootstrap-access:v1")) {
|
|
2484
|
+
requireSecret(secrets.bootstrapGitHubId, "VOID_PLATFORM_ADMIN_GITHUB_ID");
|
|
2485
|
+
requireSecret(secrets.bootstrapGitHubLogin, "VOID_PLATFORM_ADMIN_GITHUB_LOGIN");
|
|
2486
|
+
}
|
|
2487
|
+
if (plan.actions.some((action) => action.key === "api-worker" && action.kind === "worker" && action.action === "create")) {
|
|
2488
|
+
requireSecret(secrets.cloudflareApiToken, "VOID_PLATFORM_RUNTIME_CLOUDFLARE_API_TOKEN");
|
|
2489
|
+
requireSecret(secrets.githubClientId, "VOID_PLATFORM_GITHUB_CLIENT_ID");
|
|
2490
|
+
requireSecret(secrets.githubClientSecret, "VOID_PLATFORM_GITHUB_CLIENT_SECRET");
|
|
2491
|
+
requireSecret(secrets.jwtSecret, "VOID_PLATFORM_JWT_SECRET");
|
|
2492
|
+
requireSecret(secrets.projectSecretKeysJson, "VOID_PLATFORM_PROJECT_SECRET_KEYS_JSON (or VOID_PLATFORM_PROJECT_SECRET_KEY for v1)");
|
|
2493
|
+
requireSecret(secrets.projectSecretActiveKeyVersion, "VOID_PLATFORM_PROJECT_SECRET_ACTIVE_KEY_VERSION (or VOID_PLATFORM_PROJECT_SECRET_KEY for v1)");
|
|
2494
|
+
requireSecret(secrets.r2AccessKeyId, "VOID_PLATFORM_R2_ACCESS_KEY_ID");
|
|
2495
|
+
requireSecret(secrets.r2SecretAccessKey, "VOID_PLATFORM_R2_SECRET_ACCESS_KEY");
|
|
2496
|
+
}
|
|
2497
|
+
if (plan.actions.some((action) => action.key === "proxy-worker" && action.kind === "worker" && action.action === "create")) {
|
|
2498
|
+
requireSecret(secrets.cloudflareApiToken, "VOID_PLATFORM_RUNTIME_CLOUDFLARE_API_TOKEN");
|
|
2499
|
+
requireSecret(secrets.jwtSecret, "VOID_PLATFORM_JWT_SECRET");
|
|
2500
|
+
}
|
|
2501
|
+
if (missing.size > 0) throw new Error(`platform: Creating a missing platform Worker requires: ${[...missing].sort().join(", ")}. Restore these values from your secret manager and retry.`);
|
|
2502
|
+
}
|
|
2503
|
+
/** Make pg's current strict TLS behavior explicit without rewriting saved connection details. */
|
|
2504
|
+
function platformDatabaseClientUrl(databaseUrl) {
|
|
2505
|
+
let url;
|
|
2506
|
+
try {
|
|
2507
|
+
url = new URL(databaseUrl);
|
|
2508
|
+
} catch {
|
|
2509
|
+
return databaseUrl;
|
|
2510
|
+
}
|
|
2511
|
+
if (url.protocol !== "postgres:" && url.protocol !== "postgresql:" || url.searchParams.getAll("sslmode").at(-1) !== "require" || url.searchParams.getAll("uselibpqcompat").at(-1) === "true") return databaseUrl;
|
|
2512
|
+
url.searchParams.set("sslmode", "verify-full");
|
|
2513
|
+
return url.toString();
|
|
2514
|
+
}
|
|
2515
|
+
async function claimPlatformDatabase(databaseUrl, installationId, ownershipNonce) {
|
|
2516
|
+
const { Pool } = await import("pg");
|
|
2517
|
+
const pool = new Pool({
|
|
2518
|
+
connectionString: platformDatabaseClientUrl(databaseUrl),
|
|
2519
|
+
max: 1
|
|
2520
|
+
});
|
|
2521
|
+
const client = await pool.connect();
|
|
2522
|
+
try {
|
|
2523
|
+
await claimPlatformDatabaseConnection(client, installationId, ownershipNonce);
|
|
2524
|
+
} finally {
|
|
2525
|
+
client.release();
|
|
2526
|
+
await pool.end();
|
|
2527
|
+
}
|
|
2528
|
+
}
|
|
2529
|
+
async function acquirePlatformDatabaseLifecycleLock(databaseUrl, installationId, ownershipNonce) {
|
|
2530
|
+
const { Pool } = await import("pg");
|
|
2531
|
+
const pool = new Pool({
|
|
2532
|
+
connectionString: platformDatabaseClientUrl(databaseUrl),
|
|
2533
|
+
max: 1
|
|
2534
|
+
});
|
|
2535
|
+
const client = await pool.connect();
|
|
2536
|
+
try {
|
|
2537
|
+
const releaseConnectionLock = await acquirePlatformDatabaseLifecycleLockConnection(client, installationId, ownershipNonce);
|
|
2538
|
+
const release = async () => {
|
|
2539
|
+
try {
|
|
2540
|
+
await releaseConnectionLock();
|
|
2541
|
+
} finally {
|
|
2542
|
+
client.release();
|
|
2543
|
+
await pool.end();
|
|
2544
|
+
}
|
|
2545
|
+
};
|
|
2546
|
+
return Object.assign(release, releaseConnectionLock);
|
|
2547
|
+
} catch (error) {
|
|
2548
|
+
client.release();
|
|
2549
|
+
await pool.end();
|
|
2550
|
+
throw error;
|
|
2551
|
+
}
|
|
2552
|
+
}
|
|
2553
|
+
async function readPlatformDatabaseLifecycleManifest(databaseUrl, installationId, ownershipNonce) {
|
|
2554
|
+
const { Pool } = await import("pg");
|
|
2555
|
+
const pool = new Pool({
|
|
2556
|
+
connectionString: platformDatabaseClientUrl(databaseUrl),
|
|
2557
|
+
max: 1
|
|
2558
|
+
});
|
|
2559
|
+
try {
|
|
2560
|
+
return await readPlatformDatabaseLifecycleManifestConnection(pool, installationId, ownershipNonce);
|
|
2561
|
+
} catch (error) {
|
|
2562
|
+
if (error instanceof Error && error.message.startsWith("platform:")) throw error;
|
|
2563
|
+
throw new Error(`platform: Could not read the PostgreSQL lifecycle checkpoint: ${error instanceof Error ? error.message : String(error)}`);
|
|
2564
|
+
} finally {
|
|
2565
|
+
await pool.end();
|
|
2566
|
+
}
|
|
2567
|
+
}
|
|
2568
|
+
async function inspectPlatformDatabaseStateConnection(client, database) {
|
|
2569
|
+
const relation = await client.query(`SELECT to_regclass('"drizzle"."__drizzle_migrations"')::text AS "relation"`);
|
|
2570
|
+
if (relation.rows.length === 0 || relation.rows[0]?.relation === null) return {
|
|
2571
|
+
pendingMigrations: database.migrations,
|
|
2572
|
+
schemaDigest: platformDatabaseSchemaDigest(database.lineage, [])
|
|
2573
|
+
};
|
|
2574
|
+
const result = await client.query(`SELECT "id", "hash", "created_at"
|
|
2575
|
+
FROM "drizzle"."__drizzle_migrations"
|
|
2576
|
+
ORDER BY "created_at", "id"`);
|
|
2577
|
+
if (result.rows.length > database.migrations.length) throw new Error("platform: PostgreSQL contains migrations newer than this Void Platform package.");
|
|
2578
|
+
let previousTimestamp = -1;
|
|
2579
|
+
const seenTimestamps = /* @__PURE__ */ new Set();
|
|
2580
|
+
for (const [position, row] of result.rows.entries()) {
|
|
2581
|
+
const timestampMillis = databaseInteger(row.created_at);
|
|
2582
|
+
if (timestampMillis === null || timestampMillis <= previousTimestamp || seenTimestamps.has(timestampMillis)) throw new Error("platform: PostgreSQL migration history is duplicated or not strictly ordered.");
|
|
2583
|
+
const expected = database.migrations[position];
|
|
2584
|
+
if (!expected || timestampMillis !== expected.timestampMillis || row.hash !== expected.sha256) throw new Error("platform: PostgreSQL migration history is not an exact prefix of this Void Platform package. Refusing to apply edited, forked, or out-of-order migrations.");
|
|
2585
|
+
previousTimestamp = timestampMillis;
|
|
2586
|
+
seenTimestamps.add(timestampMillis);
|
|
2587
|
+
}
|
|
2588
|
+
const applied = database.migrations.slice(0, result.rows.length);
|
|
2589
|
+
return {
|
|
2590
|
+
pendingMigrations: database.migrations.slice(result.rows.length),
|
|
2591
|
+
schemaDigest: platformDatabaseSchemaDigest(database.lineage, applied)
|
|
2592
|
+
};
|
|
2593
|
+
}
|
|
2594
|
+
async function preparePlatformDatabaseConnection(client, runtimeDirectory, database, sourcePlatformVersion, hasPriorWorkers) {
|
|
2595
|
+
verifyPlatformDatabaseArtifacts(runtimeDirectory, database);
|
|
2596
|
+
const before = await inspectPlatformDatabaseStateConnection(client, database);
|
|
2597
|
+
if (before.pendingMigrations.length === 0) {
|
|
2598
|
+
if (before.schemaDigest !== database.schemaDigest) throw new Error("platform: PostgreSQL schema digest does not match the packaged runtime.");
|
|
2599
|
+
return {
|
|
2600
|
+
appliedMigrations: 0,
|
|
2601
|
+
previousSchemaDigest: before.schemaDigest,
|
|
2602
|
+
schemaDigest: before.schemaDigest
|
|
2603
|
+
};
|
|
2604
|
+
}
|
|
2605
|
+
if (hasPriorWorkers && !database.rollbackSafeFrom.some((entry) => entry.platformVersion === sourcePlatformVersion && entry.schemaDigest === before.schemaDigest)) throw new Error(`platform: Void Platform ${sourcePlatformVersion ?? "unknown"} cannot safely roll back after migrating database schema ${before.schemaDigest} to this release. Upgrade through a supported intermediate version or install into a fresh database.`);
|
|
2606
|
+
await client.query("BEGIN");
|
|
2607
|
+
try {
|
|
2608
|
+
await client.query("CREATE SCHEMA IF NOT EXISTS \"drizzle\"");
|
|
2609
|
+
await client.query(`
|
|
2610
|
+
CREATE TABLE IF NOT EXISTS "drizzle"."__drizzle_migrations" (
|
|
2611
|
+
"id" serial PRIMARY KEY,
|
|
2612
|
+
"hash" text NOT NULL,
|
|
2613
|
+
"created_at" bigint
|
|
2614
|
+
)
|
|
2615
|
+
`);
|
|
2616
|
+
const rechecked = await inspectPlatformDatabaseStateConnection(client, database);
|
|
2617
|
+
if (rechecked.schemaDigest !== before.schemaDigest) throw new Error("platform: PostgreSQL migration history changed while preparing this upgrade.");
|
|
2618
|
+
for (const migration of rechecked.pendingMigrations) {
|
|
2619
|
+
const sql = readFileSync(join(runtimeDirectory, "drizzle", `${migration.tag}.sql`), "utf8");
|
|
2620
|
+
for (const statement of sql.split("--> statement-breakpoint").map((value) => value.trim()).filter(Boolean)) await client.query(statement);
|
|
2621
|
+
await client.query(`INSERT INTO "drizzle"."__drizzle_migrations" ("hash", "created_at")
|
|
2622
|
+
VALUES ($1, $2)`, [migration.sha256, migration.timestampMillis]);
|
|
2623
|
+
}
|
|
2624
|
+
await client.query("COMMIT");
|
|
2625
|
+
} catch (error) {
|
|
2626
|
+
await client.query("ROLLBACK").catch(() => ({ rows: [] }));
|
|
2627
|
+
throw error;
|
|
2628
|
+
}
|
|
2629
|
+
const after = await inspectPlatformDatabaseStateConnection(client, database);
|
|
2630
|
+
if (after.pendingMigrations.length !== 0 || after.schemaDigest !== database.schemaDigest) throw new Error("platform: PostgreSQL migration transaction completed without reaching the packaged schema.");
|
|
2631
|
+
return {
|
|
2632
|
+
appliedMigrations: before.pendingMigrations.length,
|
|
2633
|
+
previousSchemaDigest: before.schemaDigest,
|
|
2634
|
+
schemaDigest: after.schemaDigest
|
|
2635
|
+
};
|
|
2636
|
+
}
|
|
2637
|
+
async function readPlatformDatabaseLifecycleManifestConnection(client, installationId, ownershipNonce) {
|
|
2638
|
+
const relation = await client.query(`SELECT to_regclass('"__void_platform_installation"')::text AS "relation"`);
|
|
2639
|
+
if (relation.rows[0]?.relation === null || relation.rows.length === 0) return null;
|
|
2640
|
+
const result = await client.query(`SELECT "installation_id", "ownership_nonce", "lifecycle_manifest"
|
|
2641
|
+
FROM "__void_platform_installation"
|
|
2642
|
+
WHERE "singleton" = true`);
|
|
2643
|
+
const row = result.rows[0];
|
|
2644
|
+
if (result.rows.length !== 1 || row?.installation_id !== installationId || row.ownership_nonce !== ownershipNonce) throw new Error("platform: The PostgreSQL database ownership claim does not match this installation.");
|
|
2645
|
+
if (row.lifecycle_manifest === null || row.lifecycle_manifest === void 0) return null;
|
|
2646
|
+
const manifest = parsePlatformInstallationManifest(row.lifecycle_manifest);
|
|
2647
|
+
if (manifest.id !== installationId || manifest.ownershipNonce !== ownershipNonce) throw new Error("platform: The PostgreSQL lifecycle checkpoint does not match this installation.");
|
|
2648
|
+
return manifest;
|
|
2649
|
+
}
|
|
2650
|
+
async function acquirePlatformDatabaseLifecycleLockConnection(client, installationId, ownershipNonce) {
|
|
2651
|
+
await client.query(`ALTER TABLE "__void_platform_installation"
|
|
2652
|
+
ADD COLUMN IF NOT EXISTS "lifecycle_manifest" jsonb`);
|
|
2653
|
+
const owner = (await client.query(`SELECT "installation_id", "ownership_nonce"
|
|
2654
|
+
FROM "__void_platform_installation"
|
|
2655
|
+
WHERE "singleton" = true`)).rows[0];
|
|
2656
|
+
if (!owner || owner.installation_id !== installationId || owner.ownership_nonce !== ownershipNonce) throw new Error("platform: The PostgreSQL database ownership claim does not match this installation.");
|
|
2657
|
+
const lockIdentity = `void-platform-lifecycle-v1:${installationId}:${ownershipNonce}`;
|
|
2658
|
+
if ((await client.query("SELECT pg_try_advisory_lock(hashtextextended($1, 0)) AS \"acquired\"", [lockIdentity])).rows[0]?.acquired !== true) throw new Error(`platform: Another lifecycle operation is active for installation ${installationId} on another host.`);
|
|
2659
|
+
let authoritativeManifest = null;
|
|
2660
|
+
try {
|
|
2661
|
+
const checkpoint = await client.query(`SELECT "lifecycle_manifest"
|
|
2662
|
+
FROM "__void_platform_installation"
|
|
2663
|
+
WHERE "singleton" = true
|
|
2664
|
+
AND "installation_id" = $1
|
|
2665
|
+
AND "ownership_nonce" = $2`, [installationId, ownershipNonce]);
|
|
2666
|
+
const value = checkpoint.rows[0]?.lifecycle_manifest;
|
|
2667
|
+
if (checkpoint.rows.length !== 1) throw new Error("platform: The PostgreSQL database ownership claim changed unexpectedly.");
|
|
2668
|
+
if (value !== null && value !== void 0) {
|
|
2669
|
+
authoritativeManifest = parsePlatformInstallationManifest(value);
|
|
2670
|
+
if (authoritativeManifest.id !== installationId || authoritativeManifest.ownershipNonce !== ownershipNonce) throw new Error("platform: The PostgreSQL lifecycle checkpoint does not match this installation.");
|
|
2671
|
+
}
|
|
2672
|
+
} catch (error) {
|
|
2673
|
+
await client.query("SELECT pg_advisory_unlock(hashtextextended($1, 0))", [lockIdentity]);
|
|
2674
|
+
if (error instanceof Error && error.message.startsWith("platform:")) throw error;
|
|
2675
|
+
throw new Error(`platform: The PostgreSQL lifecycle checkpoint is invalid: ${error instanceof Error ? error.message : String(error)}`);
|
|
2676
|
+
}
|
|
2677
|
+
let released = false;
|
|
2678
|
+
const release = async () => {
|
|
2679
|
+
if (released) return;
|
|
2680
|
+
released = true;
|
|
2681
|
+
await client.query("SELECT pg_advisory_unlock(hashtextextended($1, 0))", [lockIdentity]);
|
|
2682
|
+
};
|
|
2683
|
+
release.authoritativeManifest = authoritativeManifest;
|
|
2684
|
+
release.requiredSafetyProtocols = await readRequiredPlatformSafetyProtocols(client);
|
|
2685
|
+
release.checkpoint = async (manifest) => {
|
|
2686
|
+
if (released) throw new Error("platform: Cannot checkpoint lifecycle state after releasing its lock.");
|
|
2687
|
+
if (manifest.id !== installationId || manifest.ownershipNonce !== ownershipNonce) throw new Error("platform: Refusing to checkpoint lifecycle state for another installation.");
|
|
2688
|
+
if ((await client.query(`UPDATE "__void_platform_installation"
|
|
2689
|
+
SET "lifecycle_manifest" = $1::jsonb
|
|
2690
|
+
WHERE "singleton" = true
|
|
2691
|
+
AND "installation_id" = $2
|
|
2692
|
+
AND "ownership_nonce" = $3
|
|
2693
|
+
RETURNING "singleton"`, [
|
|
2694
|
+
JSON.stringify(manifest),
|
|
2695
|
+
installationId,
|
|
2696
|
+
ownershipNonce
|
|
2697
|
+
])).rows.length !== 1) throw new Error("platform: The PostgreSQL database ownership claim changed unexpectedly.");
|
|
2698
|
+
release.authoritativeManifest = manifest;
|
|
2699
|
+
};
|
|
2700
|
+
release.prepareDatabase = (runtimeDirectory, database, sourcePlatformVersion, hasPriorWorkers) => preparePlatformDatabaseConnection(client, runtimeDirectory, database, sourcePlatformVersion, hasPriorWorkers);
|
|
2701
|
+
release.prepareSandboxDrain = (requiredAdmissionProtocol) => preparePlatformSandboxDrainConnection(client, requiredAdmissionProtocol);
|
|
2702
|
+
release.verifyDatabase = (runtimeDirectory, database) => verifyPlatformDatabaseConnection(client, runtimeDirectory, database);
|
|
2703
|
+
return release;
|
|
2704
|
+
}
|
|
2705
|
+
async function preparePlatformSandboxDrainConnection(client, requiredAdmissionProtocol = 0) {
|
|
2706
|
+
if (!(await client.query(`SELECT to_regclass('"sandbox_drain_checkpoints"')::text AS "relation"`)).rows[0]?.relation) throw new Error("platform: Runtime declares sandbox-drain safety but its database checkpoint table is missing.");
|
|
2707
|
+
const hasAdmissionColumn = (await client.query(`SELECT EXISTS(
|
|
2708
|
+
SELECT 1
|
|
2709
|
+
FROM information_schema.columns
|
|
2710
|
+
WHERE table_schema = 'public'
|
|
2711
|
+
AND table_name = 'sandbox_drain_checkpoints'
|
|
2712
|
+
AND column_name = 'admission_protocol'
|
|
2713
|
+
) AS "present"`)).rows[0]?.present === true;
|
|
2714
|
+
if (requiredAdmissionProtocol === 2 && !hasAdmissionColumn) throw new Error("platform: The runtime database does not include the sandbox deployment admission barrier.");
|
|
2715
|
+
const readCheckpoint = () => client.query(`SELECT ${hasAdmissionColumn ? "\"admission_protocol\"" : "0 AS \"admission_protocol\""}, "generation", "state", "phase", "cursor", "started_at", "updated_at", "completed_at"
|
|
2716
|
+
FROM "sandbox_drain_checkpoints"
|
|
2717
|
+
WHERE "singleton" = true`);
|
|
2718
|
+
const classify = (row) => {
|
|
2719
|
+
if (!row) return null;
|
|
2720
|
+
const cursor = row.cursor;
|
|
2721
|
+
if (!row.generation || row.admission_protocol !== 0 && row.admission_protocol !== 2 || !row.started_at || !row.updated_at || row.state !== "draining" && row.state !== "complete" || row.phase !== "deployments" && row.phase !== "resources" || !cursor || cursor.pass !== "1" && cursor.pass !== "2" || row.state === "complete" && (!row.completed_at || row.phase !== "resources" || cursor.pass !== "2") || row.state === "draining" && row.completed_at !== null) throw new Error("platform: The authoritative sandbox-drain database checkpoint is invalid. Traffic remains disabled.");
|
|
2722
|
+
return {
|
|
2723
|
+
admissionProtocol: row.admission_protocol,
|
|
2724
|
+
generation: row.generation,
|
|
2725
|
+
mirrorValue: JSON.stringify({
|
|
2726
|
+
version: 1,
|
|
2727
|
+
generation: row.generation,
|
|
2728
|
+
state: row.state,
|
|
2729
|
+
startedAt: row.started_at,
|
|
2730
|
+
updatedAt: row.updated_at,
|
|
2731
|
+
...row.completed_at ? { completedAt: row.completed_at } : {}
|
|
2732
|
+
}),
|
|
2733
|
+
state: row.state === "complete" ? "complete" : "pending"
|
|
2734
|
+
};
|
|
2735
|
+
};
|
|
2736
|
+
const existingState = classify((await readCheckpoint()).rows[0]);
|
|
2737
|
+
if (existingState && (requiredAdmissionProtocol === 0 || existingState.admissionProtocol >= requiredAdmissionProtocol)) return existingState;
|
|
2738
|
+
if (requiredAdmissionProtocol === 2) {
|
|
2739
|
+
const generation = randomUUID();
|
|
2740
|
+
await client.query("BEGIN");
|
|
2741
|
+
try {
|
|
2742
|
+
await client.query("LOCK TABLE \"deployments\" IN SHARE MODE");
|
|
2743
|
+
await client.query(`INSERT INTO "sandbox_drain_checkpoints" (
|
|
2744
|
+
"singleton", "admission_protocol", "generation", "state", "phase", "cursor"
|
|
2745
|
+
) VALUES (true, 2, $1, 'draining', 'deployments', '{"pass":"1"}'::jsonb)
|
|
2746
|
+
ON CONFLICT ("singleton") DO UPDATE
|
|
2747
|
+
SET "admission_protocol" = 2,
|
|
2748
|
+
"generation" = EXCLUDED."generation",
|
|
2749
|
+
"state" = 'draining',
|
|
2750
|
+
"phase" = 'deployments',
|
|
2751
|
+
"cursor" = '{"pass":"1"}'::jsonb,
|
|
2752
|
+
"lease_owner" = NULL,
|
|
2753
|
+
"lease_expires_at" = NULL,
|
|
2754
|
+
"started_at" = now(),
|
|
2755
|
+
"updated_at" = now(),
|
|
2756
|
+
"completed_at" = NULL`, [generation]);
|
|
2757
|
+
if ((await client.query(`SELECT
|
|
2758
|
+
NOT EXISTS(SELECT 1 FROM "deployments" LIMIT 1)
|
|
2759
|
+
AND NOT EXISTS(SELECT 1 FROM "resources" WHERE "type" = 'container' LIMIT 1)
|
|
2760
|
+
AS "empty"`)).rows[0]?.empty === true) await client.query(`UPDATE "sandbox_drain_checkpoints"
|
|
2761
|
+
SET "state" = 'complete',
|
|
2762
|
+
"phase" = 'resources',
|
|
2763
|
+
"cursor" = '{"pass":"2"}'::jsonb,
|
|
2764
|
+
"lease_owner" = NULL,
|
|
2765
|
+
"lease_expires_at" = NULL,
|
|
2766
|
+
"updated_at" = now(),
|
|
2767
|
+
"completed_at" = now()
|
|
2768
|
+
WHERE "singleton" = true
|
|
2769
|
+
AND "admission_protocol" = 2
|
|
2770
|
+
AND "generation" = $1`, [generation]);
|
|
2771
|
+
const armed = classify((await readCheckpoint()).rows[0]);
|
|
2772
|
+
if (!armed || armed.admissionProtocol !== 2) throw new Error("platform: The sandbox deployment admission barrier could not be armed.");
|
|
2773
|
+
await client.query("COMMIT");
|
|
2774
|
+
return armed;
|
|
2775
|
+
} catch (error) {
|
|
2776
|
+
await client.query("ROLLBACK").catch(() => void 0);
|
|
2777
|
+
throw error;
|
|
2778
|
+
}
|
|
2779
|
+
}
|
|
2780
|
+
const empty = (await client.query(`SELECT
|
|
2781
|
+
NOT EXISTS(SELECT 1 FROM "deployments" LIMIT 1)
|
|
2782
|
+
AND NOT EXISTS(SELECT 1 FROM "resources" WHERE "type" = 'container' LIMIT 1)
|
|
2783
|
+
AS "empty"`)).rows[0]?.empty === true;
|
|
2784
|
+
const generation = randomUUID();
|
|
2785
|
+
const state = empty ? "complete" : "draining";
|
|
2786
|
+
const phase = empty ? "resources" : "deployments";
|
|
2787
|
+
const cursor = JSON.stringify({ pass: empty ? "2" : "1" });
|
|
2788
|
+
await client.query(`INSERT INTO "sandbox_drain_checkpoints" (
|
|
2789
|
+
"singleton", "generation", "state", "phase", "cursor", "completed_at"
|
|
2790
|
+
) VALUES (true, $1, $2, $3, $4::jsonb, CASE WHEN $2 = 'complete' THEN now() ELSE NULL END)
|
|
2791
|
+
ON CONFLICT ("singleton") DO NOTHING`, [
|
|
2792
|
+
generation,
|
|
2793
|
+
state,
|
|
2794
|
+
phase,
|
|
2795
|
+
cursor
|
|
2796
|
+
]);
|
|
2797
|
+
const preparedState = classify((await readCheckpoint()).rows[0]);
|
|
2798
|
+
if (!preparedState) throw new Error("platform: The sandbox-drain database checkpoint could not be established.");
|
|
2799
|
+
return preparedState;
|
|
2800
|
+
}
|
|
2801
|
+
async function verifyPlatformDatabaseConnection(client, runtimeDirectory, database) {
|
|
2802
|
+
verifyPlatformDatabaseArtifacts(runtimeDirectory, database);
|
|
2803
|
+
const state = await inspectPlatformDatabaseStateConnection(client, database);
|
|
2804
|
+
if (state.pendingMigrations.length !== 0 || state.schemaDigest !== database.schemaDigest) throw new Error("platform: PostgreSQL is not at the exact forward-only schema recorded by the installed runtime.");
|
|
2805
|
+
}
|
|
2806
|
+
async function readRequiredPlatformSafetyProtocols(client) {
|
|
2807
|
+
if (!(await client.query(`SELECT to_regclass('"sandbox_drain_checkpoints"')::text AS "relation"`)).rows[0]?.relation) return { sandboxDrain: 0 };
|
|
2808
|
+
if ((await client.query(`SELECT EXISTS(
|
|
2809
|
+
SELECT 1
|
|
2810
|
+
FROM information_schema.columns
|
|
2811
|
+
WHERE table_schema = 'public'
|
|
2812
|
+
AND table_name = 'sandbox_drain_checkpoints'
|
|
2813
|
+
AND column_name = 'admission_protocol'
|
|
2814
|
+
) AS "present"`)).rows[0]?.present === true) {
|
|
2815
|
+
if ((await client.query(`SELECT EXISTS(
|
|
2816
|
+
SELECT 1
|
|
2817
|
+
FROM "sandbox_drain_checkpoints"
|
|
2818
|
+
WHERE "singleton" = true
|
|
2819
|
+
AND "admission_protocol" >= 2
|
|
2820
|
+
) AS "required"`)).rows[0]?.required === true) return { sandboxDrain: 2 };
|
|
2821
|
+
}
|
|
2822
|
+
return { sandboxDrain: (await client.query(`SELECT EXISTS(
|
|
2823
|
+
SELECT 1
|
|
2824
|
+
FROM "sandbox_drain_checkpoints"
|
|
2825
|
+
WHERE "singleton" = true
|
|
2826
|
+
) AS "required"`)).rows[0]?.required === true ? 1 : 0 };
|
|
2827
|
+
}
|
|
2828
|
+
function assertPlatformRuntimeSafety(runtime, installed, database = { sandboxDrain: 0 }) {
|
|
2829
|
+
const requiredSandboxDrain = Math.max(installed?.sandboxDrain ?? 0, database?.sandboxDrain ?? 0);
|
|
2830
|
+
if ((runtime.safetyProtocols?.sandboxDrain ?? 0) < requiredSandboxDrain) throw new Error(`platform: Void Platform ${runtime.platformVersion} predates the required sandbox-drain safety protocol. Refusing to upload or activate a runtime that could reopen sandbox creation after drain.`);
|
|
2831
|
+
}
|
|
2832
|
+
async function claimPlatformDatabaseConnection(client, installationId, ownershipNonce) {
|
|
2833
|
+
await client.query("BEGIN");
|
|
2834
|
+
try {
|
|
2835
|
+
await client.query(`SELECT pg_advisory_xact_lock(hashtext('void-platform-installation-ownership-v1'))`);
|
|
2836
|
+
await client.query(`
|
|
2837
|
+
CREATE TABLE IF NOT EXISTS "__void_platform_installation" (
|
|
2838
|
+
"singleton" boolean PRIMARY KEY DEFAULT true CHECK ("singleton"),
|
|
2839
|
+
"installation_id" text NOT NULL,
|
|
2840
|
+
"ownership_nonce" text NOT NULL,
|
|
2841
|
+
"claimed_at" timestamptz NOT NULL DEFAULT now(),
|
|
2842
|
+
"lifecycle_manifest" jsonb
|
|
2843
|
+
)
|
|
2844
|
+
`);
|
|
2845
|
+
await client.query(`ALTER TABLE "__void_platform_installation"
|
|
2846
|
+
ADD COLUMN IF NOT EXISTS "lifecycle_manifest" jsonb`);
|
|
2847
|
+
const claim = (await client.query(`SELECT "installation_id", "ownership_nonce"
|
|
2848
|
+
FROM "__void_platform_installation"
|
|
2849
|
+
WHERE "singleton" = true
|
|
2850
|
+
FOR UPDATE`)).rows[0];
|
|
2851
|
+
if (!claim) {
|
|
2852
|
+
const contents = await client.query(`SELECT "schemaname", "tablename"
|
|
2853
|
+
FROM "pg_catalog"."pg_tables"
|
|
2854
|
+
WHERE "schemaname" = ANY (current_schemas(false))
|
|
2855
|
+
AND "tablename" <> '__void_platform_installation'
|
|
2856
|
+
ORDER BY "schemaname", "tablename"
|
|
2857
|
+
LIMIT 1`);
|
|
2858
|
+
if (contents.rows.length > 0) {
|
|
2859
|
+
const table = contents.rows[0];
|
|
2860
|
+
throw new Error(`platform: Refusing to claim a non-empty PostgreSQL database (found ${table.schemaname}.${table.tablename}). Use a dedicated empty database for each installation.`);
|
|
2861
|
+
}
|
|
2862
|
+
await client.query(`INSERT INTO "__void_platform_installation"
|
|
2863
|
+
("singleton", "installation_id", "ownership_nonce")
|
|
2864
|
+
VALUES (true, $1, $2)`, [installationId, ownershipNonce]);
|
|
2865
|
+
} else if (claim.installation_id !== installationId || claim.ownership_nonce !== ownershipNonce) throw new Error(`platform: This PostgreSQL database is already claimed by Void platform installation ${claim.installation_id}. Use a dedicated empty database for each installation.`);
|
|
2866
|
+
await client.query("COMMIT");
|
|
2867
|
+
} catch (error) {
|
|
2868
|
+
await client.query("ROLLBACK").catch(() => ({ rows: [] }));
|
|
2869
|
+
throw error;
|
|
2870
|
+
}
|
|
2871
|
+
}
|
|
2872
|
+
async function deployPlatformWorker(config, secrets, options) {
|
|
2873
|
+
const directory = mkdtempSync(join(tmpdir(), "void-platform-deploy-"));
|
|
2874
|
+
const workerName = String(config.name);
|
|
2875
|
+
const accountId = String(config.account_id);
|
|
2876
|
+
let activatedVersionId = null;
|
|
2877
|
+
let activatedDeploymentId = null;
|
|
2878
|
+
let uploadedVersionId = null;
|
|
2879
|
+
let previousDeployment = null;
|
|
2880
|
+
let previousDeploymentId = null;
|
|
2881
|
+
let previousVersionId = null;
|
|
2882
|
+
const nextTraffic = isolatePlatformWorkerCustomDomains(config);
|
|
2883
|
+
const previousTraffic = isolatePlatformWorkerCustomDomains(options.previousConfig);
|
|
2884
|
+
let uploadConfig = withoutPlatformWorkerTrafficAttachments(nextTraffic.wranglerConfig);
|
|
2885
|
+
const nextTriggers = readTriggerConfigFromObject(nextTraffic.wranglerConfig);
|
|
2886
|
+
let previousTriggers = readTriggerConfigFromObject(previousTraffic.wranglerConfig);
|
|
2887
|
+
try {
|
|
2888
|
+
const configPath = join(directory, "wrangler.json");
|
|
2889
|
+
writeFileSync(configPath, `${JSON.stringify(uploadConfig, null, 2)}\n`, { mode: 384 });
|
|
2890
|
+
if (options.workerExisted) {
|
|
2891
|
+
const previous = await readActivePlatformWorkerSnapshot(configPath, accountId, workerName, readPlatformWorkerOwnershipMarker(config), options.runner);
|
|
2892
|
+
previousVersionId = previous.versionId;
|
|
2893
|
+
const deployments = await readPlatformWorkerDeployments(configPath, accountId, workerName, options.runner);
|
|
2894
|
+
if (!deploymentRunsOnlyVersion(deployments[0], previousVersionId)) throw new Error(`platform: Worker ${workerName} changed concurrently while its deployment lineage was inspected.`);
|
|
2895
|
+
previousDeployment = deployments[0];
|
|
2896
|
+
previousDeploymentId = previousDeployment.id;
|
|
2897
|
+
uploadConfig = withoutCompletedPlatformWorkerMigrations(uploadConfig, previous.migrationTag);
|
|
2898
|
+
writeFileSync(configPath, `${JSON.stringify(uploadConfig, null, 2)}\n`, { mode: 384 });
|
|
2899
|
+
previousTriggers = resolvePlatformPreviousTriggerConfig(withoutPlatformCustomDomainTriggers(previous.triggerConfig), previousTriggers, options.previousPublicEndpoints);
|
|
2900
|
+
previousTriggers = withoutPlatformCustomDomainTriggers(mergePreservedTriggerRoutes(previousTriggers, options.preservedRoutes));
|
|
2901
|
+
}
|
|
2902
|
+
const argv = [
|
|
2903
|
+
...options.workerExisted ? ["versions", "upload"] : ["deploy"],
|
|
2904
|
+
"--config",
|
|
2905
|
+
configPath,
|
|
2906
|
+
"--strict",
|
|
2907
|
+
"--experimental-provision=false",
|
|
2908
|
+
"--experimental-auto-create=false",
|
|
2909
|
+
...!options.workerExisted ? ["--autoconfig=false"] : [],
|
|
2910
|
+
"--message",
|
|
2911
|
+
encodeCloudflareVersionMessage([], nextTriggers)
|
|
2912
|
+
];
|
|
2913
|
+
if (secrets) {
|
|
2914
|
+
const secretsPath = join(directory, "secrets.json");
|
|
2915
|
+
writeFileSync(secretsPath, JSON.stringify(secrets), { mode: 384 });
|
|
2916
|
+
argv.push("--secrets-file", secretsPath);
|
|
2917
|
+
}
|
|
2918
|
+
const uploaded = options.workerExisted ? await options.runner(argv, accountId, directory, true) : await runPlatformWranglerForConditionalCreate(argv, accountId, directory, true, workerName, options.apiBaseUrl);
|
|
2919
|
+
uploadedVersionId = parseUploadedPlatformWorkerVersion(`${uploaded.stdout}\n${uploaded.stderr}`);
|
|
2920
|
+
if (!options.workerExisted) activatedVersionId = uploadedVersionId;
|
|
2921
|
+
assertCleanPlatformWrangler(uploaded, `${options.workerExisted ? "upload" : "bootstrap"} ${workerName}`);
|
|
2922
|
+
if (!uploadedVersionId) throw new Error(`platform: Wrangler uploaded ${workerName} without reporting its immutable version id.`);
|
|
2923
|
+
await options.onStaged(previousVersionId, uploadedVersionId, previousTriggers);
|
|
2924
|
+
if (options.workerExisted) {
|
|
2925
|
+
const deploymentsBeforeActivation = await readPlatformWorkerDeployments(configPath, accountId, workerName, options.runner);
|
|
2926
|
+
if (deploymentsBeforeActivation[0]?.id !== previousDeploymentId || !deploymentRunsOnlyVersion(deploymentsBeforeActivation[0], previousVersionId)) throw new Error(`platform: Worker ${workerName} changed concurrently before activation; refusing to overwrite it.`);
|
|
2927
|
+
activatedVersionId = uploadedVersionId;
|
|
2928
|
+
assertCleanPlatformWrangler(await options.runner([
|
|
2929
|
+
"versions",
|
|
2930
|
+
"deploy",
|
|
2931
|
+
`${uploadedVersionId}@100`,
|
|
2932
|
+
"--config",
|
|
2933
|
+
configPath,
|
|
2934
|
+
"--message",
|
|
2935
|
+
`Void platform activation ${uploadedVersionId}`,
|
|
2936
|
+
"--yes"
|
|
2937
|
+
], accountId, directory, true), `activate ${workerName}`);
|
|
2938
|
+
}
|
|
2939
|
+
const deploymentsAfterActivation = await readPlatformWorkerDeployments(configPath, accountId, workerName, options.runner);
|
|
2940
|
+
const lineage = classifyPlatformWorkerDeploymentLineage(deploymentsAfterActivation, uploadedVersionId, previousDeploymentId);
|
|
2941
|
+
if (lineage.kind === "concurrent-after") {
|
|
2942
|
+
activatedVersionId = null;
|
|
2943
|
+
throw new Error(`platform: Worker ${workerName} changed concurrently after activation; refusing to overwrite the newer deployment.`);
|
|
2944
|
+
}
|
|
2945
|
+
activatedDeploymentId = lineage.deploymentId;
|
|
2946
|
+
if (lineage.kind === "intervening") {
|
|
2947
|
+
const interveningDeployment = deploymentsAfterActivation[1];
|
|
2948
|
+
activatedVersionId = null;
|
|
2949
|
+
activatedDeploymentId = null;
|
|
2950
|
+
if (!interveningDeployment) throw new PlatformWorkerRecoveryIncompleteError(`platform: Worker ${workerName} lost its previous deployment during activation. The cutover cannot be recovered automatically.`);
|
|
2951
|
+
try {
|
|
2952
|
+
await restorePlatformWorkerDeployment({
|
|
2953
|
+
accountId,
|
|
2954
|
+
configPath,
|
|
2955
|
+
directory,
|
|
2956
|
+
expectedActiveDeploymentId: lineage.deploymentId,
|
|
2957
|
+
expectedActiveVersionId: uploadedVersionId,
|
|
2958
|
+
restoration: interveningDeployment,
|
|
2959
|
+
runner: options.runner,
|
|
2960
|
+
workerName
|
|
2961
|
+
});
|
|
2962
|
+
} catch (recoveryError) {
|
|
2963
|
+
const recoveryFailures = await recoverFailedPlatformWorkerDeployment({
|
|
2964
|
+
checkpointUnsafeTraffic: options.onUnsafeTraffic,
|
|
2965
|
+
closeRoutingGate: options.retainNewWorker,
|
|
2966
|
+
rollback: null,
|
|
2967
|
+
unsafeTraffic: true
|
|
2968
|
+
});
|
|
2969
|
+
throw new PlatformWorkerRecoveryIncompleteError(`platform: Worker ${workerName} changed concurrently during activation, and restoring intervening deployment ${interveningDeployment.id} failed: ${errorMessage(recoveryError)}${recoveryFailures.length > 0 ? ` Safety recovery also failed: ${recoveryFailures.join("; ")}` : ""}`, { cause: recoveryError });
|
|
2970
|
+
}
|
|
2971
|
+
throw new Error(`platform: Worker ${workerName} changed concurrently during activation. The intervening deployment was restored without deleting deployment history.`);
|
|
2972
|
+
}
|
|
2973
|
+
assertPlatformTriggerStabilization(await stabilizeCloudflareVersionTriggers({
|
|
2974
|
+
configPath,
|
|
2975
|
+
knownTriggerConfigs: {
|
|
2976
|
+
[uploadedVersionId]: nextTriggers,
|
|
2977
|
+
...previousVersionId ? { [previousVersionId]: previousTriggers } : {}
|
|
2978
|
+
},
|
|
2979
|
+
manageRoutes: false,
|
|
2980
|
+
previousTriggerConfig: previousTriggers,
|
|
2981
|
+
publishEmptyCustomDomains: async () => {
|
|
2982
|
+
await options.removeCustomDomains();
|
|
2983
|
+
return cleanPlatformCommandResult();
|
|
2984
|
+
},
|
|
2985
|
+
runWrangler: platformWorkerCommandRunner(accountId, directory, options.runner),
|
|
2986
|
+
targetTriggerConfig: nextTriggers,
|
|
2987
|
+
targetVersionId: uploadedVersionId,
|
|
2988
|
+
workerName
|
|
2989
|
+
}), workerName, uploadedVersionId, "publish");
|
|
2990
|
+
if (nextTraffic.customDomains.length > 0) await options.publishCustomDomains(workerName, nextTraffic.customDomains);
|
|
2991
|
+
if (options.managedRoutes.length > 0) await options.publishRoutes(workerName, options.managedRoutes);
|
|
2992
|
+
await options.onTrafficPublished();
|
|
2993
|
+
const rollback = () => rollbackPlatformWorker({
|
|
2994
|
+
accountId,
|
|
2995
|
+
activatedDeploymentId,
|
|
2996
|
+
activatedVersionId: uploadedVersionId,
|
|
2997
|
+
config,
|
|
2998
|
+
nextTriggers,
|
|
2999
|
+
previousConfig: options.previousConfig,
|
|
3000
|
+
previousCustomDomains: previousTraffic.customDomains,
|
|
3001
|
+
previousDeployment,
|
|
3002
|
+
previousTriggers,
|
|
3003
|
+
previousVersionId,
|
|
3004
|
+
preserveCustomDomains: false,
|
|
3005
|
+
inspectNewWorker: options.inspectNewWorker,
|
|
3006
|
+
removeCustomDomains: options.removeCustomDomains,
|
|
3007
|
+
publishCustomDomains: options.publishCustomDomains,
|
|
3008
|
+
retainNewWorker: options.retainNewWorker,
|
|
3009
|
+
runner: options.runner,
|
|
3010
|
+
workerExisted: options.workerExisted,
|
|
3011
|
+
workerName
|
|
3012
|
+
});
|
|
3013
|
+
return rollback;
|
|
3014
|
+
} catch (error) {
|
|
3015
|
+
const unsafeTraffic = error instanceof PlatformWorkerCustomDomainsChangedError || error instanceof PlatformWorkerRoutesChangedError;
|
|
3016
|
+
const rollbackVersionId = activatedVersionId && uploadedVersionId ? uploadedVersionId : null;
|
|
3017
|
+
const recoveryFailures = await recoverFailedPlatformWorkerDeployment({
|
|
3018
|
+
checkpointUnsafeTraffic: options.onUnsafeTraffic,
|
|
3019
|
+
closeRoutingGate: options.retainNewWorker,
|
|
3020
|
+
rollback: rollbackVersionId ? () => rollbackPlatformWorker({
|
|
3021
|
+
accountId,
|
|
3022
|
+
activatedDeploymentId,
|
|
3023
|
+
activatedVersionId: rollbackVersionId,
|
|
3024
|
+
config,
|
|
3025
|
+
nextTriggers: readTriggerConfigFromObject(nextTraffic.wranglerConfig),
|
|
3026
|
+
previousConfig: options.previousConfig,
|
|
3027
|
+
previousCustomDomains: previousTraffic.customDomains,
|
|
3028
|
+
previousDeployment,
|
|
3029
|
+
previousTriggers,
|
|
3030
|
+
previousVersionId,
|
|
3031
|
+
preserveCustomDomains: unsafeTraffic,
|
|
3032
|
+
inspectNewWorker: options.inspectNewWorker,
|
|
3033
|
+
removeCustomDomains: options.removeCustomDomains,
|
|
3034
|
+
publishCustomDomains: options.publishCustomDomains,
|
|
3035
|
+
retainNewWorker: options.retainNewWorker,
|
|
3036
|
+
runner: options.runner,
|
|
3037
|
+
workerExisted: options.workerExisted,
|
|
3038
|
+
workerName
|
|
3039
|
+
}) : null,
|
|
3040
|
+
unsafeTraffic
|
|
3041
|
+
});
|
|
3042
|
+
if (recoveryFailures.length > 0) throw new PlatformWorkerRecoveryIncompleteError(`platform: ${errorMessage(error)} Automatic recovery for ${workerName} was incomplete: ${recoveryFailures.join("; ")}`, { cause: error });
|
|
3043
|
+
if (unsafeTraffic) throw new PlatformWorkerRecoveryIncompleteError(`platform: ${errorMessage(error)} The routing gate remains closed and live Custom Domains were retained for safe reconciliation on retry.`, { cause: error });
|
|
3044
|
+
throw error;
|
|
3045
|
+
} finally {
|
|
3046
|
+
rmSync(directory, {
|
|
3047
|
+
force: true,
|
|
3048
|
+
recursive: true
|
|
3049
|
+
});
|
|
3050
|
+
}
|
|
3051
|
+
}
|
|
3052
|
+
async function recoverFailedPlatformWorkerDeployment(input) {
|
|
3053
|
+
const failures = [];
|
|
3054
|
+
const attempt = async (label, operation) => {
|
|
3055
|
+
try {
|
|
3056
|
+
await operation();
|
|
3057
|
+
} catch (error) {
|
|
3058
|
+
failures.push(`${label}: ${errorMessage(error)}`);
|
|
3059
|
+
}
|
|
3060
|
+
};
|
|
3061
|
+
if (input.unsafeTraffic) {
|
|
3062
|
+
await attempt("closing the routing gate failed", input.closeRoutingGate);
|
|
3063
|
+
await attempt("checkpointing unsafe traffic failed", input.checkpointUnsafeTraffic);
|
|
3064
|
+
}
|
|
3065
|
+
if (input.rollback) await attempt("Worker rollback failed", input.rollback);
|
|
3066
|
+
return failures;
|
|
3067
|
+
}
|
|
3068
|
+
async function rollbackPlatformWorker(input) {
|
|
3069
|
+
const directory = mkdtempSync(join(tmpdir(), "void-platform-rollback-"));
|
|
3070
|
+
try {
|
|
3071
|
+
const configPath = join(directory, "wrangler.json");
|
|
3072
|
+
const currentTraffic = isolatePlatformWorkerCustomDomains(input.config);
|
|
3073
|
+
writeFileSync(configPath, `${JSON.stringify(currentTraffic.wranglerConfig, null, 2)}\n`, { mode: 384 });
|
|
3074
|
+
if (!input.workerExisted) {
|
|
3075
|
+
if (await input.inspectNewWorker() === "missing") return;
|
|
3076
|
+
const currentVersionId = await readActivePlatformWorkerVersion(configPath, input.accountId, input.workerName, input.runner);
|
|
3077
|
+
if (currentVersionId !== input.activatedVersionId) throw new Error(`Worker ${input.workerName} changed concurrently from installer version ${input.activatedVersionId} to ${currentVersionId ?? "no active version"}; refusing to delete it.`);
|
|
3078
|
+
if (await input.inspectNewWorker() === "missing") return;
|
|
3079
|
+
await input.retainNewWorker();
|
|
3080
|
+
if (!input.preserveCustomDomains) await input.removeCustomDomains();
|
|
3081
|
+
return;
|
|
3082
|
+
}
|
|
3083
|
+
const currentVersionId = await readActivePlatformWorkerVersion(configPath, input.accountId, input.workerName, input.runner);
|
|
3084
|
+
if (currentVersionId !== input.previousVersionId && currentVersionId !== input.activatedVersionId) throw new Error(`Worker ${input.workerName} changed concurrently from installer version ${input.activatedVersionId} to ${currentVersionId ?? "no active version"}; refusing to overwrite it.`);
|
|
3085
|
+
if (!input.previousVersionId) throw new Error(`Worker ${input.workerName} has no previous version to restore.`);
|
|
3086
|
+
if (currentVersionId === input.activatedVersionId) {
|
|
3087
|
+
const currentDeployments = await readPlatformWorkerDeployments(configPath, input.accountId, input.workerName, input.runner);
|
|
3088
|
+
const activeDeploymentId = input.activatedDeploymentId ?? currentDeployments[0]?.id ?? null;
|
|
3089
|
+
const restoration = input.previousDeployment ?? currentDeployments[1];
|
|
3090
|
+
if (!activeDeploymentId || currentDeployments[0]?.id !== activeDeploymentId || !deploymentRunsOnlyVersion(currentDeployments[0], input.activatedVersionId) || !restoration || currentDeployments[1]?.id !== restoration.id || !deploymentRunsOnlyVersion(restoration, input.previousVersionId)) throw new Error(`Worker ${input.workerName} changed concurrently after installer deployment; refusing to alter its deployment history.`);
|
|
3091
|
+
await restorePlatformWorkerDeployment({
|
|
3092
|
+
accountId: input.accountId,
|
|
3093
|
+
configPath,
|
|
3094
|
+
directory,
|
|
3095
|
+
expectedActiveDeploymentId: activeDeploymentId,
|
|
3096
|
+
expectedActiveVersionId: input.activatedVersionId,
|
|
3097
|
+
restoration,
|
|
3098
|
+
runner: input.runner,
|
|
3099
|
+
workerName: input.workerName
|
|
3100
|
+
});
|
|
3101
|
+
if (await readActivePlatformWorkerVersion(configPath, input.accountId, input.workerName, input.runner) !== input.previousVersionId) throw new Error(`Worker ${input.workerName} did not restore immutable version ${input.previousVersionId} after rolling back installer deployment ${activeDeploymentId}.`);
|
|
3102
|
+
}
|
|
3103
|
+
const previousConfigPath = join(directory, "wrangler.previous.json");
|
|
3104
|
+
writeFileSync(previousConfigPath, `${JSON.stringify(input.previousConfig, null, 2)}\n`, { mode: 384 });
|
|
3105
|
+
assertPlatformTriggerStabilization(await stabilizeCloudflareVersionTriggers({
|
|
3106
|
+
configPath: previousConfigPath,
|
|
3107
|
+
knownTriggerConfigs: {
|
|
3108
|
+
[input.activatedVersionId]: input.nextTriggers,
|
|
3109
|
+
[input.previousVersionId]: input.previousTriggers
|
|
3110
|
+
},
|
|
3111
|
+
manageRoutes: false,
|
|
3112
|
+
previousTriggerConfig: input.nextTriggers,
|
|
3113
|
+
publishEmptyCustomDomains: async () => {
|
|
3114
|
+
await input.removeCustomDomains();
|
|
3115
|
+
return cleanPlatformCommandResult();
|
|
3116
|
+
},
|
|
3117
|
+
runWrangler: platformWorkerCommandRunner(input.accountId, directory, input.runner),
|
|
3118
|
+
targetTriggerConfig: input.previousTriggers,
|
|
3119
|
+
targetVersionId: input.previousVersionId,
|
|
3120
|
+
workerName: input.workerName
|
|
3121
|
+
}), input.workerName, input.previousVersionId, "restore");
|
|
3122
|
+
if (!input.preserveCustomDomains && input.previousCustomDomains.length > 0) await input.publishCustomDomains(input.workerName, input.previousCustomDomains);
|
|
3123
|
+
} finally {
|
|
3124
|
+
rmSync(directory, {
|
|
3125
|
+
force: true,
|
|
3126
|
+
recursive: true
|
|
3127
|
+
});
|
|
3128
|
+
}
|
|
3129
|
+
}
|
|
3130
|
+
function platformWorkerCommandRunner(accountId, cwd, runner) {
|
|
3131
|
+
return (args, options) => runner(args, accountId, cwd, options.capture);
|
|
3132
|
+
}
|
|
3133
|
+
async function runPlatformWrangler(args, accountId, cwd, capture) {
|
|
3134
|
+
return runPlatformWranglerAtApiBase(args, accountId, cwd, capture);
|
|
3135
|
+
}
|
|
3136
|
+
const PLATFORM_WRANGLER_TIMEOUT_MS = 6e5;
|
|
3137
|
+
async function runPlatformWranglerAtApiBase(args, accountId, cwd, capture, apiBaseUrl = resolvePinnedApiBaseUrl()) {
|
|
3138
|
+
const result = await createDefaultRunWrangler(process.cwd(), {
|
|
3139
|
+
launcher: resolveWranglerArgv(process.cwd()),
|
|
3140
|
+
timeoutMs: PLATFORM_WRANGLER_TIMEOUT_MS
|
|
3141
|
+
})([...args], {
|
|
3142
|
+
accountId,
|
|
3143
|
+
apiBaseUrl,
|
|
3144
|
+
captureJson: capture,
|
|
3145
|
+
cwd
|
|
3146
|
+
});
|
|
3147
|
+
const stdout = result.stdout || "";
|
|
3148
|
+
const stderr = result.stderr || "";
|
|
3149
|
+
if (!capture && result.status !== 0) {
|
|
3150
|
+
if (stdout) process.stdout.write(stdout);
|
|
3151
|
+
if (stderr) process.stderr.write(stderr);
|
|
3152
|
+
}
|
|
3153
|
+
return {
|
|
3154
|
+
status: result.status,
|
|
3155
|
+
stderr,
|
|
3156
|
+
stdout
|
|
3157
|
+
};
|
|
3158
|
+
}
|
|
3159
|
+
const HOP_BY_HOP_HEADERS = /* @__PURE__ */ new Set([
|
|
3160
|
+
"connection",
|
|
3161
|
+
"keep-alive",
|
|
3162
|
+
"proxy-authenticate",
|
|
3163
|
+
"proxy-authorization",
|
|
3164
|
+
"te",
|
|
3165
|
+
"trailer",
|
|
3166
|
+
"transfer-encoding",
|
|
3167
|
+
"upgrade"
|
|
3168
|
+
]);
|
|
3169
|
+
function forwardHeaders(headers) {
|
|
3170
|
+
return Object.fromEntries(Object.entries(headers).filter(([name]) => !HOP_BY_HOP_HEADERS.has(name.toLowerCase())));
|
|
3171
|
+
}
|
|
3172
|
+
function closeHttpServer(server) {
|
|
3173
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
3174
|
+
server.close((error) => {
|
|
3175
|
+
if (error) rejectPromise(error);
|
|
3176
|
+
else resolvePromise();
|
|
3177
|
+
});
|
|
3178
|
+
server.closeAllConnections();
|
|
3179
|
+
});
|
|
3180
|
+
}
|
|
3181
|
+
async function startConditionalWorkerCreateProxy(upstreamApiBaseUrl, accountId, workerName) {
|
|
3182
|
+
const upstreamBase = new URL(upstreamApiBaseUrl);
|
|
3183
|
+
if (upstreamBase.protocol !== "http:" && upstreamBase.protocol !== "https:") throw new Error("platform: Cloudflare API base URL must use HTTP or HTTPS.");
|
|
3184
|
+
const basePath = upstreamBase.pathname.replace(/\/+$/, "");
|
|
3185
|
+
const expectedPath = `${basePath}/accounts/${encodeURIComponent(accountId)}/workers/scripts/${encodeURIComponent(workerName)}`;
|
|
3186
|
+
const state = {
|
|
3187
|
+
attempted: false,
|
|
3188
|
+
conflictStatus: null,
|
|
3189
|
+
created: false,
|
|
3190
|
+
forwarded: false,
|
|
3191
|
+
rejectedMutation: null
|
|
3192
|
+
};
|
|
3193
|
+
const server = createServer((incoming, outgoing) => {
|
|
3194
|
+
(async () => {
|
|
3195
|
+
const incomingUrl = new URL(incoming.url ?? "/", "http://127.0.0.1");
|
|
3196
|
+
if (incomingUrl.pathname !== basePath && !incomingUrl.pathname.startsWith(`${basePath}/`)) {
|
|
3197
|
+
outgoing.writeHead(502, { "Content-Type": "application/json" });
|
|
3198
|
+
outgoing.end("{\"success\":false,\"errors\":[{\"message\":\"Invalid API proxy path\"}]}");
|
|
3199
|
+
return;
|
|
3200
|
+
}
|
|
3201
|
+
const createQuery = [...incomingUrl.searchParams];
|
|
3202
|
+
const supportedCreateQuery = new Set(createQuery.map(([key]) => key)).size === createQuery.length && createQuery.every(([key, value]) => key === "excludeScript" && value === "true" || key === "bindings_inherit" && value === "strict");
|
|
3203
|
+
const isConditionalCreate = incoming.method === "PUT" && incomingUrl.pathname === expectedPath && supportedCreateQuery;
|
|
3204
|
+
const isTopLevelWorkerUpload = incoming.method === "PUT" && new RegExp(`^${basePath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/accounts/[^/]+/workers/scripts/[^/]+$`).test(incomingUrl.pathname);
|
|
3205
|
+
const isReadOnly = incoming.method === "GET" || incoming.method === "HEAD" || incoming.method === "OPTIONS";
|
|
3206
|
+
if (isTopLevelWorkerUpload && !isConditionalCreate || !isReadOnly && !isConditionalCreate && !state.created) {
|
|
3207
|
+
state.rejectedMutation = `${incoming.method ?? "UNKNOWN"} ${incomingUrl.pathname}${incomingUrl.pathname === expectedPath && !supportedCreateQuery ? " (unsupported Worker create query)" : ""}`;
|
|
3208
|
+
incoming.resume();
|
|
3209
|
+
outgoing.writeHead(409, { "Content-Type": "application/json" });
|
|
3210
|
+
outgoing.end("{\"success\":false,\"errors\":[{\"message\":\"Mutation blocked until conditional Worker creation succeeds\"}]}");
|
|
3211
|
+
return;
|
|
3212
|
+
}
|
|
3213
|
+
const headers = forwardHeaders(incoming.headers);
|
|
3214
|
+
delete headers.host;
|
|
3215
|
+
if (isConditionalCreate) {
|
|
3216
|
+
state.attempted = true;
|
|
3217
|
+
headers["if-none-match"] = "*";
|
|
3218
|
+
}
|
|
3219
|
+
const upstreamUrl = new URL(`${incomingUrl.pathname}${incomingUrl.search}`, upstreamBase.origin);
|
|
3220
|
+
const request$2 = (upstreamUrl.protocol === "https:" ? request$1 : request)(upstreamUrl, {
|
|
3221
|
+
headers,
|
|
3222
|
+
method: incoming.method
|
|
3223
|
+
}, (response) => {
|
|
3224
|
+
if (isConditionalCreate) {
|
|
3225
|
+
state.forwarded = true;
|
|
3226
|
+
if (response.statusCode === 409 || response.statusCode === 412) state.conflictStatus = response.statusCode;
|
|
3227
|
+
state.created = response.statusCode !== void 0 && response.statusCode >= 200 && response.statusCode < 300;
|
|
3228
|
+
}
|
|
3229
|
+
const responseHeaders = forwardHeaders(response.headers);
|
|
3230
|
+
outgoing.writeHead(response.statusCode ?? 502, responseHeaders);
|
|
3231
|
+
response.pipe(outgoing);
|
|
3232
|
+
});
|
|
3233
|
+
request$2.on("error", () => {
|
|
3234
|
+
if (!outgoing.headersSent) outgoing.writeHead(502, { "Content-Type": "application/json" });
|
|
3235
|
+
outgoing.end("{\"success\":false,\"errors\":[{\"message\":\"Cloudflare API proxy request failed\"}]}");
|
|
3236
|
+
});
|
|
3237
|
+
incoming.pipe(request$2);
|
|
3238
|
+
})().catch(() => {
|
|
3239
|
+
if (!outgoing.headersSent) outgoing.writeHead(502, { "Content-Type": "application/json" });
|
|
3240
|
+
outgoing.end("{\"success\":false,\"errors\":[{\"message\":\"Cloudflare API proxy failed\"}]}");
|
|
3241
|
+
});
|
|
3242
|
+
});
|
|
3243
|
+
await new Promise((resolvePromise, rejectPromise) => {
|
|
3244
|
+
server.once("error", rejectPromise);
|
|
3245
|
+
server.listen(0, "127.0.0.1", () => {
|
|
3246
|
+
server.off("error", rejectPromise);
|
|
3247
|
+
resolvePromise();
|
|
3248
|
+
});
|
|
3249
|
+
});
|
|
3250
|
+
return {
|
|
3251
|
+
apiBaseUrl: `http://127.0.0.1:${server.address().port}${basePath}`,
|
|
3252
|
+
close: () => closeHttpServer(server),
|
|
3253
|
+
state
|
|
3254
|
+
};
|
|
3255
|
+
}
|
|
3256
|
+
async function withConditionalPlatformWorkerCreate(accountId, workerName, operation, upstreamApiBaseUrl = resolvePinnedApiBaseUrl()) {
|
|
3257
|
+
const proxy = await startConditionalWorkerCreateProxy(upstreamApiBaseUrl, accountId, workerName);
|
|
3258
|
+
let result;
|
|
3259
|
+
try {
|
|
3260
|
+
result = await operation(proxy.apiBaseUrl);
|
|
3261
|
+
} finally {
|
|
3262
|
+
await proxy.close();
|
|
3263
|
+
}
|
|
3264
|
+
if (proxy.state.rejectedMutation) throw new Error(`platform: Wrangler attempted unexpected mutation ${proxy.state.rejectedMutation} while conditionally creating new Worker ${workerName}; the mutation was blocked.`);
|
|
3265
|
+
if (!proxy.state.attempted || !proxy.state.forwarded) throw new Error(`platform: Wrangler did not use the guarded create endpoint for new Worker ${workerName}; refusing an unverified deployment.`);
|
|
3266
|
+
if (proxy.state.conflictStatus !== null) throw new Error(`platform: Worker ${workerName} was created concurrently (HTTP ${proxy.state.conflictStatus}); no existing Worker was changed. Re-run to inspect the collision.`);
|
|
3267
|
+
return result;
|
|
3268
|
+
}
|
|
3269
|
+
async function runPlatformWranglerForConditionalCreate(args, accountId, cwd, capture, workerName, upstreamApiBaseUrl) {
|
|
3270
|
+
return withConditionalPlatformWorkerCreate(accountId, workerName, (apiBaseUrl) => runPlatformWranglerAtApiBase(args, accountId, cwd, capture, apiBaseUrl), upstreamApiBaseUrl);
|
|
3271
|
+
}
|
|
3272
|
+
async function readActivePlatformWorkerVersion(configPath, accountId, workerName, runner = runPlatformWrangler) {
|
|
3273
|
+
const result = await runner([
|
|
3274
|
+
"deployments",
|
|
3275
|
+
"status",
|
|
3276
|
+
"--config",
|
|
3277
|
+
configPath,
|
|
3278
|
+
"--name",
|
|
3279
|
+
workerName,
|
|
3280
|
+
"--json"
|
|
3281
|
+
], accountId, process.cwd(), true);
|
|
3282
|
+
if (result.status !== 0) assertCleanPlatformWrangler(result, `read active version for ${workerName}`);
|
|
3283
|
+
let value;
|
|
3284
|
+
try {
|
|
3285
|
+
value = JSON.parse(result.stdout);
|
|
3286
|
+
} catch {
|
|
3287
|
+
throw new Error(`platform: Wrangler returned malformed deployment status for ${workerName}.`);
|
|
3288
|
+
}
|
|
3289
|
+
if (!value || typeof value !== "object" || !Array.isArray(value.versions)) throw new Error(`platform: Wrangler returned malformed deployment status for ${workerName}.`);
|
|
3290
|
+
const active = value.versions.filter((entry) => entry && typeof entry === "object" && Number(entry.percentage) === 100);
|
|
3291
|
+
if (active.length !== 1 || typeof active[0].version_id !== "string") return null;
|
|
3292
|
+
return active[0].version_id;
|
|
3293
|
+
}
|
|
3294
|
+
async function readPlatformWorkerDeployments(configPath, accountId, workerName, runner = runPlatformWrangler) {
|
|
3295
|
+
const result = await runner([
|
|
3296
|
+
"deployments",
|
|
3297
|
+
"list",
|
|
3298
|
+
"--config",
|
|
3299
|
+
configPath,
|
|
3300
|
+
"--name",
|
|
3301
|
+
workerName,
|
|
3302
|
+
"--json"
|
|
3303
|
+
], accountId, process.cwd(), true);
|
|
3304
|
+
if (result.status !== 0) assertCleanPlatformWrangler(result, `read deployment lineage for ${workerName}`);
|
|
3305
|
+
let value;
|
|
3306
|
+
try {
|
|
3307
|
+
value = JSON.parse(result.stdout);
|
|
3308
|
+
} catch {
|
|
3309
|
+
throw new Error(`platform: Wrangler returned malformed deployment history for ${workerName}.`);
|
|
3310
|
+
}
|
|
3311
|
+
if (!Array.isArray(value)) throw new Error(`platform: Wrangler returned malformed deployment history for ${workerName}.`);
|
|
3312
|
+
return value.map((entry, index) => {
|
|
3313
|
+
if (!entry || typeof entry !== "object" || typeof entry.id !== "string" || typeof entry.created_on !== "string" || !Array.isArray(entry.versions)) throw new Error(`platform: Wrangler returned malformed deployment history for ${workerName}.`);
|
|
3314
|
+
const createdAt = Date.parse(entry.created_on);
|
|
3315
|
+
if (!Number.isFinite(createdAt)) throw new Error(`platform: Wrangler returned malformed deployment history for ${workerName}.`);
|
|
3316
|
+
const versions = entry.versions.map((version) => {
|
|
3317
|
+
if (!version || typeof version !== "object" || typeof version.version_id !== "string" || !Number.isFinite(Number(version.percentage))) throw new Error(`platform: Wrangler returned malformed deployment history for ${workerName}.`);
|
|
3318
|
+
return {
|
|
3319
|
+
percentage: Number(version.percentage),
|
|
3320
|
+
versionId: version.version_id
|
|
3321
|
+
};
|
|
3322
|
+
});
|
|
3323
|
+
return {
|
|
3324
|
+
createdAt,
|
|
3325
|
+
id: entry.id,
|
|
3326
|
+
index,
|
|
3327
|
+
versions
|
|
3328
|
+
};
|
|
3329
|
+
}).sort((left, right) => right.createdAt - left.createdAt || left.index - right.index).map(({ id, versions }) => ({
|
|
3330
|
+
id,
|
|
3331
|
+
versions
|
|
3332
|
+
}));
|
|
3333
|
+
}
|
|
3334
|
+
function samePlatformWorkerTraffic(left, right) {
|
|
3335
|
+
if (!left) return false;
|
|
3336
|
+
const normalize = (deployment) => deployment.versions.filter((version) => version.percentage > 0).map((version) => `${version.versionId}@${version.percentage}`).sort();
|
|
3337
|
+
return JSON.stringify(normalize(left)) === JSON.stringify(normalize(right));
|
|
3338
|
+
}
|
|
3339
|
+
async function restorePlatformWorkerDeployment(input) {
|
|
3340
|
+
const restorationVersions = input.restoration.versions.filter((version) => version.percentage > 0);
|
|
3341
|
+
const totalPercentage = restorationVersions.reduce((total, version) => total + version.percentage, 0);
|
|
3342
|
+
if (restorationVersions.length === 0 || restorationVersions.length > 2 || restorationVersions.some((version, index) => version.percentage > 100 || restorationVersions.findIndex((entry) => entry.versionId === version.versionId) !== index) || Math.abs(totalPercentage - 100) > .001) throw new Error(`Worker ${input.workerName} deployment ${input.restoration.id} has an unsupported traffic distribution.`);
|
|
3343
|
+
const before = await readPlatformWorkerDeployments(input.configPath, input.accountId, input.workerName, input.runner);
|
|
3344
|
+
if (before[0]?.id !== input.expectedActiveDeploymentId || !deploymentRunsOnlyVersion(before[0], input.expectedActiveVersionId) || before[1]?.id !== input.restoration.id || !samePlatformWorkerTraffic(before[1], input.restoration)) throw new Error(`Worker ${input.workerName} changed concurrently before deployment restoration; refusing to overwrite the newer deployment.`);
|
|
3345
|
+
assertCleanPlatformWrangler(await input.runner([
|
|
3346
|
+
"versions",
|
|
3347
|
+
"deploy",
|
|
3348
|
+
...restorationVersions.map((version) => `${version.versionId}@${version.percentage}`),
|
|
3349
|
+
"--config",
|
|
3350
|
+
input.configPath,
|
|
3351
|
+
"--message",
|
|
3352
|
+
`Void platform restoration ${input.restoration.id}`,
|
|
3353
|
+
"--yes"
|
|
3354
|
+
], input.accountId, input.directory, true), `restore deployment ${input.restoration.id}`);
|
|
3355
|
+
const after = await readPlatformWorkerDeployments(input.configPath, input.accountId, input.workerName, input.runner);
|
|
3356
|
+
if (!samePlatformWorkerTraffic(after[0], input.restoration) || after[1]?.id !== input.expectedActiveDeploymentId) throw new Error(`Worker ${input.workerName} deployment restoration was superseded or did not restore deployment ${input.restoration.id}.`);
|
|
3357
|
+
}
|
|
3358
|
+
function deploymentRunsOnlyVersion(deployment, versionId) {
|
|
3359
|
+
return versionId !== null && deployment?.versions.length === 1 && deployment.versions[0]?.versionId === versionId && deployment.versions[0].percentage === 100;
|
|
3360
|
+
}
|
|
3361
|
+
function classifyPlatformWorkerDeploymentLineage(deployments, installedVersionId, previousDeploymentId) {
|
|
3362
|
+
const installed = deployments[0];
|
|
3363
|
+
if (!deploymentRunsOnlyVersion(installed, installedVersionId)) return { kind: "concurrent-after" };
|
|
3364
|
+
return (deployments[1]?.id ?? null) === previousDeploymentId ? {
|
|
3365
|
+
deploymentId: installed.id,
|
|
3366
|
+
kind: "committed"
|
|
3367
|
+
} : {
|
|
3368
|
+
deploymentId: installed.id,
|
|
3369
|
+
kind: "intervening"
|
|
3370
|
+
};
|
|
3371
|
+
}
|
|
3372
|
+
async function inspectNewPlatformWorker(client, accountId, manifest, resourceKey, workerName) {
|
|
3373
|
+
if (!client.listWorkerNames || !client.getWorkerPlainTextBindings) throw new Error("platform: Cloudflare Worker ownership inspection is unavailable.");
|
|
3374
|
+
if (!(await client.listWorkerNames(accountId)).includes(workerName)) return "missing";
|
|
3375
|
+
const bindings = await client.getWorkerPlainTextBindings(accountId, workerName);
|
|
3376
|
+
let marker;
|
|
3377
|
+
try {
|
|
3378
|
+
marker = parsePlatformResourceOwnershipMarker(JSON.parse(bindings.PLATFORM_OWNERSHIP_MARKER ?? "null"));
|
|
3379
|
+
} catch {
|
|
3380
|
+
throw new Error(`platform: Worker ${workerName} no longer has this installation's ownership marker; refusing to treat it as installer-owned.`);
|
|
3381
|
+
}
|
|
3382
|
+
const expected = createPlatformResourceOwnershipMarker({
|
|
3383
|
+
accountId,
|
|
3384
|
+
installationId: manifest.id,
|
|
3385
|
+
ownershipNonce: manifest.ownershipNonce,
|
|
3386
|
+
resourceId: workerName,
|
|
3387
|
+
resourceKey,
|
|
3388
|
+
resourceKind: "worker",
|
|
3389
|
+
resourceName: workerName
|
|
3390
|
+
});
|
|
3391
|
+
if (marker.accountId !== expected.accountId || marker.installationId !== expected.installationId || marker.ownershipNonce !== expected.ownershipNonce || marker.resourceId !== expected.resourceId || marker.resourceKey !== expected.resourceKey || marker.resourceKind !== expected.resourceKind || marker.resourceName !== expected.resourceName) throw new Error(`platform: Worker ${workerName} no longer has this installation's ownership marker; refusing to treat it as installer-owned.`);
|
|
3392
|
+
return "owned";
|
|
3393
|
+
}
|
|
3394
|
+
function readPlatformWorkerOwnershipMarker(config) {
|
|
3395
|
+
const vars = config.vars;
|
|
3396
|
+
if (!vars || typeof vars !== "object" || Array.isArray(vars)) throw new Error("platform: Platform Worker config is missing its ownership bindings.");
|
|
3397
|
+
const marker = vars.PLATFORM_OWNERSHIP_MARKER;
|
|
3398
|
+
if (typeof marker !== "string" || marker === "") throw new Error("platform: Platform Worker config is missing its ownership marker.");
|
|
3399
|
+
return marker;
|
|
3400
|
+
}
|
|
3401
|
+
async function readActivePlatformWorkerSnapshot(configPath, accountId, workerName, expectedOwnershipMarker, runner = runPlatformWrangler) {
|
|
3402
|
+
const readActiveVersion = () => readActivePlatformWorkerVersion(configPath, accountId, workerName, runner);
|
|
3403
|
+
const versionId = await readActiveVersion();
|
|
3404
|
+
if (!versionId) throw new Error(`platform: Existing Worker ${workerName} has no single active production version.`);
|
|
3405
|
+
const result = await runner([
|
|
3406
|
+
"versions",
|
|
3407
|
+
"view",
|
|
3408
|
+
versionId,
|
|
3409
|
+
"--config",
|
|
3410
|
+
configPath,
|
|
3411
|
+
"--name",
|
|
3412
|
+
workerName,
|
|
3413
|
+
"--json"
|
|
3414
|
+
], accountId, process.cwd(), true);
|
|
3415
|
+
assertCleanPlatformWrangler(result, `read rollback metadata for ${workerName}`);
|
|
3416
|
+
let value;
|
|
3417
|
+
try {
|
|
3418
|
+
value = JSON.parse(result.stdout);
|
|
3419
|
+
} catch {
|
|
3420
|
+
throw new Error(`platform: Wrangler returned malformed version metadata for ${workerName}.`);
|
|
3421
|
+
}
|
|
3422
|
+
if (!value || typeof value !== "object") throw new Error(`platform: Wrangler returned malformed version metadata for ${workerName}.`);
|
|
3423
|
+
const resources = value.resources;
|
|
3424
|
+
const bindings = resources && typeof resources === "object" && !Array.isArray(resources) ? resources.bindings : null;
|
|
3425
|
+
const ownershipBindings = Array.isArray(bindings) ? bindings.filter((binding) => binding && typeof binding === "object" && binding.name === "PLATFORM_OWNERSHIP_MARKER" && binding.type === "plain_text" && typeof binding.text === "string") : [];
|
|
3426
|
+
if (ownershipBindings.length !== 1 || ownershipBindings[0].text !== expectedOwnershipMarker) throw new Error(`platform: Active Worker version ${versionId} for ${workerName} does not belong to this installation.`);
|
|
3427
|
+
if (await readActiveVersion() !== versionId) throw new Error(`platform: Worker ${workerName} changed concurrently while its active version was inspected.`);
|
|
3428
|
+
const annotations = value.annotations;
|
|
3429
|
+
const message = annotations && typeof annotations === "object" && typeof annotations["workers/message"] === "string" ? annotations["workers/message"] ?? null : null;
|
|
3430
|
+
const metadata = parseCloudflareVersionMessage(message);
|
|
3431
|
+
if (metadata?.schema !== 2) throw new Error(`platform: Active Worker ${workerName} predates safe trigger snapshots. Refusing to upgrade because its routes, cron, and queue consumers could not be restored after a failed cutover.`);
|
|
3432
|
+
return {
|
|
3433
|
+
migrationTag: readCloudflareVersionMigrationTag(value),
|
|
3434
|
+
triggerConfig: metadata.triggerConfig,
|
|
3435
|
+
versionId
|
|
3436
|
+
};
|
|
3437
|
+
}
|
|
3438
|
+
function withoutCompletedPlatformWorkerMigrations(config, activeMigrationTag) {
|
|
3439
|
+
const target = (Array.isArray(config.migrations) ? config.migrations : []).at(-1);
|
|
3440
|
+
if (activeMigrationTag === null || !target || typeof target !== "object" || Array.isArray(target) || target.tag !== activeMigrationTag) return config;
|
|
3441
|
+
const result = { ...config };
|
|
3442
|
+
delete result.migrations;
|
|
3443
|
+
return result;
|
|
3444
|
+
}
|
|
3445
|
+
function parseUploadedPlatformWorkerVersion(output) {
|
|
3446
|
+
return /(?:Worker|Current) Version ID:\s*([0-9a-f]{8}-[0-9a-f-]{27})/i.exec(output)?.[1] ?? null;
|
|
3447
|
+
}
|
|
3448
|
+
function assertCleanPlatformWrangler(result, operation) {
|
|
3449
|
+
if (result.status !== 0) throw new Error(`platform: Wrangler failed to ${operation} (${result.stderr.trim() || `exit ${result.status}`}).`);
|
|
3450
|
+
}
|
|
3451
|
+
function cleanPlatformCommandResult() {
|
|
3452
|
+
return {
|
|
3453
|
+
status: 0,
|
|
3454
|
+
stderr: "",
|
|
3455
|
+
stdout: ""
|
|
3456
|
+
};
|
|
3457
|
+
}
|
|
3458
|
+
function assertPlatformTriggerStabilization(result, workerName, targetVersionId, operation) {
|
|
3459
|
+
if (!result.ok) throw new Error(`platform: Failed to ${operation} triggers for ${workerName} (${result.reason}).`);
|
|
3460
|
+
if (!result.targetOwnsTraffic) throw new Error(`platform: Worker ${workerName} was superseded by ${result.activeVersionId} while publishing ${targetVersionId}; its active trigger state was repaired.`);
|
|
3461
|
+
}
|
|
3462
|
+
function readTriggerConfigFromObject(config) {
|
|
3463
|
+
const directory = mkdtempSync(join(tmpdir(), "void-platform-triggers-"));
|
|
3464
|
+
try {
|
|
3465
|
+
const path = join(directory, "wrangler.json");
|
|
3466
|
+
writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`, { mode: 384 });
|
|
3467
|
+
return readCloudflareTriggerConfig(path);
|
|
3468
|
+
} finally {
|
|
3469
|
+
rmSync(directory, {
|
|
3470
|
+
force: true,
|
|
3471
|
+
recursive: true
|
|
3472
|
+
});
|
|
3473
|
+
}
|
|
3474
|
+
}
|
|
3475
|
+
function verifyRuntimeArtifacts(runtimeDirectory, manifest) {
|
|
3476
|
+
for (const artifact of Object.values(manifest.artifacts)) {
|
|
3477
|
+
const path = join(runtimeDirectory, artifact.path);
|
|
3478
|
+
let contents;
|
|
3479
|
+
try {
|
|
3480
|
+
contents = readFileSync(path);
|
|
3481
|
+
} catch (error) {
|
|
3482
|
+
throw new Error(`platform: Runtime Worker artifact is missing: ${errorMessage(error)}`);
|
|
3483
|
+
}
|
|
3484
|
+
if (contents.byteLength !== artifact.size || hash(contents) !== artifact.sha256) throw new Error(`platform: Runtime Worker artifact failed integrity verification: ${path}.`);
|
|
3485
|
+
}
|
|
3486
|
+
verifyPlatformDatabaseArtifacts(runtimeDirectory, manifest.database);
|
|
3487
|
+
}
|
|
3488
|
+
function verifyPlatformDatabaseArtifacts(runtimeDirectory, database) {
|
|
3489
|
+
const journalPath = join(runtimeDirectory, "drizzle", "meta", "_journal.json");
|
|
3490
|
+
let journal;
|
|
3491
|
+
try {
|
|
3492
|
+
journal = readFileSync(journalPath);
|
|
3493
|
+
} catch (error) {
|
|
3494
|
+
throw new Error(`platform: Runtime database migration journal is missing: ${errorMessage(error)}`);
|
|
3495
|
+
}
|
|
3496
|
+
if (hash(journal) !== database.journalSha256) throw new Error(`platform: Runtime database migration journal failed integrity verification: ${journalPath}.`);
|
|
3497
|
+
for (const migration of database.migrations) {
|
|
3498
|
+
const path = join(runtimeDirectory, "drizzle", `${migration.tag}.sql`);
|
|
3499
|
+
let contents;
|
|
3500
|
+
try {
|
|
3501
|
+
contents = readFileSync(path);
|
|
3502
|
+
} catch (error) {
|
|
3503
|
+
throw new Error(`platform: Runtime database migration is missing: ${errorMessage(error)}`);
|
|
3504
|
+
}
|
|
3505
|
+
if (hash(contents) !== migration.sha256) throw new Error(`platform: Runtime database migration failed integrity verification: ${path}.`);
|
|
3506
|
+
}
|
|
3507
|
+
}
|
|
3508
|
+
function nextTrafficGeneration(manifest) {
|
|
3509
|
+
return Math.max(0, ...manifest.steps.flatMap((step) => {
|
|
3510
|
+
const match = /^traffic-enabled:(\d+)$/.exec(step.key);
|
|
3511
|
+
return match ? [Number(match[1])] : [];
|
|
3512
|
+
})) + 1;
|
|
3513
|
+
}
|
|
3514
|
+
function nextWorkerCutoverGeneration(manifest) {
|
|
3515
|
+
return latestPlatformWorkerCutoverGeneration(manifest) + 1;
|
|
3516
|
+
}
|
|
3517
|
+
function recordWorkerSecretUpdateState(manifest, generation, state, intentId, completedAt) {
|
|
3518
|
+
return recordInstallationStep(manifest, {
|
|
3519
|
+
completedAt,
|
|
3520
|
+
key: `worker-secret-updates:${generation}:${state}`,
|
|
3521
|
+
resultHash: intentId
|
|
3522
|
+
}, completedAt);
|
|
3523
|
+
}
|
|
3524
|
+
function recordWorkerCutoverStage(manifest, generation, key, previousVersionId, stagedVersionId, workerName, previousTriggers, completedAt) {
|
|
3525
|
+
return recordInstallationStep(manifest, {
|
|
3526
|
+
completedAt,
|
|
3527
|
+
key: `worker-cutover:${generation}:staged:${key}`,
|
|
3528
|
+
resultHash: Buffer.from(JSON.stringify({
|
|
3529
|
+
previousTriggers,
|
|
3530
|
+
previousVersionId,
|
|
3531
|
+
schema: 1,
|
|
3532
|
+
stagedVersionId,
|
|
3533
|
+
workerName
|
|
3534
|
+
})).toString("base64url")
|
|
3535
|
+
}, completedAt);
|
|
3536
|
+
}
|
|
3537
|
+
function recordWorkerCutoverResolution(manifest, generation, resolution, completedAt) {
|
|
3538
|
+
return recordInstallationStep(manifest, {
|
|
3539
|
+
completedAt,
|
|
3540
|
+
key: `worker-cutover:${generation}:${resolution}`,
|
|
3541
|
+
resultHash: resolution
|
|
3542
|
+
}, completedAt);
|
|
3543
|
+
}
|
|
3544
|
+
function recordWorkerUnsafeTraffic(manifest, generation, key, workerName, completedAt) {
|
|
3545
|
+
return recordInstallationStep(manifest, {
|
|
3546
|
+
completedAt,
|
|
3547
|
+
key: `worker-cutover:${generation}:unsafe-traffic:${key}`,
|
|
3548
|
+
resultHash: workerName
|
|
3549
|
+
}, completedAt);
|
|
3550
|
+
}
|
|
3551
|
+
function parseWorkerCutoverSnapshot(value) {
|
|
3552
|
+
try {
|
|
3553
|
+
const parsed = JSON.parse(Buffer.from(value, "base64url").toString("utf8"));
|
|
3554
|
+
const metadata = parseCloudflareVersionMessage(`void:${JSON.stringify({
|
|
3555
|
+
migrations: [],
|
|
3556
|
+
schema: 2,
|
|
3557
|
+
triggerConfig: parsed.previousTriggers
|
|
3558
|
+
})}`);
|
|
3559
|
+
if (parsed.schema !== 1 || parsed.previousVersionId !== null && typeof parsed.previousVersionId !== "string" || typeof parsed.stagedVersionId !== "string" || parsed.stagedVersionId === "" || typeof parsed.workerName !== "string" || parsed.workerName === "" || metadata?.schema !== 2) return null;
|
|
3560
|
+
return {
|
|
3561
|
+
previousTriggers: metadata.triggerConfig,
|
|
3562
|
+
previousVersionId: parsed.previousVersionId,
|
|
3563
|
+
stagedVersionId: parsed.stagedVersionId,
|
|
3564
|
+
workerName: parsed.workerName
|
|
3565
|
+
};
|
|
3566
|
+
} catch {
|
|
3567
|
+
return null;
|
|
3568
|
+
}
|
|
3569
|
+
}
|
|
3570
|
+
function interruptedWorkerCutover(manifest) {
|
|
3571
|
+
const generations = /* @__PURE__ */ new Map();
|
|
3572
|
+
for (const step of manifest.steps) {
|
|
3573
|
+
const resolution = /^worker-cutover:(\d+):(committed|restored)$/.exec(step.key);
|
|
3574
|
+
if (resolution) {
|
|
3575
|
+
const generation = Number(resolution[1]);
|
|
3576
|
+
const entry = generations.get(generation) ?? {
|
|
3577
|
+
resolved: false,
|
|
3578
|
+
staged: /* @__PURE__ */ new Map(),
|
|
3579
|
+
unsafeTraffic: /* @__PURE__ */ new Set()
|
|
3580
|
+
};
|
|
3581
|
+
entry.resolved = true;
|
|
3582
|
+
generations.set(generation, entry);
|
|
3583
|
+
continue;
|
|
3584
|
+
}
|
|
3585
|
+
const unsafeTraffic = /^worker-cutover:(\d+):unsafe-traffic:(api|proxy|tail|dispatch)$/.exec(step.key);
|
|
3586
|
+
if (unsafeTraffic) {
|
|
3587
|
+
const generation = Number(unsafeTraffic[1]);
|
|
3588
|
+
const entry = generations.get(generation) ?? {
|
|
3589
|
+
resolved: false,
|
|
3590
|
+
staged: /* @__PURE__ */ new Map(),
|
|
3591
|
+
unsafeTraffic: /* @__PURE__ */ new Set()
|
|
3592
|
+
};
|
|
3593
|
+
entry.unsafeTraffic.add(unsafeTraffic[2]);
|
|
3594
|
+
generations.set(generation, entry);
|
|
3595
|
+
continue;
|
|
3596
|
+
}
|
|
3597
|
+
const staged = /^worker-cutover:(\d+):staged:(api|proxy|tail|dispatch)$/.exec(step.key);
|
|
3598
|
+
if (!staged) continue;
|
|
3599
|
+
const generation = Number(staged[1]);
|
|
3600
|
+
const snapshot = parseWorkerCutoverSnapshot(step.resultHash);
|
|
3601
|
+
if (!snapshot) throw new Error(`platform: Invalid Worker cutover checkpoint ${step.key}.`);
|
|
3602
|
+
const entry = generations.get(generation) ?? {
|
|
3603
|
+
resolved: false,
|
|
3604
|
+
staged: /* @__PURE__ */ new Map(),
|
|
3605
|
+
unsafeTraffic: /* @__PURE__ */ new Set()
|
|
3606
|
+
};
|
|
3607
|
+
entry.staged.set(staged[2], snapshot);
|
|
3608
|
+
generations.set(generation, entry);
|
|
3609
|
+
}
|
|
3610
|
+
const unresolved = [...generations.entries()].filter(([, entry]) => !entry.resolved && entry.staged.size > 0).sort(([left], [right]) => right - left)[0];
|
|
3611
|
+
return unresolved ? {
|
|
3612
|
+
generation: unresolved[0],
|
|
3613
|
+
staged: unresolved[1].staged,
|
|
3614
|
+
unsafeTraffic: unresolved[1].unsafeTraffic
|
|
3615
|
+
} : null;
|
|
3616
|
+
}
|
|
3617
|
+
async function verifyPlatformWorkerCutoverVersions(manifest, generation, configs, accountId, runner = runPlatformWrangler) {
|
|
3618
|
+
const cutover = interruptedWorkerCutover(manifest);
|
|
3619
|
+
if (cutover?.generation !== generation || cutover.staged.size !== 4) throw new Error(`platform: Worker cutover generation ${generation} is missing a complete immutable-version checkpoint.`);
|
|
3620
|
+
const directory = mkdtempSync(join(tmpdir(), "void-platform-cutover-verify-"));
|
|
3621
|
+
try {
|
|
3622
|
+
for (const key of [
|
|
3623
|
+
"api",
|
|
3624
|
+
"proxy",
|
|
3625
|
+
"tail",
|
|
3626
|
+
"dispatch"
|
|
3627
|
+
]) {
|
|
3628
|
+
const snapshot = cutover.staged.get(key);
|
|
3629
|
+
if (!snapshot) throw new Error(`platform: Worker cutover generation ${generation} is missing its ${key} checkpoint.`);
|
|
3630
|
+
const config = configs[key];
|
|
3631
|
+
const configPath = join(directory, `${key}.wrangler.json`);
|
|
3632
|
+
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, { mode: 384 });
|
|
3633
|
+
const active = await readActivePlatformWorkerSnapshot(configPath, accountId, snapshot.workerName, readPlatformWorkerOwnershipMarker(config), runner);
|
|
3634
|
+
if (active.versionId !== snapshot.stagedVersionId) throw new Error(`platform: Worker ${snapshot.workerName} changed concurrently from staged version ${snapshot.stagedVersionId} to ${active.versionId}; refusing to enable platform traffic.`);
|
|
3635
|
+
}
|
|
3636
|
+
} finally {
|
|
3637
|
+
rmSync(directory, {
|
|
3638
|
+
force: true,
|
|
3639
|
+
recursive: true
|
|
3640
|
+
});
|
|
3641
|
+
}
|
|
3642
|
+
}
|
|
3643
|
+
function recordWorkerTrafficState(manifest, generation, enabled, completedAt) {
|
|
3644
|
+
return recordInstallationStep(manifest, {
|
|
3645
|
+
completedAt,
|
|
3646
|
+
key: `worker-traffic-state:${generation}`,
|
|
3647
|
+
resultHash: enabled ? "enabled" : "disabled"
|
|
3648
|
+
}, completedAt);
|
|
3649
|
+
}
|
|
3650
|
+
function stablePublicEndpointState(existing) {
|
|
3651
|
+
if (!existing) return false;
|
|
3652
|
+
if (existing.status === "disabled" || existing.desiredState === "disabled") return false;
|
|
3653
|
+
const latest = existing.steps.flatMap((step) => {
|
|
3654
|
+
const match = /^worker-traffic-state:(\d+)$/.exec(step.key);
|
|
3655
|
+
return match ? [{
|
|
3656
|
+
generation: Number(match[1]),
|
|
3657
|
+
state: step.resultHash
|
|
3658
|
+
}] : [];
|
|
3659
|
+
}).sort((left, right) => right.generation - left.generation)[0];
|
|
3660
|
+
if (latest) return latest.state === "enabled";
|
|
3661
|
+
return existing.status === "installed";
|
|
3662
|
+
}
|
|
3663
|
+
function resolvePlatformPreviousTriggerConfig(versionSnapshot, configuredSnapshot, publicEndpoints) {
|
|
3664
|
+
if (publicEndpoints) return versionSnapshot;
|
|
3665
|
+
return {
|
|
3666
|
+
...versionSnapshot,
|
|
3667
|
+
preview_urls: configuredSnapshot.preview_urls,
|
|
3668
|
+
routes: configuredSnapshot.routes,
|
|
3669
|
+
workers_dev: configuredSnapshot.workers_dev
|
|
3670
|
+
};
|
|
3671
|
+
}
|
|
3672
|
+
function errorMessage(error) {
|
|
3673
|
+
return error instanceof Error ? error.message : String(error);
|
|
3674
|
+
}
|
|
3675
|
+
var PlatformAccessBlockedError = class extends Error {
|
|
3676
|
+
constructor(url, context) {
|
|
3677
|
+
super([
|
|
3678
|
+
"platform: Cloudflare Access blocked the platform health checks (Default-Deny, error 1050).",
|
|
3679
|
+
`Blocked URL: ${url}`,
|
|
3680
|
+
"",
|
|
3681
|
+
"To make this test platform publicly reachable:",
|
|
3682
|
+
"",
|
|
3683
|
+
`1. In ${context ? `account ${JSON.stringify(context.accountName)}` : "your Cloudflare account"}, open:`,
|
|
3684
|
+
" Zero Trust → Access controls → Applications → Create new application.",
|
|
3685
|
+
"2. Choose Self-hosted. When adding destinations, choose Worker and select:",
|
|
3686
|
+
` ${context ? context.workers.join(", ") + "." : "This installation's API, proxy, and dispatch Workers."}`,
|
|
3687
|
+
"3. Add a policy: Action → Bypass; Include → Everyone.",
|
|
3688
|
+
context?.resumeCommand ? `4. Save the policy and application, then resume:\n\n ${context.resumeCommand}` : "4. Save the policy and application, then rerun your platform command.",
|
|
3689
|
+
"",
|
|
3690
|
+
"This removes Cloudflare Access protection for these Workers.",
|
|
3691
|
+
"Void’s own authentication stays enabled. Keep account-wide Default-Deny on.",
|
|
3692
|
+
"",
|
|
3693
|
+
"For platforms that must remain Access-protected:",
|
|
3694
|
+
"https://void.cloud/guide/self-hosted-platform#cloudflare-access"
|
|
3695
|
+
].join("\n"));
|
|
3696
|
+
}
|
|
3697
|
+
};
|
|
3698
|
+
async function platformProbeFailure(label, url, response, context) {
|
|
3699
|
+
const message = `platform: ${label} failed at ${url} (HTTP ${response.status}).`;
|
|
3700
|
+
if (response.status === 403 && response.headers.get("server")?.toLowerCase() === "cloudflare" && response.headers.get("content-type")?.toLowerCase().includes("text/html")) {
|
|
3701
|
+
const body = await response.text().catch(() => "");
|
|
3702
|
+
if (body.includes("Default-Deny") && /errorCode:\s*1050\b/.test(body)) return new PlatformAccessBlockedError(url, context);
|
|
3703
|
+
}
|
|
3704
|
+
return new Error(message);
|
|
3705
|
+
}
|
|
3706
|
+
async function verifyPlatformHealth(apiUrl, installationId, proxyUrl, applicationBaseDomain, platformVersion, usageProtocolVersion, healthProbeToken, fetcher = fetch, dispatchOrigin, context) {
|
|
3707
|
+
const request = (url) => fetcher(url, {
|
|
3708
|
+
headers: {
|
|
3709
|
+
"X-Void-Platform-Probe": healthProbeToken,
|
|
3710
|
+
...cfAccessHeaders(process.env, url)
|
|
3711
|
+
},
|
|
3712
|
+
redirect: "manual",
|
|
3713
|
+
signal: AbortSignal.timeout(3e4)
|
|
3714
|
+
});
|
|
3715
|
+
const dispatchUrl = dispatchOrigin ?? `https://test.${applicationBaseDomain}`;
|
|
3716
|
+
const [health, discovery, proxy, dispatch] = await Promise.all([
|
|
3717
|
+
request(`${apiUrl}/health`),
|
|
3718
|
+
request(`${apiUrl}/.well-known/void-platform`),
|
|
3719
|
+
request(`${proxyUrl}/health`),
|
|
3720
|
+
request(`${dispatchUrl}/health`)
|
|
3721
|
+
]);
|
|
3722
|
+
if (!health.ok) throw await platformProbeFailure("Health check", apiUrl, health, context);
|
|
3723
|
+
if (!discovery.ok) throw await platformProbeFailure("Discovery check", apiUrl, discovery, context);
|
|
3724
|
+
const body = await discovery.json();
|
|
3725
|
+
if (body.installationId !== installationId) throw new Error("platform: Deployed API discovery identity does not match this installation.");
|
|
3726
|
+
if (body.platformVersion !== platformVersion) throw new Error("platform: Deployed API discovery version does not match this release.");
|
|
3727
|
+
if ((body.usageProtocolVersion ?? 0) !== usageProtocolVersion) throw new Error("platform: Deployed API usage protocol does not match this runtime. Traffic remains disabled.");
|
|
3728
|
+
if (!proxy.ok) throw await platformProbeFailure("Proxy health check", proxyUrl, proxy, context);
|
|
3729
|
+
if (!dispatch.ok) throw await platformProbeFailure("Dispatch health check", dispatchUrl, dispatch, context);
|
|
3730
|
+
}
|
|
3731
|
+
async function verifyPlatformSandboxDrain(apiUrl, healthProbeToken, fetcher = fetch) {
|
|
3732
|
+
const response = await fetcher(`${apiUrl}/internal/platform/sandbox-drain`, {
|
|
3733
|
+
headers: {
|
|
3734
|
+
"X-Void-Platform-Probe": healthProbeToken,
|
|
3735
|
+
...cfAccessHeaders(process.env, apiUrl)
|
|
3736
|
+
},
|
|
3737
|
+
signal: AbortSignal.timeout(3e4)
|
|
3738
|
+
});
|
|
3739
|
+
if (!response.ok) throw new Error(`platform: Sandbox drain probe failed at ${apiUrl} (HTTP ${response.status}). Traffic remains disabled.`);
|
|
3740
|
+
let value;
|
|
3741
|
+
try {
|
|
3742
|
+
value = await response.json();
|
|
3743
|
+
} catch {
|
|
3744
|
+
throw new Error("platform: Sandbox drain probe did not return its DB-backed protocol v1 document. Traffic remains disabled.");
|
|
3745
|
+
}
|
|
3746
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("platform: Sandbox drain probe returned an invalid DB-backed protocol v1 document. Traffic remains disabled.");
|
|
3747
|
+
const status = value;
|
|
3748
|
+
const validState = status.state === "required" || status.state === "draining" || status.state === "complete";
|
|
3749
|
+
const validPhase = status.phase === null || status.phase === "deployments" || status.phase === "resources";
|
|
3750
|
+
if (status.version !== 1 || !validState || typeof status.complete !== "boolean" || !validPhase || status.generation !== null && (typeof status.generation !== "string" || status.generation.length === 0) || status.updatedAt !== null && (typeof status.updatedAt !== "string" || !Number.isFinite(Date.parse(status.updatedAt))) || status.state === "required" && (status.complete || status.generation !== null || status.updatedAt !== null || status.phase !== null) || status.state === "draining" && (status.complete || typeof status.generation !== "string" || typeof status.updatedAt !== "string" || status.phase === null) || status.state === "complete" && (!status.complete || typeof status.generation !== "string" || typeof status.updatedAt !== "string" || status.phase === null)) throw new Error("platform: Sandbox drain probe returned an invalid DB-backed protocol v1 document. Traffic remains disabled.");
|
|
3751
|
+
return status.state === "complete";
|
|
3752
|
+
}
|
|
3753
|
+
function createPlatformHealthProbeToken(ownershipNonce) {
|
|
3754
|
+
return createHash("sha256").update("void-platform-health-probe:v1\0").update(ownershipNonce).digest("base64url");
|
|
3755
|
+
}
|
|
3756
|
+
//#endregion
|
|
3757
|
+
//#region src/cli/platform-setup-draft.ts
|
|
3758
|
+
const STARTED = "credential-setup:v1";
|
|
3759
|
+
const COMPLETED = "credential-setup-complete:v1";
|
|
3760
|
+
/** Includes interrupted provisioning, but not maintenance of an already deployed platform. */
|
|
3761
|
+
function isPlatformInstallInProgress(manifest) {
|
|
3762
|
+
return manifest.runtime === null && manifest.desiredState === "enabled" && !manifest.resources.some((resource) => resource.state === "deleted") && [
|
|
3763
|
+
"planned",
|
|
3764
|
+
"provisioning",
|
|
3765
|
+
"waiting-for-dns",
|
|
3766
|
+
"waiting-for-oauth",
|
|
3767
|
+
"deploying",
|
|
3768
|
+
"verifying",
|
|
3769
|
+
"failed"
|
|
3770
|
+
].includes(manifest.status);
|
|
3771
|
+
}
|
|
3772
|
+
function isPlatformSetupDraft(manifest) {
|
|
3773
|
+
return manifest.status === "planned" && manifest.resources.length === 0 && manifest.endpoints.apiUrl === null && manifest.steps.some((step) => step.key === STARTED) && !manifest.steps.some((step) => step.key === COMPLETED);
|
|
3774
|
+
}
|
|
3775
|
+
function savePlatformSetupDraft(plan, version) {
|
|
3776
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
3777
|
+
let manifest = createPlatformInstallationManifest({
|
|
3778
|
+
account: plan.account,
|
|
3779
|
+
deployment: plan.deployment,
|
|
3780
|
+
id: plan.installationId,
|
|
3781
|
+
createdAt: now,
|
|
3782
|
+
platformVersion: version,
|
|
3783
|
+
ownershipNonce: randomBytes(32).toString("base64url")
|
|
3784
|
+
});
|
|
3785
|
+
manifest = transitionInstallation(manifest, "planned", now);
|
|
3786
|
+
manifest = recordInstallationStep(manifest, {
|
|
3787
|
+
key: STARTED,
|
|
3788
|
+
completedAt: now,
|
|
3789
|
+
resultHash: plan.installationId
|
|
3790
|
+
}, now);
|
|
3791
|
+
writeInstallationManifest(manifest);
|
|
3792
|
+
writePendingInstallationSecrets(manifest, {});
|
|
3793
|
+
return manifest;
|
|
3794
|
+
}
|
|
3795
|
+
function completePlatformSetupDraft(manifest) {
|
|
3796
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
3797
|
+
const completed = recordInstallationStep(manifest, {
|
|
3798
|
+
key: COMPLETED,
|
|
3799
|
+
completedAt: now,
|
|
3800
|
+
resultHash: manifest.id
|
|
3801
|
+
}, now);
|
|
3802
|
+
writeInstallationManifest(completed);
|
|
3803
|
+
return completed;
|
|
3804
|
+
}
|
|
3805
|
+
//#endregion
|
|
3806
|
+
//#region src/cli/platform-setup.ts
|
|
3807
|
+
function platformSetupLink(page, hyperlinks = supportsSetupLinks()) {
|
|
3808
|
+
return hyperlinks ? "\x1B]8;;" + page.url + "\x1B\\" + page.title + "\x1B]8;;\x1B\\" : page.title + ": " + page.url;
|
|
3809
|
+
}
|
|
3810
|
+
function platformSetupFieldHelp(key) {
|
|
3811
|
+
const docs = (section, title) => platformSetupLink({
|
|
3812
|
+
title,
|
|
3813
|
+
url: "https://void.cloud/guide/self-hosted-platform#" + section
|
|
3814
|
+
});
|
|
3815
|
+
switch (key) {
|
|
3816
|
+
case "databaseUrl": return {
|
|
3817
|
+
placeholder: "postgresql://user:pw@…/database",
|
|
3818
|
+
description: "Stores platform users, projects, and deployments; use an empty hosted database, e.g. " + platformSetupLink({
|
|
3819
|
+
title: "Neon",
|
|
3820
|
+
url: "https://neon.com/"
|
|
3821
|
+
}) + ". " + docs("platform-database", "Database setup")
|
|
3822
|
+
};
|
|
3823
|
+
case "jwtSecret": return { description: "Signs platform login tokens. Run openssl rand -base64 32; save the key in your password manager. " + docs("signing-and-encryption-keys", "Key setup") };
|
|
3824
|
+
case "projectSecretKey": return { description: "Encrypts app secrets. Run openssl rand -base64 32 separately; save this key for recovery. " + docs("signing-and-encryption-keys", "Key setup") };
|
|
3825
|
+
default: return;
|
|
3826
|
+
}
|
|
3827
|
+
}
|
|
3828
|
+
function supportsSetupLinks() {
|
|
3829
|
+
return !!process.stdout.isTTY && ([
|
|
3830
|
+
"ghostty",
|
|
3831
|
+
"iTerm.app",
|
|
3832
|
+
"WezTerm",
|
|
3833
|
+
"vscode",
|
|
3834
|
+
"WarpTerminal"
|
|
3835
|
+
].includes(process.env.TERM_PROGRAM ?? "") || !!process.env.WT_SESSION || !!process.env.VTE_VERSION || !!process.env.KONSOLE_VERSION || process.env.TERM === "xterm-kitty");
|
|
3836
|
+
}
|
|
3837
|
+
/** Dashboard keys use permission-catalog labels, which can differ from product names. */
|
|
3838
|
+
function platformSetupPages(plan) {
|
|
3839
|
+
const needsGateway = plan.actions.some((action) => action.kind === "ai-gateway" && action.action === "inspect");
|
|
3840
|
+
const permissions = [
|
|
3841
|
+
{
|
|
3842
|
+
key: "account_settings",
|
|
3843
|
+
type: "read"
|
|
3844
|
+
},
|
|
3845
|
+
{
|
|
3846
|
+
key: "workers_scripts",
|
|
3847
|
+
type: "edit"
|
|
3848
|
+
},
|
|
3849
|
+
{
|
|
3850
|
+
key: "workers_kv_storage",
|
|
3851
|
+
type: "edit"
|
|
3852
|
+
},
|
|
3853
|
+
{
|
|
3854
|
+
key: "workers_r2",
|
|
3855
|
+
type: "edit"
|
|
3856
|
+
},
|
|
3857
|
+
{
|
|
3858
|
+
key: "d1",
|
|
3859
|
+
type: "edit"
|
|
3860
|
+
},
|
|
3861
|
+
{
|
|
3862
|
+
key: "queues",
|
|
3863
|
+
type: "edit"
|
|
3864
|
+
},
|
|
3865
|
+
{
|
|
3866
|
+
key: "account_analytics",
|
|
3867
|
+
type: "read"
|
|
3868
|
+
},
|
|
3869
|
+
{
|
|
3870
|
+
key: "workers_tail",
|
|
3871
|
+
type: "read"
|
|
3872
|
+
},
|
|
3873
|
+
{
|
|
3874
|
+
key: "query_cache",
|
|
3875
|
+
type: "edit"
|
|
3876
|
+
},
|
|
3877
|
+
...needsGateway ? [{
|
|
3878
|
+
key: "aig",
|
|
3879
|
+
type: "edit"
|
|
3880
|
+
}] : [],
|
|
3881
|
+
...plan.deployment.application.zoneName ? [{
|
|
3882
|
+
key: "cache",
|
|
3883
|
+
type: "purge"
|
|
3884
|
+
}] : [],
|
|
3885
|
+
...plan.deployment.capabilities.customDomains ? [{
|
|
3886
|
+
key: "ssl_and_certificates",
|
|
3887
|
+
type: "edit"
|
|
3888
|
+
}] : []
|
|
3889
|
+
];
|
|
3890
|
+
return {
|
|
3891
|
+
runtime: {
|
|
3892
|
+
title: "Cloudflare runtime token",
|
|
3893
|
+
url: "https://dash.cloudflare.com/?" + new URLSearchParams({
|
|
3894
|
+
to: "/" + plan.account.id + "/api-tokens",
|
|
3895
|
+
name: plan.deployment.displayName + " Runtime",
|
|
3896
|
+
permissionGroupKeys: JSON.stringify(permissions)
|
|
3897
|
+
}),
|
|
3898
|
+
instructions: [
|
|
3899
|
+
"Review the preselected permissions for account " + plan.account.name + ".",
|
|
3900
|
+
"Edit: Workers Scripts, KV, R2, D1, Queues, Hyperdrive" + (needsGateway ? ", AI Gateway." : "."),
|
|
3901
|
+
"Read: Account Settings, Account Analytics, Workers Tail.",
|
|
3902
|
+
...plan.deployment.application.zoneName ? ["Zone " + plan.deployment.application.zoneName + ": Cache Purge: Purge."] : [],
|
|
3903
|
+
...plan.deployment.capabilities.customDomains ? ["Custom project domains also need SSL and Certificates: Edit on that zone."] : [],
|
|
3904
|
+
"If the form differs, match the permissions above. Create the token, then paste it here."
|
|
3905
|
+
].join("\n")
|
|
3906
|
+
},
|
|
3907
|
+
github: {
|
|
3908
|
+
title: "GitHub OAuth App",
|
|
3909
|
+
url: "https://github.com/settings/applications/new",
|
|
3910
|
+
instructions: "GitHub Auth will be used by the users of your Void Platform instance. Register a GitHub app and generate a client secret.\nHomepage: " + plan.apiUrl + "\nRedirect URI: " + plan.apiUrl + "/auth/callback"
|
|
3911
|
+
},
|
|
3912
|
+
r2: {
|
|
3913
|
+
title: "R2 upload token",
|
|
3914
|
+
url: "https://dash.cloudflare.com/?" + new URLSearchParams({
|
|
3915
|
+
to: "/" + plan.account.id + "/r2/api-tokens/create",
|
|
3916
|
+
type: "account"
|
|
3917
|
+
}),
|
|
3918
|
+
instructions: [
|
|
3919
|
+
"Select Object Read & Write (the form defaults to read-only).",
|
|
3920
|
+
"Keep Apply to all buckets in this account (including newly created buckets) selected.",
|
|
3921
|
+
"Create the token, then copy the Access Key ID and Secret Access Key into the CLI."
|
|
3922
|
+
].join("\n")
|
|
3923
|
+
}
|
|
3924
|
+
};
|
|
3925
|
+
}
|
|
3926
|
+
function formatPlatformSetupPlan(plan) {
|
|
3927
|
+
const pages = platformSetupPages(plan);
|
|
3928
|
+
return [
|
|
3929
|
+
"Credentials needed (paste them into the installer; keep a password-manager copy):",
|
|
3930
|
+
platformSetupLink(pages.runtime),
|
|
3931
|
+
pages.runtime.instructions,
|
|
3932
|
+
"",
|
|
3933
|
+
platformSetupLink(pages.r2),
|
|
3934
|
+
pages.r2.instructions,
|
|
3935
|
+
"",
|
|
3936
|
+
platformSetupLink(pages.github),
|
|
3937
|
+
pages.github.instructions
|
|
3938
|
+
].join("\n");
|
|
3939
|
+
}
|
|
3940
|
+
function openPlatformSetupPage(page, dependencies = {}) {
|
|
3941
|
+
log.info(page.title + "\n" + page.instructions);
|
|
3942
|
+
console.log(platformSetupLink({
|
|
3943
|
+
...page,
|
|
3944
|
+
title: "Open " + page.title
|
|
3945
|
+
}));
|
|
3946
|
+
if (dependencies.interactive ?? process.stdin.isTTY === true) try {
|
|
3947
|
+
(dependencies.open ?? ((url) => openBrowser(url, execFile)))(page.url);
|
|
3948
|
+
} catch {
|
|
3949
|
+
log.info("Could not open the browser. Use the link above.");
|
|
3950
|
+
}
|
|
3951
|
+
}
|
|
3952
|
+
//#endregion
|
|
3953
|
+
//#region src/cli/prompt-password.ts
|
|
3954
|
+
var import_picocolors = /* @__PURE__ */ __toESM(require_picocolors(), 1);
|
|
3955
|
+
/** A visual example for an empty secret field; never a default value or an unmasked input. */
|
|
3956
|
+
async function passwordWithPlaceholder(options) {
|
|
3957
|
+
return await new u$1({
|
|
3958
|
+
input: options.input,
|
|
3959
|
+
output: options.output,
|
|
3960
|
+
signal: options.signal,
|
|
3961
|
+
mask: options.mask ?? S_PASSWORD_MASK,
|
|
3962
|
+
validate: options.validate,
|
|
3963
|
+
render() {
|
|
3964
|
+
const guide = options.withGuide ?? settings.withGuide;
|
|
3965
|
+
const prefix = `${guide ? `${import_picocolors.default.gray(S_BAR)}\n` : ""}${symbol(this.state)} ${options.message}\n`;
|
|
3966
|
+
const bar = guide ? `${import_picocolors.default.gray(S_BAR)} ` : "";
|
|
3967
|
+
if (this.state === "submit" || this.state === "cancel") return `${prefix}${bar}${import_picocolors.default.dim(this.masked)}`;
|
|
3968
|
+
if (this.state === "error") {
|
|
3969
|
+
const masked = this.masked;
|
|
3970
|
+
if (options.clearOnError) this.clear();
|
|
3971
|
+
return `${prefix}${bar}${import_picocolors.default.dim(masked)}\n${guide ? `${import_picocolors.default.yellow(S_BAR_END)} ` : ""}${import_picocolors.default.yellow(this.error)}\n`;
|
|
3972
|
+
}
|
|
3973
|
+
const input = this.userInput.length === 0 ? import_picocolors.default.dim(options.placeholder) : this.userInputWithCursor;
|
|
3974
|
+
return `${prefix}${guide ? `${import_picocolors.default.cyan(S_BAR)} ` : ""}${input}\n${guide ? import_picocolors.default.cyan(S_BAR_END) : ""}\n`;
|
|
3975
|
+
}
|
|
3976
|
+
}).prompt() ?? CANCEL_SYMBOL;
|
|
3977
|
+
}
|
|
3978
|
+
//#endregion
|
|
3979
|
+
//#region src/cli/platform-lifecycle.ts
|
|
3980
|
+
function resolvePlatformInstallDatabaseUrl(checkpoint, manifest = null) {
|
|
3981
|
+
const explicit = process.env.VOID_PLATFORM_DATABASE_URL || process.env.DATABASE_URL || "";
|
|
3982
|
+
const recorded = checkpoint?.databaseUrl || "";
|
|
3983
|
+
if (checkpoint?.databaseClaimState === "claimed" || manifest?.resources.some((resource) => resource.state === "ready" && (resource.kind === "hyperdrive" || resource.kind === "worker")) || manifest?.steps.some((step) => step.key.startsWith("database-schema:") || step.key.startsWith("database-migrations:"))) {
|
|
3984
|
+
if (!recorded) {
|
|
3985
|
+
if (explicit) return explicit;
|
|
3986
|
+
throw new Error("platform: The claimed PostgreSQL database URL is missing from local recovery state. Export VOID_PLATFORM_DATABASE_URL with the original installation database before continuing.");
|
|
3987
|
+
}
|
|
3988
|
+
if (explicit && explicit !== recorded) throw new Error("platform: This installation is already bound to its recorded PostgreSQL database. Unset DATABASE_URL and VOID_PLATFORM_DATABASE_URL before resuming; changing databases requires an explicit migration flow.");
|
|
3989
|
+
return recorded;
|
|
3990
|
+
}
|
|
3991
|
+
return explicit || recorded;
|
|
3992
|
+
}
|
|
3993
|
+
async function runPlatformLifecycleCommand(args) {
|
|
3994
|
+
if (args.subcommand === "domain") {
|
|
3995
|
+
const { runPlatformDomainCommand } = await import("./platform-domain-BszUfiS8.mjs");
|
|
3996
|
+
await runPlatformDomainCommand(args);
|
|
3997
|
+
return;
|
|
3998
|
+
}
|
|
3999
|
+
if (args.subcommand === "discover") {
|
|
4000
|
+
const client = new PlatformCloudflareClient((await resolvePlatformCloudflareCredential()).token);
|
|
4001
|
+
const accountId = await selectAccount(await readAccounts(client), args.account);
|
|
4002
|
+
const { recoverPlatformInstallations } = await import("./platform-recovery-pHHv4ZSg.mjs");
|
|
4003
|
+
const recovered = await recoverPlatformInstallations(client, accountId, void 0, args.selector);
|
|
4004
|
+
if (recovered.length === 0) {
|
|
4005
|
+
log.info(`No recoverable Void platform installations were found in ${accountId}.`);
|
|
4006
|
+
return;
|
|
4007
|
+
}
|
|
4008
|
+
for (const manifest of recovered) log.success(`Recovered ${manifest.deployment.displayName} (${manifest.id}) at ${manifest.endpoints.apiUrl}.`);
|
|
4009
|
+
return;
|
|
4010
|
+
}
|
|
4011
|
+
if (args.subcommand !== "install") {
|
|
4012
|
+
const { runInstalledPlatformCommand } = await import("./platform-management-W30iCaTL.mjs");
|
|
4013
|
+
await runInstalledPlatformCommand(args);
|
|
4014
|
+
return;
|
|
4015
|
+
}
|
|
4016
|
+
const runtime = loadPlatformRuntime(args.runtime);
|
|
4017
|
+
assertNoninteractivePlatformMutationAcknowledged(args, process.stdin.isTTY === true);
|
|
4018
|
+
console.log();
|
|
4019
|
+
intro(cliTitle("platform install"));
|
|
4020
|
+
let existing = args.resume ? resolveInstallationManifest(args.name) : null;
|
|
4021
|
+
let selectedInstall = false;
|
|
4022
|
+
let selectedDraft = false;
|
|
4023
|
+
const { input: installationSlug, credential: operatorCredential } = await resolvePlatformCloudflareCredentialWithInput(async () => {
|
|
4024
|
+
let requestedName = args.name;
|
|
4025
|
+
const resumableInstalls = () => listInstallationManifests().filter((manifest) => isPlatformInstallInProgress(manifest) && (!args.account || manifest.account.id === args.account || manifest.account.name.toLowerCase() === args.account.toLowerCase()));
|
|
4026
|
+
const selectInstall = (manifest) => {
|
|
4027
|
+
existing = manifest;
|
|
4028
|
+
selectedInstall = true;
|
|
4029
|
+
selectedDraft = isPlatformSetupDraft(manifest);
|
|
4030
|
+
};
|
|
4031
|
+
const assertNewNameAvailable = (slug) => {
|
|
4032
|
+
const collision = listInstallationManifests().find((manifest) => manifest.status !== "uninstalled" && manifest.deployment.installationSlug === slug);
|
|
4033
|
+
if (collision && !isPlatformInstallInProgress(collision)) throw new Error(`platform: ${collision.deployment.displayName} (${slug}) already exists (${collision.status}). Run \`void platform status ${collision.id}\` to inspect it.`);
|
|
4034
|
+
assertPlatformInstallationSlugAvailable(slug);
|
|
4035
|
+
};
|
|
4036
|
+
if (!args.resume && process.stdin.isTTY) {
|
|
4037
|
+
const installs = resumableInstalls().filter((manifest) => !args.name || manifest.deployment.installationSlug === normalizeInstallationSlug(args.name));
|
|
4038
|
+
if (installs.length > 0) {
|
|
4039
|
+
const choice = await select({
|
|
4040
|
+
message: "Continue a previous platform install?",
|
|
4041
|
+
options: [...installs.map((install) => ({
|
|
4042
|
+
value: install.id,
|
|
4043
|
+
label: `Continue setup for ${install.deployment.displayName} (${install.deployment.installationSlug})`,
|
|
4044
|
+
hint: install.account.name
|
|
4045
|
+
})), {
|
|
4046
|
+
value: "new",
|
|
4047
|
+
label: "Start a new platform install"
|
|
4048
|
+
}]
|
|
4049
|
+
});
|
|
4050
|
+
if (isCancel(choice) || typeof choice === "symbol") throw new Error("platform: Cancelled.");
|
|
4051
|
+
if (choice === "new") requestedName = "";
|
|
4052
|
+
else {
|
|
4053
|
+
const selected = installs.find((install) => install.id === choice);
|
|
4054
|
+
if (!selected) throw new Error("platform: The selected setup is no longer available.");
|
|
4055
|
+
selectInstall(selected);
|
|
4056
|
+
}
|
|
4057
|
+
}
|
|
4058
|
+
}
|
|
4059
|
+
while (!existing) {
|
|
4060
|
+
const name = requestedName || await promptText("Installation name:", { placeholder: "company" }, false, (value) => {
|
|
4061
|
+
try {
|
|
4062
|
+
const slug = normalizeInstallationSlug(value);
|
|
4063
|
+
if (!resumableInstalls().some((install) => install.deployment.installationSlug === slug)) assertNewNameAvailable(slug);
|
|
4064
|
+
} catch (error) {
|
|
4065
|
+
return error instanceof Error ? error.message : "Choose another installation name.";
|
|
4066
|
+
}
|
|
4067
|
+
});
|
|
4068
|
+
const slug = normalizeInstallationSlug(name);
|
|
4069
|
+
const matches = resumableInstalls().filter((install) => install.deployment.installationSlug === slug);
|
|
4070
|
+
if (process.stdin.isTTY && matches.length > 0) {
|
|
4071
|
+
let selected = matches[0];
|
|
4072
|
+
if (matches.length > 1) {
|
|
4073
|
+
const choice = await select({
|
|
4074
|
+
message: `Which ${slug} installation do you want to resume?`,
|
|
4075
|
+
options: matches.map((install) => ({
|
|
4076
|
+
value: install.id,
|
|
4077
|
+
label: install.deployment.displayName,
|
|
4078
|
+
hint: install.account.name
|
|
4079
|
+
}))
|
|
4080
|
+
});
|
|
4081
|
+
if (isCancel(choice) || typeof choice === "symbol") throw new Error("platform: Cancelled.");
|
|
4082
|
+
const matching = matches.find((install) => install.id === choice);
|
|
4083
|
+
if (!matching) throw new Error("platform: The selected setup is no longer available.");
|
|
4084
|
+
selected = matching;
|
|
4085
|
+
}
|
|
4086
|
+
const resume = await confirm({
|
|
4087
|
+
message: `Resume installation for ${selected.deployment.displayName} (${slug})?`,
|
|
4088
|
+
initialValue: true
|
|
4089
|
+
});
|
|
4090
|
+
if (isCancel(resume) || typeof resume === "symbol") throw new Error("platform: Cancelled.");
|
|
4091
|
+
if (resume) selectInstall(selected);
|
|
4092
|
+
else requestedName = "";
|
|
4093
|
+
continue;
|
|
4094
|
+
}
|
|
4095
|
+
assertNewNameAvailable(slug);
|
|
4096
|
+
return slug;
|
|
4097
|
+
}
|
|
4098
|
+
if (existing.endpoints.apiUrl && !readPendingInstallationSecrets(existing)) throw new Error(`platform: Installation ${existing.id} has already been deployed. Use \`void platform repair ${existing.id}\` or \`void platform upgrade ${existing.id}\` instead.`);
|
|
4099
|
+
return existing.deployment.installationSlug;
|
|
4100
|
+
});
|
|
4101
|
+
let client = new PlatformCloudflareClient(operatorCredential.token);
|
|
4102
|
+
const accounts = await readAccounts(client);
|
|
4103
|
+
const accountId = existing ? existing.account.id : await selectAccount(accounts, args.account);
|
|
4104
|
+
if (existing && args.account && await selectAccount(accounts, args.account) !== existing.account.id) throw new Error("platform: The installation belongs to a different Cloudflare account.");
|
|
4105
|
+
const recorded = existing?.deployment;
|
|
4106
|
+
if (existing && isPlatformSetupDraft(existing) && (args.applicationDomain && normalizeApplicationDomain(args.applicationDomain) !== recorded.application.baseDomain || args.zone && normalizeApplicationDomain(args.zone) !== recorded.application.zoneName || args.workersDev && !!recorded.application.zoneName || args.controlPlaneDomain && normalizeApplicationDomain(args.controlPlaneDomain) !== recorded.controlPlane.apiHostname || args.dedicatedZone && !recorded.application.dedicatedZone)) throw new Error("platform: These options differ from the saved setup. Resume its recorded configuration or choose another installation name.");
|
|
4107
|
+
let workersDev = !!recorded?.application.workersDev && !recorded.application.zoneName || args.workersDev === true;
|
|
4108
|
+
if (!recorded && !args.workersDev && !args.applicationDomain && !args.zone && process.stdin.isTTY) {
|
|
4109
|
+
const mode = await select({
|
|
4110
|
+
message: "Where should your apps live?",
|
|
4111
|
+
options: [{
|
|
4112
|
+
value: "domain",
|
|
4113
|
+
label: "Use a domain",
|
|
4114
|
+
hint: "recommended"
|
|
4115
|
+
}, {
|
|
4116
|
+
value: "workers-dev",
|
|
4117
|
+
label: "Use workers.dev for testing",
|
|
4118
|
+
hint: "add a domain later"
|
|
4119
|
+
}]
|
|
4120
|
+
});
|
|
4121
|
+
if (isCancel(mode)) throw new Error("platform: Cancelled.");
|
|
4122
|
+
workersDev = mode === "workers-dev";
|
|
4123
|
+
}
|
|
4124
|
+
const applicationDomain = recorded?.application.baseDomain ?? (workersDev ? "" : normalizeApplicationDomain(args.applicationDomain || await promptText("Application base domain:", "example.app")));
|
|
4125
|
+
const zoneName = recorded?.application.zoneName ?? (workersDev ? "" : normalizeApplicationDomain(args.zone || await promptText("Cloudflare zone:", applicationDomain)));
|
|
4126
|
+
const displayName = recorded?.displayName ?? (args.displayName || await promptText("Platform display name:", `${installationSlug} Void`));
|
|
4127
|
+
const controlPlaneDomain = recorded ? recorded.controlPlane.apiHostname ?? "" : args.controlPlaneDomain || (process.stdin.isTTY ? await promptText("Optional custom API hostname (leave empty for workers.dev):", "", true) : "");
|
|
4128
|
+
let dedicatedZone = recorded?.application.dedicatedZone ?? args.dedicatedZone;
|
|
4129
|
+
if (!workersDev && !recorded && !args.dedicatedZone && applicationDomain === zoneName && process.stdin.isTTY) {
|
|
4130
|
+
const catchAll = await confirm({
|
|
4131
|
+
message: `Dedicate all unmatched ${zoneName} traffic to Void apps?`,
|
|
4132
|
+
initialValue: false
|
|
4133
|
+
});
|
|
4134
|
+
if (isCancel(catchAll) || typeof catchAll === "symbol") throw new Error("platform: Cancelled.");
|
|
4135
|
+
dedicatedZone = catchAll;
|
|
4136
|
+
}
|
|
4137
|
+
const release = args.plan ? null : acquirePlatformLifecycleLock(accountId, installationSlug);
|
|
4138
|
+
let progress;
|
|
4139
|
+
const startProgress = (message) => {
|
|
4140
|
+
const active = createSpinner({ onCancel: () => {
|
|
4141
|
+
if (!process.emit("SIGINT")) process.exit(130);
|
|
4142
|
+
} });
|
|
4143
|
+
active.start(message);
|
|
4144
|
+
return active;
|
|
4145
|
+
};
|
|
4146
|
+
try {
|
|
4147
|
+
if (!existing && !args.plan) assertPlatformInstallationSlugAvailable(installationSlug);
|
|
4148
|
+
if (existing && !args.plan) {
|
|
4149
|
+
existing = resolveInstallationManifest(existing.id);
|
|
4150
|
+
if (selectedInstall && (!isPlatformInstallInProgress(existing) || selectedDraft && !isPlatformSetupDraft(existing))) throw new Error("platform: Setup advanced in another process. Rerun with --resume to review its current state.");
|
|
4151
|
+
}
|
|
4152
|
+
const checkpoint = existing ? readPendingInstallationSecrets(existing) : null;
|
|
4153
|
+
const databaseUrl = resolvePlatformInstallDatabaseUrl(checkpoint, existing);
|
|
4154
|
+
let authoritativeProvisionIntents = false;
|
|
4155
|
+
if (existing && !isPlatformSetupDraft(existing)) {
|
|
4156
|
+
if (!databaseUrl) throw new Error("platform: DATABASE_URL is required to verify an interrupted installation before resuming it.");
|
|
4157
|
+
progress = startProgress("Reading the saved installation");
|
|
4158
|
+
const authoritative = await readPlatformDatabaseLifecycleManifest(databaseUrl, existing.id, existing.ownershipNonce);
|
|
4159
|
+
progress?.stop("Read the saved installation.");
|
|
4160
|
+
progress = void 0;
|
|
4161
|
+
if (authoritative) {
|
|
4162
|
+
const { selectAuthoritativePlatformManifest } = await import("./platform-management-W30iCaTL.mjs");
|
|
4163
|
+
const selected = selectAuthoritativePlatformManifest(existing, authoritative);
|
|
4164
|
+
authoritativeProvisionIntents = true;
|
|
4165
|
+
if (!args.plan && JSON.stringify(selected) !== JSON.stringify(existing)) writeInstallationManifest(selected);
|
|
4166
|
+
existing = selected;
|
|
4167
|
+
if (selectedInstall && !isPlatformInstallInProgress(existing)) throw new Error(`platform: Installation ${existing.id} is no longer unfinished. Run \`void platform status ${existing.id}\` to inspect it.`);
|
|
4168
|
+
}
|
|
4169
|
+
}
|
|
4170
|
+
const planInput = {
|
|
4171
|
+
accountId,
|
|
4172
|
+
applicationDomain,
|
|
4173
|
+
controlPlaneDomain,
|
|
4174
|
+
dedicatedZone,
|
|
4175
|
+
displayName,
|
|
4176
|
+
installationSlug,
|
|
4177
|
+
zoneName,
|
|
4178
|
+
...workersDev ? { workersDev: true } : {}
|
|
4179
|
+
};
|
|
4180
|
+
let plan = await createPlatformInstallPlan(client, planInput, existing, {
|
|
4181
|
+
authoritativeProvisionIntents,
|
|
4182
|
+
allowUnverifiedAiGateway: true
|
|
4183
|
+
});
|
|
4184
|
+
note(formatPlatformInstallPlan(plan), args.plan ? "Read-only installation plan" : "Installation plan");
|
|
4185
|
+
note(formatPlatformRuntime(runtime), "Platform runtime");
|
|
4186
|
+
if (args.plan) {
|
|
4187
|
+
console.log(formatPlatformSetupPlan(plan));
|
|
4188
|
+
outro("No Cloudflare or local installation state was changed.");
|
|
4189
|
+
return;
|
|
4190
|
+
}
|
|
4191
|
+
assertPlatformOperatorCredentialCanApplyPlan(operatorCredential, plan);
|
|
4192
|
+
if (process.stdin.isTTY && !args.yes) {
|
|
4193
|
+
const proceed = await confirm({
|
|
4194
|
+
message: "Apply this plan?",
|
|
4195
|
+
initialValue: false
|
|
4196
|
+
});
|
|
4197
|
+
if (isCancel(proceed) || !proceed) {
|
|
4198
|
+
outro("No changes made.");
|
|
4199
|
+
return;
|
|
4200
|
+
}
|
|
4201
|
+
}
|
|
4202
|
+
if (!existing) existing = savePlatformSetupDraft(plan, runtime.manifest.platformVersion);
|
|
4203
|
+
const setupManifest = existing;
|
|
4204
|
+
const credentialSetup = isPlatformSetupDraft(existing);
|
|
4205
|
+
const values = { ...checkpoint };
|
|
4206
|
+
const saveValues = (updates) => {
|
|
4207
|
+
Object.assign(values, updates);
|
|
4208
|
+
if (credentialSetup) writePendingInstallationSecrets(setupManifest, values);
|
|
4209
|
+
};
|
|
4210
|
+
const configured = (key, environment) => credentialSetup ? process.env[environment] || values[key] || "" : values[key] || process.env[environment] || "";
|
|
4211
|
+
const collect = async (key, environment, message, masked = true, persist = true, validate) => {
|
|
4212
|
+
let value = configured(key, environment);
|
|
4213
|
+
if (!value) {
|
|
4214
|
+
const help = platformSetupFieldHelp(key);
|
|
4215
|
+
if (help && process.stdin.isTTY) log.info(help.description);
|
|
4216
|
+
value = await promptSecret(message, masked, validate, help?.placeholder);
|
|
4217
|
+
}
|
|
4218
|
+
const error = validate?.(value);
|
|
4219
|
+
if (error) throw new Error("platform: " + error);
|
|
4220
|
+
if (persist) saveValues({ [key]: value });
|
|
4221
|
+
return value;
|
|
4222
|
+
};
|
|
4223
|
+
const pages = platformSetupPages(plan);
|
|
4224
|
+
if (!configured("cloudflareApiToken", "VOID_PLATFORM_RUNTIME_CLOUDFLARE_API_TOKEN")) openPlatformSetupPage(pages.runtime);
|
|
4225
|
+
const usingSavedRuntimeToken = credentialSetup && !process.env.VOID_PLATFORM_RUNTIME_CLOUDFLARE_API_TOKEN && Boolean(values.cloudflareApiToken);
|
|
4226
|
+
let runtimeCloudflareApiToken = await collect("cloudflareApiToken", "VOID_PLATFORM_RUNTIME_CLOUDFLARE_API_TOKEN", "Paste runtime API token:", true, false);
|
|
4227
|
+
let rejectedSavedRuntimeToken = false;
|
|
4228
|
+
const verifyRuntimeToken = async (token) => {
|
|
4229
|
+
let checkingRuntimePermissions = true;
|
|
4230
|
+
try {
|
|
4231
|
+
await verifyPlatformRuntimeTokenPermissions(new PlatformCloudflareClient(token), {
|
|
4232
|
+
accountId,
|
|
4233
|
+
applicationDomain,
|
|
4234
|
+
customDomains: plan.deployment.capabilities.customDomains,
|
|
4235
|
+
zoneId: plan.zone?.id ?? null
|
|
4236
|
+
});
|
|
4237
|
+
checkingRuntimePermissions = false;
|
|
4238
|
+
if (plan.actions.some((action) => action.action === "inspect")) {
|
|
4239
|
+
client = client.withAiGatewayToken(token);
|
|
4240
|
+
plan = await verifyPlatformAiGatewayPlan(client, planInput, existing, plan, authoritativeProvisionIntents);
|
|
4241
|
+
}
|
|
4242
|
+
} catch (error) {
|
|
4243
|
+
if (credentialSetup && token === values.cloudflareApiToken && isRuntimeTokenAuthorizationError(error, checkingRuntimePermissions ? void 0 : "/ai-gateway/")) {
|
|
4244
|
+
delete values.cloudflareApiToken;
|
|
4245
|
+
writePendingInstallationSecrets(setupManifest, values);
|
|
4246
|
+
rejectedSavedRuntimeToken = true;
|
|
4247
|
+
}
|
|
4248
|
+
throw error;
|
|
4249
|
+
}
|
|
4250
|
+
};
|
|
4251
|
+
try {
|
|
4252
|
+
await verifyRuntimeToken(runtimeCloudflareApiToken);
|
|
4253
|
+
} catch (error) {
|
|
4254
|
+
if (!usingSavedRuntimeToken || !process.stdin.isTTY || !rejectedSavedRuntimeToken) throw error;
|
|
4255
|
+
log.warn("Cloudflare rejected the saved runtime token. Create a replacement to continue setup.");
|
|
4256
|
+
openPlatformSetupPage(pages.runtime);
|
|
4257
|
+
runtimeCloudflareApiToken = await collect("cloudflareApiToken", "VOID_PLATFORM_RUNTIME_CLOUDFLARE_API_TOKEN", "Paste runtime API token:", true, false);
|
|
4258
|
+
await verifyRuntimeToken(runtimeCloudflareApiToken);
|
|
4259
|
+
}
|
|
4260
|
+
saveValues({ cloudflareApiToken: runtimeCloudflareApiToken });
|
|
4261
|
+
const previousLogin = values.bootstrapGitHubLogin;
|
|
4262
|
+
const bootstrapGitHubLogin = await collect("bootstrapGitHubLogin", "VOID_PLATFORM_ADMIN_GITHUB_LOGIN", "Initial administrator GitHub username:", false);
|
|
4263
|
+
const bootstrapGitHubId = (previousLogin === bootstrapGitHubLogin ? values.bootstrapGitHubId : "") || process.env.VOID_PLATFORM_ADMIN_GITHUB_ID || await resolveBootstrapGitHubId(bootstrapGitHubLogin);
|
|
4264
|
+
saveValues({ bootstrapGitHubId });
|
|
4265
|
+
const installationDatabaseUrl = databaseUrl || await collect("databaseUrl", "VOID_PLATFORM_DATABASE_URL", "PostgreSQL DATABASE_URL:");
|
|
4266
|
+
saveValues({ databaseUrl: installationDatabaseUrl });
|
|
4267
|
+
if (!configured("githubClientId", "VOID_PLATFORM_GITHUB_CLIENT_ID") || !configured("githubClientSecret", "VOID_PLATFORM_GITHUB_CLIENT_SECRET")) openPlatformSetupPage(configured("githubClientId", "VOID_PLATFORM_GITHUB_CLIENT_ID") ? {
|
|
4268
|
+
...pages.github,
|
|
4269
|
+
url: "https://github.com/settings/developers"
|
|
4270
|
+
} : pages.github);
|
|
4271
|
+
const githubClientId = await collect("githubClientId", "VOID_PLATFORM_GITHUB_CLIENT_ID", "GitHub OAuth client ID:", false, false);
|
|
4272
|
+
const githubClientSecret = await collect("githubClientSecret", "VOID_PLATFORM_GITHUB_CLIENT_SECRET", "GitHub OAuth client secret:", true, false);
|
|
4273
|
+
saveValues({
|
|
4274
|
+
githubClientId,
|
|
4275
|
+
githubClientSecret
|
|
4276
|
+
});
|
|
4277
|
+
if (!configured("r2AccessKeyId", "VOID_PLATFORM_R2_ACCESS_KEY_ID") || !configured("r2SecretAccessKey", "VOID_PLATFORM_R2_SECRET_ACCESS_KEY")) openPlatformSetupPage(pages.r2);
|
|
4278
|
+
const r2AccessKeyId = await collect("r2AccessKeyId", "VOID_PLATFORM_R2_ACCESS_KEY_ID", "Access Key ID:", false, false);
|
|
4279
|
+
const r2SecretAccessKey = await collect("r2SecretAccessKey", "VOID_PLATFORM_R2_SECRET_ACCESS_KEY", "Secret Access Key:", true, false);
|
|
4280
|
+
saveValues({
|
|
4281
|
+
r2AccessKeyId,
|
|
4282
|
+
r2SecretAccessKey
|
|
4283
|
+
});
|
|
4284
|
+
const hasFullProjectSecretKeyring = Boolean(checkpoint?.projectSecretKeysJson || checkpoint?.projectSecretActiveKeyVersion || process.env.VOID_PLATFORM_PROJECT_SECRET_KEYS_JSON || process.env.VOID_PLATFORM_PROJECT_SECRET_ACTIVE_KEY_VERSION);
|
|
4285
|
+
const secrets = createPlatformInstallationSecrets({
|
|
4286
|
+
bootstrapGitHubId,
|
|
4287
|
+
bootstrapGitHubLogin,
|
|
4288
|
+
cloudflareApiToken: runtimeCloudflareApiToken,
|
|
4289
|
+
checkpoint: values,
|
|
4290
|
+
databaseUrl: installationDatabaseUrl,
|
|
4291
|
+
githubClientId,
|
|
4292
|
+
githubClientSecret,
|
|
4293
|
+
r2AccessKeyId,
|
|
4294
|
+
r2SecretAccessKey,
|
|
4295
|
+
existingInstallation: !credentialSetup,
|
|
4296
|
+
jwtSecret: await collect("jwtSecret", "VOID_PLATFORM_JWT_SECRET", "JWT signing key:", true, true, credentialSetup ? (value) => Buffer.byteLength(value, "utf8") >= 32 ? void 0 : "The JWT signing secret must contain at least 32 random bytes." : void 0),
|
|
4297
|
+
projectSecretKey: configured("projectSecretKey", "VOID_PLATFORM_PROJECT_SECRET_KEY") || (hasFullProjectSecretKeyring ? "" : await collect("projectSecretKey", "VOID_PLATFORM_PROJECT_SECRET_KEY", "Project encryption key:", true, true, credentialSetup ? (value) => {
|
|
4298
|
+
const key = Buffer.from(value, "base64");
|
|
4299
|
+
return key.length === 32 && key.toString("base64") === value ? void 0 : "The project encryption key must be canonical base64 encoding of exactly 32 bytes.";
|
|
4300
|
+
} : void 0)),
|
|
4301
|
+
projectSecretKeysJson: process.env.VOID_PLATFORM_PROJECT_SECRET_KEYS_JSON || "",
|
|
4302
|
+
projectSecretActiveKeyVersion: process.env.VOID_PLATFORM_PROJECT_SECRET_ACTIVE_KEY_VERSION || ""
|
|
4303
|
+
});
|
|
4304
|
+
progress = startProgress("Installing the platform");
|
|
4305
|
+
if (credentialSetup) {
|
|
4306
|
+
saveValues({ projectSecretKey: secrets.projectSecretKey });
|
|
4307
|
+
existing = completePlatformSetupDraft(existing);
|
|
4308
|
+
}
|
|
4309
|
+
const result = await installPlatform(plan, secrets, existing, {
|
|
4310
|
+
acquireDatabaseLifecycleLock: acquirePlatformDatabaseLifecycleLock,
|
|
4311
|
+
client,
|
|
4312
|
+
runtime,
|
|
4313
|
+
healthCheckResumeCommand: `${cliCommandName()} platform install --resume --name ${existing.id}`,
|
|
4314
|
+
onProgress: (message) => progress?.message(message),
|
|
4315
|
+
replanWorkers: async (manifest) => (await createPlatformInstallPlan(client, planInput, manifest, {
|
|
4316
|
+
authoritativeProvisionIntents: true,
|
|
4317
|
+
repairMissing: true
|
|
4318
|
+
})).actions.filter((action) => action.kind === "worker"),
|
|
4319
|
+
revalidateBeforeWorkerDeploy: (manifest, action) => revalidatePlatformInstallAction(client, plan, manifest, action),
|
|
4320
|
+
verifyExistingOwnership: async (manifest, databaseCheckpointExists) => {
|
|
4321
|
+
const { verifyPlatformInstallationOwnership } = await import("./platform-management-W30iCaTL.mjs");
|
|
4322
|
+
const retainedReplacementKeys = new Set(plan.actions.filter((action) => {
|
|
4323
|
+
const previous = manifest.resources.find((resource) => resource.key === action.key);
|
|
4324
|
+
return action.action === "create" && previous && previous.name !== action.name;
|
|
4325
|
+
}).map((action) => action.key));
|
|
4326
|
+
await verifyPlatformInstallationOwnership(client, manifest, {
|
|
4327
|
+
allowUninitializedRoutingCheckpoint: databaseCheckpointExists,
|
|
4328
|
+
databaseUrl: secrets.databaseUrl,
|
|
4329
|
+
retainedReplacementKeys
|
|
4330
|
+
});
|
|
4331
|
+
}
|
|
4332
|
+
});
|
|
4333
|
+
progress?.stop(result.waitingForDns || result.waitingForSandboxDrain ? "Installation paused; progress is saved." : "Platform installed.");
|
|
4334
|
+
progress = void 0;
|
|
4335
|
+
if (result.waitingForDns) {
|
|
4336
|
+
note(result.zoneNameServers.length > 0 ? `Update the domain at your registrar to use:\n${result.zoneNameServers.join("\n")}\n\nThen rerun: void platform install --resume --name ${result.manifest.id}` : `Wait for ${zoneName} to become active, then rerun:\nvoid platform install --resume --name ${result.manifest.id}`, "Waiting for nameservers");
|
|
4337
|
+
outro(`Installation ${result.manifest.id} is safely checkpointed.`);
|
|
4338
|
+
return;
|
|
4339
|
+
}
|
|
4340
|
+
if (result.waitingForSandboxDrain) {
|
|
4341
|
+
note("The compatible candidate runtime is live in sandbox-drain maintenance mode and application traffic remains disabled. Preview bounded work with `void platform system sandbox-drain --plan`; follow any returned `nextCursor` with `--cursor '<value>'`. Then apply with `void platform system sandbox-drain --yes` and rerun until it reports DB-backed `complete: true`. Rerun this install command afterward to enable traffic.", "Waiting for managed Sandbox drain");
|
|
4342
|
+
outro(`Installation ${result.manifest.id} is safely checkpointed.`);
|
|
4343
|
+
return;
|
|
4344
|
+
}
|
|
4345
|
+
log.success(`${result.manifest.deployment.displayName} is installed at ${new URL("/admin", plan.apiUrl).href}.`);
|
|
4346
|
+
log.info(`API URL: ${plan.apiUrl}`);
|
|
4347
|
+
if (!plan.deployment.application.zoneName) log.info("Apps will use workers.dev addresses. When your domain is ready: void platform domain set <domain>");
|
|
4348
|
+
outro("Run `void auth login`, then deploy an app with `void deploy --platform void`.");
|
|
4349
|
+
} catch (error) {
|
|
4350
|
+
progress?.error("Platform installation failed.");
|
|
4351
|
+
progress = void 0;
|
|
4352
|
+
if (existing && !args.plan && !(error instanceof PlatformAccessBlockedError)) log.info(`Continue with: ${cliCommandName()} platform install --resume --name ${existing.id}`);
|
|
4353
|
+
throw error;
|
|
4354
|
+
} finally {
|
|
4355
|
+
try {
|
|
4356
|
+
progress?.clear();
|
|
4357
|
+
} finally {
|
|
4358
|
+
release?.();
|
|
4359
|
+
}
|
|
4360
|
+
}
|
|
4361
|
+
}
|
|
4362
|
+
function isRuntimeTokenAuthorizationError(error, requestPathIncludes) {
|
|
4363
|
+
for (let depth = 0; depth < 5 && error instanceof Error; depth++, error = error.cause) if (error instanceof PlatformCloudflareApiError && [401, 403].includes(error.status) && (!requestPathIncludes || error.requestPath?.includes(requestPathIncludes))) return true;
|
|
4364
|
+
return false;
|
|
4365
|
+
}
|
|
4366
|
+
function formatPlatformRuntime(runtime) {
|
|
4367
|
+
return [
|
|
4368
|
+
`${import_picocolors$1.default.bold("Version:")} ${runtime.manifest.platformVersion}`,
|
|
4369
|
+
`${import_picocolors$1.default.bold("Source:")} ${runtime.source}`,
|
|
4370
|
+
`${import_picocolors$1.default.bold("Revision:")} ${runtime.manifest.sourceRevision ?? "not recorded"}`,
|
|
4371
|
+
`${import_picocolors$1.default.bold("Manifest SHA-256:")} ${runtime.manifestSha256}`
|
|
4372
|
+
].join("\n");
|
|
4373
|
+
}
|
|
4374
|
+
async function verifyPlatformAiGatewayPlan(client, input, existing, preview, authoritativeProvisionIntents) {
|
|
4375
|
+
let verified;
|
|
4376
|
+
try {
|
|
4377
|
+
verified = await createPlatformInstallPlan(client, input, existing, { authoritativeProvisionIntents });
|
|
4378
|
+
} catch (error) {
|
|
4379
|
+
if (error instanceof PlatformCloudflareApiError && (error.status === 401 || error.status === 403) && error.requestPath?.includes("/ai-gateway/")) throw new Error("platform: The runtime token could not verify AI Gateway access. Include Account → AI Gateway → Edit for this account, then retry. No platform resources were created.", { cause: error });
|
|
4380
|
+
throw error;
|
|
4381
|
+
}
|
|
4382
|
+
const infrastructure = (plan) => plan.actions.filter((action) => action.kind !== "ai-gateway").map(({ action, id, key, kind, name }) => ({
|
|
4383
|
+
action,
|
|
4384
|
+
id,
|
|
4385
|
+
key,
|
|
4386
|
+
kind,
|
|
4387
|
+
name
|
|
4388
|
+
}));
|
|
4389
|
+
if (verified.installationId !== preview.installationId || JSON.stringify(verified.deployment) !== JSON.stringify(preview.deployment) || JSON.stringify(infrastructure(verified)) !== JSON.stringify(infrastructure(preview))) throw new Error("platform: Resources changed while checking AI Gateway access. Rerun the command to review the updated plan. No platform resources were created.");
|
|
4390
|
+
return verified;
|
|
4391
|
+
}
|
|
4392
|
+
function assertNoninteractivePlatformMutationAcknowledged(args, interactive) {
|
|
4393
|
+
if (args.subcommand === "list" || args.subcommand === "use" || args.subcommand === "status" || args.subcommand === "discover" || args.plan || interactive || args.yes) return;
|
|
4394
|
+
throw new Error(`platform: Non-interactive \`void platform ${args.subcommand}\` requires --yes to acknowledge Cloudflare changes. Run with --plan first to inspect them.`);
|
|
4395
|
+
}
|
|
4396
|
+
async function readAccounts(client) {
|
|
4397
|
+
const accounts = await client.listAccounts();
|
|
4398
|
+
if (accounts.length === 0) throw new Error("platform: This Cloudflare token has no accessible accounts.");
|
|
4399
|
+
return accounts;
|
|
4400
|
+
}
|
|
4401
|
+
async function selectAccount(accounts, selector) {
|
|
4402
|
+
if (selector) {
|
|
4403
|
+
const matches = accounts.filter((account) => account.id === selector || account.name.toLowerCase() === selector.toLowerCase());
|
|
4404
|
+
if (matches.length !== 1) throw new Error(`platform: Cloudflare account ${JSON.stringify(selector)} is ambiguous or unavailable.`);
|
|
4405
|
+
return matches[0].id;
|
|
4406
|
+
}
|
|
4407
|
+
if (accounts.length === 1) return accounts[0].id;
|
|
4408
|
+
if (!process.stdin.isTTY) throw new Error("platform: --account <id> is required when the token can access multiple accounts.");
|
|
4409
|
+
const value = await select({
|
|
4410
|
+
message: "Cloudflare account:",
|
|
4411
|
+
options: accounts.map((account) => ({
|
|
4412
|
+
label: account.name,
|
|
4413
|
+
value: account.id,
|
|
4414
|
+
hint: account.id
|
|
4415
|
+
}))
|
|
4416
|
+
});
|
|
4417
|
+
if (isCancel(value)) throw new Error("platform: Cancelled.");
|
|
4418
|
+
return String(value);
|
|
4419
|
+
}
|
|
4420
|
+
async function promptText(message, value, optional = false, validate) {
|
|
4421
|
+
if (!process.stdin.isTTY) {
|
|
4422
|
+
if (optional) return "";
|
|
4423
|
+
throw new Error(`platform: ${message.replace(/:$/, "")} is required in non-interactive mode.`);
|
|
4424
|
+
}
|
|
4425
|
+
const input = await text({
|
|
4426
|
+
message,
|
|
4427
|
+
...typeof value === "string" ? { initialValue: value } : value,
|
|
4428
|
+
validate: (input) => input ? validate?.(input) : optional ? void 0 : "A value is required."
|
|
4429
|
+
});
|
|
4430
|
+
if (isCancel(input) || typeof input === "symbol") throw new Error("platform: Cancelled.");
|
|
4431
|
+
return input;
|
|
4432
|
+
}
|
|
4433
|
+
async function promptSecret(message, masked = true, validate, placeholder) {
|
|
4434
|
+
if (!process.stdin.isTTY) throw new Error(`platform: ${message.replace(/:$/, "")} is required in non-interactive mode. Set the documented VOID_PLATFORM_* environment variable.`);
|
|
4435
|
+
const options = {
|
|
4436
|
+
message,
|
|
4437
|
+
validate: (input) => input ? validate?.(input) : "A value is required."
|
|
4438
|
+
};
|
|
4439
|
+
const value = masked ? placeholder ? await passwordWithPlaceholder({
|
|
4440
|
+
...options,
|
|
4441
|
+
placeholder
|
|
4442
|
+
}) : await password(options) : await text({
|
|
4443
|
+
...options,
|
|
4444
|
+
placeholder
|
|
4445
|
+
});
|
|
4446
|
+
if (isCancel(value) || typeof value === "symbol") throw new Error("platform: Cancelled.");
|
|
4447
|
+
return value;
|
|
4448
|
+
}
|
|
4449
|
+
//#endregion
|
|
4450
|
+
export { resolvePlatformCloudflareCredential as S, hyperdriveOriginConfigHash as _, installPlatform as a, assertNoninteractivePlatformMutationAcknowledged, verifyPlatformRuntimeTokenPermissions as b, resolveBootstrapGitHubId as c, createPlatformInstallPlan as d, formatPlatformInstallPlan as f, PlatformCloudflareClient as g, revalidatePlatformInstallAction as h, createPlatformInstallationSecrets as i, recordPlatformWorkerTraffic as l, platformProvisionIntentHash as m, acquirePlatformDatabaseLifecycleLock as n, loadPlatformRuntime as o, platformAdoptionIntentHash as p, createPlatformHealthProbeToken as r, resolvePlatformInstallDatabaseUrl, runPlatformLifecycleCommand, recordResource as s, REMOTE_INSTALLATION_MANIFEST_KEY as t, trackedPlatformWorkerTraffic as u, hyperdriveOriginFromConnectionString as v, assertPlatformOperatorCredentialCanApplyPlan as x, hyperdriveOriginMatches as y };
|