Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c3f199b41 |
@@ -340,6 +340,8 @@ value where a list belongs or a word where a number belongs), is refused.
|
||||
does not exist, or when the local index holds snapshots and the
|
||||
destination has no `vaultik-destination.txt` (see
|
||||
[remote storage layout](#remote-storage-layout))
|
||||
* Fails, before anything is pruned, when a configured path is a symlink
|
||||
whose target does not exist, as one into a volume that is not mounted
|
||||
|
||||
**`snapshot list`**: Show every snapshot known to this host — the union
|
||||
of the local index and the backup destination store — with timestamps
|
||||
|
||||
@@ -28,7 +28,8 @@ owner's; a `v*` tag runs the release build in
|
||||
that found nothing in a configured path
|
||||
([issue #388](https://git.eeqj.de/sneak/vaultik/issues/388)), as in
|
||||
the mount point of a volume that is not mounted. Such a prune could
|
||||
delete every snapshot still holding that path's files.
|
||||
delete every snapshot still holding that path's files. A configured
|
||||
path that is a symlink into such a volume now fails the backup.
|
||||
|
||||
- 2026-10-14: Stopped `snapshot create --prune` pruning after a backup
|
||||
that skipped files it could not read
|
||||
|
||||
@@ -87,6 +87,36 @@ func TestEntrySnapshotCreateReportsPostBackupPruneFailure(t *testing.T) {
|
||||
"the new snapshot is on the store beside the planted one")
|
||||
}
|
||||
|
||||
// TestEntrySnapshotCreatePruneExitsNonZeroWhenSourceIsEmpty backs up,
|
||||
// empties the source directory, as a volume that is not mounted leaves
|
||||
// its mount point, and runs `snapshot create --prune`. The command exits
|
||||
// non-zero and reports the backup as completed, not as a failed snapshot
|
||||
// creation. See https://git.eeqj.de/sneak/vaultik/issues/388.
|
||||
//
|
||||
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
|
||||
func TestEntrySnapshotCreatePruneExitsNonZeroWhenSourceIsEmpty(t *testing.T) {
|
||||
configPath, storeDir := writeBackupConfig(t)
|
||||
|
||||
code, _, _ := runEntry(t, flagConfig, configPath, cmdSnapshot, cmdCreate)
|
||||
require.Equal(t, 0, code)
|
||||
|
||||
// writeBackupConfig puts the source directory beside the config file.
|
||||
require.NoError(t, os.Remove(
|
||||
filepath.Join(filepath.Dir(configPath), "source", "file.txt")))
|
||||
|
||||
code, _, stderr := runEntry(t,
|
||||
flagConfig, configPath, cmdSnapshot, cmdCreate, flagPrune)
|
||||
|
||||
assert.Equal(t, 1, code)
|
||||
assert.Contains(t, stderr,
|
||||
"Backup completed, but 1 source path(s) were empty")
|
||||
assert.NotContains(t, stderr, "Snapshot creation failed")
|
||||
|
||||
snapshots, err := os.ReadDir(filepath.Join(storeDir, "metadata"))
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, snapshots, 2, "the older snapshot is kept")
|
||||
}
|
||||
|
||||
// writeBackupConfig builds a hermetic config with one small file in its
|
||||
// source directory and an empty destination store directory. Returns the
|
||||
// config path and the store directory.
|
||||
|
||||
@@ -154,23 +154,8 @@ func (v *Vaultik) CreateSnapshot(opts *SnapshotCreateOptions) error {
|
||||
}
|
||||
|
||||
switch {
|
||||
case opts.Prune && filesUnreadable > 0:
|
||||
// An older snapshot may be the only one that still holds a file
|
||||
// this run could not read.
|
||||
log.Warn("Skipping post-backup prune",
|
||||
"files_unreadable", filesUnreadable)
|
||||
v.UI.Warningf("Skipped the post-backup prune because %d file(s) "+
|
||||
"could not be read; older snapshots are kept.", filesUnreadable)
|
||||
case opts.Prune && len(emptyPaths) > 0:
|
||||
// An older snapshot may be the only one that still holds what an
|
||||
// empty path held. The previous snapshot is no guide: after one
|
||||
// run like this it is empty too.
|
||||
log.Warn("Skipping post-backup prune", "empty_paths", emptyPaths)
|
||||
v.UI.Warningf("Skipped the post-backup prune because nothing was "+
|
||||
"found in %s; older snapshots are kept.",
|
||||
strings.Join(emptyPaths, ", "))
|
||||
|
||||
return fmt.Errorf("%d %w", len(emptyPaths), ErrSourcePathEmpty)
|
||||
case opts.Prune && (filesUnreadable > 0 || len(emptyPaths) > 0):
|
||||
return v.skipPostBackupPrune(filesUnreadable, emptyPaths)
|
||||
case opts.Prune:
|
||||
err = v.runPostBackupPrune(snapshotNames, opts.KeepNewerThan)
|
||||
if err != nil {
|
||||
@@ -209,6 +194,33 @@ func (v *Vaultik) createNamedSnapshots(
|
||||
return filesUnreadable, emptyPaths, nil
|
||||
}
|
||||
|
||||
// skipPostBackupPrune says why the post-backup prune does not run, naming
|
||||
// every cause, and returns the error the run ends with. An older snapshot
|
||||
// may be the only one that still holds a file this run could not read,
|
||||
// or what an empty path held. The previous snapshot is no guide: after
|
||||
// one run with an empty path it is empty too.
|
||||
func (v *Vaultik) skipPostBackupPrune(
|
||||
filesUnreadable int, emptyPaths []string,
|
||||
) error {
|
||||
log.Warn("Skipping post-backup prune",
|
||||
"files_unreadable", filesUnreadable, "empty_paths", emptyPaths)
|
||||
|
||||
if len(emptyPaths) > 0 {
|
||||
v.UI.Warningf("Skipped the post-backup prune because nothing was "+
|
||||
"found in %s; older snapshots are kept.",
|
||||
strings.Join(emptyPaths, ", "))
|
||||
}
|
||||
|
||||
if filesUnreadable > 0 {
|
||||
v.UI.Warningf("Skipped the post-backup prune because %d file(s) "+
|
||||
"could not be read; older snapshots are kept.", filesUnreadable)
|
||||
|
||||
return fmt.Errorf("%d %w", filesUnreadable, ErrFilesUnreadable)
|
||||
}
|
||||
|
||||
return fmt.Errorf("%d %w", len(emptyPaths), ErrSourcePathEmpty)
|
||||
}
|
||||
|
||||
// finishCreateSnapshot ends a run whose snapshots are all complete and
|
||||
// kept. Like finishRestore, it returns an error when --skip-errors
|
||||
// skipped anything it could not read or list; otherwise it prints the
|
||||
@@ -402,7 +414,8 @@ func (v *Vaultik) previousSnapshotID(snapName string) (string, error) {
|
||||
}
|
||||
|
||||
// resolveSnapshotPaths resolves source directories to absolute paths
|
||||
// with symlink resolution.
|
||||
// with symlink resolution. A path that is a symlink whose target does
|
||||
// not exist is an error.
|
||||
func (v *Vaultik) resolveSnapshotPaths(snapName string) ([]string, error) {
|
||||
snapConfig := v.Config.Snapshots[snapName]
|
||||
resolvedDirs := make([]string, 0, len(snapConfig.Paths))
|
||||
@@ -415,12 +428,18 @@ func (v *Vaultik) resolveSnapshotPaths(snapName string) ([]string, error) {
|
||||
|
||||
resolvedPath, err := filepath.EvalSymlinks(absPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
resolvedPath = absPath
|
||||
} else {
|
||||
// A path that does not exist is left for the scan to report.
|
||||
// One that exists is a symlink whose target is gone, as one
|
||||
// into a volume that is not mounted: the scan would record
|
||||
// only the link, and --prune would then drop the older
|
||||
// snapshots that hold the files.
|
||||
_, lstatErr := os.Lstat(absPath)
|
||||
if !os.IsNotExist(err) || lstatErr == nil {
|
||||
return nil, fmt.Errorf(
|
||||
"failed to resolve symlinks for %s: %w", absPath, err)
|
||||
}
|
||||
|
||||
resolvedPath = absPath
|
||||
}
|
||||
|
||||
resolvedDirs = append(resolvedDirs, resolvedPath)
|
||||
|
||||
@@ -120,6 +120,99 @@ func TestBackupWithEmptySourcePathDoesNotPrune(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A backup under --prune of a configured path that is a symlink whose
|
||||
// target is gone, as one into a volume that is not mounted, fails before
|
||||
// anything is pruned: the snapshot that holds the files is kept with
|
||||
// everything it stored. See https://git.eeqj.de/sneak/vaultik/issues/388.
|
||||
func TestBackupThroughSymlinkWhoseTargetIsGoneDoesNotPrune(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
const snapshotName = "photos"
|
||||
|
||||
ctx := context.Background()
|
||||
env := newDeletedCountEnv(t, map[string][]byte{
|
||||
"volume/a.jpg": []byte("photo a"),
|
||||
"volume/b.jpg": []byte("photo b"),
|
||||
})
|
||||
|
||||
target := filepath.Join(env.src, "volume")
|
||||
link := filepath.Join(env.src, "photos")
|
||||
require.NoError(t, os.Symlink(target, link))
|
||||
env.setSnapshot(snapshotName, "photos")
|
||||
env.backUp(t, snapshotName)
|
||||
|
||||
firstID := localSnapshotID(ctx, t, env.v.Repositories, snapshotName)
|
||||
stored, err := env.v.Storage.List(ctx, "")
|
||||
require.NoError(t, err)
|
||||
|
||||
// The volume is not mounted, so the symlink points at nothing.
|
||||
require.NoError(t, os.RemoveAll(target))
|
||||
|
||||
env.out.Reset()
|
||||
err = env.v.CreateSnapshot(&vaultik.SnapshotCreateOptions{
|
||||
Prune: true,
|
||||
Snapshots: []string{snapshotName},
|
||||
})
|
||||
require.ErrorIs(t, err, os.ErrNotExist)
|
||||
assert.Contains(t, err.Error(), link)
|
||||
assert.NotContains(t, env.out.String(), "Running post-backup prune.")
|
||||
|
||||
snapshots, err := env.v.Repositories.Snapshots.ListRecent(
|
||||
ctx, listRecentTestLimit)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, snapshots, 1)
|
||||
assert.Equal(t, firstID, snapshots[0].ID.String(),
|
||||
"the snapshot that holds the files is kept")
|
||||
|
||||
for _, key := range stored {
|
||||
_, err := env.v.Storage.Stat(ctx, key)
|
||||
require.NoError(t, err, "%s is kept", key)
|
||||
}
|
||||
}
|
||||
|
||||
// A backup under --skip-errors --prune that both skipped a file it could
|
||||
// not read and found nothing in a configured path names the empty path
|
||||
// too when it skips the prune. See
|
||||
// https://git.eeqj.de/sneak/vaultik/issues/388.
|
||||
func TestBackupWithEmptySourcePathAndUnreadableFileNamesBoth(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
const snapshotName = "media"
|
||||
|
||||
env := newDeletedCountEnv(t, map[string][]byte{
|
||||
"docs/notes.txt": []byte("notes"),
|
||||
})
|
||||
require.NoError(t, os.Mkdir(filepath.Join(env.src, "music"), 0o750))
|
||||
env.v.Config.Snapshots[snapshotName] = config.SnapshotConfig{
|
||||
Paths: []string{
|
||||
filepath.Join(env.src, "docs"),
|
||||
filepath.Join(env.src, "music"),
|
||||
},
|
||||
}
|
||||
|
||||
// A mode does not lock out root, who runs the tests, so opening the
|
||||
// file is made to fail instead.
|
||||
env.v.Fs = &openFailFs{
|
||||
path: filepath.Join(env.src, "docs", "notes.txt"),
|
||||
err: os.ErrPermission,
|
||||
}
|
||||
|
||||
err := env.v.CreateSnapshot(&vaultik.SnapshotCreateOptions{
|
||||
Prune: true,
|
||||
SkipErrors: true,
|
||||
Snapshots: []string{snapshotName},
|
||||
})
|
||||
require.ErrorIs(t, err, vaultik.ErrFilesUnreadable)
|
||||
assert.Contains(t, env.out.String(),
|
||||
"Skipped the post-backup prune because 1 file(s) could not be read")
|
||||
assert.Contains(t, env.out.String(),
|
||||
"Skipped the post-backup prune because nothing was found in ")
|
||||
assert.Contains(t, env.out.String(), "music of snapshot "+snapshotName+";")
|
||||
assert.NotContains(t, env.out.String(), "Running post-backup prune.")
|
||||
}
|
||||
|
||||
// A backup under --prune that finds something in each configured path
|
||||
// prunes the previous snapshot, also when a path is a single file. See
|
||||
// https://git.eeqj.de/sneak/vaultik/issues/388.
|
||||
|
||||
Reference in New Issue
Block a user