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:
@@ -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"`
|
||||
|
||||
@@ -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 != ''`,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user