Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b6b0b7199 | ||
|
|
e9b17cc5fd | ||
|
|
850e2de25a |
+15
-10
@@ -312,7 +312,7 @@ Repository interfaces:
|
||||
```
|
||||
CreateSnapshot(opts)
|
||||
│
|
||||
├─► markDestination() // Refuse a destination without the marker, then write it
|
||||
├─► markDestination() // Check the marker (see Storage Layout in S3), then write it
|
||||
│
|
||||
├─► PruneDatabase() // Critical: avoid dedup errors
|
||||
│
|
||||
@@ -382,15 +382,20 @@ bucket/
|
||||
└── manifest.json.zst # Blob list (for pruning/verification)
|
||||
```
|
||||
|
||||
A destination without `vaultik-destination.txt` is treated as not present
|
||||
while the local index holds snapshots stored there (`checkDestinationMarker`
|
||||
in `internal/vaultik/destination_marker.go`): `snapshot create` refuses to
|
||||
write to it, `prune`, `snapshot purge` and `remote nuke` fail, and
|
||||
`snapshot list` and `snapshot remove` behave as for a destination that cannot
|
||||
be listed. This keeps those commands from taking an unplugged volume's empty
|
||||
mount point for a destination whose snapshots are all gone. The `file://`
|
||||
backend never creates its destination directory, so a write to a missing one
|
||||
fails.
|
||||
A destination that answers a look-up of `vaultik-destination.txt` with not
|
||||
found is treated as not present while the local index holds snapshots stored
|
||||
there (`checkDestinationMarker` in `internal/vaultik/destination_marker.go`):
|
||||
`snapshot create` refuses to write to it, `prune`, `snapshot purge` and
|
||||
`remote nuke` fail, and `snapshot list` and `snapshot remove` behave as for a
|
||||
destination that cannot be listed. This keeps those commands from taking an
|
||||
unplugged volume's empty mount point for a destination whose snapshots are
|
||||
all gone. When the look-up fails for any reason other than not found, for
|
||||
example when S3 refuses it to credentials that allow only PUT, the check is
|
||||
skipped, so a host with such credentials does not notice an emptied
|
||||
destination; see
|
||||
[docs/REPOSTRUCTURE.md](docs/REPOSTRUCTURE.md#marker-file-vaultik-destinationtxt).
|
||||
The `file://` backend never creates its destination directory, so a write to a
|
||||
missing one fails.
|
||||
|
||||
The `{remote-key}` directory name is a one-way double SHA-256 hash of the human
|
||||
snapshot ID, so the human ID (hostname, snapshot name, timestamp) is never
|
||||
|
||||
@@ -330,15 +330,25 @@ value where a list belongs or a word where a number belongs), is refused.
|
||||
`--skip-errors` skipped a file or directory it could not read or list, the
|
||||
prune does not run and every older snapshot is kept, since one of them may
|
||||
be the only one that holds what was skipped; the command says so and exits
|
||||
non-zero. A file removed during the backup does not stop the prune.
|
||||
non-zero. When the backup found nothing in a configured path, as in the
|
||||
mount point of a volume that is not mounted, the prune does not run
|
||||
either: every older snapshot is kept, and the command names the path and
|
||||
exits non-zero, on every run until the path holds something again. Take a
|
||||
path that is empty on purpose out of the config, or remove the older
|
||||
snapshots by hand with `snapshot purge`. A file removed during the backup
|
||||
does not stop the prune.
|
||||
* `--keep-newer-than <duration>`: With `--prune`, keep snapshots newer than
|
||||
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
|
||||
command fails before the backup starts.
|
||||
* Fails before it writes anything when a `file://` destination directory
|
||||
does not exist, or when the local index holds snapshots and the
|
||||
destination has no `vaultik-destination.txt` (see
|
||||
destination answers that it has no `vaultik-destination.txt`. When that
|
||||
look-up fails for any reason other than not found, for example when S3
|
||||
refuses it to credentials that allow only PUT, the check is skipped (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
|
||||
@@ -373,9 +383,9 @@ gives: revert the configuration, or run `vaultik database delete`.
|
||||
If the destination store cannot be listed (unmounted volume,
|
||||
permission denied, network down), the command warns, falls back to the
|
||||
local index alone, and still exits zero. It does the same when the
|
||||
destination has no `vaultik-destination.txt` while the local index
|
||||
holds snapshots stored there, as when a volume mounted at the
|
||||
destination is unplugged and leaves its empty mount point behind.
|
||||
destination answers that it has no `vaultik-destination.txt` while the
|
||||
local index holds snapshots stored there, as when a volume mounted at
|
||||
the destination is unplugged and leaves its empty mount point behind.
|
||||
* `--json`: Output in JSON format. Each entry carries `locally_tracked`
|
||||
(whether the snapshot is in the local index), `remote_key` (the full
|
||||
64-character storage key), and `remote_present` (whether it was seen
|
||||
@@ -418,8 +428,8 @@ with `--local-only`, the snapshot must be in the local index. Local row
|
||||
cleanup (files, chunks, blobs the snapshot was the last referrer for)
|
||||
runs automatically. If removing the snapshot's metadata from the
|
||||
destination store fails (for example because the store is unreachable,
|
||||
or the local index holds snapshots and the destination has no
|
||||
`vaultik-destination.txt`), the local-DB removal still completes and a
|
||||
or the local index holds snapshots and the destination answers that it
|
||||
has no `vaultik-destination.txt`), the local-DB removal still completes and a
|
||||
warning is emitted; run `vaultik snapshot remove <snapshot-id>` again
|
||||
once the store is reachable to remove the snapshot's metadata from it
|
||||
(`vaultik prune` does not). For a snapshot that is only on the
|
||||
@@ -467,9 +477,9 @@ not a remote key is left alone. Step (3) reads all remote manifests — network
|
||||
cost scales with the number of snapshots.
|
||||
`snapshot create --prune` runs the same cleanup automatically; this is the
|
||||
manual entry point for the same work. If the destination store cannot
|
||||
be listed, or the local index holds snapshots and the destination has
|
||||
no `vaultik-destination.txt` (an unplugged volume's empty mount point),
|
||||
prune fails and deletes nothing. `snapshot purge` does the same. If a
|
||||
be listed, or the local index holds snapshots and the destination answers
|
||||
that it has no `vaultik-destination.txt` (an unplugged volume's empty mount
|
||||
point), prune fails and deletes nothing. `snapshot purge` does the same. If a
|
||||
blob or such a directory cannot be deleted, the command (and
|
||||
`snapshot create --prune`) exits non-zero.
|
||||
* `--force`: Skip confirmation prompt
|
||||
@@ -506,8 +516,8 @@ and irreversible. This is the single supported way to wipe the entire
|
||||
destination store. A snapshot is removed from the local index only once
|
||||
its metadata is deleted from the destination store. If the destination
|
||||
store cannot be listed, or the local index holds snapshots and the
|
||||
destination has no `vaultik-destination.txt` (an unplugged volume's
|
||||
empty mount point), the command fails before it removes anything. If a
|
||||
destination answers that it has no `vaultik-destination.txt` (an unplugged
|
||||
volume's empty mount point), the command fails before it removes anything. If a
|
||||
listing afterwards still finds anything under `metadata/` or `blobs/`,
|
||||
the command names each object left and exits non-zero; running it again
|
||||
deletes what is left.
|
||||
@@ -590,15 +600,22 @@ For an implementation-level view of the internals — the data model, the
|
||||
```
|
||||
|
||||
* `vaultik-destination.txt` holds one fixed line of text, with no host
|
||||
name, path or time. A destination without it is treated as not
|
||||
present while the local index holds snapshots stored there:
|
||||
`snapshot create` refuses to write to it, `prune`, `snapshot purge`
|
||||
and `remote nuke` fail, and `snapshot list` and `snapshot remove`
|
||||
behave as for a destination that cannot be listed. This keeps an
|
||||
unplugged volume's empty mount point from being taken for a
|
||||
destination whose snapshots are all gone. Mount
|
||||
name, path or time. While the local index holds snapshots stored
|
||||
there, a destination that answers a look-up of it with not found is
|
||||
treated as not present: `snapshot create` refuses to write to it,
|
||||
`prune`, `snapshot purge` and `remote nuke` fail, and `snapshot list`
|
||||
and `snapshot remove` behave as for a destination that cannot be
|
||||
listed. This keeps an unplugged volume's empty mount point from being
|
||||
taken for a destination whose snapshots are all gone. Mount
|
||||
the volume if it is removable; if the destination was emptied on
|
||||
purpose, run `vaultik database delete` and back up again.
|
||||
When the look-up fails for any reason other than not found, the check
|
||||
is skipped and the command goes on. S3 refuses it on every run to
|
||||
credentials that allow only PUT, so a host with such credentials does
|
||||
not notice a destination emptied since its last backup, and its next
|
||||
backup would leave out the data its local index records as already
|
||||
stored. After the destination is emptied, run
|
||||
`vaultik database delete` on such a host before it backs up again.
|
||||
* Blobs are two-level directory sharded using the first 4 hex chars of the blob hash
|
||||
* `db.zst.age` is a binary SQLite database (zstd compressed, age encrypted)
|
||||
containing all file metadata, chunk mappings, and relationships for the snapshot
|
||||
|
||||
@@ -24,6 +24,14 @@ owner's; a `v*` tag runs the release build in
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-10-14: Stopped `snapshot create --prune` pruning after a backup
|
||||
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. A configured
|
||||
path that is a symlink whose target is gone, as one into such a
|
||||
volume, now fails the backup, with or without `--prune`.
|
||||
|
||||
- 2026-10-14: Stopped `snapshot create --prune` pruning after a backup
|
||||
that skipped files it could not read
|
||||
([issue #382](https://git.eeqj.de/sneak/vaultik/issues/382)). Such a
|
||||
|
||||
@@ -27,7 +27,9 @@ The metadata subdirectory is named with the **remote key**, a one-way hash of th
|
||||
|
||||
## Marker File (`vaultik-destination.txt`)
|
||||
|
||||
Every backup writes this file at the root before it uploads anything. It holds one fixed line of plain text and names no host, path or time. A destination without it is treated as not present while the local index holds snapshots stored there: `snapshot create` refuses to write to it, and `prune`, `snapshot purge`, `snapshot remove`, `snapshot list` and `remote nuke` behave as for a destination that cannot be listed. An unplugged volume leaves an empty directory at its mount point, and without the marker that directory would read as a destination whose snapshots are all gone. `remote nuke` deletes the marker last.
|
||||
Every backup writes this file at the root before it uploads anything. It holds one fixed line of plain text and names no host, path or time. While the local index holds snapshots stored there, a destination that answers a look-up of the marker with not found is treated as not present: `snapshot create` refuses to write to it, and `prune`, `snapshot purge`, `snapshot remove`, `snapshot list` and `remote nuke` behave as for a destination that cannot be listed. An unplugged volume leaves an empty directory at its mount point, and without the marker that directory would read as a destination whose snapshots are all gone. `remote nuke` deletes the marker last.
|
||||
|
||||
When the look-up of the marker fails for any reason other than not found, the check is skipped and the command goes on. S3 refuses it on every run to credentials that allow only PUT, so a host with such credentials does not notice a destination emptied since its last backup, and its next backup would leave out the data its local index records as already stored. After the destination is emptied, run `vaultik database delete` on such a host before it backs up again.
|
||||
|
||||
## Blobs Directory (`blobs/`)
|
||||
|
||||
|
||||
@@ -134,7 +134,7 @@ func writeHermeticPruneConfig(t *testing.T, seedStale bool) string {
|
||||
seedStaleSnapshotRecord(t, indexPath)
|
||||
|
||||
// While the local index holds a snapshot, prune fails on a
|
||||
// destination without the marker a backup writes.
|
||||
// destination that answers that it has no marker.
|
||||
require.NoError(t, os.WriteFile(
|
||||
filepath.Join(storeDir, "vaultik-destination.txt"), nil, 0o600))
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -42,7 +42,7 @@ func TestEntrySnapshotPurgeKeepsMetadataWhenLocalIndexDeleteFails(t *testing.T)
|
||||
seedStaleSnapshotRecord(t, indexPath)
|
||||
|
||||
// While the local index holds a snapshot, purge fails on a
|
||||
// destination without the marker a backup writes.
|
||||
// destination that answers that it has no marker.
|
||||
require.NoError(t, os.WriteFile(
|
||||
filepath.Join(dir, "store", "vaultik-destination.txt"), nil, 0o600))
|
||||
|
||||
|
||||
@@ -115,8 +115,9 @@ on Linux), then /etc/vaultik/config.yml.`,
|
||||
return
|
||||
}
|
||||
|
||||
if errors.Is(err, vaultik.ErrFilesUnreadable) {
|
||||
log.Error("Backup completed with unreadable files", "error", err)
|
||||
if errors.Is(err, vaultik.ErrFilesUnreadable) ||
|
||||
errors.Is(err, vaultik.ErrSourcePathEmpty) {
|
||||
log.Error("Backup completed with an error", "error", err)
|
||||
ReportErrorf("Backup completed, but %v", err)
|
||||
|
||||
return
|
||||
@@ -136,7 +137,7 @@ on Linux), then /etc/vaultik/config.yml.`,
|
||||
"directory under metadata/ with no manifest once all its "+
|
||||
"objects are older than prune_grace_period and its snapshot is "+
|
||||
"not in the local index (not after a backup that skipped "+
|
||||
"unreadable files)")
|
||||
"unreadable files or found a configured path empty)")
|
||||
cmd.Flags().StringVar(&opts.KeepNewerThan, "keep-newer-than", "",
|
||||
"With --prune: keep snapshots newer than this duration "+
|
||||
"(e.g. 30d, 4w, 6mo, 1y; m is minutes, mo is months) "+
|
||||
|
||||
@@ -168,6 +168,12 @@ type ScanResult struct {
|
||||
// skipped because it could not be read or listed; the rest are files
|
||||
// removed after the walk listed them.
|
||||
FilesUnreadable int
|
||||
|
||||
// EntriesFound counts the entries the walk found and could look up
|
||||
// that no exclude pattern matches, leaving out the scanned path
|
||||
// itself when it is a directory. It is zero for an empty directory,
|
||||
// such as the mount point of a volume that is not mounted.
|
||||
EntriesFound int
|
||||
}
|
||||
|
||||
// NewScanner creates a new scanner instance
|
||||
@@ -928,6 +934,11 @@ func (s *Scanner) scanPhase(
|
||||
default:
|
||||
}
|
||||
|
||||
// The scanned directory itself is not an entry found in it.
|
||||
if filePath != path || !info.IsDir() {
|
||||
result.EntriesFound++
|
||||
}
|
||||
|
||||
// Handle symlinks and directories
|
||||
handled, err := s.recordSpecialEntry(
|
||||
filePath, info, existingFiles, knownFiles, collector, result)
|
||||
@@ -940,18 +951,8 @@ func (s *Scanner) scanPhase(
|
||||
return nil
|
||||
}
|
||||
|
||||
// Track this file as existing (for deletion detection)
|
||||
existingFiles[filePath] = struct{}{}
|
||||
|
||||
// Check file against in-memory map (no DB query!)
|
||||
file := s.buildRegularFileEntry(filePath, info)
|
||||
needsProcessing := s.checkFileInMemory(file, knownFiles)
|
||||
|
||||
filesScanned, changedCount := collector.recordFile(
|
||||
filePath, info, file, needsProcessing)
|
||||
|
||||
// Update result stats
|
||||
s.updateScanEntryStats(result, needsProcessing, info)
|
||||
filesScanned, changedCount := s.recordRegularFile(
|
||||
filePath, info, existingFiles, knownFiles, collector, result)
|
||||
|
||||
// Output periodic status
|
||||
if time.Since(lastStatusTime) >= scanStatusInterval {
|
||||
@@ -1027,6 +1028,30 @@ func (s *Scanner) recordSpecialEntry(
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// recordRegularFile records a regular file as recordSpecialEntry does a
|
||||
// symlink and returns the running scanned and changed counts.
|
||||
func (s *Scanner) recordRegularFile(
|
||||
filePath string, info os.FileInfo,
|
||||
existingFiles map[string]struct{},
|
||||
knownFiles map[string]*database.File,
|
||||
collector *scanCollector, result *ScanResult,
|
||||
) (int64, int) {
|
||||
// Track this file as existing (for deletion detection)
|
||||
existingFiles[filePath] = struct{}{}
|
||||
|
||||
// Check file against in-memory map (no DB query!)
|
||||
file := s.buildRegularFileEntry(filePath, info)
|
||||
needsProcessing := s.checkFileInMemory(file, knownFiles)
|
||||
|
||||
filesScanned, changedCount := collector.recordFile(
|
||||
filePath, info, file, needsProcessing)
|
||||
|
||||
// Update result stats
|
||||
s.updateScanEntryStats(result, needsProcessing, info)
|
||||
|
||||
return filesScanned, changedCount
|
||||
}
|
||||
|
||||
// countRemovedDuringWalk counts an entry that was in its directory's
|
||||
// listing but was gone when the walk looked it up, like a file removed
|
||||
// before phase 2 reads it: as examined and failed, and not as an error,
|
||||
|
||||
@@ -2,15 +2,17 @@
|
||||
// faults on demand, so tests can reproduce the failure modes a real
|
||||
// backend exhibits: an upload that fails partway, a backend that reports
|
||||
// success while storing nothing, reads that return corrupt or truncated
|
||||
// bytes, and a delete the backend refuses. It is the seam called for by
|
||||
// the fault-injection tests (sneak/vaultik issue 72) and is meant to be
|
||||
// reused by future tests rather than re-implemented per case.
|
||||
// bytes, and a delete or a stat the backend refuses. It is the seam
|
||||
// called for by the fault-injection tests (sneak/vaultik issue 72) and
|
||||
// is meant to be reused by future tests rather than re-implemented per
|
||||
// case.
|
||||
//
|
||||
// The wrapper delegates every method to the inner Storer. Three hooks
|
||||
// The wrapper delegates every method to the inner Storer. Four hooks
|
||||
// change that: OnPut decides the fate of each write, OnGet decides how
|
||||
// each read's bytes are returned, and OnDelete decides whether each
|
||||
// delete fails. All are keyed by the object key, so a test can fault
|
||||
// only blobs, only metadata, or a single object.
|
||||
// each read's bytes are returned, OnDelete decides whether each delete
|
||||
// fails, and OnStat whether each stat fails. All are keyed by the object
|
||||
// key, so a test can fault only blobs, only metadata, or a single
|
||||
// object.
|
||||
package faultstore
|
||||
|
||||
import (
|
||||
@@ -30,6 +32,11 @@ var ErrInjectedUpload = errors.New("faultstore: injected upload failure")
|
||||
// fail.
|
||||
var ErrInjectedDelete = errors.New("faultstore: injected delete failure")
|
||||
|
||||
// ErrInjectedStat is returned by a Stat the OnStat hook chose to fail.
|
||||
// It is not storage.ErrNotFound: the backend refused the request, as S3
|
||||
// refuses a HEAD to credentials that allow only PUT.
|
||||
var ErrInjectedStat = errors.New("faultstore: injected stat failure")
|
||||
|
||||
// PutAction is the disposition OnPut assigns to a write.
|
||||
type PutAction int
|
||||
|
||||
@@ -76,6 +83,10 @@ type Storer struct {
|
||||
// object key. Returning true fails the delete and leaves the object
|
||||
// in place.
|
||||
OnDelete func(key string) bool
|
||||
|
||||
// OnStat, when set, is consulted before every Stat with the object
|
||||
// key. Returning true fails the stat with ErrInjectedStat.
|
||||
OnStat func(key string) bool
|
||||
}
|
||||
|
||||
// New wraps inner. inner must be non-nil.
|
||||
@@ -159,8 +170,12 @@ func damage(fault GetFault, data []byte) []byte {
|
||||
return out
|
||||
}
|
||||
|
||||
// Stat delegates unchanged.
|
||||
// Stat returns the object's info unless OnStat fails the stat.
|
||||
func (f *Storer) Stat(ctx context.Context, key string) (*storage.ObjectInfo, error) {
|
||||
if f.OnStat != nil && f.OnStat(key) {
|
||||
return nil, fmt.Errorf("%w for %q", ErrInjectedStat, key)
|
||||
}
|
||||
|
||||
return f.inner.Stat(ctx, key)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,14 +5,16 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"sneak.berlin/go/vaultik/internal/log"
|
||||
"sneak.berlin/go/vaultik/internal/storage"
|
||||
)
|
||||
|
||||
// destinationMarkerKey is the object every backup writes at the root of
|
||||
// the backup destination store. While the local index holds a snapshot
|
||||
// stored there, a destination without it is treated as not present: an
|
||||
// unplugged volume leaves an empty directory at its mount point, which
|
||||
// would otherwise read as a store whose snapshots are all gone.
|
||||
// stored there, a destination that answers a look-up of it with not
|
||||
// found is treated as not present: an unplugged volume leaves an empty
|
||||
// directory at its mount point, which would otherwise read as a store
|
||||
// whose snapshots are all gone.
|
||||
const destinationMarkerKey = "vaultik-destination.txt"
|
||||
|
||||
// destinationMarkerText is the whole content of the marker. It names no
|
||||
@@ -20,16 +22,24 @@ const destinationMarkerKey = "vaultik-destination.txt"
|
||||
const destinationMarkerText = "This is a vaultik backup destination store.\n"
|
||||
|
||||
// errDestinationNotPresent is returned when the local index holds a
|
||||
// snapshot stored on the backup destination store and the store has no
|
||||
// marker.
|
||||
// snapshot stored on the backup destination store and the store answers
|
||||
// that it has no marker.
|
||||
var errDestinationNotPresent = errors.New("backup destination store is not present")
|
||||
|
||||
// checkDestinationMarker returns errDestinationNotPresent when the local
|
||||
// index holds a snapshot stored on the backup destination store and the
|
||||
// store has no marker. With no snapshot in the local index, as on a
|
||||
// first backup or on another machine, there is nothing to check; nor
|
||||
// when the local index is bound to a different destination than the
|
||||
// configured one, since its snapshots are stored there.
|
||||
// store answers that it has no marker. With no snapshot in the local
|
||||
// index, as on a first backup or on another machine, there is nothing to
|
||||
// check; nor when the local index is bound to a different destination
|
||||
// than the configured one, since its snapshots are stored there.
|
||||
//
|
||||
// A look-up of the marker that fails for any other reason than not
|
||||
// found is logged and otherwise ignored, because the check could not be
|
||||
// made: S3 refuses it to credentials that allow only PUT, which is all
|
||||
// a backup needs. The check exists for a file:// volume's empty mount
|
||||
// point, which always answers not found, and a destination that cannot
|
||||
// be reached fails on the next request made to it, such as the backup's
|
||||
// PUT of the marker.
|
||||
func (v *Vaultik) checkDestinationMarker() error {
|
||||
ids, err := v.localSnapshotIDs()
|
||||
if err != nil {
|
||||
@@ -63,8 +73,11 @@ func (v *Vaultik) checkDestinationMarker() error {
|
||||
destinationMarkerKey)
|
||||
}
|
||||
|
||||
// Info, not a warning: --cron must stay silent on a successful
|
||||
// backup from a host whose credentials allow only PUT.
|
||||
if err != nil {
|
||||
return fmt.Errorf("checking for %s: %w", destinationMarkerKey, err)
|
||||
log.Info("Could not check the backup destination store for its marker",
|
||||
"key", destinationMarkerKey, "error", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -13,15 +13,16 @@ import (
|
||||
"sneak.berlin/go/vaultik/internal/database"
|
||||
"sneak.berlin/go/vaultik/internal/log"
|
||||
"sneak.berlin/go/vaultik/internal/storage"
|
||||
"sneak.berlin/go/vaultik/internal/storage/faultstore"
|
||||
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||
)
|
||||
|
||||
// These tests cover https://git.eeqj.de/sneak/vaultik/issues/239: a
|
||||
// file:// destination at a volume's mount point leaves an empty directory
|
||||
// behind when the volume is unplugged. While the local index holds
|
||||
// snapshots, a destination without vaultik-destination.txt is not present,
|
||||
// so no command may write to it or conclude from it that the local
|
||||
// snapshots are gone.
|
||||
// snapshots, a destination that answers that it has no
|
||||
// vaultik-destination.txt is not present, so no command may write to it
|
||||
// or conclude from it that the local snapshots are gone.
|
||||
|
||||
// markerFile is the name of the file a backup writes at the root of the
|
||||
// destination.
|
||||
@@ -93,6 +94,39 @@ func TestBackupRefusesDestinationWithoutMarker(t *testing.T) {
|
||||
assertUntouched(ctx, t, repos, storeDir)
|
||||
}
|
||||
|
||||
// TestBackupGoesOnWhenMarkerCannotBeChecked checks that a backup goes on
|
||||
// and writes the marker when looking it up fails with an error other
|
||||
// than not found, as S3 answers credentials that allow only PUT
|
||||
// (https://git.eeqj.de/sneak/vaultik/issues/389), and that a marker
|
||||
// that is not found still stops it.
|
||||
func TestBackupGoesOnWhenMarkerCannotBeChecked(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
storeDir := t.TempDir()
|
||||
v, repos, _ := backUpToFileDestination(ctx, t, storeDir)
|
||||
|
||||
// Removed so the test sees the backup write it again: with the
|
||||
// look-up failing, the backup cannot tell that it is gone.
|
||||
markerPath := filepath.Join(storeDir, markerFile)
|
||||
require.NoError(t, os.Remove(markerPath))
|
||||
|
||||
store := faultstore.New(v.Storage)
|
||||
store.OnStat = func(key string) bool { return key == markerFile }
|
||||
v.Storage = store
|
||||
|
||||
require.NoError(t, backUp(v, "second"))
|
||||
localSnapshotID(ctx, t, repos, "second")
|
||||
assert.FileExists(t, markerPath)
|
||||
|
||||
require.NoError(t, os.Remove(markerPath))
|
||||
|
||||
store.OnStat = nil
|
||||
|
||||
require.ErrorContains(t, backUp(v, "third"), markerFile)
|
||||
}
|
||||
|
||||
// TestBackupChecksMarkerBeforeDroppingIncompleteSnapshots checks that
|
||||
// snapshot create writes nothing when the local index holds only an
|
||||
// incomplete snapshot and the destination has no marker. The backup drops
|
||||
|
||||
@@ -76,7 +76,7 @@ func TestNukeRemoteLeavesNoFiles(t *testing.T) {
|
||||
// TestNukeRemoteFailsWhenADeleteFails checks that remote nuke returns an
|
||||
// error when a snapshot's metadata or a blob cannot be deleted, does not
|
||||
// report the store empty, and advises running nuke again: prune deletes
|
||||
// no metadata.
|
||||
// no snapshot metadata that has its manifest.
|
||||
func TestNukeRemoteFailsWhenADeleteFails(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
@@ -66,8 +66,8 @@ func (v *Vaultik) NukeRemote(force bool) error {
|
||||
|
||||
// Checked before anything is deleted: running nuke again would fail
|
||||
// the same way, so the advice below must not follow these errors.
|
||||
// The listing also fails on a destination without the marker while
|
||||
// the local index holds snapshots.
|
||||
// The listing also fails on a destination that answers that it has
|
||||
// no marker while the local index holds snapshots.
|
||||
err := v.EnsureStorageBinding()
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -44,6 +44,14 @@ var ErrPostBackupPruneFailed = errors.New("post-backup prune failed")
|
||||
// kept, so the caller can report it apart from a failed backup.
|
||||
var ErrFilesUnreadable = errors.New("file(s) could not be read")
|
||||
|
||||
// ErrSourcePathEmpty is returned when --prune skipped the post-backup
|
||||
// prune because the scan found nothing in a configured path of a
|
||||
// snapshot, as in the mount point of a volume that is not mounted. The
|
||||
// snapshots are complete and kept, so the caller can report it apart
|
||||
// from a failed backup. A run that also skipped unreadable files
|
||||
// returns ErrFilesUnreadable instead.
|
||||
var ErrSourcePathEmpty = errors.New("source path(s) were empty")
|
||||
|
||||
// listRecentLimit caps how many snapshot rows are fetched from the
|
||||
// local index for listing and purge operations.
|
||||
const listRecentLimit = 10000
|
||||
@@ -134,17 +142,10 @@ func (v *Vaultik) CreateSnapshot(opts *SnapshotCreateOptions) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Process each named snapshot
|
||||
filesUnreadable := 0
|
||||
|
||||
for snapIdx, snapName := range snapshotNames {
|
||||
unreadable, err := v.createNamedSnapshot(
|
||||
opts, hostname, snapName, snapIdx+1, len(snapshotNames))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
filesUnreadable += unreadable
|
||||
filesUnreadable, emptyPaths, err := v.createNamedSnapshots(
|
||||
opts, hostname, snapshotNames)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Print overall summary if multiple snapshots
|
||||
@@ -154,13 +155,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 && (filesUnreadable > 0 || len(emptyPaths) > 0):
|
||||
return v.skipPostBackupPrune(filesUnreadable, emptyPaths)
|
||||
case opts.Prune:
|
||||
err = v.runPostBackupPrune(snapshotNames, opts.KeepNewerThan)
|
||||
if err != nil {
|
||||
@@ -171,6 +167,61 @@ func (v *Vaultik) CreateSnapshot(opts *SnapshotCreateOptions) error {
|
||||
return v.finishCreateSnapshot(filesUnreadable)
|
||||
}
|
||||
|
||||
// createNamedSnapshots creates each named snapshot in turn. It returns
|
||||
// the number of entries --skip-errors skipped because it could not read
|
||||
// or list them, and each configured path in which the scan found
|
||||
// nothing, named with its snapshot.
|
||||
func (v *Vaultik) createNamedSnapshots(
|
||||
opts *SnapshotCreateOptions, hostname string, snapshotNames []string,
|
||||
) (int, []string, error) {
|
||||
filesUnreadable := 0
|
||||
|
||||
var emptyPaths []string
|
||||
|
||||
for snapIdx, snapName := range snapshotNames {
|
||||
stats, err := v.createNamedSnapshot(
|
||||
opts, hostname, snapName, snapIdx+1, len(snapshotNames))
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
|
||||
filesUnreadable += stats.totalFilesUnreadable
|
||||
|
||||
for _, dir := range stats.emptyPaths {
|
||||
emptyPaths = append(emptyPaths, dir+" of snapshot "+snapName)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -275,6 +326,9 @@ type snapshotStats struct {
|
||||
// could not be read or listed.
|
||||
totalFilesUnreadable int
|
||||
|
||||
// The configured paths in which the scan found nothing.
|
||||
emptyPaths []string
|
||||
|
||||
// The sizes of all blobs the snapshot references, set by
|
||||
// finalizeSnapshotMetadata once snapshot_blobs is populated.
|
||||
blobSize int64
|
||||
@@ -282,11 +336,10 @@ type snapshotStats struct {
|
||||
}
|
||||
|
||||
// createNamedSnapshot creates a single named snapshot and returns the
|
||||
// number of entries --skip-errors skipped because it could not read or
|
||||
// list them.
|
||||
// statistics of its scan.
|
||||
func (v *Vaultik) createNamedSnapshot(
|
||||
opts *SnapshotCreateOptions, hostname, snapName string, idx, total int,
|
||||
) (int, error) {
|
||||
) (*snapshotStats, error) {
|
||||
snapshotStartTime := time.Now()
|
||||
|
||||
if total > 1 {
|
||||
@@ -295,12 +348,12 @@ func (v *Vaultik) createNamedSnapshot(
|
||||
|
||||
resolvedDirs, err := v.resolveSnapshotPaths(snapName)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
previousSnapshotID, err := v.previousSnapshotID(snapName)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
scanner := v.ScannerFactory(snapshot.ScannerParams{
|
||||
@@ -315,7 +368,7 @@ func (v *Vaultik) createNamedSnapshot(
|
||||
snapshotID, err := v.SnapshotManager.CreateSnapshotWithName(
|
||||
v.ctx, hostname, snapName, v.Globals.Version, v.Globals.Commit)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("creating snapshot: %w", err)
|
||||
return nil, fmt.Errorf("creating snapshot: %w", err)
|
||||
}
|
||||
|
||||
log.Info("Beginning snapshot", "snapshot_id", snapshotID, "name", snapName)
|
||||
@@ -323,12 +376,12 @@ func (v *Vaultik) createNamedSnapshot(
|
||||
|
||||
stats, err := v.scanAllDirectories(scanner, resolvedDirs, snapshotID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = v.finalizeSnapshotMetadata(snapshotID, stats)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Info("Snapshot complete",
|
||||
@@ -341,7 +394,7 @@ func (v *Vaultik) createNamedSnapshot(
|
||||
|
||||
v.printSnapshotSummary(snapshotID, snapshotStartTime, stats)
|
||||
|
||||
return stats.totalFilesUnreadable, nil
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// previousSnapshotID returns the ID of the most recent completed snapshot
|
||||
@@ -363,7 +416,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))
|
||||
@@ -376,12 +430,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)
|
||||
@@ -435,6 +495,10 @@ func (v *Vaultik) scanAllDirectories(
|
||||
stats.totalBytesUploaded += result.BytesUploaded
|
||||
stats.uploadDuration += result.UploadDuration
|
||||
|
||||
if result.EntriesFound == 0 {
|
||||
stats.emptyPaths = append(stats.emptyPaths, dir)
|
||||
}
|
||||
|
||||
log.Info("Directory scan complete",
|
||||
"path", dir,
|
||||
"files", result.FilesScanned,
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
package vaultik_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/config"
|
||||
"sneak.berlin/go/vaultik/internal/log"
|
||||
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||
)
|
||||
|
||||
@@ -25,3 +32,230 @@ func TestSnapshotCreateOptions_PruneFlag(t *testing.T) {
|
||||
t.Error("Expected Prune to be false")
|
||||
}
|
||||
}
|
||||
|
||||
// A backup under --prune that finds nothing in a configured path, as in
|
||||
// the mount point of a volume that is not mounted, does not prune: run
|
||||
// after run, the snapshot that holds the files is kept with everything
|
||||
// it stored, and the run fails. That holds whether the empty path is the
|
||||
// snapshot's only path or one of two. See
|
||||
// https://git.eeqj.de/sneak/vaultik/issues/388.
|
||||
func TestBackupWithEmptySourcePathDoesNotPrune(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
// emptied is the path, relative to the source directory, that is
|
||||
// emptied after the first backup.
|
||||
const (
|
||||
snapshotName = "media"
|
||||
emptied = "music"
|
||||
)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
paths []string // relative to the source directory
|
||||
}{
|
||||
{name: "only path", paths: []string{emptied}},
|
||||
{name: "one of two paths", paths: []string{emptied, "docs"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
env := newDeletedCountEnv(t, map[string][]byte{
|
||||
emptied + "/a.mp3": []byte("song a"),
|
||||
emptied + "/b.mp3": []byte("song b"),
|
||||
"docs/notes.txt": []byte("notes"),
|
||||
})
|
||||
|
||||
paths := make([]string, 0, len(tt.paths))
|
||||
|
||||
for _, rel := range tt.paths {
|
||||
paths = append(paths, filepath.Join(env.src, rel))
|
||||
}
|
||||
|
||||
env.v.Config.Snapshots[snapshotName] = config.SnapshotConfig{
|
||||
Paths: paths,
|
||||
}
|
||||
env.backUp(t, snapshotName)
|
||||
|
||||
firstID := localSnapshotID(ctx, t, env.v.Repositories, snapshotName)
|
||||
stored, err := env.v.Storage.List(ctx, "")
|
||||
require.NoError(t, err)
|
||||
|
||||
// A volume that is not mounted leaves its mount point behind
|
||||
// as an empty directory.
|
||||
emptiedDir := filepath.Join(env.src, emptied)
|
||||
require.NoError(t, os.RemoveAll(emptiedDir))
|
||||
require.NoError(t, os.Mkdir(emptiedDir, 0o750))
|
||||
|
||||
for range 2 {
|
||||
env.out.Reset()
|
||||
|
||||
err := env.v.CreateSnapshot(&vaultik.SnapshotCreateOptions{
|
||||
Prune: true,
|
||||
Snapshots: []string{snapshotName},
|
||||
})
|
||||
require.ErrorIs(t, err, vaultik.ErrSourcePathEmpty)
|
||||
assert.Contains(t, env.out.String(),
|
||||
"Skipped the post-backup prune because nothing was found in ")
|
||||
assert.Contains(t, env.out.String(),
|
||||
emptied+" of snapshot "+snapshotName+";")
|
||||
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, 3)
|
||||
assert.Equal(t, firstID, snapshots[2].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 --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.
|
||||
func TestBackupWithNoEmptySourcePathPrunes(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
snapshotName = "album"
|
||||
file = "pictures/one.jpg" // relative to the source directory
|
||||
)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string // relative to the source directory
|
||||
}{
|
||||
{name: "directory", path: filepath.Dir(file)},
|
||||
{name: "single file", path: file},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
env := newDeletedCountEnv(t, map[string][]byte{
|
||||
file: []byte("picture"),
|
||||
})
|
||||
env.setSnapshot(snapshotName, tt.path)
|
||||
env.backUp(t, snapshotName)
|
||||
|
||||
firstID := localSnapshotID(ctx, t, env.v.Repositories, snapshotName)
|
||||
|
||||
require.NoError(t, env.v.CreateSnapshot(&vaultik.SnapshotCreateOptions{
|
||||
Prune: true,
|
||||
Snapshots: []string{snapshotName},
|
||||
}))
|
||||
|
||||
snapshots, err := env.v.Repositories.Snapshots.ListRecent(
|
||||
ctx, listRecentTestLimit)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, snapshots, 1, "the previous snapshot is pruned")
|
||||
assert.NotEqual(t, firstID, snapshots[0].ID.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user