Skip to content
Merged
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
55 changes: 55 additions & 0 deletions internal/db/migrations/031_backups.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
-- 031_backups.sql — customer-facing Postgres backups + restore.
--
-- Adds two append-only tables that record each backup attempt (manual or
-- scheduled, taken by the worker) and each restore attempt. The worker
-- (sibling repo, /tmp/wt-customer-backups-worker) polls rows in status
-- 'pending', flips to 'running', performs pg_dump → S3 (or pg_restore from
-- S3), and writes the terminal status + size_bytes + error_summary.
--
-- The API only WRITES 'pending' rows (one per POST /backup or /restore)
-- and READS rows for the list endpoints. Status transitions and S3 keys
-- are owned by the worker.
--
-- backup_kind:
-- 'scheduled' — fired by the worker's daily backup job.
-- 'manual' — fired by a customer POST /api/v1/resources/:id/backup.
--
-- tier_at_backup snapshots the customer's plan tier at the time the backup
-- was taken so that retention enforcement (worker) can reason about a row
-- in isolation — e.g. a row taken while Pro stays for 30 days even after
-- the team downgrades. Mirrors resources.tier semantics.
--
-- Restores ALWAYS require an authenticated user (triggered_by NOT NULL)
-- — there is no anonymous restore path. Backups CAN have NULL triggered_by
-- when produced by the scheduled job (no human in the loop).

CREATE TABLE IF NOT EXISTS resource_backups (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
resource_id UUID NOT NULL REFERENCES resources(id) ON DELETE CASCADE,
status TEXT NOT NULL CHECK (status IN ('pending','running','ok','failed')) DEFAULT 'pending',
backup_kind TEXT NOT NULL CHECK (backup_kind IN ('scheduled','manual')),
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
finished_at TIMESTAMPTZ,
s3_key TEXT,
size_bytes BIGINT,
tier_at_backup TEXT,
error_summary TEXT,
triggered_by UUID REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_backups_resource ON resource_backups(resource_id);
CREATE INDEX IF NOT EXISTS idx_backups_pending ON resource_backups(status) WHERE status IN ('pending','running');

CREATE TABLE IF NOT EXISTS resource_restores (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
resource_id UUID NOT NULL REFERENCES resources(id) ON DELETE CASCADE,
backup_id UUID NOT NULL REFERENCES resource_backups(id),
status TEXT NOT NULL CHECK (status IN ('pending','running','ok','failed')) DEFAULT 'pending',
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
finished_at TIMESTAMPTZ,
error_summary TEXT,
triggered_by UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_restores_resource ON resource_restores(resource_id);
CREATE INDEX IF NOT EXISTS idx_restores_pending ON resource_restores(status) WHERE status IN ('pending','running');
32 changes: 32 additions & 0 deletions internal/handlers/agent_action.go
Original file line number Diff line number Diff line change
Expand Up @@ -324,3 +324,35 @@ const AgentActionPromoteTokenExpired = "Tell the user the approval link expired.

// AgentActionReadOnlySession — RequireWritable middleware returns this on 403 when JWT has read_only:true.
const AgentActionReadOnlySession = "Tell the user this is a read-only impersonated session. Mutations are disabled. Switch back to your real account at https://instanode.dev/app to make changes."

// ─────────────────────────────────────────────────────────────────────────────
// Backup / restore walls (migration 031)
// ─────────────────────────────────────────────────────────────────────────────

// AgentActionBackupRequiresClaim is returned when an anonymous (unclaimed)
// caller hits POST /api/v1/resources/:id/backup. Backups are a registered-
// account feature — there is no claim-free path. Names the gated feature
// and the full claim URL.
const AgentActionBackupRequiresClaim = "Tell the user backups require a claimed account. Have them claim their resources at https://instanode.dev/app/claim — takes 30 seconds, no card."

// newAgentActionBackupRateLimited builds the 429 copy returned when a team
// exceeds its manual_backups_per_day cap. Names the tier, the cap, and
// points hobby callers at the Pro upgrade (where the cap is 100/day).
func newAgentActionBackupRateLimited(tier string, perDay int) string {
return fmt.Sprintf(
"Tell the user they've hit the %s tier manual-backup cap (%d/day). Upgrade to Pro for 100/day at https://instanode.dev/pricing — Pro also includes self-serve restore.",
tier, perDay,
)
}

// AgentActionRestoreRequiresPro is returned when a hobby/free team hits
// POST /api/v1/resources/:id/restore. Restore is the Pro upgrade hook —
// Hobby can take backups but cannot restore from them without upgrading.
// Names the gated feature, the required tier, and the upgrade URL.
const AgentActionRestoreRequiresPro = "Tell the user self-serve restore requires Pro tier. Hobby keeps 7-day backups but cannot restore — have them upgrade at https://instanode.dev/pricing for 30-day retention + 1-click restore. Takes 30 seconds."

// AgentActionRestoreBackupNotReady is returned when POST /restore references
// a backup_id that exists but is not in status='ok' (still pending/running,
// or failed). The user must wait for the backup to finish (or pick a
// different one) before they can restore from it.
const AgentActionRestoreBackupNotReady = "Tell the user this backup is not ready to restore from yet. Have them check https://instanode.dev/app — pending/running backups need a few minutes, failed backups can never be restored."
4 changes: 4 additions & 0 deletions internal/handlers/agent_action_contract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,14 @@ func agentActionContractCases() map[string]string {
"AgentActionPauseRequiresPro": AgentActionPauseRequiresPro,
"AgentActionResourceAlreadyPaused": AgentActionResourceAlreadyPaused,
"AgentActionResourceNotPaused": AgentActionResourceNotPaused,
"AgentActionBackupRequiresClaim": AgentActionBackupRequiresClaim,
"AgentActionRestoreRequiresPro": AgentActionRestoreRequiresPro,
"AgentActionRestoreBackupNotReady": AgentActionRestoreBackupNotReady,

// Builders — representative inputs covering tier/env/role/limit
// interpolation.
"newAgentActionDeploymentLimitReached(hobby,1)": newAgentActionDeploymentLimitReached("hobby", 1),
"newAgentActionBackupRateLimited(hobby,1)": newAgentActionBackupRateLimited("hobby", 1),
"newAgentActionPromoteApprovalSent(prod,email)": newAgentActionPromoteApprovalSent("production", "owner@example.com"),
"newAgentActionStorageLimitReached(hobby,500)": newAgentActionStorageLimitReached("hobby", 500),
"newAgentActionVaultQuotaExceeded(hobby,50)": newAgentActionVaultQuotaExceeded("hobby", 50),
Expand Down
Loading