Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions apps/api/src/modules/apps/app-install.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 };
}

Expand Down
4 changes: 4 additions & 0 deletions apps/api/src/modules/apps/app.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ export async function install(c: Context) {
name?: string;
config?: Record<string, string>;
routes?: InstallAppRoute[];
applyBackupDefaults?: boolean;
backupDestinationId?: string;
};
const body = await c.req.json<InstallBody>().catch((): InstallBody => ({}));
if (!body.templateId) {
Expand All @@ -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) {
Expand Down
11 changes: 11 additions & 0 deletions apps/api/src/modules/apps/app.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
151 changes: 151 additions & 0 deletions apps/api/src/modules/backups/apply-defaults.service.ts
Original file line number Diff line number Diff line change
@@ -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<string | null> {
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<ApplyDefaultsResult> {
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 };
}
49 changes: 49 additions & 0 deletions apps/api/src/modules/backups/backup.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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");
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/modules/backups/backup.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
Loading