import { env } from "cloudflare:workers"; import { claimHost, deploymentDb, hostHolder, loadSiteRow, siteDb, siteDbById } from "./store"; import { registerLocator } from "../scoped-db/locators"; import { scopedId } from "../scoped-db/ids"; import { HttpError, badRequest } from "../errors"; import { manifestKey, publishMeta, SITE_LIMITS, slotKey } from "./config"; import { isValidLabel, splitWorker } from "./naming"; import { isOrphanSite } from "./sync"; import { dropAliasesForPreview, dropSiteAliases, syncPullAliases } from "./pr-aliases"; import { loadRepoById, repoPermission } from "../repos"; import { loadUserById, toViewer } from "../auth"; import type { SiteDeploymentRow, SiteEnvironment, SiteManifest, SiteRow, SlotPointer } from "./config"; import { notifySiteDeployFailed } from "../notify/deploy"; import type { CollectedFiles } from "./collect"; const HOUR = 60 * 60 * 1000; const HTTP_TOO_MANY = 429; export type SiteDeployRequest = { site: SiteRow; environment: SiteEnvironment; label: string; branch: string | null; ref: string; sha: string; artifactId: string | null; source: SiteDeploymentRow["source"]; creatorId: string | null; /** Whose admin role unlocks preview variables; defaults to the creator. Runs pass the SHA's pusher (CI-P2). */ trustedId?: string | null; }; export async function loadSiteById(id: string): Promise { return loadSiteRow(id); } export async function loadSiteForRepo(repoId: string): Promise { return siteDb({ id: "", repo_id: repoId }).first("SELECT * FROM sites WHERE repo_id = ?", repoId); } export async function loadSiteDeployment(id: string): Promise { const db = await deploymentDb(id); return db ? db.first("SELECT * FROM site_deployments WHERE id = ?", id) : null; } /** Whether `siteId` still serves or reserves `label` (production label, live deployment or PR alias). */ async function siteHoldsLabel(siteId: string, label: string): Promise { const db = await siteDbById(siteId); if (!db) return false; const held = await db.first<{ one: number }>( `SELECT 1 AS one FROM sites WHERE id = ? AND label = ? UNION ALL SELECT 1 FROM site_deployments WHERE site_id = ? AND label = ? AND status IN ('queued', 'building', 'active') UNION ALL SELECT 1 FROM site_pr_aliases WHERE site_id = ? AND label = ? LIMIT 1`, siteId, label, siteId, label, siteId, label, ); return held !== null; } /** * Labels are first-come: a preview label may never take over another site's production or preview host. The * `site_host` locator names the last site to claim a label; a claim the holder no longer uses is free. */ export async function assertLabelFree(siteId: string, label: string): Promise { if (!isValidLabel(label)) throw badRequest(`"${label}" is not a valid site host`); const holder = await hostHolder(label); if (holder && holder !== siteId && (await siteHoldsLabel(holder, label))) { throw badRequest(`The site host "${label}" is already used by another site`); } } /** Whether the deployment's creator holds admin on the site's repository right now (CI-A3 preview variables). */ async function creatorIsAdmin(site: SiteRow, creatorId: string | null): Promise { if (!creatorId || !site.repo_id) return false; const [repo, user] = await Promise.all([loadRepoById(site.repo_id), loadUserById(creatorId)]); if (!repo || !user) return false; return (await repoPermission(repo, toViewer(user))) === "admin"; } /** Records a deployment row; branch and artifact sources are then built by the deploy Workflow. */ export async function insertDeployment(input: SiteDeployRequest): Promise { const db = siteDb(input.site); const recent = await db.first<{ count: number }>( "SELECT COUNT(*) AS count FROM site_deployments WHERE site_id = ? AND created_at > ?", input.site.id, Date.now() - HOUR, ); if ((recent?.count ?? 0) >= SITE_LIMITS.deploysPerSitePerHour) { throw new HttpError(HTTP_TOO_MANY, `At most ${SITE_LIMITS.deploysPerSitePerHour} site deploys per hour`); } await assertLabelFree(input.site.id, input.label); const sibling = await db.first<{ branch: string | null }>( "SELECT branch FROM site_deployments WHERE site_id = ? AND label = ? AND branch IS NOT ? AND status IN ('queued', 'building', 'active') LIMIT 1", input.site.id, input.label, input.branch, ); if (sibling && input.environment === "preview") { throw badRequest(`Branch ${input.branch} maps to the same preview host as ${sibling.branch}`); } const row: SiteDeploymentRow = { id: scopedId(db.scopeId), site_id: input.site.id, environment: input.environment, label: input.label, branch: input.branch, source: input.source, ref: input.ref, sha: input.sha, artifact_id: input.artifactId, kind: "static", status: "queued", file_count: 0, total_bytes: 0, error: null, creator_id: input.creatorId, creator_admin: input.environment === "preview" && (await creatorIsAdmin(input.site, input.trustedId === undefined ? input.creatorId : input.trustedId)) ? 1 : 0, created_at: Date.now(), completed_at: null, }; await registerLocator("site_deployment", row.id, db.scopeId); await claimHost(row.label, row.site_id); await db.run( `INSERT INTO site_deployments (id, site_id, environment, label, branch, source, ref, sha, artifact_id, status, creator_id, creator_admin, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'queued', ?, ?, ?)`, row.id, row.site_id, row.environment, row.label, row.branch, row.source, row.ref, row.sha, row.artifact_id, row.creator_id, row.creator_admin, row.created_at, ); return row; } export async function createSiteDeployment(input: SiteDeployRequest): Promise { const row = await insertDeployment(input); await env.SITES_WORKFLOW.create({ id: row.id, params: { deploymentId: row.id } }); return row; } export async function failSiteDeployment(id: string, message: string): Promise { const db = await deploymentDb(id); if (!db) return; const result = await db.run( "UPDATE site_deployments SET status = 'failed', error = ?, completed_at = ? WHERE id = ? AND status IN ('queued', 'building')", message.slice(0, 500), Date.now(), id, ); if (result.meta.changes > 0) await notifySiteDeployFailed(id, message.slice(0, 500)); } /** Points the deployment's label at it and supersedes whatever that label served before. */ export async function pointLabel(site: SiteRow, deployment: SiteDeploymentRow): Promise { const pointer: SlotPointer = { siteId: site.id, deploymentId: deployment.id }; const existing = await env.KV.get(slotKey(deployment.label), "json"); if (existing && existing.siteId !== site.id && !(await isOrphanSite(existing.siteId))) { throw badRequest("That site host belongs to another site"); } await env.KV.put(slotKey(deployment.label), JSON.stringify(pointer)); await claimHost(deployment.label, site.id); const db = siteDb(site); await db.run( "UPDATE site_deployments SET status = 'superseded' WHERE site_id = ? AND label = ? AND status = 'active' AND id != ?", site.id, deployment.label, deployment.id, ); await db.run("UPDATE site_deployments SET status = 'active' WHERE id = ?", deployment.id); if (deployment.environment === "production") { await db.run( "UPDATE sites SET active_deployment_id = ?, updated_at = ? WHERE id = ?", deployment.id, Date.now(), site.id, ); } await publishMeta(site); await syncPullAliases(site, deployment); } /** Publishes collected files (static assets plus any `_worker.js` modules) and activates unless a newer deploy won. */ export async function activateSiteDeployment(id: string, files: CollectedFiles): Promise { const deployment = await loadSiteDeployment(id); if (!deployment) return "missing"; if (deployment.status !== "queued" && deployment.status !== "building") return "cancelled"; const site = await loadSiteById(deployment.site_id); if (!site || site.disabled_reason) { await failSiteDeployment(id, "The site is disabled"); return "disabled"; } const paths = Object.keys(files); if (paths.length === 0) { await failSiteDeployment(id, "Nothing to deploy: the source contains no files"); return "empty"; } const { assets, worker } = splitWorker(files); const manifest: SiteManifest = { files: assets, worker }; const total = paths.reduce((sum, path) => sum + files[path][1], 0); await env.KV.put(manifestKey(id), JSON.stringify(manifest)); const db = siteDb(site); await db.run( "UPDATE site_deployments SET kind = ?, file_count = ?, total_bytes = ?, completed_at = ? WHERE id = ?", worker ? "worker" : "static", paths.length, total, Date.now(), id, ); const newer = await db.first<{ id: string }>( "SELECT id FROM site_deployments WHERE site_id = ? AND label = ? AND status = 'active' AND created_at > ?", site.id, deployment.label, deployment.created_at, ); if (newer) { await db.run("UPDATE site_deployments SET status = 'superseded' WHERE id = ?", id); return "superseded"; } await pointLabel(site, deployment); return "active"; } export async function rollbackSite(site: SiteRow, deploymentId: string): Promise { const deployment = await loadSiteDeployment(deploymentId); if (!deployment || deployment.site_id !== site.id || deployment.environment !== "production") { throw badRequest("Unknown production deployment"); } if (deployment.status !== "superseded" && deployment.status !== "active") { throw badRequest("Only previously successful deployments can be restored"); } if (!(await env.KV.get(manifestKey(deployment.id)))) throw badRequest("That deployment is no longer retained"); await pointLabel(site, { ...deployment, label: site.label }); } export async function listSiteDeployments(siteId: string): Promise { const db = await siteDbById(siteId); if (!db) return []; return db.all( "SELECT * FROM site_deployments WHERE site_id = ? ORDER BY created_at DESC LIMIT ?", siteId, SITE_LIMITS.historyLimit, ); } /** Drops a deleted branch's preview host. */ export async function removePreview(site: SiteRow, label: string): Promise { if (label === site.label) return; await env.KV.delete(slotKey(label)); await dropAliasesForPreview(site.id, label); await siteDb(site).run( "UPDATE site_deployments SET status = 'superseded' WHERE site_id = ? AND label = ? AND status = 'active'", site.id, label, ); } /** Removes every label the site serves; deployments stay for history. */ export async function unpublishSite(siteId: string): Promise { const db = await siteDbById(siteId); if (!db) return; const labels = await db.all<{ label: string }>( "SELECT DISTINCT label FROM site_deployments WHERE site_id = ? AND status = 'active' LIMIT 500", siteId, ); await Promise.all(labels.map((row) => env.KV.delete(slotKey(row.label)))); await db.run("UPDATE site_deployments SET status = 'superseded' WHERE site_id = ? AND status = 'active'", siteId); await dropSiteAliases(siteId); }