Files
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

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
}