Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4227ef8ba0 |
@@ -337,16 +337,15 @@ value where a list belongs or a word where a number belongs), is refused.
|
|||||||
path that is empty on purpose out of the config, or remove the older
|
path that is empty on purpose out of the config, or remove the older
|
||||||
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
|
and this backup finds none mounted there, the prune does not run either:
|
||||||
does not run either: every older snapshot is kept, and the command names
|
every older snapshot is kept, and the command names the directory and
|
||||||
the directory and exits non-zero, on every run until the volume is mounted
|
exits non-zero, on every run until the volume is mounted again. The same
|
||||||
again. The same happens when the volume is empty, as an automounted volume
|
happens when that directory is no longer backed up, because it is excluded
|
||||||
that could not be mounted looks, or when that directory is no longer
|
or its configured path was removed. If the volume was retired or the
|
||||||
backed up, because it is excluded or its configured path was removed. If
|
directory is no longer backed up on purpose, remove the older snapshots by
|
||||||
any of this is on purpose, as for a retired volume, remove the older
|
hand with `snapshot purge`. A file removed during the backup does not stop
|
||||||
snapshots by hand with `snapshot purge`. A file removed during the backup
|
the prune.
|
||||||
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
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ 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.
|
||||||
|
|
||||||
- 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
@@ -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 that a snapshot's backup found on a different filesystem from their parent directory (a different device number), as a mount point is, the configured path itself included.
|
||||||
|
|
||||||
**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
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ CREATE INDEX IF NOT EXISTS idx_snapshot_blobs_blob_id ON snapshot_blobs(blob_id)
|
|||||||
|
|
||||||
-- Snapshot mount points table: the directories at or below a configured
|
-- Snapshot mount points table: the directories at or below a configured
|
||||||
-- path that the snapshot's backup found on a different filesystem from
|
-- path that the snapshot's backup found on a different filesystem from
|
||||||
-- their parent directory, as a mount point is, and found an entry in
|
-- their parent directory, as a mount point is
|
||||||
CREATE TABLE IF NOT EXISTS snapshot_mount_points (
|
CREATE TABLE IF NOT EXISTS snapshot_mount_points (
|
||||||
snapshot_id TEXT NOT NULL,
|
snapshot_id TEXT NOT NULL,
|
||||||
path TEXT NOT NULL,
|
path TEXT NOT NULL,
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
@@ -16,9 +15,9 @@ 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, as a mount point is, the scanned path
|
||||||
// the scanned path included. A filesystem that gives no device numbers,
|
// included. A filesystem that gives no device numbers, as the in-memory
|
||||||
// as the in-memory one, records none. See https://git.eeqj.de/sneak/vaultik/issues/395.
|
// 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()
|
||||||
|
|
||||||
@@ -96,131 +95,3 @@ func TestScanRecordsMountPoints(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// automountFs is an in-memory filesystem in which opening mountPoint to
|
|
||||||
// list it mounts a volume there, if canMount, as a systemd automount
|
|
||||||
// does on first access. Stat records the device an entry is on at that
|
|
||||||
// moment, as a real stat does: the automounter's, 3, 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
|
|
||||||
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 {
|
|
||||||
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 = 3
|
|
||||||
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. Only the mount point is recorded, by the backup that mounts
|
|
||||||
// the volume and by a later one. An automount point whose volume cannot
|
|
||||||
// be mounted stays on the automounter's device and lists nothing; it is
|
|
||||||
// not recorded, so it does not count as the volume found mounted. 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
|
|
||||||
canMount bool
|
|
||||||
want []string
|
|
||||||
}{
|
|
||||||
{name: "volume that is mounted", canMount: true, want: []string{mountPoint}},
|
|
||||||
{name: "volume that cannot be mounted", 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, 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, afero.WriteFile(
|
|
||||||
fs, mountPoint+"/photos/a.jpg", []byte("photo"), 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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -310,7 +310,7 @@ func (s *Scanner) Scan(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
err = s.recordDirectories(ctx, scanResult)
|
err = s.recordDirectories(ctx, scanResult.Directories)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -888,10 +888,6 @@ 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
|
|
||||||
// their parent directory, the scanned path included, that the walk
|
|
||||||
// found an entry in.
|
|
||||||
MountPoints []string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// scanPhase performs the initial directory scan to identify files to process
|
// scanPhase performs the initial directory scan to identify files to process
|
||||||
@@ -911,7 +907,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{}
|
||||||
|
|
||||||
// Set up periodic status output
|
// Set up periodic status output
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
@@ -954,8 +950,6 @@ 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))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle symlinks and directories
|
// Handle symlinks and directories
|
||||||
@@ -992,7 +986,6 @@ 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(),
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1012,10 +1005,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 +1035,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 +1112,6 @@ 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
|
|
||||||
// its parent directory to whether the walk found an entry in it.
|
|
||||||
mountPoints map[string]bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// addMountPoint sets aside a directory found on a different device from
|
|
||||||
// its parent directory, to be recorded if the walk finds an entry in it.
|
|
||||||
func (c *scanCollector) addMountPoint(path string) {
|
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
|
|
||||||
c.mountPoints[path] = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// foundEntryIn notes that the walk found an entry in directory dir.
|
|
||||||
func (c *scanCollector) foundEntryIn(dir string) {
|
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
|
|
||||||
if _, ok := c.mountPoints[dir]; ok {
|
|
||||||
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
|
||||||
@@ -1360,23 +1307,29 @@ func (s *Scanner) buildDirectoryEntry(path string, info os.FileInfo) *database.F
|
|||||||
// mount points (see recordMountPoints). A directory has no data to back
|
// mount points (see recordMountPoints). A directory has no data to back
|
||||||
// up, so it is not among the files counted and backed up.
|
// up, so it is not among the files counted and backed up.
|
||||||
func (s *Scanner) recordDirectories(
|
func (s *Scanner) recordDirectories(
|
||||||
ctx context.Context, scanned *ScanPhaseResult,
|
ctx context.Context, directories []*FileToProcess,
|
||||||
) error {
|
) error {
|
||||||
for _, dir := range scanned.Directories {
|
for _, dir := range directories {
|
||||||
err := s.recordNonRegularFile(ctx, dir)
|
err := s.recordNonRegularFile(ctx, dir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("recording directory %s: %w", dir.Path, err)
|
return fmt.Errorf("recording directory %s: %w", dir.Path, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.recordMountPoints(ctx, scanned.MountPoints)
|
return s.recordMountPoints(ctx, directories)
|
||||||
}
|
}
|
||||||
|
|
||||||
// recordMountPoints records with the snapshot the mount points the walk
|
// recordMountPoints records with the snapshot each directory the walk
|
||||||
// found (see ScanPhaseResult.MountPoints).
|
// found on a different device from its parent directory, as a mount
|
||||||
|
// point is, the scanned path included.
|
||||||
func (s *Scanner) recordMountPoints(
|
func (s *Scanner) recordMountPoints(
|
||||||
ctx context.Context, mountPoints []string,
|
ctx context.Context, directories []*FileToProcess,
|
||||||
) error {
|
) error {
|
||||||
|
mountPoints, err := s.findMountPoints(directories)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
return s.repos.WithTx(ctx, func(txCtx context.Context, tx *sql.Tx) error {
|
return s.repos.WithTx(ctx, func(txCtx context.Context, tx *sql.Tx) error {
|
||||||
for _, dir := range mountPoints {
|
for _, dir := range mountPoints {
|
||||||
log.Info("Found a mount point", "path", dir)
|
log.Info("Found a mount point", "path", dir)
|
||||||
@@ -1391,32 +1344,57 @@ func (s *Scanner) recordMountPoints(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// onOtherDeviceThanParent reports whether the directory at path, which
|
// findMountPoints returns the directories, among those the walk found,
|
||||||
// info describes, is on a different device from its parent directory,
|
// that are on a different device from their parent directory. The
|
||||||
// looked up now. The walk calls it on reaching the directory, by when it
|
// scanned path is compared with the directory that holds it, which the
|
||||||
// has listed the parent (the scanned path aside). Listing a directory
|
// walk did not reach.
|
||||||
// mounts a volume mounted there on first access (a systemd automount),
|
func (s *Scanner) findMountPoints(
|
||||||
// so the walk's own entry for the parent, looked up before the listing,
|
directories []*FileToProcess,
|
||||||
// can show the automounter's device instead. A directory whose device
|
) ([]string, error) {
|
||||||
// number, or whose parent's, cannot be read counts as on its parent's
|
walked := make(map[string]os.FileInfo, len(directories))
|
||||||
// device: a mount point missed can only stop a prune, never let one run.
|
for _, dir := range directories {
|
||||||
func (s *Scanner) onOtherDeviceThanParent(path string, info os.FileInfo) bool {
|
walked[dir.Path] = dir.FileInfo
|
||||||
dirDevice, ok := s.deviceNumber(info)
|
}
|
||||||
|
|
||||||
|
var mountPoints []string
|
||||||
|
|
||||||
|
for _, dir := range directories {
|
||||||
|
parentPath := filepath.Dir(dir.Path)
|
||||||
|
|
||||||
|
parent, ok := walked[parentPath]
|
||||||
|
if !ok {
|
||||||
|
var err error
|
||||||
|
|
||||||
|
parent, err = s.fs.Stat(parentPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"looking up the parent of %s: %w", dir.Path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.onOtherDevice(dir.FileInfo, parent) {
|
||||||
|
mountPoints = append(mountPoints, dir.Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return mountPoints, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// onOtherDevice reports whether dir is on a different device from
|
||||||
|
// parent. A file whose device number cannot be read counts as on the
|
||||||
|
// same device as its parent.
|
||||||
|
func (s *Scanner) onOtherDevice(dir, parent os.FileInfo) bool {
|
||||||
|
dirDevice, ok := s.deviceNumber(dir)
|
||||||
if !ok {
|
if !ok {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
parent, err := s.fs.Stat(filepath.Dir(path))
|
parentDevice, ok := s.deviceNumber(parent)
|
||||||
if err != nil {
|
if !ok {
|
||||||
log.Warn("Could not look up the parent of a directory "+
|
|
||||||
"to check for a mount point", "path", path, "error", err)
|
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
parentDevice, ok := s.deviceNumber(parent)
|
return dirDevice != parentDevice
|
||||||
|
|
||||||
return ok && dirDevice != parentDevice
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// deviceNumberFromStat returns the number of the device that holds the
|
// deviceNumberFromStat returns the number of the device that holds the
|
||||||
|
|||||||
@@ -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, or the directory is no longer backed up.
|
||||||
// longer backed up. The snapshots are complete and kept, so the caller
|
// The snapshots are complete and kept, so the caller can report it apart
|
||||||
// can report it apart from a failed backup. A run that also skipped
|
// from a failed backup. A run that also skipped unreadable files or
|
||||||
// unreadable files or found a configured path empty returns
|
// found a configured path empty returns ErrFilesUnreadable or
|
||||||
// ErrFilesUnreadable or ErrSourcePathEmpty instead.
|
// 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: either the volume is not mounted or the directory "+
|
||||||
"directory is no longer backed up. Older snapshots are kept. "+
|
"is no longer backed up. Older snapshots are kept. Mount the "+
|
||||||
"Mount the volume again, or, if that is on purpose, remove the "+
|
"volume again, or, if the volume was retired or the directory "+
|
||||||
"older snapshots by hand with 'vaultik snapshot purge'.",
|
"is no longer backed up on purpose, remove the older snapshots "+
|
||||||
|
"by hand with 'vaultik snapshot purge'.",
|
||||||
strings.Join(unmountedPaths, ", "))
|
strings.Join(unmountedPaths, ", "))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -265,40 +265,11 @@ func TestBackupWithNoEmptySourcePathPrunes(t *testing.T) {
|
|||||||
// mounted below the configured path is not mounted does not prune: run
|
// mounted below the configured path is not mounted does not prune: run
|
||||||
// after run, the snapshot that holds the volume's files is kept with
|
// after run, the snapshot that holds the volume's files is kept with
|
||||||
// everything it stored, and the run fails. Once the volume is mounted
|
// everything it stored, and the run fails. Once the volume is mounted
|
||||||
// again, the prune runs. The volume's directory is left either as a
|
// again, the prune runs. See https://git.eeqj.de/sneak/vaultik/issues/395.
|
||||||
// plain directory or as an automount point whose volume cannot be
|
|
||||||
// mounted, which stays empty on the automounter's own device. 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 {
|
|
||||||
name string
|
|
||||||
unmountedDevice uint64
|
|
||||||
}{
|
|
||||||
{name: "plain directory", unmountedDevice: 1},
|
|
||||||
{name: "automount point that cannot be mounted", unmountedDevice: 3},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
backUpWithVolumeNotMounted(t, tt.unmountedDevice)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// backUpWithVolumeNotMounted runs the backups of
|
|
||||||
// TestBackupWithVolumeNotMountedDoesNotPrune, with the volume's directory
|
|
||||||
// on device unmountedDevice while the volume is not mounted.
|
|
||||||
func backUpWithVolumeNotMounted(t *testing.T, unmountedDevice uint64) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
// volume is the directory, below the configured path, where the
|
// volume is the directory, below the configured path, where the
|
||||||
// volume is mounted.
|
// volume is mounted.
|
||||||
const (
|
const (
|
||||||
@@ -318,14 +289,14 @@ func backUpWithVolumeNotMounted(t *testing.T, unmountedDevice uint64) {
|
|||||||
Prune: true, Snapshots: []string{snapshotName},
|
Prune: true, Snapshots: []string{snapshotName},
|
||||||
}
|
}
|
||||||
|
|
||||||
// volumeDevice is the device of the volume's directory, 2 while the
|
// While the volume is mounted, its directory is on a device of its
|
||||||
// volume is mounted. Everything else is on device 1.
|
// own.
|
||||||
volumeDevice := uint64(2)
|
mounted := true
|
||||||
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 {
|
||||||
params.DeviceNumber = func(info os.FileInfo) (uint64, bool) {
|
params.DeviceNumber = func(info os.FileInfo) (uint64, bool) {
|
||||||
if info.Name() == volume {
|
if mounted && info.Name() == volume {
|
||||||
return volumeDevice, true
|
return 2, true
|
||||||
}
|
}
|
||||||
|
|
||||||
return 1, true
|
return 1, true
|
||||||
@@ -341,12 +312,12 @@ func backUpWithVolumeNotMounted(t *testing.T, unmountedDevice uint64) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// A volume that is not mounted leaves its mount point behind as an
|
// A volume that is not mounted leaves its mount point behind as an
|
||||||
// empty directory.
|
// empty directory on its parent's device.
|
||||||
volumeDir := filepath.Join(env.src, volume)
|
volumeDir := filepath.Join(env.src, volume)
|
||||||
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
|
mounted = false
|
||||||
|
|
||||||
for range 2 {
|
for range 2 {
|
||||||
env.out.Reset()
|
env.out.Reset()
|
||||||
@@ -375,7 +346,7 @@ func backUpWithVolumeNotMounted(t *testing.T, unmountedDevice uint64) {
|
|||||||
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
|
mounted = true
|
||||||
|
|
||||||
require.NoError(t, env.v.CreateSnapshot(prune))
|
require.NoError(t, env.v.CreateSnapshot(prune))
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user