From 6d9685188b1e358afac76041b9a3059cfce789e7 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Thu, 3 Sep 2026 07:51:45 +0100 Subject: [PATCH 1/4] migrate command --- .changeset/sites-migrate-router-era.md | 5 + packages/cli/src/commands/sites/api.test.ts | 285 ++++++++++++++- packages/cli/src/commands/sites/api.ts | 181 +++++++++- .../cli/src/commands/sites/constants.test.ts | 77 +++- packages/cli/src/commands/sites/constants.ts | 72 +++- packages/cli/src/commands/sites/index.ts | 2 + packages/cli/src/commands/sites/migrate.ts | 331 ++++++++++++++++++ 7 files changed, 923 insertions(+), 30 deletions(-) create mode 100644 .changeset/sites-migrate-router-era.md create mode 100644 packages/cli/src/commands/sites/migrate.ts diff --git a/.changeset/sites-migrate-router-era.md b/.changeset/sites-migrate-router-era.md new file mode 100644 index 00000000..f495c2f5 --- /dev/null +++ b/.changeset/sites-migrate-router-era.md @@ -0,0 +1,5 @@ +--- +"@bunny.net/cli": patch +--- + +`bunny sites migrate` moves a site created with the earlier Edge Script router onto the edge-rule architecture in place, keeping its domain, certificate, and deploy history. diff --git a/packages/cli/src/commands/sites/api.test.ts b/packages/cli/src/commands/sites/api.test.ts index 28b26f23..86c1133b 100644 --- a/packages/cli/src/commands/sites/api.test.ts +++ b/packages/cli/src/commands/sites/api.test.ts @@ -3,9 +3,14 @@ import type { EdgeRule } from "../../core/edge-rules.ts"; import { ApiError } from "../../core/errors.ts"; import type { CoreClient, StorageZoneModel } from "../storage/api.ts"; import { + type ComputeClient, + classifySiteZone, createSite, deleteSiteResources, + detachMiddlewareScript, + fetchLegacySites, fetchSites, + migrateSite, promoteDeploy, promoteVerification, readRemoteState, @@ -15,6 +20,8 @@ import { } from "./api.ts"; import { GATE_RULE_DESC, + LEGACY_STATE_VERSION, + type LegacySiteState, PLACEHOLDER_DEPLOY, REMOTE_STATE_PATH, REWRITE_RULE_DESC, @@ -98,6 +105,35 @@ function fakeState(overrides?: Partial): RemoteSiteState { }; } +function fakeLegacyState( + overrides?: Partial, +): LegacySiteState { + return { + version: LEGACY_STATE_VERSION, + name: "my-site", + storageZoneId: 10, + pullZoneId: 30, + scriptId: 77, + routerVersion: 5, + deploys: [], + ...overrides, + }; +} + +function fakeComputeClient(calls: Call[], opts?: { deleteError?: Error }) { + return { + DELETE: async (path: string, options?: { params?: unknown }) => { + calls.push({ + method: "DELETE", + path, + params: options?.params as Record, + }); + if (opts?.deleteError) throw opts.deleteError; + return { data: undefined }; + }, + } as unknown as ComputeClient; +} + function fakeCoreClient(opts: { calls: Call[]; storageZones?: StorageZoneModel[]; @@ -115,6 +151,7 @@ function fakeCoreClient(opts: { error: ApiError; times?: number; }; + ignoreDetach?: boolean; }): CoreClient { const zones = opts.storageZones ?? []; const pullZones = opts.pullZones ?? []; @@ -205,7 +242,24 @@ function fakeCoreClient(opts: { pullZones.push(pz); return { data: pz }; } - if (path === "/pullzone/{id}") return { data: {} }; + if (path === "/pullzone/{id}") { + const id = (options?.params as { path: { id: number } }).path.id; + const pz = pullZones.find((p) => p.Id === id); + + if (pz) { + const body = { ...(options?.body as Record) }; + if ("MiddlewareScriptId" in body) { + const requested = body.MiddlewareScriptId; + if (requested === 0) body.MiddlewareScriptId = null; + else if (requested === null) delete body.MiddlewareScriptId; + else if (requested === -1) + throw new Error("Middleware Script ID not found."); + if (opts.ignoreDetach) delete body.MiddlewareScriptId; + } + Object.assign(pz, body); + } + return { data: {} }; + } if (path === "/pullzone/{pullZoneId}/edgerules/addOrUpdate") { const id = (options?.params as { path: { pullZoneId: number } }).path .pullZoneId; @@ -899,3 +953,232 @@ test("fetchSites pages through the /pullzone envelope", async () => { expect(sites).toHaveLength(1); expect(sites[0]?.state.name).toBe("my-site"); }); + +function legacyPullZone( + overrides?: Record, +): Record { + return { + Id: 30, + Name: "sites-my-site-abc123", + StorageZoneId: 10, + MiddlewareScriptId: 77, + Hostnames: [{ IsSystemHostname: true, Value: "my-site.b-cdn.net" }], + ...overrides, + }; +} + +test("detachMiddlewareScript clears the script and reports which one it was", async () => { + const calls: Call[] = []; + const pullZones = [legacyPullZone()]; + const coreClient = fakeCoreClient({ calls, pullZones }); + + expect(await detachMiddlewareScript(coreClient, 30)).toBe(77); + expect(pullZones[0]?.MiddlewareScriptId).toBeNull(); + // The live API clears the link only for `0`; `null` is ignored and `-1` is rejected. + expect( + calls.find( + (c) => + c.path === "/pullzone/{id}" && + (c.body as Record)?.MiddlewareScriptId !== undefined, + )?.body, + ).toEqual({ MiddlewareScriptId: 0 }); + + // A second run is a no-op, so a resumed migration doesn't trip over itself. + expect(await detachMiddlewareScript(coreClient, 30)).toBeNull(); +}); + +test("detachMiddlewareScript treats the 0 sentinel as already detached", async () => { + const coreClient = fakeCoreClient({ + calls: [], + pullZones: [legacyPullZone({ MiddlewareScriptId: 0 })], + }); + expect(await detachMiddlewareScript(coreClient, 30)).toBeNull(); +}); + +test("detachMiddlewareScript fails loudly when the API ignores the clear", async () => { + const calls: Call[] = []; + const coreClient = fakeCoreClient({ + calls, + pullZones: [legacyPullZone()], + ignoreDetach: true, + }); + + await expect(detachMiddlewareScript(coreClient, 30)).rejects.toThrow( + "Couldn't detach edge script 77", + ); +}); + +test("fetchLegacySites finds router-era sites that fetchSites can't see", async () => { + const calls: Call[] = []; + store.set(REMOTE_STATE_PATH, JSON.stringify(fakeLegacyState())); + const coreClient = fakeCoreClient({ + calls, + storageZones: [ZONE], + pullZones: [legacyPullZone()], + }); + + expect(await fetchSites(coreClient)).toHaveLength(0); + const legacy = await fetchLegacySites(coreClient); + expect(legacy).toHaveLength(1); + expect(legacy[0]?.state.scriptId).toBe(77); +}); + +test("migrateSite detaches the script, rules the zone, rewrites state, and deletes the script", async () => { + const calls: Call[] = []; + const computeCalls: Call[] = []; + const legacy = fakeLegacyState({ current: "abc123", previous: "old999" }); + store.set(REMOTE_STATE_PATH, JSON.stringify(legacy)); + const pullZones = [legacyPullZone()]; + const coreClient = fakeCoreClient({ + calls, + storageZones: [ZONE], + pullZones, + }); + + const result = await migrateSite({ + coreClient, + computeClient: fakeComputeClient(computeCalls), + legacy, + storageZone: ZONE, + connection: fakeConnection(), + }); + + expect(result.detachedScriptId).toBe(77); + expect(result.deployId).toBe("abc123"); + expect(result.scriptDeleted).toBe(true); + expect(computeCalls).toEqual([ + { + method: "DELETE", + path: "/compute/script/{id}", + params: { path: { id: 77 } }, + }, + ]); + + // State is rewritten in the current format, with the script fields gone and everything else carried over. + const written = JSON.parse(store.get(REMOTE_STATE_PATH) as string); + expect(written.version).toBe(STATE_VERSION); + expect(written.scriptId).toBeUndefined(); + expect(written.routerVersion).toBeUndefined(); + expect(written.current).toBe("abc123"); + expect(written.previous).toBe("old999"); + expect(await readRemoteState(fakeConnection())).not.toBeNull(); + + // The rewrite rule targets the published deploy, and the cache policy landed. + const rules = ( + await coreClient.GET("/pullzone/{id}", { params: { path: { id: 30 } } }) + ).data?.EdgeRules; + const rewrite = rules?.find((r) => r.Description === REWRITE_RULE_DESC); + expect(rewrite?.ActionParameter3).toBe("/deploys/abc123/"); + expect(rules?.some((r) => r.Description === GATE_RULE_DESC)).toBe(true); + expect(pullZones[0]?.CacheControlPublicMaxAgeOverride).toBe(0); +}); + +test("migrateSite detaches the router before the gate rule exists", async () => { + const calls: Call[] = []; + const legacy = fakeLegacyState({ current: "abc123" }); + store.set(REMOTE_STATE_PATH, JSON.stringify(legacy)); + const coreClient = fakeCoreClient({ + calls, + storageZones: [ZONE], + pullZones: [legacyPullZone()], + }); + + await migrateSite({ + coreClient, + computeClient: fakeComputeClient([]), + legacy, + storageZone: ZONE, + connection: fakeConnection(), + }); + + // Rules applied while the script is still attached would have the gate blocking the router's own rewrites, so the ordering is load-bearing. + const detach = calls.findIndex( + (c) => + c.path === "/pullzone/{id}" && + (c.body as { MiddlewareScriptId?: number | null } | undefined) + ?.MiddlewareScriptId === 0, + ); + const firstRule = calls.findIndex( + (c) => c.path === "/pullzone/{pullZoneId}/edgerules/addOrUpdate", + ); + expect(detach).toBeGreaterThanOrEqual(0); + expect(detach).toBeLessThan(firstRule); +}); + +test("migrateSite points an unpublished site at the placeholder and skips the promote", async () => { + const calls: Call[] = []; + const legacy = fakeLegacyState(); + store.set(REMOTE_STATE_PATH, JSON.stringify(legacy)); + const coreClient = fakeCoreClient({ + calls, + storageZones: [ZONE], + pullZones: [legacyPullZone()], + }); + + const result = await migrateSite({ + coreClient, + computeClient: fakeComputeClient([]), + legacy, + storageZone: ZONE, + connection: fakeConnection(), + }); + + expect(result.deployId).toBe(PLACEHOLDER_DEPLOY); + expect(calls.some((c) => c.path === "/pullzone/{id}/purgeCache")).toBe(false); +}); + +test("migrateSite keeps the script with keepScript, and survives a failed delete", async () => { + const legacy = fakeLegacyState({ current: "abc123" }); + store.set(REMOTE_STATE_PATH, JSON.stringify(legacy)); + + const kept: Call[] = []; + await migrateSite({ + coreClient: fakeCoreClient({ + calls: [], + storageZones: [ZONE], + pullZones: [legacyPullZone()], + }), + computeClient: fakeComputeClient(kept), + legacy, + storageZone: ZONE, + connection: fakeConnection(), + keepScript: true, + }); + expect(kept).toHaveLength(0); + + store.set(REMOTE_STATE_PATH, JSON.stringify(legacy)); + const result = await migrateSite({ + coreClient: fakeCoreClient({ + calls: [], + storageZones: [ZONE], + pullZones: [legacyPullZone()], + }), + computeClient: fakeComputeClient([], { + deleteError: new Error("script is locked"), + }), + legacy, + storageZone: ZONE, + connection: fakeConnection(), + }); + // The site is migrated either way; a surviving script is litter, not a failure. + expect(result.scriptDeleted).toBe(false); + expect(result.scriptError).toContain("script is locked"); + expect(result.state.version).toBe(STATE_VERSION); +}); + +test("classifySiteZone tells a router-era site from a migrated one", async () => { + expect((await classifySiteZone(ZONE)).kind).toBe("none"); + + store.set(REMOTE_STATE_PATH, "garbage"); + expect((await classifySiteZone(ZONE)).kind).toBe("none"); + + store.set(REMOTE_STATE_PATH, JSON.stringify(fakeLegacyState())); + const legacy = await classifySiteZone(ZONE); + expect(legacy.kind).toBe("legacy"); + expect(legacy.kind === "legacy" && legacy.state.scriptId).toBe(77); + + store.set(REMOTE_STATE_PATH, JSON.stringify(fakeState())); + const current = await classifySiteZone(ZONE); + expect(current.kind).toBe("current"); + expect(current.kind === "current" && current.state.name).toBe("my-site"); +}); diff --git a/packages/cli/src/commands/sites/api.ts b/packages/cli/src/commands/sites/api.ts index 6a1df58b..46fa69db 100644 --- a/packages/cli/src/commands/sites/api.ts +++ b/packages/cli/src/commands/sites/api.ts @@ -1,3 +1,4 @@ +import type { createComputeClient } from "@bunny.net/openapi-client"; import type { components } from "@bunny.net/openapi-client/generated/core.d.ts"; import { mapWithConcurrency } from "../../core/concurrency.ts"; import { @@ -41,7 +42,10 @@ import { DEPLOYS_DIR, deployPrefix, GATE_RULE_DESC, + type LegacySiteState, + migrateLegacyState, PLACEHOLDER_DEPLOY, + parseLegacyState, parseRemoteState, REMOTE_STATE_PATH, REWRITE_RULE_DESC, @@ -52,6 +56,7 @@ import { suffixedResourceName, } from "./constants.ts"; +export type ComputeClient = ReturnType; type PullZone = components["schemas"]["PullZoneModel"]; // Storage-file IO seam; tests swap these for an in-memory store (bun's `mock.module` leaks across files, this doesn't). @@ -71,12 +76,14 @@ export interface SiteContext { connection: StorageZone; } -export interface SiteSummary { - state: RemoteSiteState; +export interface SiteSummaryOf { + state: S; storageZone: StorageZoneModel; systemHostname?: string; } +export type SiteSummary = SiteSummaryOf; + export function sha256Hex(text: string): string { const hasher = new Bun.CryptoHasher("sha256"); hasher.update(text); @@ -105,6 +112,12 @@ function textStream(text: string): ReadableStream { return new Blob([text]).stream(); } +/** Read `_bunny/site.json` unparsed, so a caller can try more than one state format against it. + * Returns null when the file isn't there. */ +export function readRawState(connection: StorageZone): Promise { + return downloadText(connection, REMOTE_STATE_PATH); +} + /** Read `_bunny/site.json`. Returns null when the zone isn't a site. */ export async function readRemoteState( connection: StorageZone, @@ -167,6 +180,27 @@ export async function writeRemoteState( return sha256Hex(raw); } +/** What a zone's `_bunny/site.json` turned out to be. + * The three cases want three different answers, so the migrate path reads the file + * once and tries both formats rather than asking "is this a site?". */ +export type ZoneState = + | { kind: "legacy"; state: LegacySiteState } + | { kind: "current"; state: RemoteSiteState } + | { kind: "none" }; + +/** Classify a storage zone by the state format it carries. */ +export async function classifySiteZone( + zone: StorageZoneModel, +): Promise { + const raw = await readRawState(siteFiles.connect(zone)); + if (raw === null) return { kind: "none" }; + const legacy = parseLegacyState(raw); + if (legacy) return { kind: "legacy", state: legacy }; + const current = parseRemoteState(raw); + if (current) return { kind: "current", state: current }; + return { kind: "none" }; +} + export async function siteContextFromZone( zone: StorageZoneModel, ): Promise { @@ -218,8 +252,10 @@ async function fetchPullZones( } } -// Discover sites: every storage-backed pull zone gets the per-zone `_bunny/site.json` read (concurrency-capped). A candidate is only a site when the state names it as the site's own pull zone, so another zone pointed at the same storage origin is never mistaken for one. -export async function fetchSites(client: CoreClient): Promise { +async function scanSites( + client: CoreClient, + parse: (raw: string) => S | null, +): Promise>> { const candidates = (await fetchPullZones(client)).filter( (pz: PullZone) => pz.StorageZoneId != null, ); @@ -227,13 +263,14 @@ export async function fetchSites(client: CoreClient): Promise { const summaries = await mapWithConcurrency( candidates, 8, - async (pz: PullZone): Promise => { + async (pz: PullZone): Promise | null> => { try { const zone = await fetchStorageZone(client, pz.StorageZoneId as number); - const context = await siteContextFromZone(zone); - if (!context || context.state.pullZoneId !== pz.Id) return null; + const raw = await readRawState(siteFiles.connect(zone)); + const state = raw === null ? null : parse(raw); + if (!state || state.pullZoneId !== pz.Id) return null; return { - state: context.state, + state, storageZone: zone, systemHostname: systemHostname(pz.Hostnames), }; @@ -244,10 +281,21 @@ export async function fetchSites(client: CoreClient): Promise { ); return summaries - .filter((s): s is SiteSummary => s !== null) + .filter((s): s is SiteSummaryOf => s !== null) .sort((a, b) => a.state.name.localeCompare(b.state.name)); } +export async function fetchSites(client: CoreClient): Promise { + return scanSites(client, parseRemoteState); +} + +/** Router-era sites, which {@link fetchSites} can't see because their state no longer parses; `sites migrate` discovers them with this. */ +export async function fetchLegacySites( + client: CoreClient, +): Promise>> { + return scanSites(client, parseLegacyState); +} + // Account storage zones whose name is `sites-{name}-{suffix}`, re-fetched by ID because search results may omit the zone password. async function findSiteStorageZones( client: CoreClient, @@ -293,6 +341,52 @@ const SITE_CACHE_SETTINGS = { CacheControlPublicMaxAgeOverride: 0, }; +/** Apply the site cache policy to a pull zone. Unlike the edge rules this isn't reapplied on every publish, so a zone provisioned before it existed needs {@link migrateSite} to set it. */ +export async function applySiteCacheSettings( + coreClient: CoreClient, + pullZoneId: number, +): Promise { + await coreClient.POST("/pullzone/{id}", { + params: { path: { id: pullZoneId } }, + body: SITE_CACHE_SETTINGS, + }); +} + +// `0` is the API's "no middleware script" sentinel on update. `null` is accepted and silently ignored, and `-1` (which the API itself reports for an unlinked `EdgeScriptId`) is rejected as a missing script, so neither can be used to clear the field. +const NO_MIDDLEWARE_SCRIPT = 0; + +// Detach the pull zone's middleware script, returning the script that was attached (null when there was none, so a resumed migration is a no-op). The clear is verified rather than assumed: a silently ignored detach would leave the router rewriting into `*/deploys/*`, which the gate rule blocks, and the site would serve 403s. +export async function detachMiddlewareScript( + coreClient: CoreClient, + pullZoneId: number, +): Promise { + const attached = await fetchMiddlewareScriptId(coreClient, pullZoneId); + if (attached == null) return null; + await coreClient.POST("/pullzone/{id}", { + params: { path: { id: pullZoneId } }, + body: { MiddlewareScriptId: NO_MIDDLEWARE_SCRIPT }, + }); + if ((await fetchMiddlewareScriptId(coreClient, pullZoneId)) != null) { + throw new UserError( + `Couldn't detach edge script ${attached} from pull zone ${pullZoneId}.`, + "Remove the linked edge script from the pull zone in the dashboard, then re-run this command.", + ); + } + return attached; +} + +async function fetchMiddlewareScriptId( + coreClient: CoreClient, + pullZoneId: number, +): Promise { + const { data } = await coreClient.GET("/pullzone/{id}", { + params: { path: { id: pullZoneId } }, + }); + const id = data?.MiddlewareScriptId; + // A detached zone reports the field as absent, but treat the sentinel as unset too rather than trust one shape. + return id == null || id === NO_MIDDLEWARE_SCRIPT ? null : id; +} + // The four rules that serve a site; bodies are always rebuilt in full from these so a hand-edited rule heals on the next upsert. function siteRules( storageZone: { Id: number; Name: string }, @@ -517,10 +611,7 @@ export async function createSite( "Re-run the command to finish provisioning.", ); } - await coreClient.POST("/pullzone/{id}", { - params: { path: { id: pullZone.Id } }, - body: SITE_CACHE_SETTINGS, - }); + await applySiteCacheSettings(coreClient, pullZone.Id); await ensureSiteRules({ coreClient, pullZoneId: pullZone.Id, @@ -665,6 +756,70 @@ export async function promoteDeploy(opts: { await purge(); } +export interface MigrateResult { + state: RemoteSiteState; + /** The edge script detached from the pull zone, or null when it already was. */ + detachedScriptId: number | null; + /** The deploy the rewrite rule now targets; the placeholder when the site never published one. */ + deployId: string; + scriptDeleted: boolean; + /** Why the script survived, when deleting it failed; the migration itself still succeeded. */ + scriptError?: string; +} +export async function migrateSite(opts: { + coreClient: CoreClient; + computeClient: ComputeClient; + legacy: LegacySiteState; + storageZone: StorageZoneModel; + connection: StorageZone; + keepScript?: boolean; + onStep?: (message: string) => void; +}): Promise { + const { coreClient, computeClient, legacy, storageZone, connection } = opts; + const step = opts.onStep ?? (() => {}); + const state = migrateLegacyState(legacy); + const deployId = state.current ?? PLACEHOLDER_DEPLOY; + + step("Detaching the router script..."); + const detachedScriptId = await detachMiddlewareScript( + coreClient, + state.pullZoneId, + ); + + step("Applying edge rules..."); + await ensureSiteRules({ + coreClient, + pullZoneId: state.pullZoneId, + storageZone, + deployId, + }); + await applySiteCacheSettings(coreClient, state.pullZoneId); + + step("Writing site state..."); + await writeRemoteState(connection, state); + + if (state.current) { + step("Publishing the current deploy..."); + await promoteDeploy({ coreClient, state, deployId: state.current }); + } + + let scriptDeleted = false; + let scriptError: string | undefined; + if (!opts.keepScript) { + step("Deleting the router script..."); + try { + await computeClient.DELETE("/compute/script/{id}", { + params: { path: { id: legacy.scriptId } }, + }); + scriptDeleted = true; + } catch (err) { + scriptError = errorMessage(err); + } + } + + return { state, detachedScriptId, deployId, scriptDeleted, scriptError }; +} + export interface TeardownResult { resource: "pull zone" | "storage zone"; id: number; diff --git a/packages/cli/src/commands/sites/constants.test.ts b/packages/cli/src/commands/sites/constants.test.ts index a5881227..6266e4d5 100644 --- a/packages/cli/src/commands/sites/constants.test.ts +++ b/packages/cli/src/commands/sites/constants.test.ts @@ -6,6 +6,9 @@ import { findDeploy, isValidDeployId, isValidSiteName, + type LegacySiteState, + migrateLegacyState, + parseLegacyState, parseRemoteState, type RemoteSiteState, siteResourcePattern, @@ -20,11 +23,79 @@ const validState: RemoteSiteState = { deploys: [], }; +const validLegacyState: LegacySiteState = { + version: 1, + name: "my-site", + storageZoneId: 1, + pullZoneId: 2, + scriptId: 3, + routerVersion: 5, + deploys: [], +}; + test("parseRemoteState round-trips a valid state", () => { expect(parseRemoteState(JSON.stringify(validState))).toEqual(validState); - // The router-era version 1 format was never released, so it no longer parses. - const routerEra = { ...validState, version: 1, scriptId: 3 }; - expect(parseRemoteState(JSON.stringify(routerEra))).toBeNull(); + // Router-era state is `sites migrate`'s job, not something the serving path reads. + expect(parseRemoteState(JSON.stringify(validLegacyState))).toBeNull(); +}); + +test("parseLegacyState reads router-era state and nothing else", () => { + expect(parseLegacyState(JSON.stringify(validLegacyState))).toEqual( + validLegacyState, + ); + // The two parsers never both claim a file, so a caller can tell "needs migrating" from "already migrated". + expect(parseLegacyState(JSON.stringify(validState))).toBeNull(); + expect(parseLegacyState("not json")).toBeNull(); + expect(parseLegacyState("{}")).toBeNull(); + // Version 1 without a script isn't router-era state. + expect( + parseLegacyState( + JSON.stringify({ ...validLegacyState, scriptId: undefined }), + ), + ).toBeNull(); + // The shared shape checks apply to both formats. + expect( + parseLegacyState(JSON.stringify({ ...validLegacyState, deploys: {} })), + ).toBeNull(); + expect( + parseLegacyState( + JSON.stringify({ + ...validLegacyState, + name: "evil\n run: rm -rf /", + }), + ), + ).toBeNull(); +}); + +test("migrateLegacyState drops the script fields and keeps everything else", () => { + const deploy: DeployRecord = { + id: "abc123", + createdAt: "2026-01-01T00:00:00.000Z", + source: "git", + contentHash: "hash", + files: 2, + bytes: 20, + }; + const migrated = migrateLegacyState({ + ...validLegacyState, + domain: "example.com", + current: "abc123", + previous: "old999", + deploys: [deploy], + }); + + expect(migrated).toEqual({ + version: 2, + name: "my-site", + storageZoneId: 1, + pullZoneId: 2, + domain: "example.com", + current: "abc123", + previous: "old999", + deploys: [deploy], + }); + // The result is what the serving path reads back, so it has to parse. + expect(parseRemoteState(JSON.stringify(migrated))).toEqual(migrated); }); test("parseRemoteState rejects garbage", () => { diff --git a/packages/cli/src/commands/sites/constants.ts b/packages/cli/src/commands/sites/constants.ts index c02f85c5..63f7ccfc 100644 --- a/packages/cli/src/commands/sites/constants.ts +++ b/packages/cli/src/commands/sites/constants.ts @@ -7,9 +7,12 @@ export const REMOTE_STATE_PATH = "_bunny/site.json"; // Deploys live at `deploys/{id}/...` inside the storage zone. export const DEPLOYS_DIR = "deploys"; -// State format version; the router-era version 1 was never released, so only this exact version parses. +// State format version; only this exact version parses, and `sites migrate` rewrites the router-era version 1 into it. export const STATE_VERSION = 2; +// The router-era format, still on disk for sites created before edge rules replaced the router script. +export const LEGACY_STATE_VERSION = 1; + export const DEFAULT_KEEP_DEPLOYS = 5; export interface SiteManifest { @@ -44,6 +47,22 @@ export interface RemoteSiteState { deploys: DeployRecord[]; } +/** Router-era (version 1) state; the current format minus the script fields, which is all the migration has to drop. */ +export interface LegacySiteState { + version: number; + name: string; + storageZoneId: number; + pullZoneId: number; + /** The router script serving the site; version 1's defining field. */ + scriptId: number; + /** The router source generation last published to the script. */ + routerVersion?: number; + domain?: string; + current?: string; + previous?: string; + deploys: DeployRecord[]; +} + /** Storage-zone path prefix for a deploy, without a trailing slash. */ export function deployPrefix(deployId: string): string { return `${DEPLOYS_DIR}/${deployId}`; @@ -161,8 +180,8 @@ export function siteResourcePattern(siteName: string): RegExp { ); } -// Parse and shape-check remote state; returns null (not a crash) for anything that isn't a state file this CLI understands. -export function parseRemoteState(raw: string): RemoteSiteState | null { +// A state file's top-level object, or null (not a crash) when it isn't one. +function stateObject(raw: string): Record | null { let data: unknown; try { data = JSON.parse(raw); @@ -170,18 +189,45 @@ export function parseRemoteState(raw: string): RemoteSiteState | null { return null; } if (!data || typeof data !== "object") return null; - const s = data as Record; - if ( - // Any other version is rejected rather than misread; the router-era version 1 was never released. - s.version !== STATE_VERSION || - typeof s.name !== "string" || + return data as Record; +} + +// The fields both formats carry; each parser adds its own version and script checks. +function hasCommonShape(s: Record): boolean { + return ( + typeof s.name === "string" && // Reject an illegal name: it would flow unquoted into storage paths and generated CI YAML. - !isValidSiteName(s.name) || - typeof s.storageZoneId !== "number" || - typeof s.pullZoneId !== "number" || - !Array.isArray(s.deploys) + isValidSiteName(s.name) && + typeof s.storageZoneId === "number" && + typeof s.pullZoneId === "number" && + Array.isArray(s.deploys) + ); +} + +// Parse and shape-check remote state; returns null for anything that isn't a state file this CLI serves, the router-era format included. +export function parseRemoteState(raw: string): RemoteSiteState | null { + const s = stateObject(raw); + // Any other version is rejected rather than misread; version 1 goes through `sites migrate` first. + if (!s || s.version !== STATE_VERSION || !hasCommonShape(s)) return null; + return s as unknown as RemoteSiteState; +} + +// Parse router-era state; returns null for every other format, so a caller can tell "needs migrating" from "not a site". +export function parseLegacyState(raw: string): LegacySiteState | null { + const s = stateObject(raw); + if ( + !s || + s.version !== LEGACY_STATE_VERSION || + typeof s.scriptId !== "number" || + !hasCommonShape(s) ) { return null; } - return s as unknown as RemoteSiteState; + return s as unknown as LegacySiteState; +} + +/** The current-format equivalent of a router-era state; everything but the script fields carries over untouched. */ +export function migrateLegacyState(legacy: LegacySiteState): RemoteSiteState { + const { scriptId, routerVersion, ...rest } = legacy; + return { ...rest, version: STATE_VERSION }; } diff --git a/packages/cli/src/commands/sites/index.ts b/packages/cli/src/commands/sites/index.ts index c24e3536..aca908f1 100644 --- a/packages/cli/src/commands/sites/index.ts +++ b/packages/cli/src/commands/sites/index.ts @@ -7,6 +7,7 @@ import { sitesDeploymentsNamespace } from "./deployments/index.ts"; import { sitesDomainsCommands } from "./domains/index.ts"; import { sitesLinkCommand } from "./link.ts"; import { sitesListCommand } from "./list.ts"; +import { sitesMigrateCommand } from "./migrate.ts"; import { sitesOpenCommand } from "./open.ts"; import { sitesShowCommand } from "./show.ts"; import { sitesSslCommand } from "./ssl.ts"; @@ -25,4 +26,5 @@ export const sitesNamespace = defineNamespace("sites", false, [ sitesLinkCommand, sitesUnlinkCommand, sitesDeleteCommand, + sitesMigrateCommand, ]); diff --git a/packages/cli/src/commands/sites/migrate.ts b/packages/cli/src/commands/sites/migrate.ts new file mode 100644 index 00000000..3d5c0339 --- /dev/null +++ b/packages/cli/src/commands/sites/migrate.ts @@ -0,0 +1,331 @@ +// TODO: Remove this in the next major release + +import { + createComputeClient, + createCoreClient, +} from "@bunny.net/openapi-client"; +import { resolveConfig } from "../../config/index.ts"; +import { clientOptions } from "../../core/client-options.ts"; +import { defineCommand } from "../../core/define-command.ts"; +import { UserError } from "../../core/errors.ts"; +import { logger } from "../../core/logger.ts"; +import { loadManifest } from "../../core/manifest.ts"; +import { + confirm, + isInteractive, + prompts, + requireConfirmable, + withSpinner, +} from "../../core/ui.ts"; +import { + type CoreClient, + fetchStorageZone, + resolveStorageZone, + type StorageZoneModel, +} from "../storage/api.ts"; +import type { StorageZone } from "../storage/files-api.ts"; +import { + classifySiteZone, + fetchLegacySites, + fetchSystemHostname, + type MigrateResult, + migrateSite, + siteFiles, +} from "./api.ts"; +import { loadSiteConfig } from "./config.ts"; +import { + type LegacySiteState, + SITES_MANIFEST, + type SiteManifest, +} from "./constants.ts"; +import { productionUrl } from "./deploy.ts"; +import { sitePositionalBuilder } from "./interactive.ts"; + +interface MigrateArgs { + site?: string; + force?: boolean; + "dry-run"?: boolean; + "keep-script"?: boolean; +} + +interface LegacySite { + state: LegacySiteState; + storageZone: StorageZoneModel; + connection: StorageZone; +} + +function legacySite( + state: LegacySiteState, + storageZone: StorageZoneModel, +): LegacySite { + return { state, storageZone, connection: siteFiles.connect(storageZone) }; +} + +function alreadyMigrated(name: string): UserError { + return new UserError( + `Site "${name}" is already on the edge-rule architecture.`, + "Deploy it as usual with `bunny sites deploy`.", + ); +} + +async function legacySiteFromRef( + client: CoreClient, + ref: string, +): Promise { + let zone: StorageZoneModel | undefined; + try { + zone = await resolveStorageZone(client, ref); + } catch { + zone = undefined; + } + if (zone) { + const state = await classifySiteZone(zone); + if (state.kind === "legacy") return legacySite(state.state, zone); + if (state.kind === "current") throw alreadyMigrated(state.state.name); + } + + const matches = (await fetchLegacySites(client)).filter( + (s) => s.state.name.toLowerCase() === ref.toLowerCase(), + ); + if (matches.length > 1) { + throw new UserError( + `Multiple router-era sites are named "${ref}".`, + "Pass the storage zone ID instead.", + ); + } + const match = matches[0]; + if (match) return legacySite(match.state, match.storageZone); + + if (zone) { + throw new UserError( + `Storage zone "${zone.Name}" is not a router-era site.`, + "Only sites created before edge rules replaced the router script need migrating.", + ); + } + throw new UserError( + `No router-era site found for "${ref}".`, + "Run `bunny sites migrate` with no arguments to pick from the ones this account has.", + ); +} + +// Pick the site to migrate, walking the same precedence as `selectSite`: explicit ref, `.bunny/site.json`, `sites.name` in bunny.jsonc, then a picker over the account scan. Discovery is separate because a router-era site's state no longer parses, so `selectSite` can't see it at all. +async function selectLegacySite( + client: CoreClient, + args: { site?: string; output: string; force?: boolean }, +): Promise { + if (args.site) { + const ref = args.site; + return withSpinner("Resolving site...", () => + legacySiteFromRef(client, ref), + ); + } + + const manifest = loadManifest(SITES_MANIFEST); + if (manifest.id) { + const id = manifest.id; + const linked = await withSpinner("Loading linked site...", async () => { + const zone = await fetchStorageZone(client, id); + return { zone, state: await classifySiteZone(zone) }; + }); + if (linked.state.kind === "current") { + throw alreadyMigrated(linked.state.state.name); + } + if (linked.state.kind === "none") { + throw new UserError( + `The linked storage zone ${id} is not a site.`, + "Run `bunny sites unlink`, then link or create a site.", + ); + } + return legacySite(linked.state.state, linked.zone); + } + + const configured = loadSiteConfig()?.config.name; + if (configured) { + return withSpinner( + `Resolving site "${configured}" from bunny.jsonc...`, + () => legacySiteFromRef(client, configured), + ); + } + + const sites = await withSpinner("Scanning for router-era sites...", () => + fetchLegacySites(client), + ); + if (sites.length === 0) { + throw new UserError( + "No router-era sites found in your account.", + "Nothing to migrate; sites on the current architecture deploy with `bunny sites deploy`.", + ); + } + + if (args.force || !isInteractive(args.output)) { + throw new UserError( + "No site specified.", + `Pass one: ${sites.map((s) => s.state.name).join(", ")}.`, + ); + } + + const { selected } = await prompts({ + type: "select", + name: "selected", + message: "Migrate which site?", + choices: sites.map((s) => ({ + title: `${s.state.name} (${s.state.storageZoneId})`, + value: s, + })), + }); + if (!selected) throw new UserError("A site is required."); + const summary = selected as (typeof sites)[number]; + return legacySite(summary.state, summary.storageZone); +} + +function reportText( + state: LegacySiteState, + result: MigrateResult, + production?: string, +): void { + logger.success(`Migrated "${state.name}" to the edge-rule architecture.`); + if (result.detachedScriptId != null) { + logger.dim(` Detached edge script ${result.detachedScriptId}.`); + } + if (result.scriptDeleted) { + logger.dim(` Deleted edge script ${state.scriptId}.`); + } else if (result.scriptError) { + logger.warn( + `Couldn't delete edge script ${state.scriptId}: ${result.scriptError}`, + ); + logger.dim(" Delete it by hand; the site no longer uses it."); + } + if (result.state.current) { + logger.dim(` Serving deploy ${result.state.current}.`); + + if (production) logger.info(`Production: ${production}`); + } else { + logger.info("The site has no published deploy; run `bunny sites deploy`."); + } + + logger.warn( + "Edge rules don't redirect `/path` to `/path/` the way the router did.", + ); + logger.dim( + " Directory URLs ending in a slash still work; check any link to an extensionless path.", + ); +} + +export const sitesMigrateCommand = defineCommand({ + command: "migrate [site]", + describe: "Migrate a router-era site to the edge-rule architecture.", + hidden: true, + + builder: (yargs) => + sitePositionalBuilder(yargs) + .option("force", { + alias: "f", + type: "boolean", + default: false, + describe: "Skip confirmation prompts", + }) + .option("dry-run", { + type: "boolean", + default: false, + describe: "Report what would change without touching the site", + }) + .option("keep-script", { + type: "boolean", + default: false, + describe: "Detach the router script but don't delete it", + }), + + handler: async (args) => { + const { profile, output, verbose, apiKey, force } = args; + const config = resolveConfig(profile, apiKey, verbose); + const options = clientOptions(config, verbose); + const coreClient = createCoreClient(options); + const computeClient = createComputeClient(options); + + const site = await selectLegacySite(coreClient, { + site: args.site, + output, + force, + }); + const { state } = site; + + if (args["dry-run"]) { + const actions = [ + `Detach edge script ${state.scriptId} from pull zone ${state.pullZoneId}`, + `Apply the site edge rules and cache settings to pull zone ${state.pullZoneId}`, + "Rewrite _bunny/site.json as state version 2", + state.current + ? `Republish deploy ${state.current}` + : "Point the rewrite rule at the placeholder (no published deploy)", + ...(args["keep-script"] + ? [] + : [`Delete edge script ${state.scriptId}`]), + ]; + if (output === "json") { + logger.log( + JSON.stringify({ site: state.name, dryRun: true, actions }, null, 2), + ); + return; + } + logger.info(`Would migrate "${state.name}":`); + for (const action of actions) logger.dim(` ${action}`); + return; + } + + requireConfirmable(output, { + force, + message: `Migrating "${state.name}" needs a confirmation prompt.`, + hint: "Re-run with --force to migrate non-interactively.", + }); + const confirmed = await confirm( + `Migrate "${state.name}" to the edge-rule architecture? It briefly serves its raw storage origin while the rules land.`, + { force }, + ); + if (!confirmed) { + logger.log("Cancelled."); + return; + } + + const result = await withSpinner(`Migrating "${state.name}"...`, (spin) => + migrateSite({ + coreClient, + computeClient, + legacy: state, + storageZone: site.storageZone, + connection: site.connection, + keepScript: args["keep-script"], + onStep: (message) => { + spin.text = message; + }, + }), + ); + + const systemHost = result.state.domain + ? undefined + : await fetchSystemHostname(coreClient, result.state.pullZoneId); + const production = productionUrl(result.state, systemHost); + + if (output === "json") { + logger.log( + JSON.stringify( + { + site: state.name, + migrated: true, + storageZoneId: state.storageZoneId, + pullZoneId: state.pullZoneId, + detachedScriptId: result.detachedScriptId, + deploy: result.state.current ?? null, + production: production ?? null, + scriptDeleted: result.scriptDeleted, + ...(result.scriptError ? { scriptError: result.scriptError } : {}), + }, + null, + 2, + ), + ); + return; + } + + reportText(state, result, production); + }, +}); From e9618204c4a4440736c5779ce3379c174ee4922f Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Thu, 3 Sep 2026 08:47:16 +0100 Subject: [PATCH 2/4] tidy --- packages/cli/src/commands/sites/api.test.ts | 279 ++++------------ packages/cli/src/commands/sites/api.ts | 90 +++-- .../cli/src/commands/sites/constants.test.ts | 41 +-- packages/cli/src/commands/sites/constants.ts | 14 +- packages/cli/src/commands/sites/migrate.ts | 312 ++++-------------- 5 files changed, 179 insertions(+), 557 deletions(-) diff --git a/packages/cli/src/commands/sites/api.test.ts b/packages/cli/src/commands/sites/api.test.ts index 86c1133b..a40ea5c3 100644 --- a/packages/cli/src/commands/sites/api.test.ts +++ b/packages/cli/src/commands/sites/api.test.ts @@ -7,13 +7,12 @@ import { classifySiteZone, createSite, deleteSiteResources, - detachMiddlewareScript, - fetchLegacySites, fetchSites, migrateSite, promoteDeploy, promoteVerification, readRemoteState, + sha256Hex, siteContextFromZone, siteFiles, writeRemoteState, @@ -120,6 +119,12 @@ function fakeLegacyState( }; } +function seedLegacy(legacy: LegacySiteState): string { + const raw = JSON.stringify(legacy); + store.set(REMOTE_STATE_PATH, raw); + return sha256Hex(raw); +} + function fakeComputeClient(calls: Call[], opts?: { deleteError?: Error }) { return { DELETE: async (path: string, options?: { params?: unknown }) => { @@ -151,7 +156,6 @@ function fakeCoreClient(opts: { error: ApiError; times?: number; }; - ignoreDetach?: boolean; }): CoreClient { const zones = opts.storageZones ?? []; const pullZones = opts.pullZones ?? []; @@ -245,17 +249,10 @@ function fakeCoreClient(opts: { if (path === "/pullzone/{id}") { const id = (options?.params as { path: { id: number } }).path.id; const pz = pullZones.find((p) => p.Id === id); - if (pz) { const body = { ...(options?.body as Record) }; - if ("MiddlewareScriptId" in body) { - const requested = body.MiddlewareScriptId; - if (requested === 0) body.MiddlewareScriptId = null; - else if (requested === null) delete body.MiddlewareScriptId; - else if (requested === -1) - throw new Error("Middleware Script ID not found."); - if (opts.ignoreDetach) delete body.MiddlewareScriptId; - } + // The live API clears the script link only for the `0` sentinel. + if (body.MiddlewareScriptId === 0) body.MiddlewareScriptId = null; Object.assign(pz, body); } return { data: {} }; @@ -954,6 +951,8 @@ test("fetchSites pages through the /pullzone envelope", async () => { expect(sites[0]?.state.name).toBe("my-site"); }); +// ---- router-era migration ---- + function legacyPullZone( overrides?: Record, ): Record { @@ -967,218 +966,80 @@ function legacyPullZone( }; } -test("detachMiddlewareScript clears the script and reports which one it was", async () => { - const calls: Call[] = []; - const pullZones = [legacyPullZone()]; - const coreClient = fakeCoreClient({ calls, pullZones }); - - expect(await detachMiddlewareScript(coreClient, 30)).toBe(77); - expect(pullZones[0]?.MiddlewareScriptId).toBeNull(); - // The live API clears the link only for `0`; `null` is ignored and `-1` is rejected. - expect( - calls.find( - (c) => - c.path === "/pullzone/{id}" && - (c.body as Record)?.MiddlewareScriptId !== undefined, - )?.body, - ).toEqual({ MiddlewareScriptId: 0 }); - - // A second run is a no-op, so a resumed migration doesn't trip over itself. - expect(await detachMiddlewareScript(coreClient, 30)).toBeNull(); -}); - -test("detachMiddlewareScript treats the 0 sentinel as already detached", async () => { - const coreClient = fakeCoreClient({ - calls: [], - pullZones: [legacyPullZone({ MiddlewareScriptId: 0 })], - }); - expect(await detachMiddlewareScript(coreClient, 30)).toBeNull(); -}); - -test("detachMiddlewareScript fails loudly when the API ignores the clear", async () => { - const calls: Call[] = []; - const coreClient = fakeCoreClient({ - calls, - pullZones: [legacyPullZone()], - ignoreDetach: true, - }); - - await expect(detachMiddlewareScript(coreClient, 30)).rejects.toThrow( - "Couldn't detach edge script 77", - ); -}); +function migrateFixture(pullZone?: Record) { + const legacy = fakeLegacyState({ current: "abc123", previous: "old999" }); + const raw = JSON.stringify(legacy); + store.set(REMOTE_STATE_PATH, raw); + const deletes: Call[] = []; + return { + deletes, + opts: { + coreClient: fakeCoreClient({ + calls: [], + storageZones: [ZONE], + pullZones: [pullZone ?? legacyPullZone()], + }), + computeClient: fakeComputeClient(deletes), + legacy, + expectedEtag: sha256Hex(raw), + storageZone: ZONE, + connection: fakeConnection(), + }, + }; +} -test("fetchLegacySites finds router-era sites that fetchSites can't see", async () => { - const calls: Call[] = []; - store.set(REMOTE_STATE_PATH, JSON.stringify(fakeLegacyState())); - const coreClient = fakeCoreClient({ - calls, - storageZones: [ZONE], - pullZones: [legacyPullZone()], - }); +test("migrateSite detaches the router, rules the zone, and rewrites state as version 2", async () => { + const { opts, deletes } = migrateFixture(); - expect(await fetchSites(coreClient)).toHaveLength(0); - const legacy = await fetchLegacySites(coreClient); - expect(legacy).toHaveLength(1); - expect(legacy[0]?.state.scriptId).toBe(77); -}); + const result = await migrateSite(opts); -test("migrateSite detaches the script, rules the zone, rewrites state, and deletes the script", async () => { - const calls: Call[] = []; - const computeCalls: Call[] = []; - const legacy = fakeLegacyState({ current: "abc123", previous: "old999" }); - store.set(REMOTE_STATE_PATH, JSON.stringify(legacy)); - const pullZones = [legacyPullZone()]; - const coreClient = fakeCoreClient({ - calls, - storageZones: [ZONE], - pullZones, - }); + expect(result.detachedScriptId).toBe(77); + expect(deletes[0]?.params).toEqual({ path: { id: 77 } }); - const result = await migrateSite({ - coreClient, - computeClient: fakeComputeClient(computeCalls), - legacy, - storageZone: ZONE, - connection: fakeConnection(), + expect(JSON.parse(store.get(REMOTE_STATE_PATH) as string)).toEqual({ + version: STATE_VERSION, + name: "my-site", + storageZoneId: 10, + pullZoneId: 30, + current: "abc123", + previous: "old999", + deploys: [], }); - expect(result.detachedScriptId).toBe(77); - expect(result.deployId).toBe("abc123"); - expect(result.scriptDeleted).toBe(true); - expect(computeCalls).toEqual([ - { - method: "DELETE", - path: "/compute/script/{id}", - params: { path: { id: 77 } }, - }, - ]); - - // State is rewritten in the current format, with the script fields gone and everything else carried over. - const written = JSON.parse(store.get(REMOTE_STATE_PATH) as string); - expect(written.version).toBe(STATE_VERSION); - expect(written.scriptId).toBeUndefined(); - expect(written.routerVersion).toBeUndefined(); - expect(written.current).toBe("abc123"); - expect(written.previous).toBe("old999"); - expect(await readRemoteState(fakeConnection())).not.toBeNull(); - - // The rewrite rule targets the published deploy, and the cache policy landed. const rules = ( - await coreClient.GET("/pullzone/{id}", { params: { path: { id: 30 } } }) + await opts.coreClient.GET("/pullzone/{id}", { + params: { path: { id: 30 } }, + }) ).data?.EdgeRules; - const rewrite = rules?.find((r) => r.Description === REWRITE_RULE_DESC); - expect(rewrite?.ActionParameter3).toBe("/deploys/abc123/"); + expect( + rules?.find((r) => r.Description === REWRITE_RULE_DESC)?.ActionParameter3, + ).toBe("/deploys/abc123/"); expect(rules?.some((r) => r.Description === GATE_RULE_DESC)).toBe(true); - expect(pullZones[0]?.CacheControlPublicMaxAgeOverride).toBe(0); }); -test("migrateSite detaches the router before the gate rule exists", async () => { - const calls: Call[] = []; - const legacy = fakeLegacyState({ current: "abc123" }); - store.set(REMOTE_STATE_PATH, JSON.stringify(legacy)); - const coreClient = fakeCoreClient({ - calls, - storageZones: [ZONE], - pullZones: [legacyPullZone()], - }); +test("migrateSite aborts rather than clobber state that changed mid-migration", async () => { + const { opts } = migrateFixture(); - await migrateSite({ - coreClient, - computeClient: fakeComputeClient([]), - legacy, - storageZone: ZONE, - connection: fakeConnection(), - }); - - // Rules applied while the script is still attached would have the gate blocking the router's own rewrites, so the ordering is load-bearing. - const detach = calls.findIndex( - (c) => - c.path === "/pullzone/{id}" && - (c.body as { MiddlewareScriptId?: number | null } | undefined) - ?.MiddlewareScriptId === 0, - ); - const firstRule = calls.findIndex( - (c) => c.path === "/pullzone/{pullZoneId}/edgerules/addOrUpdate", + // A deploy from an older CLI lands between selection and the version-2 write. + store.set( + REMOTE_STATE_PATH, + JSON.stringify(fakeLegacyState({ current: "newer1" })), ); - expect(detach).toBeGreaterThanOrEqual(0); - expect(detach).toBeLessThan(firstRule); -}); -test("migrateSite points an unpublished site at the placeholder and skips the promote", async () => { - const calls: Call[] = []; - const legacy = fakeLegacyState(); - store.set(REMOTE_STATE_PATH, JSON.stringify(legacy)); - const coreClient = fakeCoreClient({ - calls, - storageZones: [ZONE], - pullZones: [legacyPullZone()], - }); - - const result = await migrateSite({ - coreClient, - computeClient: fakeComputeClient([]), - legacy, - storageZone: ZONE, - connection: fakeConnection(), - }); - - expect(result.deployId).toBe(PLACEHOLDER_DEPLOY); - expect(calls.some((c) => c.path === "/pullzone/{id}/purgeCache")).toBe(false); -}); - -test("migrateSite keeps the script with keepScript, and survives a failed delete", async () => { - const legacy = fakeLegacyState({ current: "abc123" }); - store.set(REMOTE_STATE_PATH, JSON.stringify(legacy)); - - const kept: Call[] = []; - await migrateSite({ - coreClient: fakeCoreClient({ - calls: [], - storageZones: [ZONE], - pullZones: [legacyPullZone()], - }), - computeClient: fakeComputeClient(kept), - legacy, - storageZone: ZONE, - connection: fakeConnection(), - keepScript: true, - }); - expect(kept).toHaveLength(0); - - store.set(REMOTE_STATE_PATH, JSON.stringify(legacy)); - const result = await migrateSite({ - coreClient: fakeCoreClient({ - calls: [], - storageZones: [ZONE], - pullZones: [legacyPullZone()], - }), - computeClient: fakeComputeClient([], { - deleteError: new Error("script is locked"), - }), - legacy, - storageZone: ZONE, - connection: fakeConnection(), - }); - // The site is migrated either way; a surviving script is litter, not a failure. - expect(result.scriptDeleted).toBe(false); - expect(result.scriptError).toContain("script is locked"); - expect(result.state.version).toBe(STATE_VERSION); + await expect(migrateSite(opts)).rejects.toThrow( + "state changed while the migration was running", + ); + expect((await classifySiteZone(ZONE)).kind).toBe("legacy"); }); -test("classifySiteZone tells a router-era site from a migrated one", async () => { - expect((await classifySiteZone(ZONE)).kind).toBe("none"); +test("migrateSite deletes the live script, never the one stale state recorded", async () => { + // State names 77, but the zone is actually serving 99. + const attached = migrateFixture(legacyPullZone({ MiddlewareScriptId: 99 })); + await migrateSite(attached.opts); + expect(attached.deletes[0]?.params).toEqual({ path: { id: 99 } }); - store.set(REMOTE_STATE_PATH, "garbage"); - expect((await classifySiteZone(ZONE)).kind).toBe("none"); - - store.set(REMOTE_STATE_PATH, JSON.stringify(fakeLegacyState())); - const legacy = await classifySiteZone(ZONE); - expect(legacy.kind).toBe("legacy"); - expect(legacy.kind === "legacy" && legacy.state.scriptId).toBe(77); - - store.set(REMOTE_STATE_PATH, JSON.stringify(fakeState())); - const current = await classifySiteZone(ZONE); - expect(current.kind).toBe("current"); - expect(current.kind === "current" && current.state.name).toBe("my-site"); + const detached = migrateFixture(legacyPullZone({ MiddlewareScriptId: null })); + const result = await migrateSite(detached.opts); + expect(result.detachedScriptId).toBeNull(); + expect(detached.deletes).toHaveLength(0); }); diff --git a/packages/cli/src/commands/sites/api.ts b/packages/cli/src/commands/sites/api.ts index 46fa69db..4b71c9d8 100644 --- a/packages/cli/src/commands/sites/api.ts +++ b/packages/cli/src/commands/sites/api.ts @@ -76,14 +76,12 @@ export interface SiteContext { connection: StorageZone; } -export interface SiteSummaryOf { - state: S; +export interface SiteSummary { + state: RemoteSiteState; storageZone: StorageZoneModel; systemHostname?: string; } -export type SiteSummary = SiteSummaryOf; - export function sha256Hex(text: string): string { const hasher = new Bun.CryptoHasher("sha256"); hasher.update(text); @@ -112,9 +110,7 @@ function textStream(text: string): ReadableStream { return new Blob([text]).stream(); } -/** Read `_bunny/site.json` unparsed, so a caller can try more than one state format against it. - * Returns null when the file isn't there. */ -export function readRawState(connection: StorageZone): Promise { +function readRawState(connection: StorageZone): Promise { return downloadText(connection, REMOTE_STATE_PATH); } @@ -180,22 +176,18 @@ export async function writeRemoteState( return sha256Hex(raw); } -/** What a zone's `_bunny/site.json` turned out to be. - * The three cases want three different answers, so the migrate path reads the file - * once and tries both formats rather than asking "is this a site?". */ export type ZoneState = - | { kind: "legacy"; state: LegacySiteState } + | { kind: "legacy"; state: LegacySiteState; etag: string } | { kind: "current"; state: RemoteSiteState } | { kind: "none" }; -/** Classify a storage zone by the state format it carries. */ export async function classifySiteZone( zone: StorageZoneModel, ): Promise { const raw = await readRawState(siteFiles.connect(zone)); if (raw === null) return { kind: "none" }; const legacy = parseLegacyState(raw); - if (legacy) return { kind: "legacy", state: legacy }; + if (legacy) return { kind: "legacy", state: legacy, etag: sha256Hex(raw) }; const current = parseRemoteState(raw); if (current) return { kind: "current", state: current }; return { kind: "none" }; @@ -252,10 +244,8 @@ async function fetchPullZones( } } -async function scanSites( - client: CoreClient, - parse: (raw: string) => S | null, -): Promise>> { +// Discover sites: every storage-backed pull zone gets the per-zone `_bunny/site.json` read (concurrency-capped). A candidate is only a site when the state names it as the site's own pull zone, so another zone pointed at the same storage origin is never mistaken for one. +export async function fetchSites(client: CoreClient): Promise { const candidates = (await fetchPullZones(client)).filter( (pz: PullZone) => pz.StorageZoneId != null, ); @@ -263,14 +253,13 @@ async function scanSites( const summaries = await mapWithConcurrency( candidates, 8, - async (pz: PullZone): Promise | null> => { + async (pz: PullZone): Promise => { try { const zone = await fetchStorageZone(client, pz.StorageZoneId as number); - const raw = await readRawState(siteFiles.connect(zone)); - const state = raw === null ? null : parse(raw); - if (!state || state.pullZoneId !== pz.Id) return null; + const context = await siteContextFromZone(zone); + if (!context || context.state.pullZoneId !== pz.Id) return null; return { - state, + state: context.state, storageZone: zone, systemHostname: systemHostname(pz.Hostnames), }; @@ -281,21 +270,10 @@ async function scanSites( ); return summaries - .filter((s): s is SiteSummaryOf => s !== null) + .filter((s): s is SiteSummary => s !== null) .sort((a, b) => a.state.name.localeCompare(b.state.name)); } -export async function fetchSites(client: CoreClient): Promise { - return scanSites(client, parseRemoteState); -} - -/** Router-era sites, which {@link fetchSites} can't see because their state no longer parses; `sites migrate` discovers them with this. */ -export async function fetchLegacySites( - client: CoreClient, -): Promise>> { - return scanSites(client, parseLegacyState); -} - // Account storage zones whose name is `sites-{name}-{suffix}`, re-fetched by ID because search results may omit the zone password. async function findSiteStorageZones( client: CoreClient, @@ -341,7 +319,7 @@ const SITE_CACHE_SETTINGS = { CacheControlPublicMaxAgeOverride: 0, }; -/** Apply the site cache policy to a pull zone. Unlike the edge rules this isn't reapplied on every publish, so a zone provisioned before it existed needs {@link migrateSite} to set it. */ +// Not reapplied on publish like the edge rules are, so a zone provisioned before it existed needs `migrateSite` to set it. export async function applySiteCacheSettings( coreClient: CoreClient, pullZoneId: number, @@ -352,10 +330,10 @@ export async function applySiteCacheSettings( }); } -// `0` is the API's "no middleware script" sentinel on update. `null` is accepted and silently ignored, and `-1` (which the API itself reports for an unlinked `EdgeScriptId`) is rejected as a missing script, so neither can be used to clear the field. +// The API's "no middleware script" sentinel on update. `null` is accepted and silently ignored, and `-1` is rejected as a missing script, so neither clears the field. const NO_MIDDLEWARE_SCRIPT = 0; -// Detach the pull zone's middleware script, returning the script that was attached (null when there was none, so a resumed migration is a no-op). The clear is verified rather than assumed: a silently ignored detach would leave the router rewriting into `*/deploys/*`, which the gate rule blocks, and the site would serve 403s. +// Verified rather than assumed: an ignored detach would leave the router rewriting into `*/deploys/*`, which the gate rule blocks, so the site would serve 403s. export async function detachMiddlewareScript( coreClient: CoreClient, pullZoneId: number, @@ -383,7 +361,6 @@ async function fetchMiddlewareScriptId( params: { path: { id: pullZoneId } }, }); const id = data?.MiddlewareScriptId; - // A detached zone reports the field as absent, but treat the sentinel as unset too rather than trust one shape. return id == null || id === NO_MIDDLEWARE_SCRIPT ? null : id; } @@ -756,23 +733,31 @@ export async function promoteDeploy(opts: { await purge(); } +// Refuse to replace version-1 state that changed since it was read. `writeRemoteState`'s own conflict merge can't cover this: it reconciles against the current format, and the file being replaced is the older one, so it would abort as unparseable rather than merge. +async function guardLegacyState( + connection: StorageZone, + expectedEtag: string, +): Promise { + const current = await readRawState(connection); + if (current !== null && sha256Hex(current) === expectedEtag) return; + throw new UserError( + "The site's state changed while the migration was running.", + "A deploy from an older CLI may have landed. Re-run `bunny sites migrate` to pick it up.", + ); +} + export interface MigrateResult { state: RemoteSiteState; - /** The edge script detached from the pull zone, or null when it already was. */ detachedScriptId: number | null; - /** The deploy the rewrite rule now targets; the placeholder when the site never published one. */ - deployId: string; - scriptDeleted: boolean; - /** Why the script survived, when deleting it failed; the migration itself still succeeded. */ scriptError?: string; } export async function migrateSite(opts: { coreClient: CoreClient; computeClient: ComputeClient; legacy: LegacySiteState; + expectedEtag: string; storageZone: StorageZoneModel; connection: StorageZone; - keepScript?: boolean; onStep?: (message: string) => void; }): Promise { const { coreClient, computeClient, legacy, storageZone, connection } = opts; @@ -795,29 +780,30 @@ export async function migrateSite(opts: { }); await applySiteCacheSettings(coreClient, state.pullZoneId); - step("Writing site state..."); - await writeRemoteState(connection, state); - if (state.current) { step("Publishing the current deploy..."); await promoteDeploy({ coreClient, state, deployId: state.current }); } - let scriptDeleted = false; + // Committed only once every fallible remote step is done: while the file still reads as version 1, a failed run above is fully resumable. + step("Writing site state..."); + await guardLegacyState(connection, opts.expectedEtag); + await writeRemoteState(connection, state); + let scriptError: string | undefined; - if (!opts.keepScript) { + // Only ever delete the script this run detached from the zone. A `legacy.scriptId` that state has gone stale on could name an unrelated script, and the delete is permanent. + if (detachedScriptId != null) { step("Deleting the router script..."); try { await computeClient.DELETE("/compute/script/{id}", { - params: { path: { id: legacy.scriptId } }, + params: { path: { id: detachedScriptId } }, }); - scriptDeleted = true; } catch (err) { scriptError = errorMessage(err); } } - return { state, detachedScriptId, deployId, scriptDeleted, scriptError }; + return { state, detachedScriptId, scriptError }; } export interface TeardownResult { diff --git a/packages/cli/src/commands/sites/constants.test.ts b/packages/cli/src/commands/sites/constants.test.ts index 6266e4d5..2e9f83fe 100644 --- a/packages/cli/src/commands/sites/constants.test.ts +++ b/packages/cli/src/commands/sites/constants.test.ts @@ -35,55 +35,22 @@ const validLegacyState: LegacySiteState = { test("parseRemoteState round-trips a valid state", () => { expect(parseRemoteState(JSON.stringify(validState))).toEqual(validState); - // Router-era state is `sites migrate`'s job, not something the serving path reads. expect(parseRemoteState(JSON.stringify(validLegacyState))).toBeNull(); }); -test("parseLegacyState reads router-era state and nothing else", () => { +test("parseLegacyState reads router-era state, and migrating it drops the script fields", () => { expect(parseLegacyState(JSON.stringify(validLegacyState))).toEqual( validLegacyState, ); - // The two parsers never both claim a file, so a caller can tell "needs migrating" from "already migrated". + // The parsers never both claim a file, so a caller can tell "needs migrating" from "already migrated". expect(parseLegacyState(JSON.stringify(validState))).toBeNull(); - expect(parseLegacyState("not json")).toBeNull(); expect(parseLegacyState("{}")).toBeNull(); - // Version 1 without a script isn't router-era state. - expect( - parseLegacyState( - JSON.stringify({ ...validLegacyState, scriptId: undefined }), - ), - ).toBeNull(); - // The shared shape checks apply to both formats. - expect( - parseLegacyState(JSON.stringify({ ...validLegacyState, deploys: {} })), - ).toBeNull(); - expect( - parseLegacyState( - JSON.stringify({ - ...validLegacyState, - name: "evil\n run: rm -rf /", - }), - ), - ).toBeNull(); -}); -test("migrateLegacyState drops the script fields and keeps everything else", () => { - const deploy: DeployRecord = { - id: "abc123", - createdAt: "2026-01-01T00:00:00.000Z", - source: "git", - contentHash: "hash", - files: 2, - bytes: 20, - }; const migrated = migrateLegacyState({ ...validLegacyState, domain: "example.com", current: "abc123", - previous: "old999", - deploys: [deploy], }); - expect(migrated).toEqual({ version: 2, name: "my-site", @@ -91,10 +58,8 @@ test("migrateLegacyState drops the script fields and keeps everything else", () pullZoneId: 2, domain: "example.com", current: "abc123", - previous: "old999", - deploys: [deploy], + deploys: [], }); - // The result is what the serving path reads back, so it has to parse. expect(parseRemoteState(JSON.stringify(migrated))).toEqual(migrated); }); diff --git a/packages/cli/src/commands/sites/constants.ts b/packages/cli/src/commands/sites/constants.ts index 63f7ccfc..401ff0bf 100644 --- a/packages/cli/src/commands/sites/constants.ts +++ b/packages/cli/src/commands/sites/constants.ts @@ -7,10 +7,9 @@ export const REMOTE_STATE_PATH = "_bunny/site.json"; // Deploys live at `deploys/{id}/...` inside the storage zone. export const DEPLOYS_DIR = "deploys"; -// State format version; only this exact version parses, and `sites migrate` rewrites the router-era version 1 into it. +// State format version; only this exact version parses, so `sites migrate` rewrites version 1 into it. export const STATE_VERSION = 2; -// The router-era format, still on disk for sites created before edge rules replaced the router script. export const LEGACY_STATE_VERSION = 1; export const DEFAULT_KEEP_DEPLOYS = 5; @@ -47,15 +46,13 @@ export interface RemoteSiteState { deploys: DeployRecord[]; } -/** Router-era (version 1) state; the current format minus the script fields, which is all the migration has to drop. */ +/** Router-era (version 1) state; the current format plus the script fields, which is all the migration has to drop. */ export interface LegacySiteState { version: number; name: string; storageZoneId: number; pullZoneId: number; - /** The router script serving the site; version 1's defining field. */ scriptId: number; - /** The router source generation last published to the script. */ routerVersion?: number; domain?: string; current?: string; @@ -180,7 +177,6 @@ export function siteResourcePattern(siteName: string): RegExp { ); } -// A state file's top-level object, or null (not a crash) when it isn't one. function stateObject(raw: string): Record | null { let data: unknown; try { @@ -192,7 +188,6 @@ function stateObject(raw: string): Record | null { return data as Record; } -// The fields both formats carry; each parser adds its own version and script checks. function hasCommonShape(s: Record): boolean { return ( typeof s.name === "string" && @@ -204,7 +199,7 @@ function hasCommonShape(s: Record): boolean { ); } -// Parse and shape-check remote state; returns null for anything that isn't a state file this CLI serves, the router-era format included. +// Parse and shape-check remote state; returns null (not a crash) for anything that isn't a state file this CLI understands. export function parseRemoteState(raw: string): RemoteSiteState | null { const s = stateObject(raw); // Any other version is rejected rather than misread; version 1 goes through `sites migrate` first. @@ -212,7 +207,7 @@ export function parseRemoteState(raw: string): RemoteSiteState | null { return s as unknown as RemoteSiteState; } -// Parse router-era state; returns null for every other format, so a caller can tell "needs migrating" from "not a site". +// Returns null for every other format, including the current one, so a caller can tell "needs migrating" from "not a site". export function parseLegacyState(raw: string): LegacySiteState | null { const s = stateObject(raw); if ( @@ -226,7 +221,6 @@ export function parseLegacyState(raw: string): LegacySiteState | null { return s as unknown as LegacySiteState; } -/** The current-format equivalent of a router-era state; everything but the script fields carries over untouched. */ export function migrateLegacyState(legacy: LegacySiteState): RemoteSiteState { const { scriptId, routerVersion, ...rest } = legacy; return { ...rest, version: STATE_VERSION }; diff --git a/packages/cli/src/commands/sites/migrate.ts b/packages/cli/src/commands/sites/migrate.ts index 3d5c0339..0741d07d 100644 --- a/packages/cli/src/commands/sites/migrate.ts +++ b/packages/cli/src/commands/sites/migrate.ts @@ -1,5 +1,3 @@ -// TODO: Remove this in the next major release - import { createComputeClient, createCoreClient, @@ -10,230 +8,75 @@ import { defineCommand } from "../../core/define-command.ts"; import { UserError } from "../../core/errors.ts"; import { logger } from "../../core/logger.ts"; import { loadManifest } from "../../core/manifest.ts"; -import { - confirm, - isInteractive, - prompts, - requireConfirmable, - withSpinner, -} from "../../core/ui.ts"; +import { confirm, requireConfirmable, withSpinner } from "../../core/ui.ts"; import { type CoreClient, fetchStorageZone, resolveStorageZone, type StorageZoneModel, } from "../storage/api.ts"; -import type { StorageZone } from "../storage/files-api.ts"; import { classifySiteZone, - fetchLegacySites, fetchSystemHostname, - type MigrateResult, migrateSite, siteFiles, + type ZoneState, } from "./api.ts"; -import { loadSiteConfig } from "./config.ts"; -import { - type LegacySiteState, - SITES_MANIFEST, - type SiteManifest, -} from "./constants.ts"; +import { SITES_MANIFEST, type SiteManifest } from "./constants.ts"; import { productionUrl } from "./deploy.ts"; import { sitePositionalBuilder } from "./interactive.ts"; interface MigrateArgs { site?: string; force?: boolean; - "dry-run"?: boolean; - "keep-script"?: boolean; -} - -interface LegacySite { - state: LegacySiteState; - storageZone: StorageZoneModel; - connection: StorageZone; } -function legacySite( - state: LegacySiteState, - storageZone: StorageZoneModel, -): LegacySite { - return { state, storageZone, connection: siteFiles.connect(storageZone) }; -} - -function alreadyMigrated(name: string): UserError { - return new UserError( - `Site "${name}" is already on the edge-rule architecture.`, - "Deploy it as usual with `bunny sites deploy`.", - ); -} - -async function legacySiteFromRef( +// No picker or name scan: `selectSite` can't see these sites at all, and whoever reaches for this command already knows which one is stuck. +async function selectZone( client: CoreClient, - ref: string, -): Promise { - let zone: StorageZoneModel | undefined; - try { - zone = await resolveStorageZone(client, ref); - } catch { - zone = undefined; - } - if (zone) { - const state = await classifySiteZone(zone); - if (state.kind === "legacy") return legacySite(state.state, zone); - if (state.kind === "current") throw alreadyMigrated(state.state.name); - } - - const matches = (await fetchLegacySites(client)).filter( - (s) => s.state.name.toLowerCase() === ref.toLowerCase(), - ); - if (matches.length > 1) { + ref?: string, +): Promise { + if (ref) return resolveStorageZone(client, ref); + const { id } = loadManifest(SITES_MANIFEST); + if (!id) { throw new UserError( - `Multiple router-era sites are named "${ref}".`, - "Pass the storage zone ID instead.", + "No site specified and no linked site found.", + "Pass the site's storage zone name or ID (see `bunny storage zones list`).", ); } - const match = matches[0]; - if (match) return legacySite(match.state, match.storageZone); - - if (zone) { - throw new UserError( - `Storage zone "${zone.Name}" is not a router-era site.`, - "Only sites created before edge rules replaced the router script need migrating.", - ); - } - throw new UserError( - `No router-era site found for "${ref}".`, - "Run `bunny sites migrate` with no arguments to pick from the ones this account has.", - ); + return fetchStorageZone(client, id); } -// Pick the site to migrate, walking the same precedence as `selectSite`: explicit ref, `.bunny/site.json`, `sites.name` in bunny.jsonc, then a picker over the account scan. Discovery is separate because a router-era site's state no longer parses, so `selectSite` can't see it at all. -async function selectLegacySite( - client: CoreClient, - args: { site?: string; output: string; force?: boolean }, -): Promise { - if (args.site) { - const ref = args.site; - return withSpinner("Resolving site...", () => - legacySiteFromRef(client, ref), - ); - } - - const manifest = loadManifest(SITES_MANIFEST); - if (manifest.id) { - const id = manifest.id; - const linked = await withSpinner("Loading linked site...", async () => { - const zone = await fetchStorageZone(client, id); - return { zone, state: await classifySiteZone(zone) }; - }); - if (linked.state.kind === "current") { - throw alreadyMigrated(linked.state.state.name); - } - if (linked.state.kind === "none") { - throw new UserError( - `The linked storage zone ${id} is not a site.`, - "Run `bunny sites unlink`, then link or create a site.", - ); - } - return legacySite(linked.state.state, linked.zone); - } - - const configured = loadSiteConfig()?.config.name; - if (configured) { - return withSpinner( - `Resolving site "${configured}" from bunny.jsonc...`, - () => legacySiteFromRef(client, configured), - ); - } - - const sites = await withSpinner("Scanning for router-era sites...", () => - fetchLegacySites(client), - ); - if (sites.length === 0) { - throw new UserError( - "No router-era sites found in your account.", - "Nothing to migrate; sites on the current architecture deploy with `bunny sites deploy`.", - ); - } - - if (args.force || !isInteractive(args.output)) { +function requireLegacy( + zone: ZoneState, + ref: string, +): Extract { + if (zone.kind === "legacy") return zone; + if (zone.kind === "current") { throw new UserError( - "No site specified.", - `Pass one: ${sites.map((s) => s.state.name).join(", ")}.`, + `Site "${zone.state.name}" is already on the edge-rule architecture.`, + "Deploy it as usual with `bunny sites deploy`.", ); } - - const { selected } = await prompts({ - type: "select", - name: "selected", - message: "Migrate which site?", - choices: sites.map((s) => ({ - title: `${s.state.name} (${s.state.storageZoneId})`, - value: s, - })), - }); - if (!selected) throw new UserError("A site is required."); - const summary = selected as (typeof sites)[number]; - return legacySite(summary.state, summary.storageZone); -} - -function reportText( - state: LegacySiteState, - result: MigrateResult, - production?: string, -): void { - logger.success(`Migrated "${state.name}" to the edge-rule architecture.`); - if (result.detachedScriptId != null) { - logger.dim(` Detached edge script ${result.detachedScriptId}.`); - } - if (result.scriptDeleted) { - logger.dim(` Deleted edge script ${state.scriptId}.`); - } else if (result.scriptError) { - logger.warn( - `Couldn't delete edge script ${state.scriptId}: ${result.scriptError}`, - ); - logger.dim(" Delete it by hand; the site no longer uses it."); - } - if (result.state.current) { - logger.dim(` Serving deploy ${result.state.current}.`); - - if (production) logger.info(`Production: ${production}`); - } else { - logger.info("The site has no published deploy; run `bunny sites deploy`."); - } - - logger.warn( - "Edge rules don't redirect `/path` to `/path/` the way the router did.", - ); - logger.dim( - " Directory URLs ending in a slash still work; check any link to an extensionless path.", + throw new UserError( + `"${ref}" is not a router-era site.`, + "Only sites created before edge rules replaced the router script need migrating.", ); } +// Hidden and deliberately minimal: it exists for the sites deployed before edge rules replaced the router script, and comes out once they are all across. export const sitesMigrateCommand = defineCommand({ command: "migrate [site]", describe: "Migrate a router-era site to the edge-rule architecture.", hidden: true, builder: (yargs) => - sitePositionalBuilder(yargs) - .option("force", { - alias: "f", - type: "boolean", - default: false, - describe: "Skip confirmation prompts", - }) - .option("dry-run", { - type: "boolean", - default: false, - describe: "Report what would change without touching the site", - }) - .option("keep-script", { - type: "boolean", - default: false, - describe: "Detach the router script but don't delete it", - }), + sitePositionalBuilder(yargs).option("force", { + alias: "f", + type: "boolean", + default: false, + describe: "Skip confirmation prompts", + }), handler: async (args) => { const { profile, output, verbose, apiKey, force } = args; @@ -242,43 +85,22 @@ export const sitesMigrateCommand = defineCommand({ const coreClient = createCoreClient(options); const computeClient = createComputeClient(options); - const site = await selectLegacySite(coreClient, { - site: args.site, - output, - force, - }); - const { state } = site; - - if (args["dry-run"]) { - const actions = [ - `Detach edge script ${state.scriptId} from pull zone ${state.pullZoneId}`, - `Apply the site edge rules and cache settings to pull zone ${state.pullZoneId}`, - "Rewrite _bunny/site.json as state version 2", - state.current - ? `Republish deploy ${state.current}` - : "Point the rewrite rule at the placeholder (no published deploy)", - ...(args["keep-script"] - ? [] - : [`Delete edge script ${state.scriptId}`]), - ]; - if (output === "json") { - logger.log( - JSON.stringify({ site: state.name, dryRun: true, actions }, null, 2), - ); - return; - } - logger.info(`Would migrate "${state.name}":`); - for (const action of actions) logger.dim(` ${action}`); - return; - } + const zone = await withSpinner("Resolving site...", () => + selectZone(coreClient, args.site), + ); + const legacy = requireLegacy( + await classifySiteZone(zone), + args.site ?? String(zone.Id), + ); + const { name } = legacy.state; requireConfirmable(output, { force, - message: `Migrating "${state.name}" needs a confirmation prompt.`, + message: `Migrating "${name}" needs a confirmation prompt.`, hint: "Re-run with --force to migrate non-interactively.", }); const confirmed = await confirm( - `Migrate "${state.name}" to the edge-rule architecture? It briefly serves its raw storage origin while the rules land.`, + `Migrate "${name}" to the edge-rule architecture? It briefly serves its raw storage origin while the rules land.`, { force }, ); if (!confirmed) { @@ -286,46 +108,40 @@ export const sitesMigrateCommand = defineCommand({ return; } - const result = await withSpinner(`Migrating "${state.name}"...`, (spin) => + const result = await withSpinner(`Migrating "${name}"...`, (spin) => migrateSite({ coreClient, computeClient, - legacy: state, - storageZone: site.storageZone, - connection: site.connection, - keepScript: args["keep-script"], + legacy: legacy.state, + expectedEtag: legacy.etag, + storageZone: zone, + connection: siteFiles.connect(zone), onStep: (message) => { spin.text = message; }, }), ); - const systemHost = result.state.domain - ? undefined - : await fetchSystemHostname(coreClient, result.state.pullZoneId); - const production = productionUrl(result.state, systemHost); - - if (output === "json") { - logger.log( - JSON.stringify( - { - site: state.name, - migrated: true, - storageZoneId: state.storageZoneId, - pullZoneId: state.pullZoneId, - detachedScriptId: result.detachedScriptId, - deploy: result.state.current ?? null, - production: production ?? null, - scriptDeleted: result.scriptDeleted, - ...(result.scriptError ? { scriptError: result.scriptError } : {}), - }, - null, - 2, - ), + logger.success(`Migrated "${name}" to the edge-rule architecture.`); + if (result.scriptError) { + logger.warn( + `Couldn't delete edge script ${result.detachedScriptId}: ${result.scriptError}`, + ); + } + if (result.state.current) { + const systemHost = result.state.domain + ? undefined + : await fetchSystemHostname(coreClient, result.state.pullZoneId); + const production = productionUrl(result.state, systemHost); + if (production) logger.info(`Production: ${production}`); + } else { + logger.info( + "The site has no published deploy; run `bunny sites deploy`.", ); - return; } - reportText(state, result, production); + logger.warn( + "Edge rules don't redirect `/path` to `/path/` the way the router did.", + ); }, }); From 0d5ccff1d1fa452963c1740d738a4b3ddd969f0a Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Thu, 3 Sep 2026 16:17:05 +0100 Subject: [PATCH 3/4] migrate site p1 --- packages/cli/src/commands/sites/api.test.ts | 19 +++++++++++++++++++ packages/cli/src/commands/sites/api.ts | 18 ++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/packages/cli/src/commands/sites/api.test.ts b/packages/cli/src/commands/sites/api.test.ts index a40ea5c3..8e0ff99e 100644 --- a/packages/cli/src/commands/sites/api.test.ts +++ b/packages/cli/src/commands/sites/api.test.ts @@ -1032,6 +1032,25 @@ test("migrateSite aborts rather than clobber state that changed mid-migration", expect((await classifySiteZone(ZONE)).kind).toBe("legacy"); }); +test("migrateSite refuses a pull zone that isn't the site's origin", async () => { + const { opts, deletes } = migrateFixture( + legacyPullZone({ StorageZoneId: 999 }), + ); + + await expect(migrateSite(opts)).rejects.toThrow( + "isn't the origin for storage zone", + ); + expect(deletes).toHaveLength(0); + expect((await classifySiteZone(ZONE)).kind).toBe("legacy"); + const pullZone = ( + await opts.coreClient.GET("/pullzone/{id}", { + params: { path: { id: 30 } }, + }) + ).data; + expect(pullZone?.MiddlewareScriptId).toBe(77); + expect(pullZone?.EdgeRules ?? []).toHaveLength(0); +}); + test("migrateSite deletes the live script, never the one stale state recorded", async () => { // State names 77, but the zone is actually serving 99. const attached = migrateFixture(legacyPullZone({ MiddlewareScriptId: 99 })); diff --git a/packages/cli/src/commands/sites/api.ts b/packages/cli/src/commands/sites/api.ts index 4b71c9d8..3824f39f 100644 --- a/packages/cli/src/commands/sites/api.ts +++ b/packages/cli/src/commands/sites/api.ts @@ -746,6 +746,21 @@ async function guardLegacyState( ); } +async function assertPullZoneOwnedBy( + coreClient: CoreClient, + pullZoneId: number, + storageZone: StorageZoneModel, +): Promise { + const { data } = await coreClient.GET("/pullzone/{id}", { + params: { path: { id: pullZoneId } }, + }); + if (data?.StorageZoneId === storageZone.Id) return; + throw new UserError( + `Pull zone ${pullZoneId} isn't the origin for storage zone "${storageZone.Name}".`, + "The site's state file names a pull zone that has been deleted or repointed; fix it in the dashboard before migrating.", + ); +} + export interface MigrateResult { state: RemoteSiteState; detachedScriptId: number | null; @@ -765,6 +780,9 @@ export async function migrateSite(opts: { const state = migrateLegacyState(legacy); const deployId = state.current ?? PLACEHOLDER_DEPLOY; + step("Checking pull zone..."); + await assertPullZoneOwnedBy(coreClient, state.pullZoneId, storageZone); + step("Detaching the router script..."); const detachedScriptId = await detachMiddlewareScript( coreClient, From cd75e418b201b586de45194c18954a4b0ba01342 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Thu, 3 Sep 2026 16:42:12 +0100 Subject: [PATCH 4/4] fixes --- packages/cli/src/commands/sites/api.test.ts | 26 ++++++++++++++++++--- packages/cli/src/commands/sites/api.ts | 17 +++++++++++--- packages/cli/src/commands/sites/migrate.ts | 7 ++++++ 3 files changed, 44 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/commands/sites/api.test.ts b/packages/cli/src/commands/sites/api.test.ts index 8e0ff99e..cd772966 100644 --- a/packages/cli/src/commands/sites/api.test.ts +++ b/packages/cli/src/commands/sites/api.test.ts @@ -125,8 +125,15 @@ function seedLegacy(legacy: LegacySiteState): string { return sha256Hex(raw); } -function fakeComputeClient(calls: Call[], opts?: { deleteError?: Error }) { +function fakeComputeClient( + calls: Call[], + opts?: { deleteError?: Error; scriptName?: string }, +) { return { + // Not recorded: `calls` is the delete log the migration tests assert on. + GET: async () => ({ + data: { Name: opts?.scriptName ?? `${ZONE.Name}-router` }, + }), DELETE: async (path: string, options?: { params?: unknown }) => { calls.push({ method: "DELETE", @@ -966,7 +973,10 @@ function legacyPullZone( }; } -function migrateFixture(pullZone?: Record) { +function migrateFixture( + pullZone?: Record, + scriptName?: string, +) { const legacy = fakeLegacyState({ current: "abc123", previous: "old999" }); const raw = JSON.stringify(legacy); store.set(REMOTE_STATE_PATH, raw); @@ -979,7 +989,7 @@ function migrateFixture(pullZone?: Record) { storageZones: [ZONE], pullZones: [pullZone ?? legacyPullZone()], }), - computeClient: fakeComputeClient(deletes), + computeClient: fakeComputeClient(deletes, { scriptName }), legacy, expectedEtag: sha256Hex(raw), storageZone: ZONE, @@ -1051,6 +1061,16 @@ test("migrateSite refuses a pull zone that isn't the site's origin", async () => expect(pullZone?.EdgeRules ?? []).toHaveLength(0); }); +test("migrateSite leaves a script that isn't the site's router attached-but-alive", async () => { + const { opts, deletes } = migrateFixture(undefined, "my-own-middleware"); + + const result = await migrateSite(opts); + + expect(result.detachedScriptId).toBe(77); + expect(result.deletedScriptId).toBeNull(); + expect(deletes).toHaveLength(0); +}); + test("migrateSite deletes the live script, never the one stale state recorded", async () => { // State names 77, but the zone is actually serving 99. const attached = migrateFixture(legacyPullZone({ MiddlewareScriptId: 99 })); diff --git a/packages/cli/src/commands/sites/api.ts b/packages/cli/src/commands/sites/api.ts index 3824f39f..2d1bb29b 100644 --- a/packages/cli/src/commands/sites/api.ts +++ b/packages/cli/src/commands/sites/api.ts @@ -761,9 +761,14 @@ async function assertPullZoneOwnedBy( ); } +function routerScriptName(storageZone: StorageZoneModel): string { + return `${storageZone.Name}-router`; +} + export interface MigrateResult { state: RemoteSiteState; detachedScriptId: number | null; + deletedScriptId: number | null; scriptError?: string; } export async function migrateSite(opts: { @@ -809,19 +814,25 @@ export async function migrateSite(opts: { await writeRemoteState(connection, state); let scriptError: string | undefined; - // Only ever delete the script this run detached from the zone. A `legacy.scriptId` that state has gone stale on could name an unrelated script, and the delete is permanent. + let deletedScriptId: number | null = null; if (detachedScriptId != null) { step("Deleting the router script..."); try { - await computeClient.DELETE("/compute/script/{id}", { + const { data: script } = await computeClient.GET("/compute/script/{id}", { params: { path: { id: detachedScriptId } }, }); + if (script?.Name === routerScriptName(storageZone)) { + await computeClient.DELETE("/compute/script/{id}", { + params: { path: { id: detachedScriptId } }, + }); + deletedScriptId = detachedScriptId; + } } catch (err) { scriptError = errorMessage(err); } } - return { state, detachedScriptId, scriptError }; + return { state, detachedScriptId, deletedScriptId, scriptError }; } export interface TeardownResult { diff --git a/packages/cli/src/commands/sites/migrate.ts b/packages/cli/src/commands/sites/migrate.ts index 0741d07d..2321947d 100644 --- a/packages/cli/src/commands/sites/migrate.ts +++ b/packages/cli/src/commands/sites/migrate.ts @@ -127,6 +127,13 @@ export const sitesMigrateCommand = defineCommand({ logger.warn( `Couldn't delete edge script ${result.detachedScriptId}: ${result.scriptError}`, ); + } else if ( + result.detachedScriptId != null && + result.deletedScriptId === null + ) { + logger.warn( + `Detached edge script ${result.detachedScriptId}, but left it in place: it isn't this site's router. Remove it with \`bunny scripts delete ${result.detachedScriptId}\` if nothing else uses it.`, + ); } if (result.state.current) { const systemHost = result.state.domain