Files
tiny-forge/internal/volsnap/volumes.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
5.1 KiB
Go

// Package volsnap captures and manages per-workload snapshots of an app's
// host-bind data volumes. It is deliberately independent of internal/backup
// (which is SQLite-specific): a snapshot here is a tar.gz of the resolved
// volume directories, recorded in the volume_snapshots table.
//
// Phase 2a-i covers CAPTURE only (create/list/delete/download). The restore
// path — which overwrites live data and needs container quiesce + atomic swap
// — is intentionally a separate, later phase.
package volsnap
import (
"encoding/json"
"os"
"github.com/alexei/tinyforge/internal/store"
"github.com/alexei/tinyforge/internal/volume"
)
// supportedScopes are the host-bind volume scopes phase 2a-i can snapshot.
// Each resolves to a real host directory the running container binds. Excluded
// for now: instance (needs the deployed image tag to resolve a per-tag dir),
// named/project_named (Docker named volumes — need a docker-run-tar primitive),
// and ephemeral (tmpfs — no data to capture).
var supportedScopes = map[string]bool{
string(store.VolumeScopeAbsolute): true,
string(store.VolumeScopeStage): true,
string(store.VolumeScopeProject): true,
}
// SnapshotVolume is one volume covered by a snapshot. It is persisted in the
// snapshot row's manifest (JSON) and written into the archive so a future
// restore can re-resolve the target even if volume settings drift. Index names
// the archive subdirectory holding that volume's files.
type SnapshotVolume struct {
Index int `json:"index"`
Target string `json:"target"`
Scope string `json:"scope"`
Source string `json:"source"`
}
// VolumeRef is a resolved, on-disk host-bind volume eligible for snapshotting.
type VolumeRef struct {
Target string
Scope string
Source string
HostPath string
}
// SkippedVolume is a declared volume that cannot be snapshotted, with the
// reason surfaced to the UI so users are never misled into thinking data is
// captured when it is not.
type SkippedVolume struct {
Target string `json:"target"`
Scope string `json:"scope"`
Reason string `json:"reason"`
}
// scVolumes is the minimal shape parsed out of an image workload's
// source_config — just enough to learn its declared volumes without importing
// the image source package.
type scVolumes struct {
Volumes []struct {
Source string `json:"source"`
Target string `json:"target"`
Scope string `json:"scope"`
Name string `json:"name"`
} `json:"volumes"`
}
// SnapshotableVolumes enumerates a workload's data volumes and splits them into
// those that can be snapshotted now (resolved host-bind dirs that exist on
// disk) and those that are skipped (with a reason). It mirrors the image
// source's computeMounts merge: source_config volumes overlaid by persisted
// workload_volumes rows (persisted wins on a target conflict).
//
// Only image-source workloads declare host-bind data volumes today; for any
// other source kind both slices come back empty.
func SnapshotableVolumes(st *store.Store, w store.Workload, settings store.Settings) (refs []VolumeRef, skipped []SkippedVolume, err error) {
if w.SourceKind != "image" {
return nil, nil, nil
}
byTarget := map[string]store.WorkloadVolume{}
var cfg scVolumes
if w.SourceConfig != "" {
// Best-effort: a malformed config simply yields no inline volumes; the
// persisted rows below still apply.
_ = json.Unmarshal([]byte(w.SourceConfig), &cfg)
}
for _, v := range cfg.Volumes {
if v.Target == "" {
continue
}
byTarget[v.Target] = store.WorkloadVolume{Source: v.Source, Target: v.Target, Scope: v.Scope, Name: v.Name}
}
persisted, perr := st.ListWorkloadVolumes(w.ID)
if perr != nil {
return nil, nil, perr
}
for _, p := range persisted {
byTarget[p.Target] = store.WorkloadVolume{Source: p.Source, Target: p.Target, Scope: p.Scope, Name: p.Name}
}
params := volume.ResolveWorkloadParams{
BasePath: settings.BaseVolumePath,
WorkloadID: w.ID,
WorkloadName: w.Name,
AllowedVolumePaths: settings.AllowedVolumePaths,
}
for _, v := range byTarget {
if v.Target == "" {
continue
}
if !supportedScopes[v.Scope] {
skipped = append(skipped, SkippedVolume{Target: v.Target, Scope: v.Scope, Reason: skipReason(v.Scope)})
continue
}
hostPath, rerr := volume.ResolveWorkloadPath(v, params)
if rerr != nil {
skipped = append(skipped, SkippedVolume{Target: v.Target, Scope: v.Scope, Reason: rerr.Error()})
continue
}
info, serr := os.Stat(hostPath)
if serr != nil || !info.IsDir() {
skipped = append(skipped, SkippedVolume{Target: v.Target, Scope: v.Scope, Reason: "no data on disk yet"})
continue
}
refs = append(refs, VolumeRef{Target: v.Target, Scope: v.Scope, Source: v.Source, HostPath: hostPath})
}
return refs, skipped, nil
}
func skipReason(scope string) string {
switch scope {
case string(store.VolumeScopeInstance):
return "instance-scoped volumes are not yet snapshottable"
case string(store.VolumeScopeNamed), string(store.VolumeScopeProjectNamed):
return "Docker named volumes are not yet snapshottable"
case string(store.VolumeScopeEphemeral):
return "ephemeral (tmpfs) volumes hold no persistent data"
default:
return "unsupported volume scope"
}
}