feat(snapshots): capture app data-volume snapshots
Build / build (push) Successful in 10m59s

Add per-workload capture of host-bind data volumes as downloadable tar.gz archives: a new internal/volsnap engine (enumerate host-bind volumes via the computeMounts merge, archive with archive/tar+gzip skipping symlinks/special files, per-workload retention + startup orphan cleanup), a volume_snapshots table + store CRUD, admin-gated API (list/snapshotable/create/download/delete), and a Snapshots panel on /apps/[id] that shows coverage and which volumes are skipped (and why). Scope: image-source apps, host-bind scopes (absolute/stage/project); Docker named volumes, tmpfs, and instance scope are surfaced as not-yet-supported. Restore is a separate later phase. Download/FilePath are containment-checked; create returns a typed no-data error (400) vs generic 500. Covered by archiver unit tests + full API e2e.
This commit is contained in:
2026-06-02 14:56:10 +03:00
parent 2ba49b9bb6
commit 6b45ed62bb
16 changed files with 1565 additions and 4 deletions
+19 -4
View File
@@ -91,6 +91,21 @@ type Backup struct {
CreatedAt string `json:"created_at"`
}
// VolumeSnapshot is one captured archive of a workload's host-bind data
// volumes. Unlike Backup (global, SQLite-specific) it is per-workload and the
// archive is a tar.gz of the resolved volume directories. Manifest is a
// JSON-encoded []SnapshotVolume describing what the archive covers, so a
// future restore can re-resolve each target even if volume settings drift.
type VolumeSnapshot struct {
ID string `json:"id"`
WorkloadID string `json:"workload_id"`
Label string `json:"label"`
Filename string `json:"filename"`
SizeBytes int64 `json:"size_bytes"`
Manifest string `json:"manifest"` // JSON []SnapshotVolume
CreatedAt string `json:"created_at"`
}
// DNSRecord tracks a DNS record managed by the application.
type DNSRecord struct {
ID string `json:"id"`
@@ -164,11 +179,11 @@ type WorkloadEnv struct {
// by image cfg.Env and workload_env).
type SharedSecret struct {
ID string `json:"id"`
Name string `json:"name"` // the env KEY
Value string `json:"value"` // ciphertext when Encrypted; never returned decrypted by the API
Name string `json:"name"` // the env KEY
Value string `json:"value"` // ciphertext when Encrypted; never returned decrypted by the API
Encrypted bool `json:"encrypted"`
Scope string `json:"scope"` // global | app
AppID string `json:"app_id"` // set when scope == app; "" for global
Scope string `json:"scope"` // global | app
AppID string `json:"app_id"` // set when scope == app; "" for global
Description string `json:"description"`
Enabled bool `json:"enabled"`
CreatedAt string `json:"created_at"`
+15
View File
@@ -284,6 +284,20 @@ func (s *Store) runMigrations() error {
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(workload_id, target)
)`,
// volume_snapshots: per-workload archives of host-bind data
// volumes (tar.gz). Mirrors the backups table shape but scoped to a
// workload and self-describing via the manifest column so a restore
// can re-resolve each target. ON DELETE CASCADE so deleting an app
// drops its snapshot rows (the files are pruned separately).
`CREATE TABLE IF NOT EXISTS volume_snapshots (
id TEXT PRIMARY KEY,
workload_id TEXT NOT NULL REFERENCES workloads(id) ON DELETE CASCADE,
label TEXT NOT NULL DEFAULT '',
filename TEXT NOT NULL,
size_bytes INTEGER NOT NULL DEFAULT 0,
manifest TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)`,
// triggers: first-class redeploy signal sources. Webhook secrets
// move from workload onto the trigger so one webhook URL can fan
// out to multiple workloads via workload_trigger_bindings.
@@ -493,6 +507,7 @@ func (s *Store) runMigrations() error {
`CREATE INDEX IF NOT EXISTS idx_containers_stage_id ON containers(stage_id) WHERE stage_id != ''`,
`CREATE INDEX IF NOT EXISTS idx_workload_env_workload ON workload_env(workload_id)`,
`CREATE INDEX IF NOT EXISTS idx_workload_volumes_workload ON workload_volumes(workload_id)`,
`CREATE INDEX IF NOT EXISTS idx_volume_snapshots_workload ON volume_snapshots(workload_id)`,
// Trigger-split indexes.
`CREATE INDEX IF NOT EXISTS idx_triggers_kind ON triggers(kind)`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_triggers_webhook_secret ON triggers(webhook_secret) WHERE webhook_secret != ''`,
+146
View File
@@ -0,0 +1,146 @@
package store
import (
"database/sql"
"errors"
"fmt"
"github.com/google/uuid"
)
// CreateVolumeSnapshot inserts a snapshot metadata record. ID is generated
// when empty; CreatedAt is stamped server-side.
func (s *Store) CreateVolumeSnapshot(v VolumeSnapshot) (VolumeSnapshot, error) {
if v.WorkloadID == "" || v.Filename == "" {
return VolumeSnapshot{}, fmt.Errorf("volume_snapshot: workload_id and filename are required")
}
if v.ID == "" {
v.ID = uuid.New().String()
}
if v.Manifest == "" {
v.Manifest = "[]"
}
v.CreatedAt = Now()
if _, err := s.db.Exec(
`INSERT INTO volume_snapshots (id, workload_id, label, filename, size_bytes, manifest, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
v.ID, v.WorkloadID, v.Label, v.Filename, v.SizeBytes, v.Manifest, v.CreatedAt,
); err != nil {
return VolumeSnapshot{}, fmt.Errorf("insert volume snapshot: %w", err)
}
return v, nil
}
// GetVolumeSnapshot returns one snapshot by ID.
func (s *Store) GetVolumeSnapshot(id string) (VolumeSnapshot, error) {
var v VolumeSnapshot
err := s.db.QueryRow(
`SELECT id, workload_id, label, filename, size_bytes, manifest, created_at
FROM volume_snapshots WHERE id = ?`, id,
).Scan(&v.ID, &v.WorkloadID, &v.Label, &v.Filename, &v.SizeBytes, &v.Manifest, &v.CreatedAt)
if errors.Is(err, sql.ErrNoRows) {
return VolumeSnapshot{}, fmt.Errorf("volume snapshot %s: %w", id, ErrNotFound)
}
if err != nil {
return VolumeSnapshot{}, fmt.Errorf("query volume snapshot: %w", err)
}
return v, nil
}
// ListVolumeSnapshots returns a workload's snapshots, newest first.
func (s *Store) ListVolumeSnapshots(workloadID string) ([]VolumeSnapshot, error) {
rows, err := s.db.Query(
`SELECT id, workload_id, label, filename, size_bytes, manifest, created_at
FROM volume_snapshots WHERE workload_id = ? ORDER BY created_at DESC`, workloadID,
)
if err != nil {
return nil, fmt.Errorf("query volume snapshots: %w", err)
}
defer rows.Close()
out := []VolumeSnapshot{}
for rows.Next() {
v, err := scanVolumeSnapshot(rows)
if err != nil {
return nil, err
}
out = append(out, v)
}
return out, rows.Err()
}
// DeleteVolumeSnapshot removes one snapshot row by ID.
func (s *Store) DeleteVolumeSnapshot(id string) error {
result, err := s.db.Exec(`DELETE FROM volume_snapshots WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("delete volume snapshot: %w", err)
}
if n, _ := result.RowsAffected(); n == 0 {
return fmt.Errorf("volume snapshot %s: %w", id, ErrNotFound)
}
return nil
}
// CountVolumeSnapshots returns how many snapshots a workload has.
func (s *Store) CountVolumeSnapshots(workloadID string) (int, error) {
var n int
if err := s.db.QueryRow(
`SELECT COUNT(*) FROM volume_snapshots WHERE workload_id = ?`, workloadID,
).Scan(&n); err != nil {
return 0, fmt.Errorf("count volume snapshots: %w", err)
}
return n, nil
}
// GetOldestVolumeSnapshots returns the N oldest snapshots for a workload, for
// retention pruning.
func (s *Store) GetOldestVolumeSnapshots(workloadID string, limit int) ([]VolumeSnapshot, error) {
rows, err := s.db.Query(
`SELECT id, workload_id, label, filename, size_bytes, manifest, created_at
FROM volume_snapshots WHERE workload_id = ? ORDER BY created_at ASC LIMIT ?`, workloadID, limit,
)
if err != nil {
return nil, fmt.Errorf("query oldest volume snapshots: %w", err)
}
defer rows.Close()
out := []VolumeSnapshot{}
for rows.Next() {
v, err := scanVolumeSnapshot(rows)
if err != nil {
return nil, err
}
out = append(out, v)
}
return out, rows.Err()
}
// AllVolumeSnapshotFilenames returns every snapshot archive filename across all
// workloads, for orphan-file reconciliation at startup.
func (s *Store) AllVolumeSnapshotFilenames() ([]string, error) {
rows, err := s.db.Query(`SELECT filename FROM volume_snapshots`)
if err != nil {
return nil, fmt.Errorf("query snapshot filenames: %w", err)
}
defer rows.Close()
out := []string{}
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, fmt.Errorf("scan snapshot filename: %w", err)
}
out = append(out, name)
}
return out, rows.Err()
}
func scanVolumeSnapshot(rows *sql.Rows) (VolumeSnapshot, error) {
var v VolumeSnapshot
if err := rows.Scan(&v.ID, &v.WorkloadID, &v.Label, &v.Filename,
&v.SizeBytes, &v.Manifest, &v.CreatedAt); err != nil {
return VolumeSnapshot{}, fmt.Errorf("scan volume snapshot: %w", err)
}
return v, nil
}