6b45ed62bb
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.
147 lines
4.4 KiB
Go
147 lines
4.4 KiB
Go
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
|
|
}
|