1 Commits
Author SHA1 Message Date
clawbot 5abacbc5c1 Do not prune when a backup misses an earlier mount point (closes #395)
release / release (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 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
2026-10-15 08:32:28 +02:00
7 changed files with 201 additions and 153 deletions
+10 -9
View File
@@ -338,15 +338,16 @@ value where a list belongs or a word where a number belongs), is refused.
snapshots by hand with `snapshot purge`. When an earlier snapshot of the snapshots by hand with `snapshot purge`. When an earlier snapshot of the
same name in the local index found a volume mounted at or below a same name in the local index found a volume mounted at or below a
configured path (a directory on a different filesystem from its parent, configured path (a directory on a different filesystem from its parent,
with something in it) and this backup finds none mounted there, the prune with an entry in it that the backup does not exclude) and this backup does
does not run either: every older snapshot is kept, and the command names not, the prune does not run either: every older snapshot is kept, and the
the directory and exits non-zero, on every run until the volume is mounted command names the directory and exits non-zero, on every run until the
again. The same happens when the volume is empty, as an automounted volume volume is mounted again. A backup also does not find the volume when it
that could not be mounted looks, or when that directory is no longer finds no such entry in its directory, as when an automounted volume cannot
backed up, because it is excluded or its configured path was removed. If be mounted, or when that directory is no longer backed up, because it is
any of this is on purpose, as for a retired volume, remove the older excluded or its configured path was removed. If any of this is on purpose,
snapshots by hand with `snapshot purge`. A file removed during the backup as for a retired volume, remove the older snapshots by hand with
does not stop the prune. `snapshot purge`. A file removed during the backup does not stop the
prune.
* `--keep-newer-than <duration>`: With `--prune`, keep snapshots newer than * `--keep-newer-than <duration>`: With `--prune`, keep snapshots newer than
this duration instead of only the latest (e.g. `4w`, `30d`, `6mo`, `1y`). this duration instead of only the latest (e.g. `4w`, `30d`, `6mo`, `1y`).
Without `--prune`, or with a value that is not a valid duration, the Without `--prune`, or with a value that is not a valid duration, the
+2 -1
View File
@@ -30,7 +30,8 @@ owner's; a `v*` tag runs the release build in
([issue #395](https://git.eeqj.de/sneak/vaultik/issues/395)). Such a ([issue #395](https://git.eeqj.de/sneak/vaultik/issues/395)). Such a
prune could delete every snapshot still holding the volume's files. prune could delete every snapshot still holding the volume's files.
Each snapshot now records in the local index the directories it found Each snapshot now records in the local index the directories it found
on a different filesystem from their parent, with something in them. on a different filesystem from their parent and found an entry in that
the backup does not exclude.
- 2026-10-14: Stopped `snapshot create --prune` pruning after a backup - 2026-10-14: Stopped `snapshot create --prune` pruning after a backup
that found nothing in a configured path that found nothing in a configured path
+2 -2
View File
@@ -154,7 +154,7 @@ Maps snapshots to the blobs they reference.
**Purpose:** Tracks blob dependencies for snapshots and enables manifest generation. **Purpose:** Tracks blob dependencies for snapshots and enables manifest generation.
### 10. `snapshot_mount_points` ### 10. `snapshot_mount_points`
The directories at or below a configured path, the configured path itself included, that a snapshot's backup found on a different filesystem from their parent directory (a different device number), as a mount point is, and found an entry in. An empty one, as an automount point whose volume could not be mounted, is left out. The directories at or below a configured path, the configured path itself included, that a snapshot's backup found an entry in and, once it had listed them, found on a different filesystem from their parent directory (a different device number), as a mount point is. An automount point whose volume cannot be mounted is not recorded, because the backup finds no entry in it.
**Columns:** **Columns:**
- `snapshot_id` (TEXT) - Snapshot ID (FK to snapshots.id) - `snapshot_id` (TEXT) - Snapshot ID (FK to snapshots.id)
@@ -183,7 +183,7 @@ Tracks blob upload metrics.
- `INSERT OR REPLACE INTO files` - Update file metadata - `INSERT OR REPLACE INTO files` - Update file metadata
- `SELECT * FROM files WHERE path = ?` - Check if file has changed - `SELECT * FROM files WHERE path = ?` - Check if file has changed
- `INSERT INTO snapshot_files` - Add file to current snapshot - `INSERT INTO snapshot_files` - Add file to current snapshot
- `INSERT OR IGNORE INTO snapshot_mount_points` - Record each directory on a different filesystem from its parent that holds an entry - `INSERT OR IGNORE INTO snapshot_mount_points` - Record each directory on a different filesystem from its parent that the walk found an entry in
2. **Chunking** (for changed files) 2. **Chunking** (for changed files)
- `INSERT OR IGNORE INTO chunks` - Store new chunks - `INSERT OR IGNORE INTO chunks` - Store new chunks
+46 -27
View File
@@ -6,6 +6,7 @@ import (
"path/filepath" "path/filepath"
"slices" "slices"
"strings" "strings"
"syscall"
"testing" "testing"
"github.com/spf13/afero" "github.com/spf13/afero"
@@ -16,9 +17,10 @@ import (
) )
// A scan records with the snapshot each directory on a different device // A scan records with the snapshot each directory on a different device
// from its parent directory that holds an entry, as a mount point is, // from its parent directory that the walk found an entry in, as a mount
// the scanned path included. A filesystem that gives no device numbers, // point is, the scanned path included. A filesystem that gives no device
// as the in-memory one, records none. See https://git.eeqj.de/sneak/vaultik/issues/395. // numbers, as the in-memory one, records none. See
// https://git.eeqj.de/sneak/vaultik/issues/395.
func TestScanRecordsMountPoints(t *testing.T) { func TestScanRecordsMountPoints(t *testing.T) {
t.Parallel() t.Parallel()
@@ -97,23 +99,29 @@ func TestScanRecordsMountPoints(t *testing.T) {
} }
} }
// automountFs is an in-memory filesystem in which opening mountPoint to // automountFs is an in-memory filesystem with a volume at mountPoint
// list it mounts a volume there, if canMount, as a systemd automount // that is mounted on first access: opening mountPoint to list it mounts
// does on first access. Stat records the device an entry is on at that // the volume or, if it cannot be mounted, fails with "no such file or
// moment, as a real stat does: the automounter's, 3, for mountPoint // directory", as with the autofs daemon. Stat records the device an
// until the volume is mounted; the volume's, 2, for mountPoint once it // entry is on at that moment, as a real stat does: unmountedDevice for
// is and for everything in it; 1 for everything else. // 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 { type automountFs struct {
afero.Fs afero.Fs
mountPoint string mountPoint string
unmountedDevice uint64
canMount bool canMount bool
mounted bool mounted bool
} }
//nolint:ireturn // afero.Fs.Open is defined to return the interface. //nolint:ireturn // afero.Fs.Open is defined to return the interface.
func (fs *automountFs) Open(name string) (afero.File, error) { func (fs *automountFs) Open(name string) (afero.File, error) {
if name == fs.mountPoint && fs.canMount { if name == fs.mountPoint && !fs.canMount {
return nil, &os.PathError{Op: "open", Path: name, Err: syscall.ENOENT}
}
if name == fs.mountPoint {
fs.mounted = true fs.mounted = true
} }
@@ -130,7 +138,7 @@ func (fs *automountFs) Stat(name string) (os.FileInfo, error) {
switch { switch {
case name == fs.mountPoint && !fs.mounted: case name == fs.mountPoint && !fs.mounted:
device = 3 device = fs.unmountedDevice
case name == fs.mountPoint || strings.HasPrefix(name, fs.mountPoint+"/"): case name == fs.mountPoint || strings.HasPrefix(name, fs.mountPoint+"/"):
device = 2 device = 2
} }
@@ -154,12 +162,13 @@ func deviceAtLookup(info os.FileInfo) (uint64, bool) {
} }
// A volume mounted on first access is mounted when the walk lists its // 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 // mount point. Until then the mount point is on the automounter's own
// device. Only the mount point is recorded, by the backup that mounts // device (a systemd automount) or on its parent's (an autofs map that
// the volume and by a later one. An automount point whose volume cannot // shows its mount points before mounting them). Either way only the
// be mounted stays on the automounter's device and lists nothing; it is // mount point is recorded, by the backup that mounts the volume and by a
// not recorded, so it does not count as the volume found mounted. See // later one that finds it mounted. Listing an automount point whose
// https://git.eeqj.de/sneak/vaultik/issues/395. // 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) { func TestScanRecordsOnlyTheMountPointOfAnAutomountedVolume(t *testing.T) {
t.Parallel() t.Parallel()
@@ -169,11 +178,22 @@ func TestScanRecordsOnlyTheMountPointOfAnAutomountedVolume(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
unmountedDevice uint64
canMount bool canMount bool
want []string want []string
}{ }{
{name: "volume that is mounted", canMount: true, want: []string{mountPoint}}, {
{name: "volume that cannot be mounted", canMount: false, want: nil}, 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 { for _, tt := range tests {
@@ -182,17 +202,16 @@ func TestScanRecordsOnlyTheMountPointOfAnAutomountedVolume(t *testing.T) {
ctx := context.Background() ctx := context.Background()
fs := &automountFs{ fs := &automountFs{
Fs: afero.NewMemMapFs(), mountPoint: mountPoint, canMount: tt.canMount, Fs: afero.NewMemMapFs(), mountPoint: mountPoint,
unmountedDevice: tt.unmountedDevice, canMount: tt.canMount,
} }
require.NoError(t, fs.MkdirAll(mountPoint, 0o755))
require.NoError(t, afero.WriteFile(
fs, scannedPath+"/notes.txt", []byte("notes"), 0o644))
if tt.canMount {
require.NoError(t, fs.MkdirAll(mountPoint+"/photos", 0o755)) require.NoError(t, fs.MkdirAll(mountPoint+"/photos", 0o755))
require.NoError(t, afero.WriteFile(
fs, mountPoint+"/photos/a.jpg", []byte("photo"), 0o644)) 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() db, err := database.NewTestDB()
+63 -64
View File
@@ -888,9 +888,10 @@ type ScanPhaseResult struct {
FilesToProcess []*FileToProcess FilesToProcess []*FileToProcess
UnchangedFileIDs []types.FileID // IDs of unchanged files to associate with snapshot UnchangedFileIDs []types.FileID // IDs of unchanged files to associate with snapshot
Directories []*FileToProcess // Every directory found, to record Directories []*FileToProcess // Every directory found, to record
// MountPoints are the directories found on a different device from // MountPoints are the directories, the scanned path included, that
// their parent directory, the scanned path included, that the walk // the walk found an entry in and, once it had listed them, on a
// found an entry in. // different device from their parent directory (see
// recordIfMountPoint).
MountPoints []string MountPoints []string
} }
@@ -911,7 +912,7 @@ func (s *Scanner) scanPhase(
// estimate leaves them out. // estimate leaves them out.
estimatedTotal := countNonDirectories(knownFiles) estimatedTotal := countNonDirectories(knownFiles)
collector := &scanCollector{mountPoints: make(map[string]bool)} collector := &scanCollector{dirsWithEntries: make(map[string]struct{})}
// Set up periodic status output // Set up periodic status output
startTime := time.Now() startTime := time.Now()
@@ -954,10 +955,10 @@ func (s *Scanner) scanPhase(
// The scanned directory itself is not an entry found in it. // The scanned directory itself is not an entry found in it.
if filePath != path || !info.IsDir() { if filePath != path || !info.IsDir() {
result.EntriesFound++ result.EntriesFound++
collector.foundEntryIn(filepath.Dir(filePath))
} }
s.recordIfMountPoint(filePath, path, collector)
// Handle symlinks and directories // Handle symlinks and directories
handled, err := s.recordSpecialEntry( handled, err := s.recordSpecialEntry(
filePath, info, existingFiles, knownFiles, collector, result) filePath, info, existingFiles, knownFiles, collector, result)
@@ -992,7 +993,7 @@ func (s *Scanner) scanPhase(
FilesToProcess: collector.filesToProcess, FilesToProcess: collector.filesToProcess,
UnchangedFileIDs: collector.unchangedFileIDs, UnchangedFileIDs: collector.unchangedFileIDs,
Directories: collector.directories, Directories: collector.directories,
MountPoints: collector.mountPointsWithEntries(), MountPoints: collector.mountPoints,
}, nil }, nil
} }
@@ -1012,10 +1013,8 @@ func countNonDirectories(files map[string]*database.File) int64 {
// recordSpecialEntry records symlinks and directories (which have no // recordSpecialEntry records symlinks and directories (which have no
// data to chunk) and reports whether it handled the entry. A symlink is // data to chunk) and reports whether it handled the entry. A symlink is
// counted and checked for changes as a regular file is; a directory is // counted and checked for changes as a regular file is; a directory is
// set aside to be recorded, also as a mount point when it is on a // set aside to be recorded and is not counted. For a symlink whose
// different device from its parent directory, and is not counted. For a // target cannot be read it returns handleWalkError's result.
// symlink whose target cannot be read it returns handleWalkError's
// result.
func (s *Scanner) recordSpecialEntry( func (s *Scanner) recordSpecialEntry(
filePath string, info os.FileInfo, filePath string, info os.FileInfo,
existingFiles map[string]struct{}, existingFiles map[string]struct{},
@@ -1044,10 +1043,6 @@ func (s *Scanner) recordSpecialEntry(
existingFiles[filePath] = struct{}{} existingFiles[filePath] = struct{}{}
collector.addDirectory(filePath, info, file) collector.addDirectory(filePath, info, file)
if s.onOtherDeviceThanParent(filePath, info) {
collector.addMountPoint(filePath)
}
return true, nil return true, nil
} }
@@ -1125,46 +1120,29 @@ type scanCollector struct {
unchangedFileIDs []types.FileID // Just IDs - no new records needed unchangedFileIDs []types.FileID // Just IDs - no new records needed
directories []*FileToProcess directories []*FileToProcess
filesScanned int64 filesScanned int64
// mountPoints maps each directory found on a different device from // dirsWithEntries holds each directory the walk has found an entry in.
// its parent directory to whether the walk found an entry in it. dirsWithEntries map[string]struct{}
mountPoints map[string]bool mountPoints []string
} }
// addMountPoint sets aside a directory found on a different device from // firstEntryIn notes that the walk found an entry in directory dir and
// its parent directory, to be recorded if the walk finds an entry in it. // reports whether it is the first one.
func (c *scanCollector) addMountPoint(path string) { func (c *scanCollector) firstEntryIn(dir string) bool {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
c.mountPoints[path] = false _, seen := c.dirsWithEntries[dir]
c.dirsWithEntries[dir] = struct{}{}
return !seen
} }
// foundEntryIn notes that the walk found an entry in directory dir. // addMountPoint sets a mount point aside to be recorded.
func (c *scanCollector) foundEntryIn(dir string) { func (c *scanCollector) addMountPoint(dir string) {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
if _, ok := c.mountPoints[dir]; ok { c.mountPoints = append(c.mountPoints, dir)
c.mountPoints[dir] = true
}
}
// mountPointsWithEntries returns the mount points the walk found an
// entry in. An empty one, as an automount point whose volume could not
// be mounted, does not show a volume mounted there.
func (c *scanCollector) mountPointsWithEntries() []string {
c.mu.Lock()
defer c.mu.Unlock()
var mountPoints []string
for dir, foundEntry := range c.mountPoints {
if foundEntry {
mountPoints = append(mountPoints, dir)
}
}
return mountPoints
} }
// addDirectory sets a directory aside to be recorded. It is not counted // addDirectory sets a directory aside to be recorded. It is not counted
@@ -1391,32 +1369,53 @@ func (s *Scanner) recordMountPoints(
}) })
} }
// onOtherDeviceThanParent reports whether the directory at path, which // recordIfMountPoint sets the directory that holds entry, a path the
// info describes, is on a different device from its parent directory, // walk found, aside to be recorded as a mount point if it is on a
// looked up now. The walk calls it on reaching the directory, by when it // different device from its parent directory. The walk calls it for each
// has listed the parent (the scanned path aside). Listing a directory // entry it finds, and it decides on the first entry in a directory, so a
// mounts a volume mounted there on first access (a systemd automount), // directory the walk found no entry in is never recorded. By then the
// so the walk's own entry for the parent, looked up before the listing, // walk has listed the directory, which mounts a volume mounted there on
// can show the automounter's device instead. A directory whose device // first access, so the directory and its parent are looked up again:
// number, or whose parent's, cannot be read counts as on its parent's // before the listing, the directory can be on the automounter's own
// device: a mount point missed can only stop a prune, never let one run. // device (a systemd automount) or on its parent's (an autofs map that
func (s *Scanner) onOtherDeviceThanParent(path string, info os.FileInfo) bool { // shows its mount points before mounting them). The scanned path is in
dirDevice, ok := s.deviceNumber(info) // no listing the walk made, so it is skipped as an entry. A directory
// that cannot be looked up then, or whose parent cannot, is not
// recorded, so this snapshot does not stop the prune of a later backup
// that misses its volume.
func (s *Scanner) recordIfMountPoint(
entry, scannedPath string, collector *scanCollector,
) {
dir := filepath.Dir(entry)
if entry == scannedPath || !collector.firstEntryIn(dir) {
return
}
dirDevice, ok := s.deviceNumberAt(dir)
if !ok { if !ok {
return false return
} }
parent, err := s.fs.Stat(filepath.Dir(path)) parentDevice, ok := s.deviceNumberAt(filepath.Dir(dir))
if ok && dirDevice != parentDevice {
collector.addMountPoint(dir)
}
}
// deviceNumberAt looks path up now and returns the number of the device
// that holds it, and false when the lookup fails (logged) or gives no
// device number.
func (s *Scanner) deviceNumberAt(path string) (uint64, bool) {
info, err := s.fs.Stat(path)
if err != nil { if err != nil {
log.Warn("Could not look up the parent of a directory "+ log.Warn("Could not look up a directory to check for a mount point",
"to check for a mount point", "path", path, "error", err) "path", path, "error", err)
return false return 0, false
} }
parentDevice, ok := s.deviceNumber(parent) return s.deviceNumber(info)
return ok && dirDevice != parentDevice
} }
// deviceNumberFromStat returns the number of the device that holds the // deviceNumberFromStat returns the number of the device that holds the
+10 -9
View File
@@ -56,11 +56,11 @@ var ErrSourcePathEmpty = errors.New("source path(s) were empty")
// ErrVolumeNotMounted is returned when --prune skipped the post-backup // ErrVolumeNotMounted is returned when --prune skipped the post-backup
// prune because a directory that an earlier snapshot of the same name // prune because a directory that an earlier snapshot of the same name
// found as a mount point was not found as one in this run: the volume // found as a mount point was not found as one in this run: the volume
// mounted there is not mounted or is empty, or the directory is no // mounted there is not mounted, the run found no entry in it, or the
// longer backed up. The snapshots are complete and kept, so the caller // directory is no longer backed up. The snapshots are complete and
// can report it apart from a failed backup. A run that also skipped // kept, so the caller can report it apart from a failed backup. A run
// unreadable files or found a configured path empty returns // that also skipped unreadable files or found a configured path empty
// ErrFilesUnreadable or ErrSourcePathEmpty instead. // returns ErrFilesUnreadable or ErrSourcePathEmpty instead.
var ErrVolumeNotMounted = errors.New( var ErrVolumeNotMounted = errors.New(
"mount point(s) an earlier snapshot recorded were not found") "mount point(s) an earlier snapshot recorded were not found")
@@ -237,10 +237,11 @@ func (v *Vaultik) skipPostBackupPrune(
if len(unmountedPaths) > 0 { if len(unmountedPaths) > 0 {
v.UI.Warningf("Skipped the post-backup prune because this backup "+ v.UI.Warningf("Skipped the post-backup prune because this backup "+
"did not find a mount point at %s, where an earlier snapshot "+ "did not find a mount point at %s, where an earlier snapshot "+
"found one: the volume is not mounted or is empty, or the "+ "found one: the volume is not mounted, this backup found no "+
"directory is no longer backed up. Older snapshots are kept. "+ "entry in it, or the directory is no longer backed up. Older "+
"Mount the volume again, or, if that is on purpose, remove the "+ "snapshots are kept. Mount the volume again, or, if that is on "+
"older snapshots by hand with 'vaultik snapshot purge'.", "purpose, remove the older snapshots by hand with "+
"'vaultik snapshot purge'.",
strings.Join(unmountedPaths, ", ")) strings.Join(unmountedPaths, ", "))
} }
+60 -33
View File
@@ -4,8 +4,10 @@ import (
"context" "context"
"os" "os"
"path/filepath" "path/filepath"
"syscall"
"testing" "testing"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/vaultik/internal/config" "sneak.berlin/go/vaultik/internal/config"
@@ -261,42 +263,67 @@ func TestBackupWithNoEmptySourcePathPrunes(t *testing.T) {
} }
} }
// automountFs is the real filesystem with a volume at volumeDir that is
// mounted on first access, as by an autofs map that shows its mount
// points before mounting them: listing volumeDir mounts the volume or,
// while cannotMount, fails with "no such file or directory".
type automountFs struct {
afero.OsFs
volumeDir string
cannotMount bool
mounted bool
}
//nolint:ireturn // afero.Fs.Open is defined to return the interface.
func (f *automountFs) Open(name string) (afero.File, error) {
if name == f.volumeDir && f.cannotMount {
return nil, &os.PathError{Op: "open", Path: name, Err: syscall.ENOENT}
}
if name == f.volumeDir {
f.mounted = true
}
return f.OsFs.Open(name)
}
// A backup under --prune in which a volume that an earlier snapshot found // A backup under --prune in which a volume that an earlier snapshot found
// mounted below the configured path is not mounted does not prune: run // mounted below the configured path is missing does not prune: run after
// after run, the snapshot that holds the volume's files is kept with // run, the snapshot that holds the volume's files is kept with everything
// everything it stored, and the run fails. Once the volume is mounted // it stored, and the run fails. Once the volume is back, the prune runs.
// again, the prune runs. The volume's directory is left either as a // The volume is mounted on first access and every backup starts with it
// plain directory or as an automount point whose volume cannot be // not mounted, so its directory is on its parent's device until the walk
// mounted, which stays empty on the automounter's own device. See // lists it. While the volume is missing, its directory is left empty, or
// https://git.eeqj.de/sneak/vaultik/issues/395. // listing it fails with "no such file or directory", as when an autofs
// volume cannot be mounted, which the walk counts as a directory removed
// during the backup. See https://git.eeqj.de/sneak/vaultik/issues/395.
func TestBackupWithVolumeNotMountedDoesNotPrune(t *testing.T) { func TestBackupWithVolumeNotMountedDoesNotPrune(t *testing.T) {
log.Initialize(log.Config{}) log.Initialize(log.Config{})
t.Parallel() t.Parallel()
// unmountedDevice is the device number of the volume's directory
// while the volume is not mounted. Its parent directory is on device
// 1, the mounted volume on device 2.
tests := []struct { tests := []struct {
name string name string
unmountedDevice uint64 listingFails bool
}{ }{
{name: "plain directory", unmountedDevice: 1}, {name: "directory left empty", listingFails: false},
{name: "automount point that cannot be mounted", unmountedDevice: 3}, {name: "listing fails", listingFails: true},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
t.Parallel() t.Parallel()
backUpWithVolumeNotMounted(t, tt.unmountedDevice) backUpWithVolumeNotMounted(t, tt.listingFails)
}) })
} }
} }
// backUpWithVolumeNotMounted runs the backups of // backUpWithVolumeNotMounted runs the backups of
// TestBackupWithVolumeNotMountedDoesNotPrune, with the volume's directory // TestBackupWithVolumeNotMountedDoesNotPrune. While the volume is
// on device unmountedDevice while the volume is not mounted. // missing, listing its directory fails if listingFails; otherwise the
func backUpWithVolumeNotMounted(t *testing.T, unmountedDevice uint64) { // directory is empty.
func backUpWithVolumeNotMounted(t *testing.T, listingFails bool) {
t.Helper() t.Helper()
// volume is the directory, below the configured path, where the // volume is the directory, below the configured path, where the
@@ -311,21 +338,22 @@ func backUpWithVolumeNotMounted(t *testing.T, unmountedDevice uint64) {
volume + "/a.txt": []byte("file a"), volume + "/a.txt": []byte("file a"),
"notes.txt": []byte("notes"), "notes.txt": []byte("notes"),
}) })
env.v.Config.Snapshots[snapshotName] = config.SnapshotConfig{ env.setSnapshot(snapshotName, "")
Paths: []string{env.src},
}
prune := &vaultik.SnapshotCreateOptions{ prune := &vaultik.SnapshotCreateOptions{
Prune: true, Snapshots: []string{snapshotName}, Prune: true, Snapshots: []string{snapshotName},
} }
// volumeDevice is the device of the volume's directory, 2 while the // The volume's directory is on the volume's device, 2, while the
// volume is mounted. Everything else is on device 1. // volume is mounted. Everything else is on device 1.
volumeDevice := uint64(2) volumeDir := filepath.Join(env.src, volume)
fs := &automountFs{volumeDir: volumeDir}
newScanner := env.v.ScannerFactory newScanner := env.v.ScannerFactory
env.v.ScannerFactory = func(params snapshot.ScannerParams) *snapshot.Scanner { env.v.ScannerFactory = func(params snapshot.ScannerParams) *snapshot.Scanner {
fs.mounted = false
params.Fs = fs
params.DeviceNumber = func(info os.FileInfo) (uint64, bool) { params.DeviceNumber = func(info os.FileInfo) (uint64, bool) {
if info.Name() == volume { if info.Name() == volume && fs.mounted {
return volumeDevice, true return 2, true
} }
return 1, true return 1, true
@@ -340,13 +368,13 @@ func backUpWithVolumeNotMounted(t *testing.T, unmountedDevice uint64) {
stored, err := env.v.Storage.List(ctx, "") stored, err := env.v.Storage.List(ctx, "")
require.NoError(t, err) require.NoError(t, err)
// A volume that is not mounted leaves its mount point behind as an // The volume goes missing.
// empty directory. fs.cannotMount = listingFails
volumeDir := filepath.Join(env.src, volume)
if !listingFails {
require.NoError(t, os.RemoveAll(volumeDir)) require.NoError(t, os.RemoveAll(volumeDir))
require.NoError(t, os.Mkdir(volumeDir, 0o750)) require.NoError(t, os.Mkdir(volumeDir, 0o750))
}
volumeDevice = unmountedDevice
for range 2 { for range 2 {
env.out.Reset() env.out.Reset()
@@ -371,12 +399,11 @@ func backUpWithVolumeNotMounted(t *testing.T, unmountedDevice uint64) {
require.NoError(t, err, "%s is kept", key) require.NoError(t, err, "%s is kept", key)
} }
// The volume is mounted again. // The volume is back.
fs.cannotMount = false
require.NoError(t, os.WriteFile( require.NoError(t, os.WriteFile(
filepath.Join(volumeDir, "a.txt"), []byte("file a"), 0o600)) filepath.Join(volumeDir, "a.txt"), []byte("file a"), 0o600))
volumeDevice = 2
require.NoError(t, env.v.CreateSnapshot(prune)) require.NoError(t, env.v.CreateSnapshot(prune))
snapshots, err = env.v.Repositories.Snapshots.ListRecent( snapshots, err = env.v.Repositories.Snapshots.ListRecent(