Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fbb18bf0a |
@@ -311,8 +311,6 @@ Repository interfaces:
|
|||||||
|
|
||||||
```
|
```
|
||||||
CreateSnapshot(opts)
|
CreateSnapshot(opts)
|
||||||
│
|
|
||||||
├─► markDestination() // Refuse a destination without the marker, then write it
|
|
||||||
│
|
│
|
||||||
├─► PruneDatabase() // Critical: avoid dedup errors
|
├─► PruneDatabase() // Critical: avoid dedup errors
|
||||||
│
|
│
|
||||||
@@ -369,8 +367,6 @@ CreateSnapshot(opts)
|
|||||||
|
|
||||||
```
|
```
|
||||||
bucket/
|
bucket/
|
||||||
├── vaultik-destination.txt # Marker written by every backup
|
|
||||||
│
|
|
||||||
├── blobs/
|
├── blobs/
|
||||||
│ └── {hash[0:2]}/
|
│ └── {hash[0:2]}/
|
||||||
│ └── {hash[2:4]}/
|
│ └── {hash[2:4]}/
|
||||||
@@ -382,15 +378,6 @@ bucket/
|
|||||||
└── manifest.json.zst # Blob list (for pruning/verification)
|
└── 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` and `snapshot purge` 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.
|
|
||||||
|
|
||||||
The `{remote-key}` directory name is a one-way double SHA-256 hash of the human
|
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
|
snapshot ID, so the human ID (hostname, snapshot name, timestamp) is never
|
||||||
written to the store as a directory name. See
|
written to the store as a directory name. See
|
||||||
|
|||||||
@@ -24,10 +24,6 @@ grep 'public key' vaultik_backup_private_key.txt
|
|||||||
vaultik config set age_recipients.0 age1YOUR_PUBLIC_KEY_HERE
|
vaultik config set age_recipients.0 age1YOUR_PUBLIC_KEY_HERE
|
||||||
vaultik config set storage_url "file:///Volumes/usbstick/mybackup"
|
vaultik config set storage_url "file:///Volumes/usbstick/mybackup"
|
||||||
|
|
||||||
# create the destination directory; vaultik never creates it, so a backup
|
|
||||||
# to a volume that is not plugged in fails instead of filling the disk
|
|
||||||
mkdir /Volumes/usbstick/mybackup
|
|
||||||
|
|
||||||
# macOS only: grant your terminal app Full Disk Access first
|
# macOS only: grant your terminal app Full Disk Access first
|
||||||
# (System Settings → Privacy & Security → Full Disk Access), otherwise
|
# (System Settings → Privacy & Security → Full Disk Access), otherwise
|
||||||
# the backup will abort with a permission error on protected directories
|
# the backup will abort with a permission error on protected directories
|
||||||
@@ -302,10 +298,6 @@ value where a list belongs or a word where a number belongs), is refused.
|
|||||||
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
|
||||||
command fails before the backup starts.
|
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
|
|
||||||
[remote storage layout](#remote-storage-layout))
|
|
||||||
|
|
||||||
**`snapshot list`**: Show every snapshot known to this host — the union
|
**`snapshot list`**: Show every snapshot known to this host — the union
|
||||||
of the local index and the backup destination store — with timestamps
|
of the local index and the backup destination store — with timestamps
|
||||||
@@ -339,10 +331,7 @@ gives: revert the configuration, or run `vaultik database delete`.
|
|||||||
|
|
||||||
If the destination store cannot be listed (unmounted volume,
|
If the destination store cannot be listed (unmounted volume,
|
||||||
permission denied, network down), the command warns, falls back to the
|
permission denied, network down), the command warns, falls back to the
|
||||||
local index alone, and still exits zero. It does the same when the
|
local index alone, and still exits zero.
|
||||||
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.
|
|
||||||
* `--json`: Output in JSON format. Each entry carries `locally_tracked`
|
* `--json`: Output in JSON format. Each entry carries `locally_tracked`
|
||||||
(whether the snapshot is in the local index), `remote_key` (the full
|
(whether the snapshot is in the local index), `remote_key` (the full
|
||||||
64-character storage key), and `remote_present` (whether it was seen
|
64-character storage key), and `remote_present` (whether it was seen
|
||||||
@@ -384,14 +373,13 @@ snapshot in the local index or on the destination store is an error;
|
|||||||
with `--local-only`, the snapshot must be in the local index. Local row
|
with `--local-only`, the snapshot must be in the local index. Local row
|
||||||
cleanup (files, chunks, blobs the snapshot was the last referrer for)
|
cleanup (files, chunks, blobs the snapshot was the last referrer for)
|
||||||
runs automatically. If removing the snapshot's metadata from the
|
runs automatically. If removing the snapshot's metadata from the
|
||||||
destination store fails (for example because the store is unreachable,
|
destination store fails (for example because the store is unreachable),
|
||||||
or the local index holds snapshots and the destination has no
|
the local-DB removal still completes and a warning is emitted; run
|
||||||
`vaultik-destination.txt`), the local-DB removal still completes and a
|
`vaultik snapshot remove <snapshot-id>` again once the store is reachable
|
||||||
warning is emitted; run `vaultik snapshot remove <snapshot-id>` again
|
to remove the snapshot's metadata from it (`vaultik prune` does not). For
|
||||||
once the store is reachable to remove the snapshot's metadata from it
|
a snapshot that is only on the destination store, that failure is an
|
||||||
(`vaultik prune` does not). For a snapshot that is only on the
|
error. To wipe everything on the destination in one go, use `vaultik
|
||||||
destination store, that failure is an error. To wipe everything on the
|
remote nuke --force`.
|
||||||
destination in one go, use `vaultik remote nuke --force`.
|
|
||||||
* `--local-only`: Skip remote cleanup; only touch the local index
|
* `--local-only`: Skip remote cleanup; only touch the local index
|
||||||
* `--dry-run`: Show what would be deleted without deleting
|
* `--dry-run`: Show what would be deleted without deleting
|
||||||
* `--force`: Skip confirmation prompt
|
* `--force`: Skip confirmation prompt
|
||||||
@@ -422,12 +410,8 @@ manifest on the destination store to compute the still-referenced blob
|
|||||||
set and delete any blob not in that set. Step (3) reads all remote
|
set and delete any blob not in that set. Step (3) reads all remote
|
||||||
manifests — network cost scales with the number of snapshots. `snapshot
|
manifests — network cost scales with the number of snapshots. `snapshot
|
||||||
create --prune` runs the same cleanup automatically; this is the
|
create --prune` runs the same cleanup automatically; this is the
|
||||||
manual entry point for the same work. If the destination store cannot
|
manual entry point for the same work. If a blob cannot be deleted, the
|
||||||
be listed, or the local index holds snapshots and the destination has
|
command (and `snapshot create --prune`) exits non-zero.
|
||||||
no `vaultik-destination.txt` (an unplugged volume's empty mount point),
|
|
||||||
prune fails and deletes nothing. `snapshot purge` does the same. If a
|
|
||||||
blob cannot be deleted, the command (and `snapshot create --prune`)
|
|
||||||
exits non-zero.
|
|
||||||
* `--force`: Skip confirmation prompt
|
* `--force`: Skip confirmation prompt
|
||||||
* `--json`: Output stats as JSON. Also skips the confirmation prompt, as
|
* `--json`: Output stats as JSON. Also skips the confirmation prompt, as
|
||||||
`--force` does. A blob that cannot be deleted is counted in
|
`--force` does. A blob that cannot be deleted is counted in
|
||||||
@@ -451,12 +435,11 @@ since `prune` never deletes it.
|
|||||||
* `--json`: Output as JSON
|
* `--json`: Output as JSON
|
||||||
|
|
||||||
**`remote nuke`**: Delete every object under `metadata/` and `blobs/` on
|
**`remote nuke`**: Delete every object under `metadata/` and `blobs/` on
|
||||||
the backup destination store, whatever its name, and then
|
the backup destination store, whatever its name, leaving the bucket prefix
|
||||||
`vaultik-destination.txt`, leaving the bucket prefix empty. Destructive
|
empty. Destructive and irreversible. This is the single supported way to
|
||||||
and irreversible. This is the single supported way to wipe the entire
|
wipe the entire destination store. If a listing afterwards still finds
|
||||||
destination store. If a listing afterwards still finds anything under
|
anything there, the command names each object left and exits non-zero;
|
||||||
`metadata/` or `blobs/`, the command names each object left and exits
|
running it again deletes what is left.
|
||||||
non-zero; running it again deletes what is left.
|
|
||||||
* `--force`: Required to confirm destruction.
|
* `--force`: Required to confirm destruction.
|
||||||
|
|
||||||
**`database delete`**: Delete the local SQLite state database file
|
**`database delete`**: Delete the local SQLite state database file
|
||||||
@@ -481,12 +464,7 @@ object store. Credentials are read from `s3.access_key_id` and
|
|||||||
a local or mounted filesystem. Useful for testing or backing up to a NAS. The
|
a local or mounted filesystem. Useful for testing or backing up to a NAS. The
|
||||||
path must be absolute; a relative one is refused when the config loads.
|
path must be absolute; a relative one is refused when the config loads.
|
||||||
Percent-escapes in the path are decoded, so `my%20stick` and `my stick` name
|
Percent-escapes in the path are decoded, so `my%20stick` and `my stick` name
|
||||||
the same directory. The directory must exist before the first backup: vaultik
|
the same directory.
|
||||||
never creates it. A directory on a removable volume is gone while the volume is
|
|
||||||
not mounted, so a backup then fails instead of writing to the disk under the
|
|
||||||
mount point. The mount point itself stays behind when the volume is unplugged:
|
|
||||||
there only `vaultik-destination.txt` stops such a backup, and only while the
|
|
||||||
local index holds snapshots.
|
|
||||||
|
|
||||||
**Rclone** (`rclone://remote/path`): Uses rclone's 70+ supported cloud
|
**Rclone** (`rclone://remote/path`): Uses rclone's 70+ supported cloud
|
||||||
providers. Requires rclone to be configured separately (`rclone config`).
|
providers. Requires rclone to be configured separately (`rclone config`).
|
||||||
@@ -517,7 +495,6 @@ For an implementation-level view of the internals — the data model, the
|
|||||||
|
|
||||||
```
|
```
|
||||||
<bucket>/<prefix>/
|
<bucket>/<prefix>/
|
||||||
├── vaultik-destination.txt # Marker written by every backup
|
|
||||||
├── blobs/
|
├── blobs/
|
||||||
│ └── <aa>/<bb>/<full_blob_hash>
|
│ └── <aa>/<bb>/<full_blob_hash>
|
||||||
└── metadata/
|
└── metadata/
|
||||||
@@ -526,15 +503,6 @@ For an implementation-level view of the internals — the data model, the
|
|||||||
└── manifest.json.zst # Unencrypted blob list (for pruning)
|
└── manifest.json.zst # Unencrypted blob list (for pruning)
|
||||||
```
|
```
|
||||||
|
|
||||||
* `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` and `snapshot purge`
|
|
||||||
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.
|
|
||||||
* Blobs are two-level directory sharded using the first 4 hex chars of the blob hash
|
* 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)
|
* `db.zst.age` is a binary SQLite database (zstd compressed, age encrypted)
|
||||||
containing all file metadata, chunk mappings, and relationships for the snapshot
|
containing all file metadata, chunk mappings, and relationships for the snapshot
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ This document describes the structure and organization of data stored in the S3
|
|||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
Vaultik stores all backup data in an S3-compatible object store. The repository consists of two main components, plus a marker file:
|
Vaultik stores all backup data in an S3-compatible object store. The repository consists of two main components:
|
||||||
1. **Blobs** - The actual backup data (content-addressed, encrypted)
|
1. **Blobs** - The actual backup data (content-addressed, encrypted)
|
||||||
2. **Metadata** - Snapshot information and manifests (partially encrypted)
|
2. **Metadata** - Snapshot information and manifests (partially encrypted)
|
||||||
|
|
||||||
@@ -12,7 +12,6 @@ Vaultik stores all backup data in an S3-compatible object store. The repository
|
|||||||
|
|
||||||
```
|
```
|
||||||
<bucket>/<prefix>/
|
<bucket>/<prefix>/
|
||||||
├── vaultik-destination.txt
|
|
||||||
├── blobs/
|
├── blobs/
|
||||||
│ └── <hash[0:2]>/
|
│ └── <hash[0:2]>/
|
||||||
│ └── <hash[2:4]>/
|
│ └── <hash[2:4]>/
|
||||||
@@ -25,10 +24,6 @@ Vaultik stores all backup data in an S3-compatible object store. The repository
|
|||||||
|
|
||||||
The metadata subdirectory is named with the **remote key**, a one-way hash of the snapshot ID, not with the human-readable snapshot ID itself. See [Remote Key Derivation](#remote-key-derivation).
|
The metadata subdirectory is named with the **remote key**, a one-way hash of the snapshot ID, not with the human-readable snapshot ID itself. See [Remote Key Derivation](#remote-key-derivation).
|
||||||
|
|
||||||
## 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` and `snapshot list` 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.
|
|
||||||
|
|
||||||
## Blobs Directory (`blobs/`)
|
## Blobs Directory (`blobs/`)
|
||||||
|
|
||||||
### Structure
|
### Structure
|
||||||
|
|||||||
@@ -101,8 +101,7 @@ func TestEntryPruneJSONStdoutIsExactlyOneDocument(t *testing.T) {
|
|||||||
// writeHermeticPruneConfig builds a config over a temp directory with an
|
// writeHermeticPruneConfig builds a config over a temp directory with an
|
||||||
// empty destination directory and, if seedStale is set, creates the
|
// empty destination directory and, if seedStale is set, creates the
|
||||||
// index database up front with one snapshot record that has no
|
// index database up front with one snapshot record that has no
|
||||||
// counterpart on the destination store, and gives the destination the
|
// counterpart on the destination store. Returns the config path.
|
||||||
// marker a backup writes. Returns the config path.
|
|
||||||
func writeHermeticPruneConfig(t *testing.T, seedStale bool) string {
|
func writeHermeticPruneConfig(t *testing.T, seedStale bool) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -132,11 +131,6 @@ func writeHermeticPruneConfig(t *testing.T, seedStale bool) string {
|
|||||||
|
|
||||||
if seedStale {
|
if seedStale {
|
||||||
seedStaleSnapshotRecord(t, indexPath)
|
seedStaleSnapshotRecord(t, indexPath)
|
||||||
|
|
||||||
// While the local index holds a snapshot, prune fails on a
|
|
||||||
// destination without the marker a backup writes.
|
|
||||||
require.NoError(t, os.WriteFile(
|
|
||||||
filepath.Join(storeDir, "vaultik-destination.txt"), nil, 0o600))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return configPath
|
return configPath
|
||||||
|
|||||||
@@ -41,11 +41,6 @@ func TestEntrySnapshotPurgeKeepsMetadataWhenLocalIndexDeleteFails(t *testing.T)
|
|||||||
// too, so the purge selects it rather than dropping it as stale.
|
// too, so the purge selects it rather than dropping it as stale.
|
||||||
seedStaleSnapshotRecord(t, indexPath)
|
seedStaleSnapshotRecord(t, indexPath)
|
||||||
|
|
||||||
// While the local index holds a snapshot, purge fails on a
|
|
||||||
// destination without the marker a backup writes.
|
|
||||||
require.NoError(t, os.WriteFile(
|
|
||||||
filepath.Join(dir, "store", "vaultik-destination.txt"), nil, 0o600))
|
|
||||||
|
|
||||||
manifestDir := filepath.Join(dir, "store", "metadata",
|
manifestDir := filepath.Join(dir, "store", "metadata",
|
||||||
snapshot.RemoteSnapshotKey(stalePruneSnapshotID))
|
snapshot.RemoteSnapshotKey(stalePruneSnapshotID))
|
||||||
require.NoError(t, os.MkdirAll(manifestDir, 0o750))
|
require.NoError(t, os.MkdirAll(manifestDir, 0o750))
|
||||||
@@ -64,12 +59,10 @@ func TestEntrySnapshotPurgeKeepsMetadataWhenLocalIndexDeleteFails(t *testing.T)
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, db.Close())
|
require.NoError(t, db.Close())
|
||||||
|
|
||||||
code, _, stderr := runEntry(t, flagConfig, configPath,
|
code, _, _ := runEntry(t, flagConfig, configPath,
|
||||||
cmdSnapshot, cmdPurge, flagOlderThan, "30d", flagForce)
|
cmdSnapshot, cmdPurge, flagOlderThan, "30d", flagForce)
|
||||||
|
|
||||||
assert.Equal(t, 1, code)
|
assert.Equal(t, 1, code)
|
||||||
assert.Contains(t, stderr, "simulated index error",
|
|
||||||
"the purge must fail at the local-index delete")
|
|
||||||
assert.FileExists(t, manifestPath,
|
assert.FileExists(t, manifestPath,
|
||||||
"the snapshot is still in the local index, so its metadata must stay")
|
"the snapshot is still in the local index, so its metadata must stay")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -876,10 +876,8 @@ func (s *Scanner) scanPhase(
|
|||||||
existingFiles map[string]struct{},
|
existingFiles map[string]struct{},
|
||||||
knownFiles map[string]*database.File,
|
knownFiles map[string]*database.File,
|
||||||
) (*ScanPhaseResult, error) {
|
) (*ScanPhaseResult, error) {
|
||||||
// Use known file count as estimate for progress (accurate for
|
// Use known file count as estimate for progress (accurate for subsequent backups)
|
||||||
// subsequent backups). The walk does not count directories, so the
|
estimatedTotal := int64(len(knownFiles))
|
||||||
// estimate leaves them out.
|
|
||||||
estimatedTotal := countNonDirectories(knownFiles)
|
|
||||||
|
|
||||||
collector := &scanCollector{}
|
collector := &scanCollector{}
|
||||||
|
|
||||||
@@ -957,19 +955,6 @@ func (s *Scanner) scanPhase(
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// countNonDirectories returns how many of files are not directories.
|
|
||||||
func countNonDirectories(files map[string]*database.File) int64 {
|
|
||||||
var count int64
|
|
||||||
|
|
||||||
for _, f := range files {
|
|
||||||
if f.Mode&uint32(os.ModeDir) == 0 {
|
|
||||||
count++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return count
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
//nolint:testpackage // exercises the unexported countNonDirectories helper
|
|
||||||
package snapshot
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"sneak.berlin/go/vaultik/internal/database"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The walk counts regular files and symlinks, not directories, so the
|
|
||||||
// estimate of its count made from the local index's records leaves the
|
|
||||||
// directories out. See https://git.eeqj.de/sneak/vaultik/issues/305.
|
|
||||||
func TestCountNonDirectories(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
known := map[string]*database.File{
|
|
||||||
"/src": {Mode: uint32(os.ModeDir | 0o755)},
|
|
||||||
"/src/sub": {Mode: uint32(os.ModeDir | 0o755)},
|
|
||||||
"/src/a.txt": {Mode: 0o644},
|
|
||||||
"/src/link": {Mode: uint32(os.ModeSymlink | 0o777), LinkTarget: "a.txt"},
|
|
||||||
}
|
|
||||||
|
|
||||||
got := countNonDirectories(known)
|
|
||||||
if got != 2 {
|
|
||||||
t.Errorf("countNonDirectories = %d, want 2", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -21,13 +21,9 @@ type FileStorer struct {
|
|||||||
// NewFileStorer creates a new filesystem storage backend.
|
// NewFileStorer creates a new filesystem storage backend.
|
||||||
//
|
//
|
||||||
// Construction is intentionally cheap and does not touch the filesystem.
|
// Construction is intentionally cheap and does not touch the filesystem.
|
||||||
// The backend never creates basePath: the user does, so that when
|
// The basePath is recorded; the directory is created lazily on first
|
||||||
// basePath is a directory on an unplugged volume, a write fails instead
|
// write. Write operations (Put/PutWithProgress) call MkdirAll for the
|
||||||
// of landing on the disk under the mount point. This does not hold when
|
// per-blob parent directory, which also covers basePath on first use.
|
||||||
// basePath is the mount point itself, which stays behind when its volume
|
|
||||||
// is unplugged.
|
|
||||||
// Put and PutWithProgress fail on a missing basePath and create the
|
|
||||||
// directories below it as needed.
|
|
||||||
// Get and Stat report a key under a missing basePath as ErrNotFound.
|
// Get and Stat report a key under a missing basePath as ErrNotFound.
|
||||||
// List and ListStream fail on a missing basePath, because listing it as
|
// List and ListStream fail on a missing basePath, because listing it as
|
||||||
// an empty store would make `prune` drop every local snapshot record.
|
// an empty store would make `prune` drop every local snapshot record.
|
||||||
@@ -303,19 +299,7 @@ func (f *FileStorer) writeAtomic(
|
|||||||
path := f.fullPath(key)
|
path := f.fullPath(key)
|
||||||
dir := filepath.Dir(path)
|
dir := filepath.Dir(path)
|
||||||
|
|
||||||
// Checked on every write, so a destination that disappears part-way
|
err := f.fs.MkdirAll(dir, storageDirPerm)
|
||||||
// through a backup fails it rather than being recreated by MkdirAll.
|
|
||||||
_, err := f.fs.Stat(f.basePath)
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return fmt.Errorf("destination directory does not exist; "+
|
|
||||||
"create it, or mount the volume it is on: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("checking destination directory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = f.fs.MkdirAll(dir, storageDirPerm)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("creating directories: %w", err)
|
return fmt.Errorf("creating directories: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -292,43 +292,3 @@ func TestFileStorerDeletePartialUploadsThroughSymlinkedDirectory(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestFileStorerPutMissingDestination checks that a write fails when the
|
|
||||||
// destination directory has gone, even after earlier writes to it
|
|
||||||
// succeeded, and that it does not create the directory again.
|
|
||||||
func TestFileStorerPutMissingDestination(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
base := filepath.Join(t.TempDir(), "usbstick")
|
|
||||||
|
|
||||||
err := os.Mkdir(base, 0o750)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Mkdir: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
s, err := storage.NewFileStorer(base)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewFileStorer: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = s.Put(ctx, "blobs/aa/bb/first", strings.NewReader("first"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("first Put: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = os.Rename(base, base+"-unplugged")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Rename: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = s.Put(ctx, "blobs/aa/bb/second", strings.NewReader("second"))
|
|
||||||
if !errors.Is(err, fs.ErrNotExist) {
|
|
||||||
t.Errorf("Put = %v, want a not-exist error", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = os.Stat(base)
|
|
||||||
if !errors.Is(err, fs.ErrNotExist) {
|
|
||||||
t.Errorf("Put created the destination directory again (Stat = %v)", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -41,8 +41,7 @@ type Info struct {
|
|||||||
// directory for filesystem).
|
// directory for filesystem).
|
||||||
type Storer interface {
|
type Storer interface {
|
||||||
// Put stores data at the specified key.
|
// Put stores data at the specified key.
|
||||||
// The filesystem backend fails when its destination directory does
|
// Parent directories are created automatically for filesystem backends.
|
||||||
// not exist, and creates the directories below it as needed.
|
|
||||||
Put(ctx context.Context, key string, data io.Reader) error
|
Put(ctx context.Context, key string, data io.Reader) error
|
||||||
|
|
||||||
// PutWithProgress stores data with progress reporting.
|
// PutWithProgress stores data with progress reporting.
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ func TestBackupAfterRemovingNewestSnapshotRestoresChangedFile(t *testing.T) {
|
|||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
dataDir := filepath.Join(tempDir, "src")
|
dataDir := filepath.Join(tempDir, "src")
|
||||||
storeDir := t.TempDir()
|
storeDir := filepath.Join(tempDir, "remote")
|
||||||
restoreDir := filepath.Join(tempDir, "restored")
|
restoreDir := filepath.Join(tempDir, "restored")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
changedPath := filepath.Join(dataDir, "appended.bin")
|
changedPath := filepath.Join(dataDir, "appended.bin")
|
||||||
@@ -184,7 +184,7 @@ func TestBackupAfterInterruptedRunRestoresChangedFile(t *testing.T) {
|
|||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
dataDir := filepath.Join(tempDir, "src")
|
dataDir := filepath.Join(tempDir, "src")
|
||||||
storeDir := t.TempDir()
|
storeDir := filepath.Join(tempDir, "remote")
|
||||||
restoreDir := filepath.Join(tempDir, "restored")
|
restoreDir := filepath.Join(tempDir, "restored")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
changedPath := filepath.Join(dataDir, "appended.bin")
|
changedPath := filepath.Join(dataDir, "appended.bin")
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ func TestDeepVerifyAcceptsHealthyAndRejectsCorruptBlob(t *testing.T) {
|
|||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
dataDir := filepath.Join(tempDir, "source")
|
dataDir := filepath.Join(tempDir, "source")
|
||||||
storeDir := t.TempDir()
|
storeDir := filepath.Join(tempDir, "remote")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
chunkSize := int64(64 * 1024)
|
chunkSize := int64(64 * 1024)
|
||||||
@@ -94,7 +94,7 @@ func TestDeepVerifyJSONCountsBlobs(t *testing.T) {
|
|||||||
|
|
||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
storeDir := t.TempDir()
|
storeDir := filepath.Join(t.TempDir(), "remote")
|
||||||
|
|
||||||
cfg, storer, snapshotID, keys := backUpSeveralBlobs(ctx, t, storeDir)
|
cfg, storer, snapshotID, keys := backUpSeveralBlobs(ctx, t, storeDir)
|
||||||
|
|
||||||
@@ -151,7 +151,7 @@ func TestDeepVerifyJSONCountsWrongSizeBlob(t *testing.T) {
|
|||||||
|
|
||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
storeDir := t.TempDir()
|
storeDir := filepath.Join(t.TempDir(), "remote")
|
||||||
|
|
||||||
cfg, storer, snapshotID, keys := backUpSeveralBlobs(ctx, t, storeDir)
|
cfg, storer, snapshotID, keys := backUpSeveralBlobs(ctx, t, storeDir)
|
||||||
|
|
||||||
@@ -183,7 +183,7 @@ func TestDeepVerifyStopsOnBlobThatCannotBeChecked(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
storeDir := t.TempDir()
|
storeDir := filepath.Join(t.TempDir(), "remote")
|
||||||
|
|
||||||
cfg, storer, snapshotID, keys := backUpSeveralBlobs(ctx, t, storeDir)
|
cfg, storer, snapshotID, keys := backUpSeveralBlobs(ctx, t, storeDir)
|
||||||
failing := statFailingStorer{Storer: storer, failKeys: []string{keys[0]}}
|
failing := statFailingStorer{Storer: storer, failKeys: []string{keys[0]}}
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
package vaultik
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"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.
|
|
||||||
const destinationMarkerKey = "vaultik-destination.txt"
|
|
||||||
|
|
||||||
// destinationMarkerText is the whole content of the marker. It names no
|
|
||||||
// host, path or time.
|
|
||||||
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.
|
|
||||||
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.
|
|
||||||
func (v *Vaultik) checkDestinationMarker() error {
|
|
||||||
ids, err := v.localSnapshotIDs()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("listing local snapshots: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(ids) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Config is nil only in tests, which then record no binding (see
|
|
||||||
// EnsureStorageBinding).
|
|
||||||
if v.Config != nil {
|
|
||||||
stored, configured, err := v.storageBinding()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if stored != "" && stored != configured {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = v.Storage.Stat(v.ctx, destinationMarkerKey)
|
|
||||||
if errors.Is(err, storage.ErrNotFound) {
|
|
||||||
return fmt.Errorf("%w: %s has no %s, but the local index holds "+
|
|
||||||
"snapshots stored there; mount the volume if it is removable, "+
|
|
||||||
"or if the destination was emptied on purpose, run "+
|
|
||||||
"'vaultik database delete' and back up again",
|
|
||||||
errDestinationNotPresent, v.Storage.Info().Location,
|
|
||||||
destinationMarkerKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("checking for %s: %w", destinationMarkerKey, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// markDestination checks the backup destination store with
|
|
||||||
// checkDestinationMarker and then writes the marker, so a first backup
|
|
||||||
// marks its destination and a later one writes the same text again.
|
|
||||||
func (v *Vaultik) markDestination() error {
|
|
||||||
err := v.checkDestinationMarker()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = v.Storage.Put(v.ctx, destinationMarkerKey,
|
|
||||||
strings.NewReader(destinationMarkerText))
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("writing %s to the backup destination store: %w",
|
|
||||||
destinationMarkerKey, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,278 +0,0 @@
|
|||||||
package vaultik_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"sneak.berlin/go/vaultik/internal/database"
|
|
||||||
"sneak.berlin/go/vaultik/internal/log"
|
|
||||||
"sneak.berlin/go/vaultik/internal/storage"
|
|
||||||
"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.
|
|
||||||
|
|
||||||
// markerFile is the name of the file a backup writes at the root of the
|
|
||||||
// destination.
|
|
||||||
const markerFile = "vaultik-destination.txt"
|
|
||||||
|
|
||||||
// backUpThenUnplugMountPoint backs up to a file:// destination, then
|
|
||||||
// moves the destination directory away and leaves an empty directory in
|
|
||||||
// its place, as unplugging a volume mounted there would. It returns the
|
|
||||||
// path of that empty directory.
|
|
||||||
func backUpThenUnplugMountPoint(
|
|
||||||
ctx context.Context, t *testing.T,
|
|
||||||
) (*vaultik.Vaultik, *database.Repositories, *bytes.Buffer, string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
storeDir := filepath.Join(t.TempDir(), "mnt")
|
|
||||||
v, repos, out := backUpToFileDestination(ctx, t, storeDir)
|
|
||||||
|
|
||||||
require.NoError(t, os.Rename(storeDir, storeDir+"-unplugged"))
|
|
||||||
require.NoError(t, os.Mkdir(storeDir, 0o750))
|
|
||||||
|
|
||||||
return v, repos, out, storeDir
|
|
||||||
}
|
|
||||||
|
|
||||||
// assertUntouched checks that the local index still holds its one
|
|
||||||
// snapshot and that nothing was written to the empty mount point.
|
|
||||||
func assertUntouched(
|
|
||||||
ctx context.Context, t *testing.T,
|
|
||||||
repos *database.Repositories, storeDir string,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
snapshots, err := repos.Snapshots.ListRecent(ctx, listRecentTestLimit)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Len(t, snapshots, 1, "no local snapshot record may be deleted")
|
|
||||||
|
|
||||||
entries, err := os.ReadDir(storeDir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Empty(t, entries, "nothing may be written to the empty mount point")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestFirstBackupWritesMarker checks that a backup writes the marker
|
|
||||||
// file at the root of the destination.
|
|
||||||
func TestFirstBackupWritesMarker(t *testing.T) {
|
|
||||||
log.Initialize(log.Config{})
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
storeDir := t.TempDir()
|
|
||||||
backUpToFileDestination(context.Background(), t, storeDir)
|
|
||||||
|
|
||||||
assert.FileExists(t, filepath.Join(storeDir, markerFile))
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestBackupRefusesDestinationWithoutMarker checks that snapshot create
|
|
||||||
// --prune writes nothing and drops no local snapshot record when the
|
|
||||||
// destination has no marker.
|
|
||||||
func TestBackupRefusesDestinationWithoutMarker(t *testing.T) {
|
|
||||||
log.Initialize(log.Config{})
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
v, repos, _, storeDir := backUpThenUnplugMountPoint(ctx, t)
|
|
||||||
|
|
||||||
err := v.CreateSnapshot(&vaultik.SnapshotCreateOptions{
|
|
||||||
Cron: true,
|
|
||||||
Prune: true,
|
|
||||||
})
|
|
||||||
require.ErrorContains(t, err, markerFile)
|
|
||||||
|
|
||||||
assertUntouched(ctx, t, repos, storeDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
// incomplete snapshots before it scans; checked after that, the local
|
|
||||||
// index would hold none and nothing would be checked.
|
|
||||||
func TestBackupChecksMarkerBeforeDroppingIncompleteSnapshots(t *testing.T) {
|
|
||||||
log.Initialize(log.Config{})
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
storeDir := t.TempDir()
|
|
||||||
v, repos := newFileDestinationVaultik(ctx, t, storeDir)
|
|
||||||
|
|
||||||
require.NoError(t, repos.Snapshots.Create(ctx, nil, &database.Snapshot{
|
|
||||||
ID: "host_first_2026-01-02T03:04:05Z",
|
|
||||||
Hostname: "host",
|
|
||||||
StartedAt: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC),
|
|
||||||
}))
|
|
||||||
|
|
||||||
err := backUp(v, "first")
|
|
||||||
require.ErrorContains(t, err, markerFile)
|
|
||||||
|
|
||||||
assertUntouched(ctx, t, repos, storeDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestPruneRefusesDestinationWithoutMarker checks that prune fails and
|
|
||||||
// deletes no local snapshot record when the destination has no marker.
|
|
||||||
func TestPruneRefusesDestinationWithoutMarker(t *testing.T) {
|
|
||||||
log.Initialize(log.Config{})
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
v, repos, _, storeDir := backUpThenUnplugMountPoint(ctx, t)
|
|
||||||
|
|
||||||
err := v.Prune(&vaultik.PruneOptions{Force: true})
|
|
||||||
require.ErrorContains(t, err, markerFile)
|
|
||||||
|
|
||||||
assertUntouched(ctx, t, repos, storeDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestPurgeRefusesDestinationWithoutMarker checks that snapshot purge
|
|
||||||
// fails and deletes no local snapshot record when the destination has no
|
|
||||||
// marker.
|
|
||||||
func TestPurgeRefusesDestinationWithoutMarker(t *testing.T) {
|
|
||||||
log.Initialize(log.Config{})
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
v, repos, _, storeDir := backUpThenUnplugMountPoint(ctx, t)
|
|
||||||
|
|
||||||
err := v.PurgeSnapshotsWithOptions(&vaultik.SnapshotPurgeOptions{
|
|
||||||
KeepLatest: true,
|
|
||||||
Force: true,
|
|
||||||
})
|
|
||||||
require.ErrorContains(t, err, markerFile)
|
|
||||||
|
|
||||||
assertUntouched(ctx, t, repos, storeDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRemoveSnapshotWarnsWhenDestinationHasNoMarker checks that snapshot
|
|
||||||
// remove warns that the metadata could not be removed from a destination
|
|
||||||
// without a marker, instead of reporting that it was.
|
|
||||||
func TestRemoveSnapshotWarnsWhenDestinationHasNoMarker(t *testing.T) {
|
|
||||||
log.Initialize(log.Config{})
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
v, repos, out, _ := backUpThenUnplugMountPoint(ctx, t)
|
|
||||||
|
|
||||||
result, err := v.RemoveSnapshot(localSnapshotID(ctx, t, repos, "first"),
|
|
||||||
&vaultik.RemoveOptions{Force: true})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
assert.False(t, result.RemoteRemoved)
|
|
||||||
assert.Contains(t, out.String(),
|
|
||||||
"Could not remove snapshot metadata from remote")
|
|
||||||
assert.Contains(t, out.String(), markerFile)
|
|
||||||
assert.NotContains(t, out.String(),
|
|
||||||
"Removed snapshot metadata from remote storage")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRemoveSnapshotOnlyOnDestinationFailsWithoutMarker checks that
|
|
||||||
// snapshot remove, for a snapshot that is only on the destination, fails
|
|
||||||
// saying the destination is not present when it has no marker, instead
|
|
||||||
// of saying the snapshot does not exist.
|
|
||||||
func TestRemoveSnapshotOnlyOnDestinationFailsWithoutMarker(t *testing.T) {
|
|
||||||
log.Initialize(log.Config{})
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
storeDir := filepath.Join(t.TempDir(), "mnt")
|
|
||||||
v, repos, _ := backUpToFileDestination(ctx, t, storeDir)
|
|
||||||
require.NoError(t, backUp(v, "second"))
|
|
||||||
|
|
||||||
id := localSnapshotID(ctx, t, repos, "first")
|
|
||||||
_, err := v.RemoveSnapshot(id,
|
|
||||||
&vaultik.RemoveOptions{Force: true, LocalOnly: true})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Unplug the volume: an empty directory stays at its mount point.
|
|
||||||
require.NoError(t, os.Rename(storeDir, storeDir+"-unplugged"))
|
|
||||||
require.NoError(t, os.Mkdir(storeDir, 0o750))
|
|
||||||
|
|
||||||
_, err = v.RemoveSnapshot(id, &vaultik.RemoveOptions{Force: true})
|
|
||||||
require.ErrorContains(t, err, markerFile)
|
|
||||||
require.ErrorContains(t, err, "mount the volume")
|
|
||||||
|
|
||||||
assertUntouched(ctx, t, repos, storeDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestListSnapshotsWarnsWhenDestinationHasNoMarker checks that snapshot
|
|
||||||
// list warns and shows the local index alone when the destination has no
|
|
||||||
// marker, without reporting the local snapshot as missing from it.
|
|
||||||
func TestListSnapshotsWarnsWhenDestinationHasNoMarker(t *testing.T) {
|
|
||||||
log.Initialize(log.Config{})
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
v, repos, out, _ := backUpThenUnplugMountPoint(ctx, t)
|
|
||||||
id := localSnapshotID(ctx, t, repos, "first")
|
|
||||||
|
|
||||||
require.NoError(t, v.ListSnapshots(false))
|
|
||||||
|
|
||||||
assert.Contains(t, out.String(), "Could not list backup destination store")
|
|
||||||
assert.Contains(t, out.String(), markerFile)
|
|
||||||
assert.Contains(t, out.String(), id)
|
|
||||||
assert.NotContains(t, out.String(), "not found in backup destination store")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestListSnapshotsAfterStorageURLChangeToEmptyDirectory checks that
|
|
||||||
// snapshot list, after storage_url is changed to an empty directory,
|
|
||||||
// warns only that the local index is bound to a different destination:
|
|
||||||
// the local snapshots are stored at the old one, so the new one's lack
|
|
||||||
// of a marker says nothing. With --json the local row is not on the new
|
|
||||||
// destination, so remote_present is false, not unknown.
|
|
||||||
func TestListSnapshotsAfterStorageURLChangeToEmptyDirectory(t *testing.T) {
|
|
||||||
log.Initialize(log.Config{})
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
v, repos, out := backUpToFileDestination(ctx, t, t.TempDir())
|
|
||||||
id := localSnapshotID(ctx, t, repos, "first")
|
|
||||||
|
|
||||||
newDir := t.TempDir()
|
|
||||||
store, err := storage.NewFileStorer(newDir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
v.Storage = store
|
|
||||||
v.Config.StorageURL = "file://" + newDir
|
|
||||||
|
|
||||||
require.NoError(t, v.ListSnapshots(false))
|
|
||||||
|
|
||||||
assert.Contains(t, out.String(), id)
|
|
||||||
assert.Contains(t, out.String(),
|
|
||||||
"local index is bound to a different backup destination")
|
|
||||||
assert.NotContains(t, out.String(), "Could not list backup destination store")
|
|
||||||
assert.NotContains(t, out.String(), markerFile)
|
|
||||||
|
|
||||||
out.Reset()
|
|
||||||
require.NoError(t, v.ListSnapshots(true))
|
|
||||||
|
|
||||||
rows := decodeListJSON(t, out.String())
|
|
||||||
require.Len(t, rows, 1)
|
|
||||||
require.NotNil(t, rows[0].RemotePresent)
|
|
||||||
assert.False(t, *rows[0].RemotePresent)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestBackupAfterNukeRemoteSucceeds checks that remote nuke leaves the
|
|
||||||
// destination ready for a new first backup, which writes the marker
|
|
||||||
// again.
|
|
||||||
func TestBackupAfterNukeRemoteSucceeds(t *testing.T) {
|
|
||||||
log.Initialize(log.Config{})
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
storeDir := t.TempDir()
|
|
||||||
v, _, _ := backUpToFileDestination(context.Background(), t, storeDir)
|
|
||||||
|
|
||||||
require.NoError(t, v.NukeRemote(true))
|
|
||||||
assert.NoFileExists(t, filepath.Join(storeDir, markerFile))
|
|
||||||
|
|
||||||
require.NoError(t, backUp(v, "second"))
|
|
||||||
assert.FileExists(t, filepath.Join(storeDir, markerFile))
|
|
||||||
}
|
|
||||||
@@ -192,7 +192,7 @@ func assertRestoreRejectsDamagedBlob(
|
|||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
dataDir := filepath.Join(tempDir, "src")
|
dataDir := filepath.Join(tempDir, "src")
|
||||||
storeDir := t.TempDir()
|
storeDir := filepath.Join(tempDir, "remote")
|
||||||
restoreDir := filepath.Join(tempDir, "restored")
|
restoreDir := filepath.Join(tempDir, "restored")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
@@ -238,7 +238,7 @@ func TestDeepVerifyCatchesLyingBackend(t *testing.T) {
|
|||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
dataDir := filepath.Join(tempDir, "src")
|
dataDir := filepath.Join(tempDir, "src")
|
||||||
storeDir := t.TempDir()
|
storeDir := filepath.Join(tempDir, "remote")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -290,7 +290,7 @@ func TestInterruptedBlobUploadRecordsNoUploadedBlob(t *testing.T) {
|
|||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
dataDir := filepath.Join(tempDir, "src")
|
dataDir := filepath.Join(tempDir, "src")
|
||||||
storeDir := t.TempDir()
|
storeDir := filepath.Join(tempDir, "remote")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -298,6 +298,10 @@ func TestInterruptedBlobUploadRecordsNoUploadedBlob(t *testing.T) {
|
|||||||
|
|
||||||
writeFaultSourceTree(t, fs, dataDir)
|
writeFaultSourceTree(t, fs, dataDir)
|
||||||
|
|
||||||
|
// No upload succeeds, so nothing creates the destination directory.
|
||||||
|
// It must exist for the listing below to show that no blob survived.
|
||||||
|
require.NoError(t, os.Mkdir(storeDir, 0o750))
|
||||||
|
|
||||||
inner, err := storage.NewFileStorer(storeDir)
|
inner, err := storage.NewFileStorer(storeDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@@ -361,7 +365,7 @@ func TestBackupRetryAfterInterruptedUploadIsRestorable(t *testing.T) {
|
|||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
dataDir := filepath.Join(tempDir, "src")
|
dataDir := filepath.Join(tempDir, "src")
|
||||||
storeDir := t.TempDir()
|
storeDir := filepath.Join(tempDir, "remote")
|
||||||
restoreDir := filepath.Join(tempDir, "restored")
|
restoreDir := filepath.Join(tempDir, "restored")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
@@ -425,7 +429,7 @@ func TestBackupSurvivesMetadataExportInterruption(t *testing.T) {
|
|||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
dataDir := filepath.Join(tempDir, "src")
|
dataDir := filepath.Join(tempDir, "src")
|
||||||
storeDir := t.TempDir()
|
storeDir := filepath.Join(tempDir, "remote")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -441,10 +445,6 @@ func TestBackupSurvivesMetadataExportInterruption(t *testing.T) {
|
|||||||
|
|
||||||
repos := database.NewRepositories(db)
|
repos := database.NewRepositories(db)
|
||||||
|
|
||||||
// snapshot create writes the marker; this backup runs its steps one
|
|
||||||
// by one, so the marker is written here.
|
|
||||||
require.NoError(t, inner.Put(ctx, markerFile, strings.NewReader("")))
|
|
||||||
|
|
||||||
// Back up and complete with a working backend.
|
// Back up and complete with a working backend.
|
||||||
sm := newFaultSnapshotManager(fs, inner, cfg, repos)
|
sm := newFaultSnapshotManager(fs, inner, cfg, repos)
|
||||||
scanner := newFaultScanner(fs, inner, cfg, repos)
|
scanner := newFaultScanner(fs, inner, cfg, repos)
|
||||||
@@ -493,14 +493,8 @@ func TestBackupSurvivesMetadataExportInterruption(t *testing.T) {
|
|||||||
"shallow verify must report the missing manifest")
|
"shallow verify must report the missing manifest")
|
||||||
|
|
||||||
// snapshot list must not crash on the partial snapshot.
|
// snapshot list must not crash on the partial snapshot.
|
||||||
var listOut strings.Builder
|
|
||||||
|
|
||||||
reader.UI = ui.NewWithColor(&listOut, false)
|
|
||||||
require.NoError(t, reader.ListSnapshots(false),
|
require.NoError(t, reader.ListSnapshots(false),
|
||||||
"snapshot list must tolerate a partially-exported snapshot")
|
"snapshot list must tolerate a partially-exported snapshot")
|
||||||
assert.NotContains(t, listOut.String(),
|
|
||||||
"Could not list backup destination store",
|
|
||||||
"snapshot list must read the partial snapshot from the destination")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scenario 2, repair: the process dies during the metadata export of a
|
// Scenario 2, repair: the process dies during the metadata export of a
|
||||||
@@ -517,7 +511,7 @@ func TestBackupCompletesOnlyAfterMetadataExport(t *testing.T) {
|
|||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
dataDir := filepath.Join(tempDir, "src")
|
dataDir := filepath.Join(tempDir, "src")
|
||||||
storeDir := t.TempDir()
|
storeDir := filepath.Join(tempDir, "remote")
|
||||||
restoreDir := filepath.Join(tempDir, "restored")
|
restoreDir := filepath.Join(tempDir, "restored")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
@@ -693,7 +687,7 @@ func TestRestoreReportsDiskFull(t *testing.T) {
|
|||||||
osFS := afero.NewOsFs()
|
osFS := afero.NewOsFs()
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
dataDir := filepath.Join(tempDir, "src")
|
dataDir := filepath.Join(tempDir, "src")
|
||||||
storeDir := t.TempDir()
|
storeDir := filepath.Join(tempDir, "remote")
|
||||||
restoreDir := filepath.Join(tempDir, "restored")
|
restoreDir := filepath.Join(tempDir, "restored")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
|
|||||||
@@ -771,7 +771,7 @@ func TestEndToEndFileStorage(t *testing.T) {
|
|||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
dataDir := filepath.Join(tempDir, "source")
|
dataDir := filepath.Join(tempDir, "source")
|
||||||
storeDir := t.TempDir()
|
storeDir := filepath.Join(tempDir, "remote")
|
||||||
restoreDir := filepath.Join(tempDir, "restored")
|
restoreDir := filepath.Join(tempDir, "restored")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
@@ -964,7 +964,7 @@ func TestDedupOnlySnapshotRestores(t *testing.T) {
|
|||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
dataDir := filepath.Join(tempDir, "source")
|
dataDir := filepath.Join(tempDir, "source")
|
||||||
storeDir := t.TempDir()
|
storeDir := filepath.Join(tempDir, "remote")
|
||||||
restoreDir := filepath.Join(tempDir, "restored")
|
restoreDir := filepath.Join(tempDir, "restored")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
|
|||||||
@@ -24,11 +24,13 @@ import (
|
|||||||
// that is not plugged in, cannot be listed. It is not an empty store, so
|
// that is not plugged in, cannot be listed. It is not an empty store, so
|
||||||
// no command may conclude from it that the local snapshots are gone.
|
// no command may conclude from it that the local snapshots are gone.
|
||||||
|
|
||||||
// newFileDestinationVaultik returns a Vaultik that backs up the
|
// backUpToFileDestination backs up the snapshot named "first" to a
|
||||||
// snapshot named "first" to a file:// destination at storeDir.
|
// file:// destination at storeDir, which need not exist yet. Everything
|
||||||
func newFileDestinationVaultik(
|
// the returned Vaultik prints after the backup goes to the returned
|
||||||
|
// buffer.
|
||||||
|
func backUpToFileDestination(
|
||||||
ctx context.Context, t *testing.T, storeDir string,
|
ctx context.Context, t *testing.T, storeDir string,
|
||||||
) (*vaultik.Vaultik, *database.Repositories) {
|
) (*vaultik.Vaultik, *database.Repositories, *bytes.Buffer) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
osFs := afero.NewOsFs()
|
osFs := afero.NewOsFs()
|
||||||
@@ -47,22 +49,8 @@ func newFileDestinationVaultik(
|
|||||||
|
|
||||||
repos := database.NewRepositories(db)
|
repos := database.NewRepositories(db)
|
||||||
cfg := changedFileConfig(dataDir, dbPath)
|
cfg := changedFileConfig(dataDir, dbPath)
|
||||||
|
v := newBackupVaultik(ctx, cfg, store, repos, db, osFs)
|
||||||
|
|
||||||
return newBackupVaultik(ctx, cfg, store, repos, db, osFs), repos
|
|
||||||
}
|
|
||||||
|
|
||||||
// backUpToFileDestination creates the directory storeDir, as a user must
|
|
||||||
// before the first backup, and backs up the snapshot named "first" to a
|
|
||||||
// file:// destination there. Everything the returned Vaultik prints
|
|
||||||
// after the backup goes to the returned buffer.
|
|
||||||
func backUpToFileDestination(
|
|
||||||
ctx context.Context, t *testing.T, storeDir string,
|
|
||||||
) (*vaultik.Vaultik, *database.Repositories, *bytes.Buffer) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
require.NoError(t, os.MkdirAll(storeDir, 0o750))
|
|
||||||
|
|
||||||
v, repos := newFileDestinationVaultik(ctx, t, storeDir)
|
|
||||||
require.NoError(t, backUp(v, "first"))
|
require.NoError(t, backUp(v, "first"))
|
||||||
|
|
||||||
out := &bytes.Buffer{}
|
out := &bytes.Buffer{}
|
||||||
@@ -87,28 +75,21 @@ func backUpThenUnplug(
|
|||||||
return v, repos, out
|
return v, repos, out
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestBackupFailsWhenDestinationDirectoryMissing checks that a backup to
|
// TestFirstBackupCreatesDestinationDirectory checks that a first backup
|
||||||
// a file:// destination whose directory does not exist fails before it
|
// to a destination directory that does not exist yet creates it, and
|
||||||
// records a snapshot, and does not create the directory.
|
// that the destination can be listed afterwards.
|
||||||
func TestBackupFailsWhenDestinationDirectoryMissing(t *testing.T) {
|
func TestFirstBackupCreatesDestinationDirectory(t *testing.T) {
|
||||||
log.Initialize(log.Config{})
|
log.Initialize(log.Config{})
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
volume := filepath.Join(t.TempDir(), "volume")
|
storeDir := filepath.Join(t.TempDir(), "volume", "backup")
|
||||||
v, repos := newFileDestinationVaultik(ctx, t, filepath.Join(volume, "backup"))
|
v, _, out := backUpToFileDestination(ctx, t, storeDir)
|
||||||
|
|
||||||
err := backUp(v, "first")
|
require.NoError(t, v.ListSnapshots(false))
|
||||||
require.ErrorIs(t, err, fs.ErrNotExist)
|
|
||||||
require.ErrorContains(t, err, "create it, or mount the volume it is on")
|
|
||||||
|
|
||||||
_, err = os.Stat(volume)
|
assert.NotContains(t, out.String(), "Could not list backup destination store")
|
||||||
require.ErrorIs(t, err, fs.ErrNotExist,
|
assert.NotContains(t, out.String(), "not found in backup destination store")
|
||||||
"the backup must not create the destination directory")
|
|
||||||
|
|
||||||
snapshots, err := repos.Snapshots.ListRecent(ctx, listRecentTestLimit)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Empty(t, snapshots)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestListSnapshotsWarnsWhenDestinationMissing checks that snapshot list
|
// TestListSnapshotsWarnsWhenDestinationMissing checks that snapshot list
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ func TestNewChunkSizeOfChangedFileStaysFixed(t *testing.T) {
|
|||||||
|
|
||||||
appendRandomBytes(t, fs, files, changedPath, appendedFileSize)
|
appendRandomBytes(t, fs, files, changedPath, appendedFileSize)
|
||||||
|
|
||||||
store, err := storage.NewFileStorer(t.TempDir())
|
store, err := storage.NewFileStorer(filepath.Join(tempDir, "remote"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
db, err := database.New(ctx, dbPath)
|
db, err := database.New(ctx, dbPath)
|
||||||
|
|||||||
@@ -42,10 +42,9 @@ const blobKeyParts = 4
|
|||||||
|
|
||||||
// NukeRemote deletes every object under metadata/ and blobs/ in remote
|
// NukeRemote deletes every object under metadata/ and blobs/ in remote
|
||||||
// storage, whatever its name, along with any object an upload cut off
|
// storage, whatever its name, along with any object an upload cut off
|
||||||
// part-way left under a temporary `.partial` name, and last the
|
// part-way left under a temporary `.partial` name. It returns
|
||||||
// destination marker. It returns successfully only when a listing
|
// successfully only when a listing afterwards finds nothing left there,
|
||||||
// afterwards finds nothing left under metadata/ and blobs/, and the next
|
// and the next backup then starts from scratch.
|
||||||
// backup then starts from scratch.
|
|
||||||
//
|
//
|
||||||
// Refuses to run unless force is true. The caller is responsible for
|
// Refuses to run unless force is true. The caller is responsible for
|
||||||
// confirming with the user.
|
// confirming with the user.
|
||||||
@@ -106,13 +105,6 @@ func (v *Vaultik) emptyDestinationStore() error {
|
|||||||
return fmt.Errorf("%d %w", len(left), errObjectsLeft)
|
return fmt.Errorf("%d %w", len(left), errObjectsLeft)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Last, once the local snapshot records are gone: the next backup
|
|
||||||
// then has none and writes the marker again.
|
|
||||||
err = v.Storage.Delete(v.ctx, destinationMarkerKey)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("deleting %s: %w", destinationMarkerKey, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,9 +35,6 @@ func setupConsistencyTest(
|
|||||||
repos := database.NewRepositories(db)
|
repos := database.NewRepositories(db)
|
||||||
mockStorage := NewMockStorer()
|
mockStorage := NewMockStorer()
|
||||||
|
|
||||||
// A backup wrote to this destination, so it carries the marker.
|
|
||||||
require.NoError(t, mockStorage.Put(ctx, markerFile, strings.NewReader("")))
|
|
||||||
|
|
||||||
for _, id := range snapshotIDs {
|
for _, id := range snapshotIDs {
|
||||||
parts := strings.Split(id, "_")
|
parts := strings.Split(id, "_")
|
||||||
startedAt, err := time.Parse(time.RFC3339, parts[len(parts)-1])
|
startedAt, err := time.Parse(time.RFC3339, parts[len(parts)-1])
|
||||||
|
|||||||
@@ -45,9 +45,6 @@ func setupPurgeTest(
|
|||||||
repos := database.NewRepositories(db)
|
repos := database.NewRepositories(db)
|
||||||
mockStorage := NewMockStorer()
|
mockStorage := NewMockStorer()
|
||||||
|
|
||||||
// A backup wrote to this destination, so it carries the marker.
|
|
||||||
require.NoError(t, mockStorage.Put(ctx, markerFile, strings.NewReader("")))
|
|
||||||
|
|
||||||
// Insert each snapshot into the DB and create remote metadata stubs.
|
// Insert each snapshot into the DB and create remote metadata stubs.
|
||||||
// Use timestamps parsed from snapshot IDs for realistic ordering.
|
// Use timestamps parsed from snapshot IDs for realistic ordering.
|
||||||
for _, id := range snapshotIDs {
|
for _, id := range snapshotIDs {
|
||||||
|
|||||||
@@ -96,10 +96,6 @@ func TestRemoveSnapshot_QuietSuppressesReport(t *testing.T) {
|
|||||||
store := newTestStorer()
|
store := newTestStorer()
|
||||||
addManifest(t, store, "snapshot-001", nil)
|
addManifest(t, store, "snapshot-001", nil)
|
||||||
|
|
||||||
// A backup wrote to this destination, so it carries the marker.
|
|
||||||
require.NoError(t, store.Put(context.Background(), markerFile,
|
|
||||||
bytes.NewReader(nil)))
|
|
||||||
|
|
||||||
tv := vaultik.NewForTesting(store)
|
tv := vaultik.NewForTesting(store)
|
||||||
tv.Repositories = newLocalIndex(t, "snapshot-001")
|
tv.Repositories = newLocalIndex(t, "snapshot-001")
|
||||||
tv.UI.SetQuiet(true)
|
tv.UI.SetQuiet(true)
|
||||||
|
|||||||
@@ -304,10 +304,6 @@ func TestRemoveSnapshot_DefaultRemovesMetadataNotBlobs(t *testing.T) {
|
|||||||
addBlob(t, store, blobUnique)
|
addBlob(t, store, blobUnique)
|
||||||
addBlob(t, store, blobShared)
|
addBlob(t, store, blobShared)
|
||||||
|
|
||||||
// A backup wrote to this destination, so it carries the marker.
|
|
||||||
require.NoError(t, store.Put(context.Background(), markerFile,
|
|
||||||
bytes.NewReader(nil)))
|
|
||||||
|
|
||||||
tv := vaultik.NewForTesting(store)
|
tv := vaultik.NewForTesting(store)
|
||||||
tv.Repositories = newLocalIndex(t, "snapshot-001", "snapshot-002")
|
tv.Repositories = newLocalIndex(t, "snapshot-001", "snapshot-002")
|
||||||
|
|
||||||
@@ -377,10 +373,6 @@ func TestRemoveSnapshot_UnknownIDIsError(t *testing.T) {
|
|||||||
store := newTestStorer()
|
store := newTestStorer()
|
||||||
addManifest(t, store, "snapshot-001", nil)
|
addManifest(t, store, "snapshot-001", nil)
|
||||||
|
|
||||||
// A backup wrote to this destination, so it carries the marker.
|
|
||||||
require.NoError(t, store.Put(context.Background(), markerFile,
|
|
||||||
bytes.NewReader(nil)))
|
|
||||||
|
|
||||||
tv := vaultik.NewForTesting(store)
|
tv := vaultik.NewForTesting(store)
|
||||||
tv.Repositories = newLocalIndex(t, "snapshot-001")
|
tv.Repositories = newLocalIndex(t, "snapshot-001")
|
||||||
|
|
||||||
@@ -401,12 +393,7 @@ func TestRemoveSnapshot_NoMetadataOnDestination(t *testing.T) {
|
|||||||
log.Initialize(log.Config{})
|
log.Initialize(log.Config{})
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
// A backup wrote to this destination, so it carries the marker.
|
tv := vaultik.NewForTesting(newTestStorer())
|
||||||
store := newTestStorer()
|
|
||||||
require.NoError(t, store.Put(context.Background(), markerFile,
|
|
||||||
bytes.NewReader(nil)))
|
|
||||||
|
|
||||||
tv := vaultik.NewForTesting(store)
|
|
||||||
tv.Repositories = newLocalIndex(t, "snapshot-001")
|
tv.Repositories = newLocalIndex(t, "snapshot-001")
|
||||||
|
|
||||||
result, err := tv.RemoveSnapshot("snapshot-001",
|
result, err := tv.RemoveSnapshot("snapshot-001",
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"filippo.io/age"
|
"filippo.io/age"
|
||||||
@@ -220,7 +219,9 @@ func (v *Vaultik) finishRestore(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if os.Geteuid() != 0 {
|
if os.Geteuid() != 0 {
|
||||||
v.warnIfOwnershipNotPreserved(files, opts.TargetDir)
|
v.UI.Warningf("Restore did not preserve file ownership: chown(2) " +
|
||||||
|
"requires root. Re-run as root (e.g. with sudo) if you need " +
|
||||||
|
"original UID/GID preserved.")
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.FilesFailed > 0 {
|
if result.FilesFailed > 0 {
|
||||||
@@ -246,43 +247,6 @@ func (v *Vaultik) finishRestore(
|
|||||||
return unmatchedErr
|
return unmatchedErr
|
||||||
}
|
}
|
||||||
|
|
||||||
// warnIfOwnershipNotPreserved warns once if some entry restored under
|
|
||||||
// targetDir has another owner or group on disk than the snapshot
|
|
||||||
// records. It is meant for a restore by a normal user, which changes no
|
|
||||||
// owner or group: a new entry is owned by that user, and its group is
|
|
||||||
// that user's, or the group of the directory it is created in on macOS
|
|
||||||
// and in a setgid directory on Linux. An entry that is not on disk, or
|
|
||||||
// whose filesystem reports no owner, is skipped.
|
|
||||||
func (v *Vaultik) warnIfOwnershipNotPreserved(
|
|
||||||
files []*database.File, targetDir string,
|
|
||||||
) {
|
|
||||||
for _, file := range files {
|
|
||||||
targetPath, err := containedRestorePath(
|
|
||||||
v.Fs, targetDir, file.Path.String())
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
info, err := lstatIfPossible(v.Fs, targetPath)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
owner, ok := info.Sys().(*syscall.Stat_t)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if owner.Uid != file.UID || owner.Gid != file.GID {
|
|
||||||
v.UI.Warningf("Restore did not preserve file ownership: chown(2) " +
|
|
||||||
"requires root. Re-run as root (e.g. with sudo) if you need " +
|
|
||||||
"original UID/GID preserved.")
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// reportUnmatchedPaths warns, by name, about each requested path that
|
// reportUnmatchedPaths warns, by name, about each requested path that
|
||||||
// matched nothing in the snapshot, and returns an error counting them,
|
// matched nothing in the snapshot, and returns an error counting them,
|
||||||
// or nil when there are none.
|
// or nil when there are none.
|
||||||
@@ -1001,9 +965,8 @@ type restoreSession struct {
|
|||||||
// root can chown a file to an arbitrary UID/GID — non-root chown
|
// root can chown a file to an arbitrary UID/GID — non-root chown
|
||||||
// always fails with EPERM. Attempting it anyway produces N
|
// always fails with EPERM. Attempting it anyway produces N
|
||||||
// guaranteed-failed syscalls + N noisy debug lines, so we skip
|
// guaranteed-failed syscalls + N noisy debug lines, so we skip
|
||||||
// the call entirely as non-root; warnIfOwnershipNotPreserved warns
|
// the call entirely as non-root and emit one warning at the end
|
||||||
// once at the end of the restore if that left any owner or group
|
// of the restore explaining that ownership was not preserved.
|
||||||
// other than recorded.
|
|
||||||
runningAsRoot bool
|
runningAsRoot bool
|
||||||
// directories holds every restored directory, for
|
// directories holds every restored directory, for
|
||||||
// applyDirectoryMetadata to finish after the restore loop.
|
// applyDirectoryMetadata to finish after the restore loop.
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ func TestRestoreOnAnotherMachine(t *testing.T) {
|
|||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
dataDir := filepath.Join(tempDir, "source")
|
dataDir := filepath.Join(tempDir, "source")
|
||||||
storeDir := t.TempDir()
|
storeDir := filepath.Join(tempDir, "remote")
|
||||||
restoreDir := filepath.Join(tempDir, "restored")
|
restoreDir := filepath.Join(tempDir, "restored")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ func TestRestoreCleansTempDirOnInterrupt(t *testing.T) {
|
|||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
|
|
||||||
dataDir := filepath.Join(root, "source")
|
dataDir := filepath.Join(root, "source")
|
||||||
storeDir := t.TempDir()
|
storeDir := filepath.Join(root, "remote")
|
||||||
restoreDir := filepath.Join(root, "restored")
|
restoreDir := filepath.Join(root, "restored")
|
||||||
dbPath := filepath.Join(root, "index.sqlite")
|
dbPath := filepath.Join(root, "index.sqlite")
|
||||||
|
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ func TestRestoreLocalityAndReadAt(t *testing.T) {
|
|||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
dataDir := filepath.Join(tempDir, "source")
|
dataDir := filepath.Join(tempDir, "source")
|
||||||
storeDir := t.TempDir()
|
storeDir := filepath.Join(tempDir, "remote")
|
||||||
restoreDir := filepath.Join(tempDir, "restored")
|
restoreDir := filepath.Join(tempDir, "restored")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
|
|||||||
@@ -1,131 +0,0 @@
|
|||||||
package vaultik //nolint:testpackage // calls the unexported ownership check
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"syscall"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/spf13/afero"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"sneak.berlin/go/vaultik/internal/database"
|
|
||||||
"sneak.berlin/go/vaultik/internal/log"
|
|
||||||
"sneak.berlin/go/vaultik/internal/ui"
|
|
||||||
)
|
|
||||||
|
|
||||||
// These tests cover https://git.eeqj.de/sneak/vaultik/issues/316: a
|
|
||||||
// restore by a normal user warns that ownership was not preserved only
|
|
||||||
// when some restored entry has another owner or group on disk than the
|
|
||||||
// snapshot records.
|
|
||||||
|
|
||||||
const ownershipWarning = "did not preserve file ownership"
|
|
||||||
|
|
||||||
// TestOwnershipWarningSilentWhenEntriesMatch checks that a restore whose
|
|
||||||
// entries all end up with their recorded owner and group prints no
|
|
||||||
// warning.
|
|
||||||
func TestOwnershipWarningSilentWhenEntriesMatch(t *testing.T) {
|
|
||||||
log.Initialize(log.Config{})
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
targetDir := t.TempDir()
|
|
||||||
owner := ownerOf(t, targetDir)
|
|
||||||
|
|
||||||
out := restoreAsNormalUser(t, targetDir, []*database.File{
|
|
||||||
{Path: "/a", Mode: plainFileMode, UID: owner.Uid, GID: owner.Gid},
|
|
||||||
{Path: "/b", Mode: plainFileMode, UID: owner.Uid, GID: owner.Gid},
|
|
||||||
})
|
|
||||||
|
|
||||||
assert.Empty(t, out)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestOwnershipWarningWhenOwnerDiffers checks that the warning is printed
|
|
||||||
// when an entry recorded with another owner is restored owned by the
|
|
||||||
// restoring user, even though its group matches the recorded one.
|
|
||||||
func TestOwnershipWarningWhenOwnerDiffers(t *testing.T) {
|
|
||||||
log.Initialize(log.Config{})
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
targetDir := t.TempDir()
|
|
||||||
owner := ownerOf(t, targetDir)
|
|
||||||
|
|
||||||
out := restoreAsNormalUser(t, targetDir, []*database.File{
|
|
||||||
{Path: "/a", Mode: plainFileMode, UID: otherOwnerID, GID: owner.Gid},
|
|
||||||
})
|
|
||||||
|
|
||||||
assert.Equal(t, 1, strings.Count(out, ownershipWarning), out)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestOwnershipWarningWhenDirectoryGivesItsGroup checks that the warning
|
|
||||||
// is printed, once, when entries recorded with the restoring user's
|
|
||||||
// owner and group take another group from the setgid directory they are
|
|
||||||
// restored into, as every new entry takes its directory's group on
|
|
||||||
// macOS.
|
|
||||||
func TestOwnershipWarningWhenDirectoryGivesItsGroup(t *testing.T) {
|
|
||||||
log.Initialize(log.Config{})
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
if os.Geteuid() != 0 {
|
|
||||||
t.Skip("giving a directory to another group needs root")
|
|
||||||
}
|
|
||||||
|
|
||||||
targetDir := t.TempDir()
|
|
||||||
user := ownerOf(t, targetDir)
|
|
||||||
|
|
||||||
require.NoError(t, os.Chown(targetDir, -1, int(otherOwnerID)))
|
|
||||||
require.NoError(t, os.Chmod(targetDir, os.ModeSetgid|writableTestMode))
|
|
||||||
|
|
||||||
out := restoreAsNormalUser(t, targetDir, []*database.File{
|
|
||||||
{Path: "/a", Mode: plainFileMode, UID: user.Uid, GID: user.Gid},
|
|
||||||
{Path: "/b", Mode: plainFileMode, UID: user.Uid, GID: user.Gid},
|
|
||||||
})
|
|
||||||
|
|
||||||
assert.Equal(t, 1, strings.Count(out, ownershipWarning), out)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ownerOf returns the owner and group path has on disk.
|
|
||||||
func ownerOf(t *testing.T, path string) *syscall.Stat_t {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
info, err := os.Lstat(path)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
owner, ok := info.Sys().(*syscall.Stat_t)
|
|
||||||
require.True(t, ok)
|
|
||||||
|
|
||||||
return owner
|
|
||||||
}
|
|
||||||
|
|
||||||
// restoreAsNormalUser restores rows, all empty regular files, into
|
|
||||||
// targetDir without changing any owner or group, as restore does for a
|
|
||||||
// normal user, and returns what warnIfOwnershipNotPreserved prints.
|
|
||||||
func restoreAsNormalUser(
|
|
||||||
t *testing.T, targetDir string, rows []*database.File,
|
|
||||||
) string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
rows, repos := makeFiles(ctx, t, rows)
|
|
||||||
|
|
||||||
var out bytes.Buffer
|
|
||||||
|
|
||||||
v := newContainmentVaultik(ctx, afero.NewOsFs())
|
|
||||||
v.UI = ui.NewWithColor(&out, false)
|
|
||||||
|
|
||||||
session := &restoreSession{
|
|
||||||
v: v,
|
|
||||||
ctx: ctx,
|
|
||||||
repos: repos,
|
|
||||||
opts: &RestoreOptions{TargetDir: targetDir},
|
|
||||||
result: &RestoreResult{},
|
|
||||||
}
|
|
||||||
for _, row := range rows {
|
|
||||||
require.NoError(t, session.restoreFile(row))
|
|
||||||
}
|
|
||||||
|
|
||||||
v.warnIfOwnershipNotPreserved(rows, targetDir)
|
|
||||||
|
|
||||||
return out.String()
|
|
||||||
}
|
|
||||||
@@ -107,7 +107,7 @@ func backupOneFile(
|
|||||||
require.NoError(t, afero.WriteFile(fs, srcPath, content, mode))
|
require.NoError(t, afero.WriteFile(fs, srcPath, content, mode))
|
||||||
require.NoError(t, fs.Chmod(srcPath, mode))
|
require.NoError(t, fs.Chmod(srcPath, mode))
|
||||||
|
|
||||||
storeDir := t.TempDir()
|
storeDir := filepath.Join(tempDir, "remote")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
storer, err := storage.NewFileStorer(storeDir)
|
storer, err := storage.NewFileStorer(storeDir)
|
||||||
|
|||||||
@@ -348,7 +348,7 @@ func backupSingleChunkFiles(
|
|||||||
require.NoError(t, afero.WriteFile(fs, path, files[path], 0o644))
|
require.NoError(t, afero.WriteFile(fs, path, files[path], 0o644))
|
||||||
}
|
}
|
||||||
|
|
||||||
storer, err := storage.NewFileStorer(t.TempDir())
|
storer, err := storage.NewFileStorer(filepath.Join(tempDir, "remote"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
db, err := database.New(ctx, dbPath)
|
db, err := database.New(ctx, dbPath)
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ func TestRestoreAndDeepVerifyRejectSwappedDatabase(t *testing.T) {
|
|||||||
|
|
||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
storeDir := t.TempDir()
|
storeDir := filepath.Join(tempDir, "remote")
|
||||||
|
|
||||||
chunkSize := int64(64 * 1024)
|
chunkSize := int64(64 * 1024)
|
||||||
|
|
||||||
@@ -100,7 +100,7 @@ func TestDeepVerifyRejectsSwappedDatabaseWithEmptyManifest(t *testing.T) {
|
|||||||
|
|
||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
storeDir := t.TempDir()
|
storeDir := filepath.Join(tempDir, "remote")
|
||||||
|
|
||||||
chunkSize := int64(64 * 1024)
|
chunkSize := int64(64 * 1024)
|
||||||
|
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ func TestRestoreSweeperEvictsBlobs(t *testing.T) {
|
|||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
dataDir := filepath.Join(tempDir, "source")
|
dataDir := filepath.Join(tempDir, "source")
|
||||||
storeDir := t.TempDir()
|
storeDir := filepath.Join(tempDir, "remote")
|
||||||
restoreDir := filepath.Join(tempDir, "restored")
|
restoreDir := filepath.Join(tempDir, "restored")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ func TestRestoreFreesBlobsNoPendingFileNeeds(t *testing.T) {
|
|||||||
dataDir := filepath.Join(tempDir, "source")
|
dataDir := filepath.Join(tempDir, "source")
|
||||||
originalsDir := filepath.Join(dataDir, "originals")
|
originalsDir := filepath.Join(dataDir, "originals")
|
||||||
copiesDir := filepath.Join(dataDir, "copies")
|
copiesDir := filepath.Join(dataDir, "copies")
|
||||||
storeDir := t.TempDir()
|
storeDir := filepath.Join(tempDir, "remote")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
originals := writeFilesWithCopies(t, fs, originalsDir, copiesDir, fileBytes)
|
originals := writeFilesWithCopies(t, fs, originalsDir, copiesDir, fileBytes)
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ func TestRestoreRejectsTruncatedMetadataDB(t *testing.T) {
|
|||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
dataDir := filepath.Join(tempDir, "source")
|
dataDir := filepath.Join(tempDir, "source")
|
||||||
storeDir := t.TempDir()
|
storeDir := filepath.Join(tempDir, "remote")
|
||||||
restoreDir := filepath.Join(tempDir, "restored")
|
restoreDir := filepath.Join(tempDir, "restored")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ func TestRestoreNamesUnmatchedPathAndRestoresTheMatchingOne(t *testing.T) {
|
|||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
dataDir := filepath.Join(tempDir, "src")
|
dataDir := filepath.Join(tempDir, "src")
|
||||||
storeDir := t.TempDir()
|
storeDir := filepath.Join(tempDir, "remote")
|
||||||
restoreDir := filepath.Join(tempDir, "restored")
|
restoreDir := filepath.Join(tempDir, "restored")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
@@ -89,7 +89,7 @@ func TestRestoreNamesEveryPathWhenNothingMatches(t *testing.T) {
|
|||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
dataDir := filepath.Join(tempDir, "src")
|
dataDir := filepath.Join(tempDir, "src")
|
||||||
linkDir := filepath.Join(tempDir, "srclink")
|
linkDir := filepath.Join(tempDir, "srclink")
|
||||||
storeDir := t.TempDir()
|
storeDir := filepath.Join(tempDir, "remote")
|
||||||
restoreDir := filepath.Join(tempDir, "restored")
|
restoreDir := filepath.Join(tempDir, "restored")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
@@ -152,7 +152,7 @@ func TestRestoreNamesUnmatchedPathWhenADamagedBlobStopsIt(t *testing.T) {
|
|||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
dataDir := filepath.Join(tempDir, "src")
|
dataDir := filepath.Join(tempDir, "src")
|
||||||
storeDir := t.TempDir()
|
storeDir := filepath.Join(tempDir, "remote")
|
||||||
restoreDir := filepath.Join(tempDir, "restored")
|
restoreDir := filepath.Join(tempDir, "restored")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ func TestVerifyPassesWhenBackupReusesStoredBlob(t *testing.T) {
|
|||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
dataDir := filepath.Join(tempDir, "source")
|
dataDir := filepath.Join(tempDir, "source")
|
||||||
storeDir := t.TempDir()
|
storeDir := filepath.Join(tempDir, "remote")
|
||||||
secondDBPath := filepath.Join(tempDir, "second.sqlite")
|
secondDBPath := filepath.Join(tempDir, "second.sqlite")
|
||||||
|
|
||||||
chunkSize := int64(64 * 1024)
|
chunkSize := int64(64 * 1024)
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ func TestBackupOfSameSecondRewriteRestoresNewContent(t *testing.T) {
|
|||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
dataDir := filepath.Join(tempDir, "src")
|
dataDir := filepath.Join(tempDir, "src")
|
||||||
storeDir := t.TempDir()
|
storeDir := filepath.Join(tempDir, "remote")
|
||||||
restoreDir := filepath.Join(tempDir, "restored")
|
restoreDir := filepath.Join(tempDir, "restored")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
rewrittenPath := filepath.Join(dataDir, "small.txt")
|
rewrittenPath := filepath.Join(dataDir, "small.txt")
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ func TestShallowVerifyDetectsWrongBlobSize(t *testing.T) {
|
|||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
dataDir := filepath.Join(tempDir, "source")
|
dataDir := filepath.Join(tempDir, "source")
|
||||||
storeDir := t.TempDir()
|
storeDir := filepath.Join(tempDir, "remote")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
chunkSize := int64(32 * 1024)
|
chunkSize := int64(32 * 1024)
|
||||||
@@ -116,7 +116,7 @@ func TestShallowVerifyDetectsMissingDatabase(t *testing.T) {
|
|||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
dataDir := filepath.Join(tempDir, "source")
|
dataDir := filepath.Join(tempDir, "source")
|
||||||
storeDir := t.TempDir()
|
storeDir := filepath.Join(tempDir, "remote")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
chunkSize := int64(32 * 1024)
|
chunkSize := int64(32 * 1024)
|
||||||
@@ -199,7 +199,7 @@ func TestShallowVerifyReportsStatErrorsApartFromMissing(t *testing.T) {
|
|||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
dataDir := filepath.Join(tempDir, "source")
|
dataDir := filepath.Join(tempDir, "source")
|
||||||
storeDir := t.TempDir()
|
storeDir := filepath.Join(tempDir, "remote")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
chunkSize := int64(32 * 1024)
|
chunkSize := int64(32 * 1024)
|
||||||
@@ -288,7 +288,7 @@ func TestShallowVerifyQuietFailsOnStatErrorAlone(t *testing.T) {
|
|||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
dataDir := filepath.Join(tempDir, "source")
|
dataDir := filepath.Join(tempDir, "source")
|
||||||
storeDir := t.TempDir()
|
storeDir := filepath.Join(tempDir, "remote")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
chunkSize := int64(32 * 1024)
|
chunkSize := int64(32 * 1024)
|
||||||
|
|||||||
@@ -95,13 +95,6 @@ func (v *Vaultik) CreateSnapshot(opts *SnapshotCreateOptions) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Before PruneDatabase: it drops incomplete snapshots, which could
|
|
||||||
// leave the local index without one and so skip the marker check.
|
|
||||||
err = v.markDestination()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up incomplete snapshots FIRST, before any scanning.
|
// Clean up incomplete snapshots FIRST, before any scanning.
|
||||||
// This is critical for data safety; PruneDatabase below does it.
|
// This is critical for data safety; PruneDatabase below does it.
|
||||||
hostname := v.Config.Hostname
|
hostname := v.Config.Hostname
|
||||||
@@ -124,9 +117,21 @@ func (v *Vaultik) CreateSnapshot(opts *SnapshotCreateOptions) error {
|
|||||||
return fmt.Errorf("prune database: %w", err)
|
return fmt.Errorf("prune database: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
snapshotNames, err := v.snapshotNamesToCreate(opts.Snapshots)
|
// Determine which snapshots to process
|
||||||
if err != nil {
|
snapshotNames := opts.Snapshots
|
||||||
return err
|
if len(snapshotNames) == 0 {
|
||||||
|
snapshotNames = v.Config.SnapshotNames()
|
||||||
|
} else {
|
||||||
|
// Validate requested snapshot names exist
|
||||||
|
for _, name := range snapshotNames {
|
||||||
|
if _, ok := v.Config.Snapshots[name]; !ok {
|
||||||
|
return fmt.Errorf("%w: %q", errSnapshotNotInConfig, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(snapshotNames) == 0 {
|
||||||
|
return errNoSnapshotsInConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process each named snapshot
|
// Process each named snapshot
|
||||||
@@ -166,28 +171,6 @@ func (v *Vaultik) CreateSnapshot(opts *SnapshotCreateOptions) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// snapshotNamesToCreate returns the requested snapshot names, each checked
|
|
||||||
// against the config, or every configured name when none was requested.
|
|
||||||
func (v *Vaultik) snapshotNamesToCreate(requested []string) ([]string, error) {
|
|
||||||
snapshotNames := requested
|
|
||||||
if len(snapshotNames) == 0 {
|
|
||||||
snapshotNames = v.Config.SnapshotNames()
|
|
||||||
} else {
|
|
||||||
// Validate requested snapshot names exist
|
|
||||||
for _, name := range snapshotNames {
|
|
||||||
if _, ok := v.Config.Snapshots[name]; !ok {
|
|
||||||
return nil, fmt.Errorf("%w: %q", errSnapshotNotInConfig, name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(snapshotNames) == 0 {
|
|
||||||
return nil, errNoSnapshotsInConfig
|
|
||||||
}
|
|
||||||
|
|
||||||
return snapshotNames, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// runPostBackupPrune drops older snapshots of the given names and removes
|
// runPostBackupPrune drops older snapshots of the given names and removes
|
||||||
// orphan blobs from remote storage. If keepNewerThan is set (e.g. "4w"),
|
// orphan blobs from remote storage. If keepNewerThan is set (e.g. "4w"),
|
||||||
// snapshots newer than that duration are kept. Otherwise only the latest
|
// snapshots newer than that duration are kept. Otherwise only the latest
|
||||||
@@ -1298,18 +1281,10 @@ func (v *Vaultik) RemoveSnapshot(
|
|||||||
return v.removeSnapshotDryRun(result, snapshotID, opts)
|
return v.removeSnapshotDryRun(result, snapshotID, opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !v.confirmRemoveSnapshot(snapshotID, opts) {
|
if !opts.Force && !opts.JSON && !v.confirmRemoveSnapshot(snapshotID, opts) {
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Checked before the local record is deleted: it may be the last
|
|
||||||
// snapshot in the local index, and the check applies only while the
|
|
||||||
// local index holds one.
|
|
||||||
var markerErr error
|
|
||||||
if !opts.LocalOnly {
|
|
||||||
markerErr = v.checkDestinationMarker()
|
|
||||||
}
|
|
||||||
|
|
||||||
if inLocalIndex {
|
if inLocalIndex {
|
||||||
log.Info("Removing snapshot from local database", "snapshot_id", snapshotID)
|
log.Info("Removing snapshot from local database", "snapshot_id", snapshotID)
|
||||||
|
|
||||||
@@ -1320,7 +1295,7 @@ func (v *Vaultik) RemoveSnapshot(
|
|||||||
}
|
}
|
||||||
|
|
||||||
result.RemoteRemoved, err = v.removeSnapshotRemote(
|
result.RemoteRemoved, err = v.removeSnapshotRemote(
|
||||||
snapshotID, inLocalIndex, markerErr, opts)
|
snapshotID, inLocalIndex, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
@@ -1357,9 +1332,7 @@ func (v *Vaultik) RemoveSnapshot(
|
|||||||
// remove: no row and, unless LocalOnly is set, no metadata on the
|
// remove: no row and, unless LocalOnly is set, no metadata on the
|
||||||
// destination store either. The destination store is consulted only
|
// destination store either. The destination store is consulted only
|
||||||
// when the local index has no row, so a snapshot in the local index can
|
// when the local index has no row, so a snapshot in the local index can
|
||||||
// still be removed from it while the destination store is unreachable,
|
// still be removed from it while the destination store is unreachable.
|
||||||
// and only after checkDestinationMarker passes, so an unplugged volume's
|
|
||||||
// empty mount point is not taken for a store without the snapshot.
|
|
||||||
func (v *Vaultik) findSnapshotToRemove(
|
func (v *Vaultik) findSnapshotToRemove(
|
||||||
snapshotID string, opts *RemoveOptions,
|
snapshotID string, opts *RemoveOptions,
|
||||||
) (bool, error) {
|
) (bool, error) {
|
||||||
@@ -1379,11 +1352,6 @@ func (v *Vaultik) findSnapshotToRemove(
|
|||||||
errSnapshotNotFound, snapshotID)
|
errSnapshotNotFound, snapshotID)
|
||||||
}
|
}
|
||||||
|
|
||||||
err := v.checkDestinationMarker()
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
objects, err := v.remoteSnapshotObjects(snapshot.RemoteSnapshotKey(snapshotID))
|
objects, err := v.remoteSnapshotObjects(snapshot.RemoteSnapshotKey(snapshotID))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("looking up snapshot on destination store: %w", err)
|
return false, fmt.Errorf("looking up snapshot on destination store: %w", err)
|
||||||
@@ -1422,15 +1390,11 @@ func (v *Vaultik) removeSnapshotDryRun(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// confirmRemoveSnapshot prompts for confirmation before removing a
|
// confirmRemoveSnapshot prompts for confirmation before removing a
|
||||||
// snapshot, unless --force or --json is set. Returns false (treating
|
// snapshot. Returns false (treating EOF/read errors as "no") unless the
|
||||||
// EOF/read errors as "no") unless the user answers "y". The prompt and
|
// user answers "y". The prompt and its answer bypass the UI: they are an
|
||||||
// its answer bypass the UI: they are an interactive exchange the
|
// interactive exchange the operator must see, so --quiet does not
|
||||||
// operator must see, so --quiet does not silence them.
|
// silence them.
|
||||||
func (v *Vaultik) confirmRemoveSnapshot(snapshotID string, opts *RemoveOptions) bool {
|
func (v *Vaultik) confirmRemoveSnapshot(snapshotID string, opts *RemoveOptions) bool {
|
||||||
if opts.Force || opts.JSON {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
if opts.LocalOnly {
|
if opts.LocalOnly {
|
||||||
v.stdoutf("Remove snapshot '%s' from local database "+
|
v.stdoutf("Remove snapshot '%s' from local database "+
|
||||||
"(remote untouched)? [y/N] ", snapshotID)
|
"(remote untouched)? [y/N] ", snapshotID)
|
||||||
@@ -1460,17 +1424,15 @@ func (v *Vaultik) confirmRemoveSnapshot(snapshotID string, opts *RemoveOptions)
|
|||||||
// removeSnapshotRemote strips the snapshot's metadata from the
|
// removeSnapshotRemote strips the snapshot's metadata from the
|
||||||
// destination store. It returns true only when the delete finished
|
// destination store. It returns true only when the delete finished
|
||||||
// without an error and removed at least one object; under LocalOnly it
|
// without an error and removed at least one object; under LocalOnly it
|
||||||
// does nothing. markerErr is the result of checkDestinationMarker; when
|
// does nothing. When the snapshot is only on the destination store, a
|
||||||
// it is set, nothing is deleted and it is the failure. When the snapshot
|
// failure is returned as an error, because that removal is all the
|
||||||
// is only on the destination store, a failure is returned as an error,
|
// command does. When the snapshot was in the local index, its removal
|
||||||
// because that removal is all the command does. When the snapshot was in
|
// there has already happened, so a failure is only a warning telling the
|
||||||
// the local index, its removal there has already happened, so a failure
|
// user to run `vaultik snapshot remove` for the snapshot again once the
|
||||||
// is only a warning telling the user to run `vaultik snapshot remove`
|
// destination store is reachable (`vaultik prune` never removes snapshot
|
||||||
// for the snapshot again once the destination store is reachable
|
// metadata).
|
||||||
// (`vaultik prune` never removes snapshot metadata).
|
|
||||||
func (v *Vaultik) removeSnapshotRemote(
|
func (v *Vaultik) removeSnapshotRemote(
|
||||||
snapshotID string, inLocalIndex bool, markerErr error,
|
snapshotID string, inLocalIndex bool, opts *RemoveOptions,
|
||||||
opts *RemoveOptions,
|
|
||||||
) (bool, error) {
|
) (bool, error) {
|
||||||
if opts.LocalOnly {
|
if opts.LocalOnly {
|
||||||
return false, nil
|
return false, nil
|
||||||
@@ -1479,14 +1441,9 @@ func (v *Vaultik) removeSnapshotRemote(
|
|||||||
log.Info("Removing snapshot metadata from remote storage",
|
log.Info("Removing snapshot metadata from remote storage",
|
||||||
"snapshot_id", snapshotID)
|
"snapshot_id", snapshotID)
|
||||||
|
|
||||||
var deleted int
|
remoteKey := snapshot.RemoteSnapshotKey(snapshotID)
|
||||||
|
|
||||||
err := markerErr
|
|
||||||
if err == nil {
|
|
||||||
deleted, err = v.deleteRemoteSnapshotByKey(
|
|
||||||
snapshot.RemoteSnapshotKey(snapshotID))
|
|
||||||
}
|
|
||||||
|
|
||||||
|
deleted, err := v.deleteRemoteSnapshotByKey(remoteKey)
|
||||||
if err != nil && !inLocalIndex {
|
if err != nil && !inLocalIndex {
|
||||||
return false, fmt.Errorf(
|
return false, fmt.Errorf(
|
||||||
"removing snapshot metadata from destination store: %w", err)
|
"removing snapshot metadata from destination store: %w", err)
|
||||||
@@ -1595,8 +1552,8 @@ func (v *Vaultik) localSnapshotIDs() ([]string, error) {
|
|||||||
// listAllRemoteSnapshotKeys collects the hashed remote keys
|
// listAllRemoteSnapshotKeys collects the hashed remote keys
|
||||||
// (subdirectories under metadata/) currently present in the
|
// (subdirectories under metadata/) currently present in the
|
||||||
// destination store. Returns (nil, err) when the store cannot be
|
// destination store. Returns (nil, err) when the store cannot be
|
||||||
// listed, or is not present by checkDestinationMarker; callers must
|
// listed; callers must treat that as "no remote info available," not
|
||||||
// treat that as "no remote info available," not fatal.
|
// fatal.
|
||||||
func (v *Vaultik) listAllRemoteSnapshotKeys() ([]string, error) {
|
func (v *Vaultik) listAllRemoteSnapshotKeys() ([]string, error) {
|
||||||
log.Info("Listing all remote snapshots")
|
log.Info("Listing all remote snapshots")
|
||||||
|
|
||||||
@@ -1645,11 +1602,6 @@ func (v *Vaultik) listAllRemoteSnapshotKeys() ([]string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
err := v.checkDestinationMarker()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return keys, nil
|
return keys, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ func newDeletedCountEnv(t *testing.T, files map[string][]byte) *deletedCountEnv
|
|||||||
cfg.IndexPath = filepath.Join(tempDir, "index.sqlite")
|
cfg.IndexPath = filepath.Join(tempDir, "index.sqlite")
|
||||||
cfg.Snapshots = map[string]config.SnapshotConfig{}
|
cfg.Snapshots = map[string]config.SnapshotConfig{}
|
||||||
|
|
||||||
store, err := storage.NewFileStorer(t.TempDir())
|
store, err := storage.NewFileStorer(filepath.Join(tempDir, "remote"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
db, err := database.New(ctx, cfg.IndexPath)
|
db, err := database.New(ctx, cfg.IndexPath)
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ func TestSnapshotSummaryCountsFileNotStoredAsFailed(t *testing.T) {
|
|||||||
"src": {Paths: []string{srcDir}},
|
"src": {Paths: []string{srcDir}},
|
||||||
}
|
}
|
||||||
|
|
||||||
store, err := storage.NewFileStorer(t.TempDir())
|
store, err := storage.NewFileStorer(filepath.Join(tempDir, "remote"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
db, err := database.New(ctx, cfg.IndexPath)
|
db, err := database.New(ctx, cfg.IndexPath)
|
||||||
|
|||||||
@@ -122,9 +122,6 @@ func newListEnv(t *testing.T) *listEnv {
|
|||||||
store := newObservingStorer()
|
store := newObservingStorer()
|
||||||
stdout := &bytes.Buffer{}
|
stdout := &bytes.Buffer{}
|
||||||
|
|
||||||
// A backup wrote to this destination, so it carries the marker.
|
|
||||||
require.NoError(t, store.Put(ctx, markerFile, strings.NewReader("")))
|
|
||||||
|
|
||||||
v := &vaultik.Vaultik{
|
v := &vaultik.Vaultik{
|
||||||
Config: &config.Config{
|
Config: &config.Config{
|
||||||
AgeSecretKey: "",
|
AgeSecretKey: "",
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ func TestSkipErrorsBackupStopsOnIndexErrorRecordingDirectory(t *testing.T) {
|
|||||||
"tree": {Paths: []string{srcDir}},
|
"tree": {Paths: []string{srcDir}},
|
||||||
}
|
}
|
||||||
|
|
||||||
store, err := storage.NewFileStorer(t.TempDir())
|
store, err := storage.NewFileStorer(filepath.Join(tempDir, "remote"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
db, err := database.New(ctx, cfg.IndexPath)
|
db, err := database.New(ctx, cfg.IndexPath)
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import (
|
|||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"golang.org/x/sys/unix"
|
|
||||||
"sneak.berlin/go/vaultik/internal/config"
|
"sneak.berlin/go/vaultik/internal/config"
|
||||||
"sneak.berlin/go/vaultik/internal/database"
|
"sneak.berlin/go/vaultik/internal/database"
|
||||||
"sneak.berlin/go/vaultik/internal/log"
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
@@ -85,7 +84,7 @@ func newSummaryEnv(t *testing.T) *summaryEnv {
|
|||||||
"split": {Paths: []string{dirOne, dirTwo}},
|
"split": {Paths: []string{dirOne, dirTwo}},
|
||||||
}
|
}
|
||||||
|
|
||||||
inner, err := storage.NewFileStorer(t.TempDir())
|
inner, err := storage.NewFileStorer(filepath.Join(tempDir, "remote"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
store := faultstore.New(inner)
|
store := faultstore.New(inner)
|
||||||
@@ -331,20 +330,13 @@ func TestSnapshotSummaryChangedDirectoryAndSymlink(t *testing.T) {
|
|||||||
dir, err := filepath.EvalSymlinks(filepath.Dir(env.aPath))
|
dir, err := filepath.EvalSymlinks(filepath.Dir(env.aPath))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// The new link gets the old one's mtime, and a target, which need not
|
|
||||||
// exist, of the old target's length, a symlink's size: only the
|
|
||||||
// target differs.
|
|
||||||
linkTime := unix.NsecToTimeval(
|
|
||||||
time.Date(2029, time.March, 4, 5, 6, 7, 0, time.UTC).UnixNano())
|
|
||||||
link := filepath.Join(dir, "link")
|
link := filepath.Join(dir, "link")
|
||||||
require.NoError(t, os.Symlink("a.bin", link))
|
require.NoError(t, os.Symlink("a.bin", link))
|
||||||
require.NoError(t, unix.Lutimes(link, []unix.Timeval{linkTime, linkTime}))
|
|
||||||
|
|
||||||
env.backUp(t, "first", false)
|
env.backUp(t, "first", false)
|
||||||
|
|
||||||
require.NoError(t, os.Remove(link))
|
require.NoError(t, os.Remove(link))
|
||||||
require.NoError(t, os.Symlink("b.bin", link))
|
require.NoError(t, os.Symlink("small.txt", link))
|
||||||
require.NoError(t, unix.Lutimes(link, []unix.Timeval{linkTime, linkTime}))
|
|
||||||
|
|
||||||
mtime := time.Date(2030, time.January, 2, 3, 4, 5, 0, time.UTC)
|
mtime := time.Date(2030, time.January, 2, 3, 4, 5, 0, time.UTC)
|
||||||
require.NoError(t, os.Chtimes(dir, mtime, mtime))
|
require.NoError(t, os.Chtimes(dir, mtime, mtime))
|
||||||
@@ -361,7 +353,7 @@ func TestSnapshotSummaryChangedDirectoryAndSymlink(t *testing.T) {
|
|||||||
linkRow, err := env.repos.Files.GetByPath(ctx, link)
|
linkRow, err := env.repos.Files.GetByPath(ctx, link)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, linkRow)
|
require.NotNil(t, linkRow)
|
||||||
assert.Equal(t, "b.bin", linkRow.LinkTarget.String())
|
assert.Equal(t, "small.txt", linkRow.LinkTarget.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Under --cron the progress reporter is off; the upload figures must
|
// Under --cron the progress reporter is off; the upload figures must
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ func TestBackupWithoutCronOfTwoPathSnapshotRestoresBothPaths(t *testing.T) {
|
|||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
firstDir := filepath.Join(tempDir, "first")
|
firstDir := filepath.Join(tempDir, "first")
|
||||||
secondDir := filepath.Join(tempDir, "second")
|
secondDir := filepath.Join(tempDir, "second")
|
||||||
storeDir := t.TempDir()
|
storeDir := filepath.Join(tempDir, "remote")
|
||||||
restoreDir := filepath.Join(tempDir, "restored")
|
restoreDir := filepath.Join(tempDir, "restored")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ func TestUncompressedSizeOfChangedFileStaysFixed(t *testing.T) {
|
|||||||
|
|
||||||
appendRandomBytes(t, fs, files, changedPath, appendedFileSize)
|
appendRandomBytes(t, fs, files, changedPath, appendedFileSize)
|
||||||
|
|
||||||
store, err := storage.NewFileStorer(t.TempDir())
|
store, err := storage.NewFileStorer(filepath.Join(tempDir, "remote"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
db, err := database.New(ctx, dbPath)
|
db, err := database.New(ctx, dbPath)
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ func TestVerify_QuietSuppressesReport(t *testing.T) {
|
|||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
dataDir := filepath.Join(tempDir, "source")
|
dataDir := filepath.Join(tempDir, "source")
|
||||||
storeDir := t.TempDir()
|
storeDir := filepath.Join(tempDir, "remote")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
chunkSize := int64(32 * 1024)
|
chunkSize := int64(32 * 1024)
|
||||||
|
|||||||
Reference in New Issue
Block a user