/** * The update-mode CLI the deploy workflows call around every site code * update: * * begin set UPDATE_IN_PROGRESS, then check the * site at 10, 30, and 60 seconds until it * serves its update page * end wait until the new build serves, then * remove UPDATE_IN_PROGRESS * * `begin` runs before the code upload, `end` after it. The checks are * single hits, and the gaps between them stay quiet: the gap lets the old * app stop serving and the flag set, where immediate repeated checking * kept the old app online and raced the propagation. A begin run-out * removes UPDATE_IN_PROGRESS again, so the site accepts writes, and the * error says to re-run the deploy. Any other failure leaves * UPDATE_IN_PROGRESS set: a site stuck read-only is safer than a * half-migrated one, and a re-run of the same update lifts the flag on * success. The operator can also remove the variable by hand in the Bunny * dashboard (script -> Env Configuration -> Environment Secrets). * * `end` takes the release tag of the build the deploy just published and * waits until the served page names that build before it lifts the flag, so * an active page the old build still answers cannot lift it. A 503 answer * keeps the wait going: the new build serves the migration page until its * first migrations finish. The release tag matches the build tag * build-edge.ts bakes into the bundle. */ import { BUNNY_API_BASE, formatBunnyError } from "#scripts/deploy-edge-lib.ts"; import type { FetchText, FetchTextResult } from "#scripts/fetch-text.ts"; import { siteBaseUrl } from "#shared/site-address.ts"; import { fetchUpdatePageBody, UPDATE_FLAG_VALUE, UPDATE_IN_PROGRESS_VAR, type UpdateIoOptions, type UpdatePollIo, waitForBeginPropagation, waitForUpdatePage, waitOptionsFrom, } from "#shared/update-guard.ts"; export const USAGE = "Usage: deno run scripts/update-mode.ts begin \n" + " deno run scripts/update-mode.ts end "; export interface UpdateModeIo extends UpdateIoOptions { accessKey: string; fetchText: FetchText; log: (line: string) => void; } /** One resolved command: the base io plus the script and site it targets, * with the page fetch the wait polls. */ type ScopedIo = UpdateModeIo & UpdatePollIo & { scriptId: string; siteUrl: string }; /** The edge script's secrets endpoint, which every flag call shares. */ const secretsEndpoint = (io: ScopedIo): string => `/compute/script/${encodeURIComponent(io.scriptId)}/secrets`; /** One Bunny API call shape reused by the upsert, list, and delete below. */ const secretRequest = async ( io: ScopedIo, path: string, method: string, body?: string, ): Promise => io.fetchText(`${BUNNY_API_BASE}${path}`, { ...(body === undefined ? {} : { body }), headers: { AccessKey: io.accessKey, ...(body === undefined ? {} : { "Content-Type": "application/json" }), }, method, }); /** Upsert the update flag. An empty value lifts update mode only on hosts * with no secret delete; this CLI deletes properly in `end`. */ const upsertUpdateSecret = async ( io: ScopedIo, value: string, ): Promise => { const response = await secretRequest( io, secretsEndpoint(io), "PUT", JSON.stringify({ Name: UPDATE_IN_PROGRESS_VAR, Secret: value }), ); if (!response.ok) { throw new Error(formatBunnyError("Set UPDATE_IN_PROGRESS", response)); } }; /** The secret's numeric id, or null while the script holds no update flag. */ const findUpdateSecretId = async (io: ScopedIo): Promise => { const response = await secretRequest(io, secretsEndpoint(io), "GET"); if (!response.ok) { throw new Error(formatBunnyError("List edge script secrets", response)); } const secrets = JSON.parse(response.text) as { Secrets: { Id: number; Name: string }[] | null; }; const match = (secrets.Secrets ?? []).find( (secret) => secret.Name === UPDATE_IN_PROGRESS_VAR, ); return match?.Id ?? null; }; const deleteUpdateSecret = async ( io: ScopedIo, secretId: number, ): Promise => { const response = await secretRequest( io, `${secretsEndpoint(io)}/${secretId}`, "DELETE", ); if (!response.ok) { throw new Error(formatBunnyError("Delete UPDATE_IN_PROGRESS", response)); } }; /** Remove the update flag: find it by name, then delete it. A script that * holds no flag is already lifted (a re-run after a manual clear, for * example). */ const liftUpdateFlag = async (io: ScopedIo): Promise => { const secretId = await findUpdateSecretId(io); if (secretId !== null) await deleteUpdateSecret(io, secretId); }; /** Fetch the site's read-only page. A non-200 answer makes * `fetchUpdatePageBody` raise an `UpdatePageCheckError` that names the URL. * The wait retries a 503 answer, the migration page, and ends on any other * status. */ const fetchReadOnlyPage = async (site: { fetchText: FetchText; siteUrl: string; }): Promise => { const url = `${siteBaseUrl(site.siteUrl)}/read-only`; return await fetchUpdatePageBody(site.fetchText, url, ` for ${url}`); }; export const runUpdateMode = async ( args: string[], io: UpdateModeIo, ): Promise => { const [command, scriptId, siteUrl, buildTag] = args; if ( (command !== "begin" && command !== "end") || scriptId === undefined || siteUrl === undefined || (command === "end" && buildTag === undefined) ) { io.log(USAGE); return 1; } // The deploy workflows pass the site URL from a JSON payload. A site // whose URL is missing arrives as the string "null" (jq's rendering of a // JSON null) or an empty argument. Either must fail here, before the // flag write, not become a https://null page request. if (siteUrl.trim() === "" || siteUrl.trim() === "null") { io.log( `The deploy passed no site URL for script ${scriptId}, so the update-mode wait has no page to check. Give the deploy the site's URL and re-run it.`, ); return 1; } const scopedIo: ScopedIo = { ...io, fetchPage: () => fetchReadOnlyPage({ fetchText: io.fetchText, siteUrl }), scriptId, siteUrl, }; if (command === "begin") { io.log(`Setting ${UPDATE_IN_PROGRESS_VAR} on script ${scriptId}..`); await upsertUpdateSecret(scopedIo, UPDATE_FLAG_VALUE); // The schedule checks at 10, 30, and 60 seconds and stays quiet // between them. A run-out reverts the flag, so the site accepts // writes again. await waitForBeginPropagation(waitOptionsFrom(scopedIo, true), () => liftUpdateFlag(scopedIo), ); return 0; } // Only the build this deploy published may answer: an active page the old // build still serves must not lift the flag before the new build serves. await waitForUpdatePage(waitOptionsFrom(scopedIo, false, buildTag)); io.log(`Removing ${UPDATE_IN_PROGRESS_VAR} from script ${scriptId}..`); await liftUpdateFlag(scopedIo); return 0; };