From 009232b95debc87c1c46727e6071f3fc342d6e8b Mon Sep 17 00:00:00 2001 From: AbdullahM07 Date: Fri, 14 Aug 2026 00:26:11 +0300 Subject: [PATCH] feat(backups): derive an app's backup policies from its catalog entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Installing a catalog app is one click. Protecting what it stores was the ten-field policy form, once per service — and 24 of the 28 bundled apps ship at least one service with a persistent volume, 33 stateful services in total. A fresh PostHog meant six hand-built policies before any of its data was covered, and nothing in the product said the data was uncovered in the meantime. Nothing derived a policy from what an app IS: the only two call sites that create a backup_policy row are the dashboard form's endpoint and the mail admin tab, both driven by an explicit user action. The installer created none (`grep -ci backup app-install.service.ts` was 0), and appTemplateSchema had no backup field for an entry to ask with. Two things already in the codebase make this derivable rather than 33 authored policies. A volume is the stateful signal — every entry already declares its volumes because compose needs them. And `payload_kind: "auto"` already resolves the right producer per service through the registry's detect() chain, so a derived plan defers the pg_dump-vs-volume decision to the same lookup the manual path uses instead of guessing from an image tag. So `planAppBackupDefaults` (pure, in packages/core) plans one policy per stateful service, staggered seven minutes apart off the 03:17 the dashboard already defaults to — six policies at the same minute would start six dumps on one box competing for the same disk, which is how a backup window becomes an outage. The new optional `backup` block on a template CORRECTS that plan rather than restating it: PostHog skips its broker tier (redis, kafka, zookeeper are mid-flight state, not sources of truth), and Supabase skips imgproxy, which mounts the same volume as `storage` and would otherwise upload identical bytes twice every night. `applyBackupDefaults` translates the plan through the EXISTING `createPolicy`, so cron validation, retention defaulting and syncPolicySchedule are inherited rather than reimplemented. Three properties it is built around: - it never throws. The install has already persisted the project, its services and its env by the time this runs, so a backup problem must not report a successful install as a failure. - it is idempotent. Any service that already has a policy is skipped, so the apply-defaults endpoint is safe to call twice and cannot clobber a hand-tuned schedule or collide with the unique index. - it will not invent a destination. destination_id is NOT NULL and local destinations are deliberately gated behind BACKUP_ALLOW_LOCAL_DESTINATION, so with no destination in the org it reports `no-destination` and does nothing rather than prising open a gate the operator left shut. Both callers land on that one function: the installer tail (opt out with `applyBackupDefaults: false`) and POST /projects/:id/backup-policies/ apply-defaults for projects installed before any of this existed. Related issue: #577 --- .../src/modules/apps/app-install.service.ts | 26 +++ apps/api/src/modules/apps/app.controller.ts | 4 + apps/api/src/modules/apps/app.schema.ts | 11 + .../modules/backups/apply-defaults.service.ts | 151 ++++++++++++ .../src/modules/backups/backup.controller.ts | 49 ++++ apps/api/src/modules/backups/backup.routes.ts | 1 + .../apps/app-install-backup-defaults.test.ts | 126 ++++++++++ .../modules/backups/apply-defaults.test.ts | 194 +++++++++++++++ packages/core/src/app-templates.ts | 38 +++ packages/core/src/apps/backup-defaults.ts | 127 ++++++++++ packages/core/src/apps/catalog.json | 28 +++ packages/core/src/apps/catalog/posthog.json | 19 ++ packages/core/src/apps/catalog/supabase.json | 9 + packages/core/src/apps/schema.ts | 69 ++++++ packages/core/src/audit-taxonomy.ts | 7 + packages/core/src/constants.ts | 15 ++ packages/core/src/index.ts | 1 + packages/core/test/backup-defaults.test.ts | 221 ++++++++++++++++++ 18 files changed, 1096 insertions(+) create mode 100644 apps/api/src/modules/backups/apply-defaults.service.ts create mode 100644 apps/api/test/modules/apps/app-install-backup-defaults.test.ts create mode 100644 apps/api/test/modules/backups/apply-defaults.test.ts create mode 100644 packages/core/src/apps/backup-defaults.ts create mode 100644 packages/core/test/backup-defaults.test.ts diff --git a/apps/api/src/modules/apps/app-install.service.ts b/apps/api/src/modules/apps/app-install.service.ts index abd5fd908..d7c2a314b 100644 --- a/apps/api/src/modules/apps/app-install.service.ts +++ b/apps/api/src/modules/apps/app-install.service.ts @@ -42,6 +42,7 @@ import { assertPlanAllowsServices } from "../../lib/plan-guard"; import { getTrustedHostCapacity } from "../../lib/host-capacity"; import { createProject } from "../projects/project-crud.service"; import { createService, updateService, setServiceEnvVars } from "../services/service.service"; +import { applyBackupDefaults } from "../backups/apply-defaults.service"; /** * Strong random value for generated secrets (Convex INSTANCE_SECRET, DB @@ -229,6 +230,12 @@ export interface InstallAppInput { /** Per-endpoint routing the operator CHOSE. A service with no entry gets no * public route — see planInstallRouting. */ routes?: InstallAppRoute[]; + /** Set the template's backup defaults up as part of this install (default: + * true, and a no-op when the org has no backup destination). `false` opts + * out — an install that wants no schedules attached to it. */ + applyBackupDefaults?: boolean; + /** Destination the derived policies point at. Omitted ⇒ the org's default. */ + backupDestinationId?: string; } interface PlannedEndpoint { @@ -671,6 +678,25 @@ export async function installApp( // to completion". await ensureGeneratedAppSecrets(project.id, template); + // Backups go last, and can never be fatal. By this point the project, its + // services and its env are all persisted — the install SUCCEEDED — so a + // destination that fails to resolve or a policy insert that trips must not + // turn that into an error the user sees as "install failed". It logs and the + // user can apply defaults later from the project's backup settings. + // + // `applyBackupDefaults` resolves service rows itself rather than borrowing the + // `idByName` map above: that map only exists when the template had env to + // write, and the endpoint caller has no map at all. + if (input.applyBackupDefaults !== false) { + try { + await applyBackupDefaults(ctx, project.id, template, { + destinationId: input.backupDestinationId, + }); + } catch (err) { + console.error("[app-install] backup defaults not applied", project.id, err); + } + } + return { kind: "template", projectId: project.id, slug: project.slug }; } diff --git a/apps/api/src/modules/apps/app.controller.ts b/apps/api/src/modules/apps/app.controller.ts index ad942cb8a..8119e00bb 100644 --- a/apps/api/src/modules/apps/app.controller.ts +++ b/apps/api/src/modules/apps/app.controller.ts @@ -99,6 +99,8 @@ export async function install(c: Context) { name?: string; config?: Record; routes?: InstallAppRoute[]; + applyBackupDefaults?: boolean; + backupDestinationId?: string; }; const body = await c.req.json().catch((): InstallBody => ({})); if (!body.templateId) { @@ -110,6 +112,8 @@ export async function install(c: Context) { name: body.name, config: body.config, routes: body.routes, + applyBackupDefaults: body.applyBackupDefaults, + backupDestinationId: body.backupDestinationId, }); return c.json({ data: result }); } catch (err) { diff --git a/apps/api/src/modules/apps/app.schema.ts b/apps/api/src/modules/apps/app.schema.ts index 48e96a5b8..15939afe8 100644 --- a/apps/api/src/modules/apps/app.schema.ts +++ b/apps/api/src/modules/apps/app.schema.ts @@ -49,6 +49,17 @@ export const InstallAppBody = Type.Object({ }, ), ), + applyBackupDefaults: Type.Optional( + Type.Boolean({ + description: + "Set up this app's backup defaults during install (default true). A no-op when the org has no backup destination; false attaches no schedules.", + }), + ), + backupDestinationId: Type.Optional( + Type.String({ + description: "Destination the derived backup policies write to. Omit to use the org's default destination.", + }), + ), }); /** POST /apps/custom — add a custom app from an uploaded JSON app definition. */ diff --git a/apps/api/src/modules/backups/apply-defaults.service.ts b/apps/api/src/modules/backups/apply-defaults.service.ts new file mode 100644 index 000000000..7de410bd5 --- /dev/null +++ b/apps/api/src/modules/backups/apply-defaults.service.ts @@ -0,0 +1,151 @@ +/** + * Applying an app template's backup defaults to a project — the one click that + * replaces "open Backup settings, fill the ten-field form, repeat per service". + * + * Two callers, one code path: the installer runs this at the tail of a template + * install, and `POST /projects/:id/backup-policies/apply-defaults` runs it for a + * project that was installed before any of this existed. Both land on the SAME + * `createPolicy` the dashboard form posts to, deliberately — that's where cron + * validation, retention defaulting and `syncPolicySchedule` already live, and a + * second insert path would be a second place for them to drift out of. + * + * Three properties this is built around: + * + * • **It never throws.** A missing destination, a service row that vanished, + * a policy that already exists — each is a reported outcome, not an error. + * An install must not fail because backups couldn't be arranged; the app is + * running and the user can arrange them later. + * • **It's idempotent.** Every service already carrying a policy is skipped, + * so pressing apply twice creates nothing the second time and can never + * collide with the unique index on (project, service). + * • **It won't invent a destination.** `destination_id` is NOT NULL, so a + * policy needs a real target, and `kind: local` destinations are gated off + * by default behind BACKUP_ALLOW_LOCAL_DESTINATION. Auto-provisioning one + * would prise open a gate an operator deliberately left shut, so with no + * destination in the org this reports `no-destination` and does nothing. + */ + +import { repos } from "@repo/db"; +import { planAppBackupDefaults, type AppTemplate } from "@repo/core"; +import { audit } from "../../lib/audit"; +import type { RequestContext } from "../../lib/request-context"; +import { createPolicy } from "./backup.service"; + +/** Why nothing (or not everything) was applied — surfaced to the caller as-is. */ +export type ApplyDefaultsReason = "no-destination" | "no-services" | "nothing-to-back-up"; + +export interface ApplyDefaultsResult { + /** Policies created by this call. */ + applied: number; + /** Planned services that already had a policy, or whose row is missing. */ + skipped: number; + /** Present only when `applied` is 0 and the plan couldn't be carried out. */ + reason?: ApplyDefaultsReason; + /** Service names that got a policy — for the audit trail and the response. */ + services: string[]; +} + +/** + * The destination new policies point at: the caller's explicit choice, else the + * org's default one, else the oldest one it has. + * + * Falling back past `isDefault` to "the only one you have" is deliberate: an org + * with a single destination and no default flag set has still expressed where + * its backups go, and refusing on a technicality would make the one click fail + * for the most common setup there is. + */ +async function resolveDestinationId( + organizationId: string, + explicit?: string, +): Promise { + if (explicit) { + const chosen = await repos.backupDestination.findById(explicit); + // Cross-org ids are treated as absent rather than as an error: this runs + // inside an install, and the caller doesn't get to reach into another tenant + // by passing an id, nor to fail someone's install by passing a bad one. + if (chosen && chosen.organizationId === organizationId) return chosen.id; + return null; + } + const all = await repos.backupDestination.listByOrganization(organizationId); + if (all.length === 0) return null; + return (all.find((d) => d.isDefault) ?? all[0]).id; +} + +/** + * Create the policies `template` implies for `projectId`, skipping services that + * already have one. + * + * `template` is the resolved catalog entry — the caller already has it (the + * installer is mid-install with it in hand; the endpoint looks it up from the + * project's app id), so this doesn't re-resolve the catalog. + */ +export async function applyBackupDefaults( + ctx: RequestContext, + projectId: string, + template: AppTemplate, + opts?: { destinationId?: string }, +): Promise { + const plan = planAppBackupDefaults(template); + if (plan.length === 0) { + return { applied: 0, skipped: 0, reason: "nothing-to-back-up", services: [] }; + } + + const destinationId = await resolveDestinationId(ctx.organizationId, opts?.destinationId); + if (!destinationId) { + return { applied: 0, skipped: plan.length, reason: "no-destination", services: [] }; + } + + const rows = await repos.service.listByProject(projectId); + const idByName = new Map(rows.map((s) => [s.name, s.id])); + + const created: string[] = []; + let skipped = 0; + + for (const planned of plan) { + const serviceId = idByName.get(planned.serviceName); + // The template named a service this project doesn't have — a partial install, + // or an entry edited after the project was created. Not our problem to fix. + if (!serviceId) { + skipped++; + continue; + } + + // The user's own policy always wins. This is what makes the retro-apply + // button safe to press on a project someone has already configured by hand. + const existing = await repos.backupPolicy.findServiceOverride(projectId, serviceId); + if (existing) { + skipped++; + continue; + } + + await createPolicy(ctx, { + projectId, + serviceId, + destinationId, + cronExpression: planned.cronExpression, + retainCount: planned.retainCount, + retainDays: planned.retainDays, + payloadKind: planned.payloadKind, + payloadConfig: planned.payloadConfig, + enabled: true, + }); + created.push(planned.serviceName); + } + + if (created.length > 0) { + // One event for the whole apply, not one per policy: the operator-visible + // action is "backups were set up for this project", and N rows in the audit + // log for one click reads as noise. + await audit.record( + { organizationId: ctx.organizationId, actorUserId: ctx.userId }, + { + eventType: "backup_policy.defaults_applied", + resourceType: "project", + resourceId: projectId, + after: { appId: template.id, destinationId, services: created }, + }, + ); + } + + return { applied: created.length, skipped, services: created }; +} diff --git a/apps/api/src/modules/backups/backup.controller.ts b/apps/api/src/modules/backups/backup.controller.ts index 6937421bd..7e484b950 100644 --- a/apps/api/src/modules/backups/backup.controller.ts +++ b/apps/api/src/modules/backups/backup.controller.ts @@ -16,6 +16,8 @@ import { backupRunBus } from "./backup.sse"; import { restoreRunBus } from "./restore.sse"; import { restoreOrchestrator } from "./restore.orchestrator"; import { safeErrorMessage } from "@repo/core"; +import { getTemplateForOrg } from "../apps/catalog-source"; +import { applyBackupDefaults } from "./apply-defaults.service"; import { createPolicy, deletePolicy, @@ -82,6 +84,53 @@ export async function createProjectPolicy(c: Context) { } } +/** + * POST /projects/:projectId/backup-policies/apply-defaults — set up this + * project's backups from its app template in one call. + * + * For projects installed before catalog-declared defaults existed, and for + * anyone who added a destination after installing. Idempotent by construction + * (`applyBackupDefaults` skips services that already have a policy), so this is + * safe to call repeatedly and can't clobber a hand-tuned schedule. + * + * Reports rather than fails: no destination, or a project that isn't a catalog + * app, both come back 200 with a `reason`. Nothing was wrong with the REQUEST, + * and a 4xx would push a caller toward retrying something that will keep being + * a no-op until the operator adds a destination. + */ +export async function applyProjectPolicyDefaults(c: Context) { + const ctx = getRequestContext(c); + const projectId = param(c, "projectId"); + await permission.assert(getRequestContext(c), { resourceType: "project", resourceId: projectId, action: "write" }); + const body = await c.req + .json<{ destinationId?: string }>() + .catch((): { destinationId?: string } => ({})); + + const project = await repos.project.findById(projectId); + try { + assertResourceInOrg(project, "Project", ctx.organizationId, projectId); + } catch (err) { + return c.json({ error: safeErrorMessage(err) }, 404); + } + + if (!project?.appTemplateId) { + return c.json({ data: { applied: 0, skipped: 0, services: [], reason: "not-an-app" } }); + } + const template = await getTemplateForOrg(ctx.organizationId, project.appTemplateId); + if (!template) { + return c.json({ data: { applied: 0, skipped: 0, services: [], reason: "unknown-app-template" } }); + } + + try { + const result = await applyBackupDefaults(ctx, projectId, template, { + destinationId: body.destinationId, + }); + return c.json({ data: result }); + } catch (err) { + return c.json({ error: safeErrorMessage(err) }, 400); + } +} + export async function patchPolicy(c: Context) { const ctx = getRequestContext(c); const policyId = param(c, "policyId"); diff --git a/apps/api/src/modules/backups/backup.routes.ts b/apps/api/src/modules/backups/backup.routes.ts index 727a05020..2a4ca42ad 100644 --- a/apps/api/src/modules/backups/backup.routes.ts +++ b/apps/api/src/modules/backups/backup.routes.ts @@ -29,6 +29,7 @@ r.use("/backup-restores/*", authMiddleware); // Policies — project-scoped routes proxy to the SaaS for cloud projects. r.get("/projects/:projectId/backup-policies", { tag: "project:write", ids: { project: "projectId" }, mcp: { description: "List a project's backup policies (schedules/retention)." } }, cloudProjectProxy, ctrl.listProjectPolicies); r.post("/projects/:projectId/backup-policies", { tag: "project:write", ids: { project: "projectId" } }, cloudProjectProxy, ctrl.createProjectPolicy); +r.post("/projects/:projectId/backup-policies/apply-defaults", { tag: "project:write", ids: { project: "projectId" }, mcp: { description: "Set a project's backups up from its app template's defaults, one policy per stateful service. Idempotent; skips services that already have a policy." } }, cloudProjectProxy, ctrl.applyProjectPolicyDefaults); r.patch("/backup-policies/:policyId", { tag: "backup_destination:backup_policy:write" }, ctrl.patchPolicy); r.delete("/backup-policies/:policyId", { tag: "backup_destination:backup_policy:write" }, ctrl.removePolicy); diff --git a/apps/api/test/modules/apps/app-install-backup-defaults.test.ts b/apps/api/test/modules/apps/app-install-backup-defaults.test.ts new file mode 100644 index 000000000..2ebcf5594 --- /dev/null +++ b/apps/api/test/modules/apps/app-install-backup-defaults.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +/** + * Installing a stateful app should leave it with backups attached — that is the + * feature. But the install is what the user is actually waiting on, so the + * wiring has a second requirement that matters just as much: a backup problem + * must never turn a successful install into a failed one. The project, its + * services and its env are already persisted by the time this runs. + */ + +const { + createProjectMock, + createServiceMock, + setEnvMock, + requireCloudMock, + draftMock, + applyDefaultsMock, +} = vi.hoisted(() => ({ + createProjectMock: vi.fn(), + createServiceMock: vi.fn(), + setEnvMock: vi.fn(), + requireCloudMock: vi.fn(), + draftMock: vi.fn(), + applyDefaultsMock: vi.fn(), +})); + +vi.mock("@repo/db", () => ({ + repos: { + project: { + findDraftByAppTemplate: draftMock, + // installApp runs `ensureGeneratedAppSecrets` just before the backup step; + // nothing here asserts on it, it only has to not throw. + getEnvMap: async () => ({}), + mergeEnvVars: async () => {}, + }, + service: { + listByProject: async () => [{ id: "svc-n8n", name: "n8n" }], + }, + customAppTemplate: { + findByAppId: async () => undefined, + listByOrg: async () => [], + }, + }, +})); + +vi.mock("../../../src/modules/projects/project-crud.service", () => ({ + createProject: createProjectMock, +})); + +vi.mock("../../../src/modules/services/service.service", () => ({ + createService: createServiceMock, + updateService: vi.fn(), + setServiceEnvVars: setEnvMock, +})); + +vi.mock("../../../src/lib/cloud/require-cloud", () => ({ + requireCloud: requireCloudMock, +})); + +vi.mock("../../../src/modules/backups/apply-defaults.service", () => ({ + applyBackupDefaults: applyDefaultsMock, +})); + +import { installApp } from "../../../src/modules/apps/app-install.service"; +import type { RequestContext } from "../../../src/lib/request-context"; + +const ctx = { organizationId: "org1", userId: "u1" } as RequestContext; + +beforeEach(() => { + vi.clearAllMocks(); + draftMock.mockResolvedValue(undefined); + createProjectMock.mockResolvedValue({ id: "p1", slug: "n8n", name: "n8n" }); + createServiceMock.mockResolvedValue({ id: "svc" }); + setEnvMock.mockResolvedValue(undefined); + requireCloudMock.mockResolvedValue(undefined); + applyDefaultsMock.mockResolvedValue({ applied: 1, skipped: 0, services: ["n8n"] }); + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("offline"))); +}); + +describe("app install — backup defaults", () => { + it("applies the template's defaults after the services exist", async () => { + const result = await installApp(ctx, { templateId: "n8n", name: "n8n" }); + + expect(result).toMatchObject({ kind: "template", projectId: "p1" }); + expect(applyDefaultsMock).toHaveBeenCalledTimes(1); + const [, projectId, template] = applyDefaultsMock.mock.calls[0]; + expect(projectId).toBe("p1"); + expect(template.id).toBe("n8n"); + // Ordering is load-bearing: the applier resolves service NAMES to row ids, + // so it can only run once the install has created them. + expect(createServiceMock).toHaveBeenCalled(); + }); + + it("passes an explicitly chosen destination through", async () => { + await installApp(ctx, { templateId: "n8n", backupDestinationId: "dst-1" }); + + expect(applyDefaultsMock.mock.calls[0][3]).toEqual({ destinationId: "dst-1" }); + }); + + it("opts out when the caller says applyBackupDefaults: false", async () => { + await installApp(ctx, { templateId: "n8n", applyBackupDefaults: false }); + + expect(applyDefaultsMock).not.toHaveBeenCalled(); + }); + + it("still succeeds when applying defaults throws", async () => { + // The whole reason this is wrapped: the app is installed and running. A + // backup failure here is worth logging, not worth telling the user their + // install failed and leaving them to guess what state the project is in. + applyDefaultsMock.mockRejectedValue(new Error("destination unreachable")); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + const result = await installApp(ctx, { templateId: "n8n" }); + + expect(result).toMatchObject({ kind: "template", projectId: "p1" }); + expect(errorSpy).toHaveBeenCalled(); + errorSpy.mockRestore(); + }); + + it("does not touch backups for a flow app", async () => { + const result = await installApp(ctx, { templateId: "mail" }); + + expect(result.kind).toBe("flow"); + expect(applyDefaultsMock).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/test/modules/backups/apply-defaults.test.ts b/apps/api/test/modules/backups/apply-defaults.test.ts new file mode 100644 index 000000000..253d8ba47 --- /dev/null +++ b/apps/api/test/modules/backups/apply-defaults.test.ts @@ -0,0 +1,194 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +/** + * `applyBackupDefaults` is the one click. Its whole value is that it can be + * called from an install (where it must never be able to fail the install) and + * from a button on an already-configured project (where it must never touch a + * policy the user tuned by hand). + * + * So the properties under test are the safety ones, not the happy path: it + * doesn't invent a destination, it doesn't double up on a service that already + * has a policy, and it doesn't reach into another org's destinations. + */ + +const { + createPolicyMock, + listDestinationsMock, + findDestinationMock, + findOverrideMock, + listServicesMock, + auditRecordMock, +} = vi.hoisted(() => ({ + createPolicyMock: vi.fn(), + listDestinationsMock: vi.fn(), + findDestinationMock: vi.fn(), + findOverrideMock: vi.fn(), + listServicesMock: vi.fn(), + auditRecordMock: vi.fn(), +})); + +vi.mock("@repo/db", () => ({ + repos: { + backupDestination: { + listByOrganization: listDestinationsMock, + findById: findDestinationMock, + }, + backupPolicy: { findServiceOverride: findOverrideMock }, + service: { listByProject: listServicesMock }, + }, +})); + +vi.mock("../../../src/lib/audit", () => ({ + audit: { record: auditRecordMock, recordAsync: auditRecordMock }, +})); + +vi.mock("../../../src/modules/backups/backup.service", () => ({ + createPolicy: createPolicyMock, +})); + +import { applyBackupDefaults } from "../../../src/modules/backups/apply-defaults.service"; +import type { AppTemplate } from "@repo/core"; +import type { RequestContext } from "../../../src/lib/request-context"; + +const ctx = { organizationId: "org1", userId: "u1" } as RequestContext; + +/** An app with one stateful service and one stateless one. */ +const template = { + id: "ghost", + name: "Ghost", + description: "", + kind: "template", + logo: "ghost", + category: "cms", + services: [ + { name: "db", image: "mysql:8.0", volumes: ["ghost_db:/var/lib/mysql"] }, + { name: "web", image: "ghost:5-alpine" }, + ], +} as AppTemplate; + +beforeEach(() => { + vi.clearAllMocks(); + createPolicyMock.mockResolvedValue({ id: "bkp_1" }); + findOverrideMock.mockResolvedValue(undefined); + listServicesMock.mockResolvedValue([ + { id: "svc-db", name: "db" }, + { id: "svc-web", name: "web" }, + ]); + listDestinationsMock.mockResolvedValue([ + { id: "dst-old", organizationId: "org1", isDefault: false }, + { id: "dst-default", organizationId: "org1", isDefault: true }, + ]); +}); + +describe("applyBackupDefaults", () => { + it("creates one policy per stateful service, on the org's default destination", async () => { + const result = await applyBackupDefaults(ctx, "proj1", template); + + expect(result).toMatchObject({ applied: 1, services: ["db"] }); + expect(createPolicyMock).toHaveBeenCalledTimes(1); + expect(createPolicyMock.mock.calls[0][1]).toMatchObject({ + projectId: "proj1", + serviceId: "svc-db", + destinationId: "dst-default", + payloadKind: "auto", + enabled: true, + }); + // Enabled WITH a schedule — an idle policy would look configured and back + // nothing up, which is the failure mode this whole feature exists to remove. + expect(createPolicyMock.mock.calls[0][1].cronExpression).toMatch(/^\d+ \d+ \* \* \*$/); + }); + + it("does nothing, and says why, when the org has no destination", async () => { + // `destination_id` is NOT NULL and local destinations are gated off by + // default, so there is nothing safe to fall back to. Reporting beats both + // throwing (fails an install) and silently succeeding (looks covered). + listDestinationsMock.mockResolvedValue([]); + + const result = await applyBackupDefaults(ctx, "proj1", template); + + expect(result).toEqual({ applied: 0, skipped: 1, reason: "no-destination", services: [] }); + expect(createPolicyMock).not.toHaveBeenCalled(); + expect(auditRecordMock).not.toHaveBeenCalled(); + }); + + it("is idempotent — a service that already has a policy is left alone", async () => { + findOverrideMock.mockResolvedValue({ id: "bkp_existing" }); + + const result = await applyBackupDefaults(ctx, "proj1", template); + + expect(result).toMatchObject({ applied: 0, skipped: 1 }); + expect(createPolicyMock).not.toHaveBeenCalled(); + }); + + it("skips a planned service whose row isn't there", async () => { + listServicesMock.mockResolvedValue([{ id: "svc-web", name: "web" }]); + + const result = await applyBackupDefaults(ctx, "proj1", template); + + expect(result).toMatchObject({ applied: 0, skipped: 1 }); + expect(createPolicyMock).not.toHaveBeenCalled(); + }); + + it("reports nothing-to-back-up for an app with no stateful services", async () => { + const stateless = { ...template, services: [{ name: "web", image: "nginx" }] } as AppTemplate; + + const result = await applyBackupDefaults(ctx, "proj1", stateless); + + expect(result).toMatchObject({ applied: 0, reason: "nothing-to-back-up" }); + expect(listDestinationsMock).not.toHaveBeenCalled(); + }); + + it("honours an explicit destination in the caller's own org", async () => { + findDestinationMock.mockResolvedValue({ id: "dst-x", organizationId: "org1" }); + + const result = await applyBackupDefaults(ctx, "proj1", template, { destinationId: "dst-x" }); + + expect(result.applied).toBe(1); + expect(createPolicyMock.mock.calls[0][1].destinationId).toBe("dst-x"); + // Explicit choice means the default-resolution list is never consulted. + expect(listDestinationsMock).not.toHaveBeenCalled(); + }); + + it("refuses a destination belonging to another org, without falling back", async () => { + // Falling back to this org's default would quietly do something the caller + // didn't ask for; treating it as absent keeps the blast radius at zero. + findDestinationMock.mockResolvedValue({ id: "dst-evil", organizationId: "org2" }); + + const result = await applyBackupDefaults(ctx, "proj1", template, { destinationId: "dst-evil" }); + + expect(result).toMatchObject({ applied: 0, reason: "no-destination" }); + expect(createPolicyMock).not.toHaveBeenCalled(); + }); + + it("takes the only destination when none is flagged default", async () => { + listDestinationsMock.mockResolvedValue([ + { id: "dst-only", organizationId: "org1", isDefault: false }, + ]); + + const result = await applyBackupDefaults(ctx, "proj1", template); + + expect(result.applied).toBe(1); + expect(createPolicyMock.mock.calls[0][1].destinationId).toBe("dst-only"); + }); + + it("records one audit event for the whole apply, not one per policy", async () => { + const twoStateful = { + ...template, + services: [ + { name: "db", image: "mysql:8.0", volumes: ["a:/var/lib/mysql"] }, + { name: "web", image: "ghost:5-alpine", volumes: ["b:/content"] }, + ], + } as AppTemplate; + + await applyBackupDefaults(ctx, "proj1", twoStateful); + + expect(createPolicyMock).toHaveBeenCalledTimes(2); + expect(auditRecordMock).toHaveBeenCalledTimes(1); + expect(auditRecordMock.mock.calls[0][1]).toMatchObject({ + eventType: "backup_policy.defaults_applied", + resourceType: "project", + resourceId: "proj1", + after: { appId: "ghost", destinationId: "dst-default", services: ["db", "web"] }, + }); + }); +}); diff --git a/packages/core/src/app-templates.ts b/packages/core/src/app-templates.ts index 790da9793..d40519d3a 100644 --- a/packages/core/src/app-templates.ts +++ b/packages/core/src/app-templates.ts @@ -452,6 +452,44 @@ export interface AppTemplate { provides?: readonly AppProvides[]; /** Connections this app needs from other projects (install-time auto-wire). */ requires?: readonly AppRequires[]; + /** Authored corrections to the backup defaults derived from this app's volumes + * (see apps/backup-defaults.ts). Omit and derivation stands on its own. */ + backup?: AppBackup; +} + +/** One service's authored backup default — see `AppBackup`. */ +export interface AppBackupServiceRule { + /** Service (docker alias) this rule is about. */ + service: string; + /** Don't back this service up at all, whatever derivation concluded. */ + skip?: boolean; + /** Why this rule exists — authoring rationale, not rendered anywhere yet. */ + reason?: string; + /** Producer to use. Omit ⇒ "auto" (registry detects it). */ + payloadKind?: + | "auto" + | "volume" + | "pg_dump" + | "mysql_dump" + | "redis_rdb" + | "mongo_dump" + | "custom_command"; + /** Producer-specific options, forwarded whole. */ + payloadConfig?: Record; + /** 5-field cron. Omit ⇒ the staggered default schedule. */ + cronExpression?: string; + /** Successful runs kept. Omit ⇒ DEFAULT_RETAIN_COUNT; null ⇒ unlimited. */ + retainCount?: number | null; + /** Age cap in days. Omit/null ⇒ none. */ + retainDays?: number | null; +} + +/** + * What an app wants backed up, by service. Optional: with no block at all, every + * service that declares a volume still gets a derived default. + */ +export interface AppBackup { + services: readonly AppBackupServiceRule[]; } /** A generated file bind-mounted into a service container (see AppTemplate.files). */ diff --git a/packages/core/src/apps/backup-defaults.ts b/packages/core/src/apps/backup-defaults.ts new file mode 100644 index 000000000..408176228 --- /dev/null +++ b/packages/core/src/apps/backup-defaults.ts @@ -0,0 +1,127 @@ +/** + * Turning an app template into the backup policies it should have on day one. + * + * The gap this closes: installing an app is one click, but until now protecting + * what it stores was the ten-field policy form, once per service — so a fresh + * PostHog (six services with volumes) meant six hand-built policies before any + * of its data was covered. Nothing derived a policy from what an app IS. + * + * Two decisions make that derivable rather than something 24 catalog entries + * have to spell out: + * + * • **A volume is the stateful signal.** Every entry already declares its + * volumes because they're what the compose file needs; a service with one + * holds state worth keeping and a service without one is rebuildable from + * its image. So the catalog needs no new field to say "back me up". + * • **"auto" already knows what to run.** `payload_kind: "auto"` resolves + * through the producer registry's `detect()` chain + * (packages/adapters/src/backup/registry.ts), which picks pg_dump for a + * Postgres service, mysql_dump for MySQL, mongo_dump, redis_rdb, and falls + * back to a volume copy. A derived plan therefore doesn't have to guess a + * producer per image — it defers, and the same lookup the manual path uses + * decides at run time. + * + * The template's optional `backup` block exists only to CORRECT this: skip a + * rebuildable cache volume, pin a producer derivation would get wrong, widen + * retention on the one service that matters. An app that agrees with derivation + * writes nothing. + * + * Everything here is pure — no DB, no clock, no I/O — so the interesting cases + * are unit-testable, and the apps/api applier stays a thin translation from this + * plan into the existing `createPolicy`. + */ + +import type { AppTemplate, AppBackupServiceRule } from "../app-templates"; +import { DEFAULT_BACKUP_HOUR, DEFAULT_BACKUP_MINUTE, DEFAULT_RETAIN_COUNT } from "../constants"; + +/** Minutes between two services' scheduled starts within the same app. */ +const STAGGER_MINUTES = 7; + +/** Wrap into the hour so a big app can't schedule at minute 61. */ +const MINUTES_PER_HOUR = 60; + +/** + * One policy to create, in the shape `createPolicy` wants. `serviceName` (not an + * id) because this is computed from the template, before service rows exist — + * the applier resolves names to ids. + */ +export interface PlannedBackupDefault { + serviceName: string; + /** Registry kind, or "auto" to let `detect()` choose. */ + payloadKind: string; + payloadConfig: Record; + cronExpression: string; + /** null = unlimited, deliberately (same distinction createPolicy draws). */ + retainCount: number | null; + retainDays: number | null; + /** True when a `backup` rule contributed anything — lets a caller tell an + * authored policy from a purely derived one when reporting what it did. */ + authored: boolean; +} + +/** + * The nightly cron for the Nth service of an app, staggered off the shared + * default time. + * + * Staggering matters more than it looks: a six-service app whose policies all + * said "03:17" would start six dumps in the same minute, on one box, each of + * them competing for the same disk and the same upload bandwidth — which is how + * a backup window turns into an outage. Seven-minute steps spread PostHog's six + * across 03:17–04:02 while keeping every one of them inside the quiet hours. + */ +export function staggeredCron(index: number): string { + const total = + DEFAULT_BACKUP_HOUR * MINUTES_PER_HOUR + DEFAULT_BACKUP_MINUTE + index * STAGGER_MINUTES; + const hour = Math.floor(total / MINUTES_PER_HOUR) % 24; + const minute = total % MINUTES_PER_HOUR; + return `${minute} ${hour} * * *`; +} + +/** Does this service hold state worth backing up? */ +function isStateful(service: { volumes?: readonly string[] }): boolean { + return (service.volumes ?? []).length > 0; +} + +/** + * The policies an app should come out of install with. + * + * Order is the template's own service order, so the stagger is stable for a + * given entry — reinstalling the same app produces the same schedule rather + * than shuffling it. + */ +export function planAppBackupDefaults(template: AppTemplate): PlannedBackupDefault[] { + const rules = new Map(); + for (const rule of template.backup?.services ?? []) rules.set(rule.service, rule); + + const services = template.services ?? []; + const plan: PlannedBackupDefault[] = []; + + for (const service of services) { + const rule = rules.get(service.name); + + // An explicit skip wins over everything, including a volume. + if (rule?.skip) continue; + + // In scope when it holds state, OR when the app explicitly asked for it — + // that second half is what lets an app back up a service whose data doesn't + // live in a declared volume (a dump piped out of a socket, say). + if (!isStateful(service) && !rule) continue; + + plan.push({ + serviceName: service.name, + payloadKind: rule?.payloadKind ?? "auto", + payloadConfig: rule?.payloadConfig ? { ...rule.payloadConfig } : {}, + // Stagger by POSITION IN THE PLAN, not by index in `services`: skipped and + // stateless services shouldn't burn a slot and leave gaps in the schedule. + cronExpression: rule?.cronExpression ?? staggeredCron(plan.length), + // `undefined` and `null` mean different things here and both are reachable: + // omitted ⇒ the shared default, explicit null ⇒ unlimited. + retainCount: + rule && "retainCount" in rule ? (rule.retainCount ?? null) : DEFAULT_RETAIN_COUNT, + retainDays: rule?.retainDays ?? null, + authored: !!rule, + }); + } + + return plan; +} diff --git a/packages/core/src/apps/catalog.json b/packages/core/src/apps/catalog.json index dfd9d17f9..532b0ea29 100644 --- a/packages/core/src/apps/catalog.json +++ b/packages/core/src/apps/catalog.json @@ -304,6 +304,15 @@ "restart": "unless-stopped" } ], + "backup": { + "services": [ + { + "service": "imgproxy", + "skip": true, + "reason": "Mounts the same supabase_storage_data volume as `storage`, so derivation would copy identical bytes twice under two policies. `storage` owns it." + } + ] + }, "configFields": [ { "key": "POSTGRES_PASSWORD", @@ -3409,6 +3418,25 @@ "restart": "unless-stopped" } ], + "backup": { + "services": [ + { + "service": "redis7", + "skip": true, + "reason": "Broker and cache, not a source of truth — PostHog rebuilds it. Backing up an RDB of in-flight queue state restores a snapshot of work already done or already lost." + }, + { + "service": "kafka", + "skip": true, + "reason": "An event log mid-flight. A point-in-time copy of broker data restores partially-consumed offsets, and the events that matter are already in ClickHouse." + }, + { + "service": "zookeeper", + "skip": true, + "reason": "Coordination metadata for the broker. Meaningless without a matching broker snapshot, and recreated on boot." + } + ] + }, "configFields": [ { "key": "POSTGRES_PASSWORD", diff --git a/packages/core/src/apps/catalog/posthog.json b/packages/core/src/apps/catalog/posthog.json index 3552cdd1a..bd55f04e1 100644 --- a/packages/core/src/apps/catalog/posthog.json +++ b/packages/core/src/apps/catalog/posthog.json @@ -691,6 +691,25 @@ "restart": "unless-stopped" } ], + "backup": { + "services": [ + { + "service": "redis7", + "skip": true, + "reason":"Broker and cache, not a source of truth — PostHog rebuilds it. Backing up an RDB of in-flight queue state restores a snapshot of work already done or already lost." + }, + { + "service": "kafka", + "skip": true, + "reason":"An event log mid-flight. A point-in-time copy of broker data restores partially-consumed offsets, and the events that matter are already in ClickHouse." + }, + { + "service": "zookeeper", + "skip": true, + "reason":"Coordination metadata for the broker. Meaningless without a matching broker snapshot, and recreated on boot." + } + ] + }, "configFields": [ { "key": "POSTGRES_PASSWORD", diff --git a/packages/core/src/apps/catalog/supabase.json b/packages/core/src/apps/catalog/supabase.json index 7aba37ff3..0e72faf3d 100644 --- a/packages/core/src/apps/catalog/supabase.json +++ b/packages/core/src/apps/catalog/supabase.json @@ -280,6 +280,15 @@ "restart": "unless-stopped" } ], + "backup": { + "services": [ + { + "service": "imgproxy", + "skip": true, + "reason": "Mounts the same supabase_storage_data volume as `storage`, so derivation would copy identical bytes twice under two policies. `storage` owns it." + } + ] + }, "configFields": [ { "key": "POSTGRES_PASSWORD", diff --git a/packages/core/src/apps/schema.ts b/packages/core/src/apps/schema.ts index 19d4c8975..49fa41cee 100644 --- a/packages/core/src/apps/schema.ts +++ b/packages/core/src/apps/schema.ts @@ -271,6 +271,71 @@ const management = z.union([ z.object({ kind: z.literal("custom"), href: z.string() }), ]); +/** + * Every payload kind the producer registry has an implementation for, plus the + * literal "auto" that asks it to detect one (`resolveProducerForService` in + * packages/adapters/src/backup/registry.ts walks `detect()` in registration + * order and falls back to "volume"). + * + * Enumerated rather than left as a free string because a payload kind decides + * what actually gets dumped — a typo in a catalog entry should be rejected at + * the ingest gate, not discovered at 03:17 when the producer lookup throws with + * nothing backed up. The cost is a real coupling: registering a NEW producer in + * packages/adapters means adding its kind here too, or the catalog can't name it. + * `payload_kind` in the DB stays a plain string, so nothing here needs a migration. + */ +const backupPayloadKind = z.enum([ + "auto", + "volume", + "pg_dump", + "mysql_dump", + "redis_rdb", + "mongo_dump", + "custom_command", +]); + +/** + * One service's authored backup default. Every field is optional because the + * point of this block is to CORRECT a derived default, not to restate it — a + * service that wants the derived behaviour needs no entry at all (see + * `planAppBackupDefaults` in ./backup-defaults.ts). + */ +const backupServiceRule = z.object({ + service: z.string(), + /** Derived defaults cover this service, but it shouldn't be backed up — a + * rebuildable cache, a scratch volume. Wins over every other field here. */ + skip: z.boolean().optional(), + /** + * Why this rule exists, for whoever reads the entry next. + * + * Declared rather than smuggled in as a `_comment` key because a `skip` with + * no stated reason is indistinguishable from an oversight, and the next + * contributor "fixes" it by deleting it. Authoring-facing today — no response + * or view renders it yet. + */ + reason: z.string().optional(), + /** Omitted ⇒ "auto" (let the registry detect the producer). */ + payloadKind: backupPayloadKind.optional(), + /** Producer-specific options, forwarded whole ({ command, exclude, ... }). */ + payloadConfig: z.record(z.string(), z.unknown()).optional(), + /** 5-field cron. Omitted ⇒ the staggered default schedule. */ + cronExpression: z.string().optional(), + /** Successful runs to keep. Omitted ⇒ `DEFAULT_RETAIN_COUNT`; explicit null ⇒ + * unlimited, the same distinction `createPolicy` draws. */ + retainCount: z.number().int().positive().nullable().optional(), + /** Age cap in days. Omitted/null ⇒ none. */ + retainDays: z.number().int().positive().nullable().optional(), +}); + +/** + * What this app wants backed up, by service. Optional and additive: an app that + * declares nothing still gets derived defaults from the volumes its services + * already declare, so this block exists for the cases derivation gets wrong. + */ +const backup = z.object({ + services: z.array(backupServiceRule), +}); + export const appTemplateSchema = z.object({ id: z.string(), name: z.string(), @@ -291,6 +356,7 @@ export const appTemplateSchema = z.object({ files: z.array(file).optional(), provides: z.array(provides).optional(), requires: z.array(requires).optional(), + backup: backup.optional(), available: z.boolean().optional(), // What the app needs from the machine, matched against the host's real capacity // before it installs (see `fitsCapacity`). Only what a host can be PROBED for — @@ -349,6 +415,9 @@ export const appTemplateSchema = z.object({ (data.settings ?? []).forEach((g, gi) => g.fields.forEach((f, fi) => refSvc(f.service, ["settings", gi, "fields", fi, "service"], "setting")), ); + (data.backup?.services ?? []).forEach((b, i) => + refSvc(b.service, ["backup", "services", i, "service"], "backup rule"), + ); const SOURCE_RE = /^(env:[^:]+:[^:]+|publicUrl:[^:]+(:\d+)?|template:.*)$/; const checkSource = (source: string, path: (string | number)[], where: string) => { diff --git a/packages/core/src/audit-taxonomy.ts b/packages/core/src/audit-taxonomy.ts index 011f3bb3b..fe6a03784 100644 --- a/packages/core/src/audit-taxonomy.ts +++ b/packages/core/src/audit-taxonomy.ts @@ -911,6 +911,13 @@ export const AUDIT_EVENTS: Record = { action: "triggered a backup of", label: "Backup triggered by webhook", }, + "backup_policy.defaults_applied": { + category: "system", + action: "set up backups for", + label: "Backup defaults applied", + description: + "One policy per stateful service, from the app template's defaults — at install, or from the project's apply-defaults action.", + }, "backup.webhook.disabled": { category: "system", action: "disabled the backup webhook for", diff --git a/packages/core/src/constants.ts b/packages/core/src/constants.ts index 45241bcb2..baa72e108 100644 --- a/packages/core/src/constants.ts +++ b/packages/core/src/constants.ts @@ -144,3 +144,18 @@ export const isMaskedValue = (value: unknown): boolean => value === ENV_MASK; * which is the point. Existing NULL rows were backfilled by migration 0096. */ export const DEFAULT_RETAIN_COUNT = 7; + +/** + * Wall-clock time a backup runs when nobody picked one — the base for every + * schedule derived from an app template (see `apps/backup-defaults.ts`). + * + * Same 03:17 the dashboard's schedule editor already offers as + * `DEFAULT_BACKUP_TIME` (apps/dashboard/src/lib/backup-schedule.ts), so a policy + * Openship derived and one a human clicked through the form land on the same + * hour rather than looking like two different products. The minute is off :00 + * and :30 deliberately: those are where every hand-written cron in the world + * piles up, and a dump is heavy enough that colliding with the rest of the box's + * nightly work is worth one minute of avoidance. + */ +export const DEFAULT_BACKUP_HOUR = 3; +export const DEFAULT_BACKUP_MINUTE = 17; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d4ade3111..ae85e3852 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -46,6 +46,7 @@ export { type AppTemplateRejection, } from "./apps/schema"; export * from "./apps/install-phases"; +export * from "./apps/backup-defaults"; export * from "./pricing"; export { pricingCatalogSchema, diff --git a/packages/core/test/backup-defaults.test.ts b/packages/core/test/backup-defaults.test.ts new file mode 100644 index 000000000..244da2043 --- /dev/null +++ b/packages/core/test/backup-defaults.test.ts @@ -0,0 +1,221 @@ +import { describe, it, expect } from "vitest"; +import { planAppBackupDefaults, staggeredCron } from "../src/apps/backup-defaults"; +import { DEFAULT_RETAIN_COUNT } from "../src/constants"; +import { APP_TEMPLATES } from "../src/app-templates"; +import type { AppTemplate } from "../src/app-templates"; + +/** + * The planner decides what a fresh install gets backed up, from data catalog + * entries already carry. Two failure modes are worth guarding: covering nothing + * (an app installs and its data is unprotected, the bug this feature exists to + * fix), and covering the wrong things (six dumps in one minute, or a cache + * volume shipped to S3 nightly for no reason). + */ + +const template = (overrides: Partial): AppTemplate => + ({ + id: "t", + name: "T", + description: "", + kind: "template", + logo: "t", + category: "other", + ...overrides, + }) as AppTemplate; + +describe("planAppBackupDefaults", () => { + it("covers every service that declares a volume", () => { + const plan = planAppBackupDefaults( + template({ + services: [ + { name: "db", image: "postgres:16", volumes: ["data:/var/lib/postgresql/data"] }, + { name: "cache", image: "redis:7", volumes: ["r:/data"] }, + ], + }), + ); + expect(plan.map((p) => p.serviceName)).toEqual(["db", "cache"]); + }); + + it("leaves stateless services alone", () => { + const plan = planAppBackupDefaults( + template({ services: [{ name: "web", image: "nginx", ports: ["80"] }] }), + ); + expect(plan).toEqual([]); + }); + + it("defers the producer choice to the registry rather than guessing from the image", () => { + // "auto" is the point: `resolveProducerForService` detects pg_dump for this + // service at run time. A planner that hardcoded "volume" here would copy the + // data directory of a running Postgres — a torn, possibly unrestorable copy. + const [policy] = planAppBackupDefaults( + template({ services: [{ name: "db", image: "postgres:16", volumes: ["d:/data"] }] }), + ); + expect(policy.payloadKind).toBe("auto"); + }); + + it("defaults retention to the shared constant, and treats explicit null as unlimited", () => { + const [derived] = planAppBackupDefaults( + template({ services: [{ name: "db", image: "postgres:16", volumes: ["d:/data"] }] }), + ); + expect(derived.retainCount).toBe(DEFAULT_RETAIN_COUNT); + + const [authored] = planAppBackupDefaults( + template({ + services: [{ name: "db", image: "postgres:16", volumes: ["d:/data"] }], + backup: { services: [{ service: "db", retainCount: null }] }, + }), + ); + expect(authored.retainCount).toBeNull(); + }); + + it("staggers schedules so one app can't start every dump in the same minute", () => { + const plan = planAppBackupDefaults( + template({ + services: Array.from({ length: 6 }, (_, i) => ({ + name: `s${i}`, + image: "x", + volumes: [`v${i}:/data`], + })), + }), + ); + const crons = plan.map((p) => p.cronExpression); + expect(new Set(crons).size).toBe(6); + // Still inside the quiet hours, not spilling into the working day. + for (const cron of crons) { + const hour = Number(cron.split(" ")[1]); + expect(hour).toBeGreaterThanOrEqual(3); + expect(hour).toBeLessThanOrEqual(4); + } + }); + + it("does not leave gaps in the stagger when a service is skipped", () => { + // Staggering by index-in-`services` rather than index-in-plan would leave the + // second policy on the third slot — harmless but arbitrary, and it makes the + // schedule depend on how many services were filtered out. + const plan = planAppBackupDefaults( + template({ + services: [ + { name: "a", image: "x", volumes: ["a:/d"] }, + { name: "skipped", image: "x", volumes: ["b:/d"] }, + { name: "c", image: "x", volumes: ["c:/d"] }, + ], + backup: { services: [{ service: "skipped", skip: true }] }, + }), + ); + expect(plan.map((p) => p.cronExpression)).toEqual([staggeredCron(0), staggeredCron(1)]); + }); + + it("honours skip, and an authored rule overrides every derived field", () => { + const plan = planAppBackupDefaults( + template({ + services: [ + { name: "cache", image: "redis:7", volumes: ["r:/data"] }, + { name: "db", image: "postgres:16", volumes: ["d:/data"] }, + ], + backup: { + services: [ + { service: "cache", skip: true, reason: "rebuildable" }, + { + service: "db", + payloadKind: "pg_dump", + cronExpression: "23 1 * * *", + retainCount: 30, + retainDays: 90, + payloadConfig: { exclude: ["audit"] }, + }, + ], + }, + }), + ); + expect(plan).toHaveLength(1); + expect(plan[0]).toMatchObject({ + serviceName: "db", + payloadKind: "pg_dump", + cronExpression: "23 1 * * *", + retainCount: 30, + retainDays: 90, + payloadConfig: { exclude: ["audit"] }, + authored: true, + }); + }); + + it("covers a service the app named even when it declares no volume", () => { + // The escape hatch for data that isn't in a declared volume — a dump piped + // out of the service rather than a directory copied off disk. + const plan = planAppBackupDefaults( + template({ + services: [{ name: "api", image: "x" }], + backup: { + services: [ + { + service: "api", + payloadKind: "custom_command", + payloadConfig: { command: "dump.sh" }, + }, + ], + }, + }), + ); + expect(plan.map((p) => p.serviceName)).toEqual(["api"]); + }); + + it("does not mutate the template's payloadConfig", () => { + const config = { exclude: ["x"] }; + const [policy] = planAppBackupDefaults( + template({ + services: [{ name: "db", image: "x", volumes: ["d:/data"] }], + backup: { services: [{ service: "db", payloadConfig: config }] }, + }), + ); + (policy.payloadConfig as Record).exclude = ["mutated"]; + expect(config.exclude).toEqual(["x"]); + }); +}); + +describe("the bundled catalog", () => { + /** + * The number that motivated the feature: most of the catalog is stateful, and + * before this planner every one of those services needed the ten-field policy + * form by hand. If a refactor ever makes this return nothing, installs go back + * to shipping unprotected data — silently. + */ + it("plans a policy for the apps that actually hold data", () => { + const planned = APP_TEMPLATES.filter((t) => planAppBackupDefaults(t).length > 0); + expect(planned.length).toBeGreaterThanOrEqual(20); + }); + + it("skips PostHog's broker tier and keeps its stores", () => { + const posthog = APP_TEMPLATES.find((t) => t.id === "posthog"); + expect(posthog).toBeDefined(); + const names = planAppBackupDefaults(posthog!).map((p) => p.serviceName); + expect(names).toContain("db"); + expect(names).toContain("clickhouse"); + expect(names).toContain("objectstorage"); + expect(names).not.toContain("redis"); + expect(names).not.toContain("kafka"); + expect(names).not.toContain("zookeeper"); + }); + + it("never plans two policies over one shared volume", () => { + // Supabase mounts supabase_storage_data into both `storage` and `imgproxy`; + // covering both would upload the same bytes twice, every night, forever. + for (const app of APP_TEMPLATES) { + const planned = new Set(planAppBackupDefaults(app).map((p) => p.serviceName)); + const seen = new Map(); + for (const service of app.services ?? []) { + if (!planned.has(service.name)) continue; + for (const volume of service.volumes ?? []) { + const named = volume.split(":")[0]; + // Bind mounts (host paths) aren't named volumes and can't collide this way. + if (named.startsWith("/") || named.startsWith(".")) continue; + const owner = seen.get(named); + expect( + owner, + `${app.id}: volume "${named}" is planned for both "${owner}" and "${service.name}"`, + ).toBeUndefined(); + seen.set(named, service.name); + } + } + } + }); +});