Files
tiny-forge/internal/volsnap/archive.go
T
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

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
}