check / check (push) Waiting to run
snapshot create --prune purged the older snapshots holding a volume's files when the volume, mounted below a configured path, was not mounted: its empty mount point read as deleted files. The scan now records in the local index each directory at or below a configured path that the walk found an entry in and that, once listed, is on a different device from its parent; listing mounts an automounted volume. Under --prune, when an earlier snapshot of that name recorded such a directory and this run does not, the run skips the prune, keeps every older snapshot and exits non-zero, naming the directory. For a volume retired on purpose, snapshot purge removes the older snapshots. Model: opus-5-5
246 lines
6.8 KiB
Go
246 lines
6.8 KiB
Go
package snapshot_test
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"syscall"
|
|
"testing"
|
|
|
|
"github.com/spf13/afero"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"sneak.berlin/go/vaultik/internal/database"
|
|
"sneak.berlin/go/vaultik/internal/snapshot"
|
|
)
|
|
|
|
// A scan records with the snapshot each directory on a different device
|
|
// from its parent directory that the walk found an entry in, as a mount
|
|
// point is, the scanned path included. A filesystem that gives no device
|
|
// numbers, as the in-memory one, records none. See
|
|
// https://git.eeqj.de/sneak/vaultik/issues/395.
|
|
func TestScanRecordsMountPoints(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const (
|
|
scannedPath = "/data/source"
|
|
snapshotID = "mount-points"
|
|
)
|
|
|
|
tests := []struct {
|
|
name string
|
|
// onVolume names the directories on the volume's device; every
|
|
// other directory is on another one. nil leaves the device
|
|
// numbers to the in-memory filesystem, which has none.
|
|
onVolume []string
|
|
want []string
|
|
}{
|
|
{
|
|
name: "volume below the scanned path",
|
|
onVolume: []string{"nas"},
|
|
want: []string{scannedPath + "/nas"},
|
|
},
|
|
{
|
|
name: "volume at the scanned path",
|
|
onVolume: []string{"source", "nas"},
|
|
want: []string{scannedPath},
|
|
},
|
|
{name: "no device numbers", want: nil},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := context.Background()
|
|
fs := afero.NewMemMapFs()
|
|
|
|
for _, path := range []string{
|
|
scannedPath + "/notes.txt", scannedPath + "/nas/a.txt",
|
|
} {
|
|
require.NoError(t, fs.MkdirAll(filepath.Dir(path), 0o755))
|
|
require.NoError(t, afero.WriteFile(fs, path, []byte(path), 0o644))
|
|
}
|
|
|
|
db, err := database.NewTestDB()
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
|
|
repos := database.NewRepositories(db)
|
|
createTestSnapshotRecord(ctx, t, repos, snapshotID)
|
|
|
|
cfg := snapshot.ScannerConfig{
|
|
FS: fs,
|
|
ChunkSize: int64(1024 * 16),
|
|
Repositories: repos,
|
|
MaxBlobSize: int64(1024 * 1024),
|
|
CompressionLevel: 3,
|
|
AgeRecipients: []string{testAgePublicKey},
|
|
}
|
|
if tt.onVolume != nil {
|
|
cfg.DeviceNumber = func(info os.FileInfo) (uint64, bool) {
|
|
if slices.Contains(tt.onVolume, info.Name()) {
|
|
return 2, true
|
|
}
|
|
|
|
return 1, true
|
|
}
|
|
}
|
|
|
|
_, err = snapshot.NewScanner(cfg).Scan(ctx, scannedPath, snapshotID)
|
|
require.NoError(t, err)
|
|
|
|
got, err := repos.Snapshots.GetMountPoints(ctx, snapshotID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, tt.want, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
// automountFs is an in-memory filesystem with a volume at mountPoint
|
|
// that is mounted on first access: opening mountPoint to list it mounts
|
|
// the volume or, if it cannot be mounted, fails with "no such file or
|
|
// directory", as with the autofs daemon. Stat records the device an
|
|
// entry is on at that moment, as a real stat does: unmountedDevice for
|
|
// mountPoint until the volume is mounted; the volume's, 2, for
|
|
// mountPoint once it is and for everything in it; 1 for everything else.
|
|
type automountFs struct {
|
|
afero.Fs
|
|
|
|
mountPoint string
|
|
unmountedDevice uint64
|
|
canMount bool
|
|
mounted bool
|
|
}
|
|
|
|
//nolint:ireturn // afero.Fs.Open is defined to return the interface.
|
|
func (fs *automountFs) Open(name string) (afero.File, error) {
|
|
if name == fs.mountPoint && !fs.canMount {
|
|
return nil, &os.PathError{Op: "open", Path: name, Err: syscall.ENOENT}
|
|
}
|
|
|
|
if name == fs.mountPoint {
|
|
fs.mounted = true
|
|
}
|
|
|
|
return fs.Fs.Open(name)
|
|
}
|
|
|
|
func (fs *automountFs) Stat(name string) (os.FileInfo, error) {
|
|
info, err := fs.Fs.Stat(name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
device := uint64(1)
|
|
|
|
switch {
|
|
case name == fs.mountPoint && !fs.mounted:
|
|
device = fs.unmountedDevice
|
|
case name == fs.mountPoint || strings.HasPrefix(name, fs.mountPoint+"/"):
|
|
device = 2
|
|
}
|
|
|
|
return statWithDevice{FileInfo: info, device: device}, nil
|
|
}
|
|
|
|
// statWithDevice is an entry automountFs.Stat looked up, with the device
|
|
// it was on then.
|
|
type statWithDevice struct {
|
|
os.FileInfo
|
|
|
|
device uint64
|
|
}
|
|
|
|
// deviceAtLookup returns the device automountFs.Stat recorded for info.
|
|
func deviceAtLookup(info os.FileInfo) (uint64, bool) {
|
|
stat, ok := info.(statWithDevice)
|
|
|
|
return stat.device, ok
|
|
}
|
|
|
|
// A volume mounted on first access is mounted when the walk lists its
|
|
// mount point. Until then the mount point is on the automounter's own
|
|
// device (a systemd automount) or on its parent's (an autofs map that
|
|
// shows its mount points before mounting them). Either way only the
|
|
// mount point is recorded, by the backup that mounts the volume and by a
|
|
// later one that finds it mounted. Listing an automount point whose
|
|
// volume cannot be mounted fails; it is not recorded, because the walk
|
|
// finds no entry in it. See https://git.eeqj.de/sneak/vaultik/issues/395.
|
|
func TestScanRecordsOnlyTheMountPointOfAnAutomountedVolume(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const scannedPath = "/data/source"
|
|
|
|
mountPoint := scannedPath + "/nas"
|
|
|
|
tests := []struct {
|
|
name string
|
|
unmountedDevice uint64
|
|
canMount bool
|
|
want []string
|
|
}{
|
|
{
|
|
name: "on the automounter's device until listed",
|
|
unmountedDevice: 3, canMount: true, want: []string{mountPoint},
|
|
},
|
|
{
|
|
name: "on its parent's device until listed",
|
|
unmountedDevice: 1, canMount: true, want: []string{mountPoint},
|
|
},
|
|
{
|
|
name: "volume that cannot be mounted",
|
|
unmountedDevice: 1, canMount: false, want: nil,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := context.Background()
|
|
fs := &automountFs{
|
|
Fs: afero.NewMemMapFs(), mountPoint: mountPoint,
|
|
unmountedDevice: tt.unmountedDevice, canMount: tt.canMount,
|
|
}
|
|
|
|
require.NoError(t, fs.MkdirAll(mountPoint+"/photos", 0o755))
|
|
|
|
for _, path := range []string{
|
|
scannedPath + "/notes.txt", mountPoint + "/photos/a.jpg",
|
|
} {
|
|
require.NoError(t, afero.WriteFile(fs, path, []byte(path), 0o644))
|
|
}
|
|
|
|
db, err := database.NewTestDB()
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
|
|
repos := database.NewRepositories(db)
|
|
|
|
for _, snapshotID := range []string{"first", "later"} {
|
|
createTestSnapshotRecord(ctx, t, repos, snapshotID)
|
|
|
|
scanner := snapshot.NewScanner(snapshot.ScannerConfig{
|
|
FS: fs,
|
|
ChunkSize: int64(1024 * 16),
|
|
Repositories: repos,
|
|
MaxBlobSize: int64(1024 * 1024),
|
|
CompressionLevel: 3,
|
|
AgeRecipients: []string{testAgePublicKey},
|
|
DeviceNumber: deviceAtLookup,
|
|
})
|
|
|
|
_, err = scanner.Scan(ctx, scannedPath, snapshotID)
|
|
require.NoError(t, err)
|
|
|
|
got, err := repos.Snapshots.GetMountPoints(ctx, snapshotID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, tt.want, got, "mount points of the %s backup", snapshotID)
|
|
}
|
|
})
|
|
}
|
|
}
|