import { mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; import { performBackup } from "./backup"; import { BACKUP_MEMBERS, checkMigrationCompatibility, parseManifest, type BackupManifest } from "./backup-manifest"; import { backupFilePath } from "./backup-store"; import { generateComposeYaml } from "./compose-template"; import { composeCreate, composeDown, composeUp, listImageMigrations } from "./docker"; import { mediaRestoreCommand, mysqlImportCommand } from "./docker-commands"; import { readEnvValue } from "./env-file"; import { waitForHealth } from "./health"; import { uploadsDir } from "./manager-paths"; import { assertDomainAvailable, recreateDatabaseAndUser, writeCaddySiteAndReload } from "./provisioning"; import { runPipeline } from "./process"; import { sevenZipExtractToStdoutCommand, sevenZipReadMember, sevenZipTest } from "./sevenzip"; import { stackDir, stackExists } from "./stacks"; /** * Restore a backup archive as a business — onto an existing business * (replace) or onto a fresh server where it never existed. The step order * is the contract (and is asserted in tests): * * verify → check → safety backup (replace only) → stop → recreate DB * → import dump → write config + Caddy → restore media → start → health * * The database password comes from the backed-up .env (so that .env keeps * working untouched); the dump already contains the "installed" row, so * the setup wizard doesn't appear — the final step insists on * `installed: true`. Like performBackup this does not take the business * lock; the restore job holds it. */ export const RESTORE_STEPS = [ { id: "verify", label: "Verify the archive" }, { id: "check", label: "Check for conflicts and schema version" }, { id: "safety", label: "Take a safety backup of the current business" }, { id: "stop", label: "Stop the current stack" }, { id: "database", label: "Recreate the database and user" }, { id: "import", label: "Import the database dump" }, { id: "config", label: "Write configuration and the Caddy site" }, { id: "media", label: "Restore media files" }, { id: "start", label: "Start the stack" }, { id: "health", label: "Wait for the app to report installed" }, ] as const; export type RestoreSource = { kind: "backup"; slug: string; fileName: string } | { kind: "upload"; uploadId: string }; const UPLOAD_ID_PATTERN = /^[a-f0-9]{16}$/; export function resolveArchivePath(source: RestoreSource): string { if (source.kind === "backup") return backupFilePath(source.slug, source.fileName); if (!UPLOAD_ID_PATTERN.test(source.uploadId)) throw new Error("Invalid upload reference."); return path.join(uploadsDir(), `${source.uploadId}.7z`); } export interface RestorePlan { manifest: BackupManifest; /** A stack with this slug already exists — restoring needs explicit "replace". */ slugExists: boolean; /** Another business already uses the backup's domain. */ domainConflict: string | null; schema: { ok: true } | { ok: false; reason: string }; } /** Fast: reads manifest.json from the archive. Does not verify the payload. */ export async function readArchiveManifest(archivePath: string): Promise { return parseManifest(await sevenZipReadMember(archivePath, BACKUP_MEMBERS.manifest)); } /** Full verification (`7zz t` over the whole archive) plus the manifest. Throws on a corrupt archive. */ export async function inspectArchive(archivePath: string): Promise { await sevenZipTest(archivePath); return readArchiveManifest(archivePath); } /** Read-only conflict/version report shown on the Restore page before the operator confirms. */ export async function planRestore(manifest: BackupManifest): Promise { const slugExists = await stackExists(manifest.slug); let domainConflict: string | null = null; try { await assertDomainAvailable(manifest.domain, manifest.slug); } catch (error) { domainConflict = error instanceof Error ? error.message : "Domain conflict."; } let schema: RestorePlan["schema"]; try { schema = checkMigrationCompatibility(manifest.lastMigration, await listImageMigrations()); } catch (error) { schema = { ok: false, reason: `Couldn't read the app image's migrations: ${error instanceof Error ? error.message : String(error)}` }; } return { manifest, slugExists, domainConflict, schema }; } export interface RestoreContext { log(line: string): void; step(stepId: string, fn: () => Promise): Promise; skip(stepId: string, detail?: string): void; } export interface RestoreResult { slug: string; domain: string; safetyBackup: string | null; } export async function performRestore(params: { archivePath: string; /** Operator confirmed overwriting an existing business with the same slug. */ replace: boolean; ctx: RestoreContext; }): Promise { const { archivePath, replace, ctx } = params; const { manifest, envContent } = await ctx.step("verify", async () => { ctx.log("Testing the archive (7zz t)…"); const parsed = await inspectArchive(archivePath); const env = await sevenZipReadMember(archivePath, BACKUP_MEMBERS.env); ctx.log(`Backup of "${parsed.slug}" (${parsed.domain}) taken ${parsed.createdAt}.`); // The .env inside the archive must agree with the manifest — the // password for the new database user comes from it. const envDb = readEnvValue(env, "DATABASE_NAME"); const envUser = readEnvValue(env, "DATABASE_USER"); const envPassword = readEnvValue(env, "DATABASE_PASSWORD"); if (envDb !== parsed.databaseName || envUser !== parsed.databaseUser || !envPassword) { throw new Error("The archive's .env doesn't match its manifest (database name/user/password) — refusing to restore it."); } return { manifest: parsed, envContent: env }; }); const slug = manifest.slug; const dir = stackDir(slug); const exists = await stackExists(slug); await ctx.step("check", async () => { if (exists && !replace) { throw new Error(`A business named "${slug}" already exists. Confirm "replace" to overwrite it (a safety backup is taken first).`); } await assertDomainAvailable(manifest.domain, slug); const compatibility = checkMigrationCompatibility(manifest.lastMigration, await listImageMigrations()); if (!compatibility.ok) throw new Error(compatibility.reason); ctx.log("No conflicts; the backup's schema is compatible with the running app image."); }); let safetyBackup: string | null = null; if (exists) { safetyBackup = await ctx.step("safety", async () => { ctx.log(`"${slug}" exists — taking a safety backup before replacing it…`); const result = await performBackup(slug, "safety", { log: ctx.log }); ctx.log(`Safety backup: ${result.fileName}`); return result.fileName; }); } else { ctx.skip("safety", "No existing business to protect."); } if (exists) { await ctx.step("stop", async () => { // -v: the old media volume goes too, so files deleted since the backup don't linger. await composeDown(dir, { removeVolumes: true }); }); } else { ctx.skip("stop", "Nothing running under this slug."); } await ctx.step("database", async () => { await recreateDatabaseAndUser(manifest.databaseName, manifest.databaseUser, readEnvValue(envContent, "DATABASE_PASSWORD")!); }); await ctx.step("import", async () => { ctx.log("Importing db.sql (streamed from the archive)…"); await runPipeline(sevenZipExtractToStdoutCommand(archivePath, BACKUP_MEMBERS.database), mysqlImportCommand(manifest.databaseName)); }); await ctx.step("config", async () => { await mkdir(dir, { recursive: true }); await writeFile(path.join(dir, ".env"), envContent, { encoding: "utf8", mode: 0o600 }); await writeFile(path.join(dir, "compose.yaml"), generateComposeYaml(slug), "utf8"); await writeCaddySiteAndReload(slug, manifest.domain); ctx.log("Wrote .env and compose.yaml, and reloaded Caddy."); }); await ctx.step("media", async () => { // Create (don't start) the container so the named volume exists with compose's labels before it's filled. await composeCreate(dir); await runPipeline(sevenZipExtractToStdoutCommand(archivePath, BACKUP_MEMBERS.media), mediaRestoreCommand(slug)); ctx.log("Media restored and handed to uid 1001."); }); await ctx.step("start", async () => { // The entrypoint runs `prisma migrate deploy`, so an older dump upgrades itself here. await composeUp(dir); }); await ctx.step("health", async () => { // Log only when the answer changes — polling every few seconds would otherwise bury the log tail. let lastSeen = ""; await waitForHealth(slug, { requireInstalled: true, onAttempt: (s) => { const seen = `HTTP ${s.httpStatus ?? "no response"}, installed=${String(s.installed)}`; if (seen !== lastSeen) ctx.log(`health: ${seen}`); lastSeen = seen; }, }); }); return { slug, domain: manifest.domain, safetyBackup }; }