Do not prune when a backup misses an earlier mount point (closes #395)
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
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 is on a different device from its parent directory. Under --prune, when an earlier snapshot of that name recorded such a directory and this run does not find it on a different device, the run skips the prune, keeps every older snapshot, names the directory and exits non-zero, as for an empty configured path. For a volume retired, or a directory no longer backed up, on purpose, the older snapshots are removed with snapshot purge. Model: opus-5-5
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
package snapshot_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"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, 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user