// 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, perr := volumesByTarget(st, w) if perr != nil { return nil, nil, perr } 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 } // volumesByTarget merges a workload's source_config inline volumes with its // persisted workload_volumes rows (persisted wins on a target conflict), keyed // by container target path. It is the authoritative current volume set, shared // by capture enumeration (SnapshotableVolumes) and restore pre-flight so both // resolve host paths the same way — restore must never trust a snapshot's // persisted manifest to name a host directory. func volumesByTarget(st *store.Store, w store.Workload) (map[string]store.WorkloadVolume, error) { 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, err := st.ListWorkloadVolumes(w.ID) if err != nil { return nil, err } for _, p := range persisted { byTarget[p.Target] = store.WorkloadVolume{Source: p.Source, Target: p.Target, Scope: p.Scope, Name: p.Name} } return byTarget, 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" } }