1c47030854
Restore a captured volume snapshot onto an image workload's live host-bind
data volumes, then redeploy — the most destructive workload action, built to
the adversarially-reviewed design (C1–C6) with all data-loss guards.
- Engine.Restore (engine-owned): all-or-nothing pre-flight re-resolution from
the workload's CURRENT config (never the tamperable manifest), per-filesystem
disk pre-check, per-workload lock, container quiesce, extract-to-tmp, durable
pre-restore snapshot, write-ahead journal, atomic rename swap, redeploy, and
crash-recovery sweep (RecoverInterruptedRestores) wired before serving.
- internal/keyedmutex: shared per-key lock; deployer now serializes every
deploy entrypoint per workload via DispatchPlugin (+ LockWorkload/RedeployLocked
for the restore re-dispatch, no deadlock).
- Untrusted-archive extractor: zip-slip containment, type allow-list (reg/dir
only), decompression-bomb cap, manifest-index bounds.
- POST /api/workloads/{id}/snapshots/{sid}/restore: admin, X-Confirm-Restore
header (CSRF), per-workload single-flight (409).
- WebUI: Restore button + danger ConfirmDialog + busy state + i18n (en/ru).
Scope: image-source only; scopes absolute/stage/project (driven off the same
supportedScopes constant capture uses).
Plan-reviewed before coding; per-phase go/security/ts reviews; final review
READY TO MERGE. Security review caught + fixed a CRITICAL manifest-Source path
traversal (re-derive target from current config + base containment).
Plan: plans/volume-snapshot-restore/
161 lines
5.8 KiB
Go
161 lines
5.8 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, 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"
|
|
}
|
|
}
|