Files
tiny-forge/internal/store/volume_snapshots.go
T
alexei.dolgolyov 6b45ed62bb
Build / build (push) Successful in 10m59s
feat(snapshots): capture app data-volume snapshots
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.
2026-06-02 14:56:10 +03:00

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
}