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:
@@ -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