6b45ed62bb
Build / build (push) Successful in 10m59s
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.
118 lines
3.0 KiB
Go
118 lines
3.0 KiB
Go
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
|
|
}
|