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.
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
package volsnap
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWriteArchiveRoundTrip(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustWrite(t, filepath.Join(root, "a.txt"), "hello")
|
||||
if err := os.MkdirAll(filepath.Join(root, "sub"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWrite(t, filepath.Join(root, "sub", "b.txt"), "world")
|
||||
|
||||
dest := filepath.Join(t.TempDir(), "snap.tar.gz")
|
||||
refs := []VolumeRef{{Target: "/data", Scope: "project", Source: "data", HostPath: root}}
|
||||
|
||||
manifest, err := writeArchive(dest, refs)
|
||||
if err != nil {
|
||||
t.Fatalf("writeArchive: %v", err)
|
||||
}
|
||||
if len(manifest) != 1 || manifest[0].Index != 0 || manifest[0].Target != "/data" || manifest[0].Scope != "project" {
|
||||
t.Fatalf("unexpected manifest: %+v", manifest)
|
||||
}
|
||||
|
||||
entries := readArchive(t, dest)
|
||||
for _, want := range []string{"0/a.txt", "0/sub/b.txt", "manifest.json"} {
|
||||
if _, ok := entries[want]; !ok {
|
||||
keys := make([]string, 0, len(entries))
|
||||
for k := range entries {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
t.Fatalf("archive missing %q; got %v", want, keys)
|
||||
}
|
||||
}
|
||||
if got := entries["0/a.txt"]; got != "hello" {
|
||||
t.Errorf("0/a.txt = %q, want %q", got, "hello")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteArchiveRefusesExisting(t *testing.T) {
|
||||
dest := filepath.Join(t.TempDir(), "snap.tar.gz")
|
||||
mustWrite(t, dest, "existing")
|
||||
if _, err := writeArchive(dest, nil); err == nil {
|
||||
t.Fatal("expected error writing over an existing file (O_EXCL)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteArchiveSkipsSymlinks(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustWrite(t, filepath.Join(root, "real.txt"), "data")
|
||||
if err := os.Symlink(filepath.Join(root, "real.txt"), filepath.Join(root, "link.txt")); err != nil {
|
||||
t.Skipf("symlinks unavailable on this platform: %v", err)
|
||||
}
|
||||
|
||||
dest := filepath.Join(t.TempDir(), "snap.tar.gz")
|
||||
if _, err := writeArchive(dest, []VolumeRef{{Target: "/d", Scope: "project", HostPath: root}}); err != nil {
|
||||
t.Fatalf("writeArchive: %v", err)
|
||||
}
|
||||
entries := readArchive(t, dest)
|
||||
if _, ok := entries["0/link.txt"]; ok {
|
||||
t.Error("symlink should have been skipped, but it is in the archive")
|
||||
}
|
||||
if _, ok := entries["0/real.txt"]; !ok {
|
||||
t.Error("regular file should be archived")
|
||||
}
|
||||
}
|
||||
|
||||
func mustWrite(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// readArchive returns a map of regular-file entry name -> content. Directory
|
||||
// entries are recorded with an empty string so their presence can be asserted.
|
||||
func readArchive(t *testing.T, path string) map[string]string {
|
||||
t.Helper()
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
gz, err := gzip.NewReader(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer gz.Close()
|
||||
|
||||
out := map[string]string{}
|
||||
tr := tar.NewReader(gz)
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hdr.Typeflag == tar.TypeDir {
|
||||
out[hdr.Name] = ""
|
||||
continue
|
||||
}
|
||||
data, err := io.ReadAll(tr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out[hdr.Name] = string(data)
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user