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.
141 lines
3.8 KiB
Go
141 lines
3.8 KiB
Go
package volsnap
|
|
|
|
import (
|
|
"archive/tar"
|
|
"compress/gzip"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
)
|
|
|
|
// writeArchive serializes the given host-bind volume directories into a
|
|
// gzip-compressed tar at dest. Each volume's files live under an integer
|
|
// subdirectory (its manifest Index); a manifest.json at the archive root makes
|
|
// the archive self-describing. Returns the manifest describing what was
|
|
// captured.
|
|
//
|
|
// Only regular files and directories are archived. Symlinks and special files
|
|
// (devices, sockets, fifos) are skipped — this keeps capture safe and avoids
|
|
// recording links whose targets would be meaningless or escape the volume on a
|
|
// later restore. A torn snapshot is possible if the app writes during capture;
|
|
// callers should surface that caveat.
|
|
func writeArchive(dest string, refs []VolumeRef) ([]SnapshotVolume, error) {
|
|
// O_EXCL: never clobber an existing file (filenames are unique per call).
|
|
f, err := os.OpenFile(dest, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create snapshot file: %w", err)
|
|
}
|
|
defer f.Close()
|
|
|
|
gz := gzip.NewWriter(f)
|
|
tw := tar.NewWriter(gz)
|
|
|
|
manifest := make([]SnapshotVolume, 0, len(refs))
|
|
for i, ref := range refs {
|
|
manifest = append(manifest, SnapshotVolume{Index: i, Target: ref.Target, Scope: ref.Scope, Source: ref.Source})
|
|
if err := addDir(tw, ref.HostPath, fmt.Sprintf("%d", i)); err != nil {
|
|
_ = tw.Close()
|
|
_ = gz.Close()
|
|
_ = f.Close()
|
|
os.Remove(dest)
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
if err := writeManifestEntry(tw, manifest); err != nil {
|
|
_ = tw.Close()
|
|
_ = gz.Close()
|
|
os.Remove(dest)
|
|
return nil, err
|
|
}
|
|
|
|
if err := tw.Close(); err != nil {
|
|
_ = gz.Close()
|
|
os.Remove(dest)
|
|
return nil, fmt.Errorf("finalize tar: %w", err)
|
|
}
|
|
if err := gz.Close(); err != nil {
|
|
os.Remove(dest)
|
|
return nil, fmt.Errorf("finalize gzip: %w", err)
|
|
}
|
|
if err := f.Close(); err != nil {
|
|
os.Remove(dest)
|
|
return nil, fmt.Errorf("close snapshot file: %w", err)
|
|
}
|
|
return manifest, nil
|
|
}
|
|
|
|
// addDir walks root and writes its regular files and directories into tw under
|
|
// the given archive prefix.
|
|
func addDir(tw *tar.Writer, root, prefix string) error {
|
|
return filepath.WalkDir(root, func(p string, d fs.DirEntry, walkErr error) error {
|
|
if walkErr != nil {
|
|
return fmt.Errorf("walk %s: %w", p, walkErr)
|
|
}
|
|
// Skip symlinks and special files; archive only dirs and regular files.
|
|
if d.Type()&fs.ModeSymlink != 0 {
|
|
return nil
|
|
}
|
|
if !d.IsDir() && !d.Type().IsRegular() {
|
|
return nil
|
|
}
|
|
|
|
rel, err := filepath.Rel(root, p)
|
|
if err != nil {
|
|
return fmt.Errorf("relativize %s: %w", p, err)
|
|
}
|
|
name := prefix
|
|
if rel != "." {
|
|
name = path.Join(prefix, filepath.ToSlash(rel))
|
|
}
|
|
|
|
info, err := d.Info()
|
|
if err != nil {
|
|
return fmt.Errorf("stat %s: %w", p, err)
|
|
}
|
|
hdr, err := tar.FileInfoHeader(info, "")
|
|
if err != nil {
|
|
return fmt.Errorf("tar header %s: %w", p, err)
|
|
}
|
|
hdr.Name = name
|
|
if d.IsDir() {
|
|
hdr.Name += "/"
|
|
}
|
|
if err := tw.WriteHeader(hdr); err != nil {
|
|
return fmt.Errorf("write tar header %s: %w", name, err)
|
|
}
|
|
if d.IsDir() {
|
|
return nil
|
|
}
|
|
|
|
src, err := os.Open(p)
|
|
if err != nil {
|
|
return fmt.Errorf("open %s: %w", p, err)
|
|
}
|
|
defer src.Close()
|
|
if _, err := io.Copy(tw, src); err != nil {
|
|
return fmt.Errorf("copy %s: %w", p, err)
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func writeManifestEntry(tw *tar.Writer, manifest []SnapshotVolume) error {
|
|
data, err := json.MarshalIndent(manifest, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("encode manifest: %w", err)
|
|
}
|
|
hdr := &tar.Header{Name: "manifest.json", Mode: 0o600, Size: int64(len(data)), Typeflag: tar.TypeReg}
|
|
if err := tw.WriteHeader(hdr); err != nil {
|
|
return fmt.Errorf("write manifest header: %w", err)
|
|
}
|
|
if _, err := tw.Write(data); err != nil {
|
|
return fmt.Errorf("write manifest: %w", err)
|
|
}
|
|
return nil
|
|
}
|