Compare commits
4
Commits
aae82914ed
...
1984d5fd22
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1984d5fd22 | ||
|
|
9fd88a34c7 | ||
|
|
5db2e920e5 | ||
|
|
66ea7f5f45 |
No files matched your search
@@ -311,6 +311,8 @@ 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
|
||||||
│
|
│
|
||||||
@@ -367,6 +369,8 @@ 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]}/
|
||||||
@@ -378,6 +382,15 @@ bucket/
|
|||||||
└── manifest.json.zst # Blob list (for pruning/verification)
|
└── manifest.json.zst # Blob list (for pruning/verification)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
While the local index holds snapshots, a destination without
|
||||||
|
`vaultik-destination.txt` is treated as not present
|
||||||
|
(`checkDestinationMarker` in `internal/vaultik/destination_marker.go`):
|
||||||
|
`snapshot create` refuses to write to it, and listing it fails as for a
|
||||||
|
destination that cannot be listed. An unplugged volume's empty mount point
|
||||||
|
is therefore never taken 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,6 +24,10 @@ 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
|
||||||
@@ -298,6 +302,10 @@ 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
|
||||||
@@ -327,7 +335,10 @@ invocation that reconciles them.
|
|||||||
|
|
||||||
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.
|
local index alone, and still exits zero. It does the same when the
|
||||||
|
local index holds snapshots and the destination has no
|
||||||
|
`vaultik-destination.txt`, 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
|
||||||
@@ -360,16 +371,21 @@ snapshot from the local index and strips the snapshot's metadata from
|
|||||||
the backup destination store. Blobs are NOT touched — deleting blobs
|
the backup destination store. Blobs are NOT touched — deleting blobs
|
||||||
requires reading every remaining remote manifest (the destination store
|
requires reading every remaining remote manifest (the destination store
|
||||||
may hold snapshots this host doesn't know about), which is what
|
may hold snapshots this host doesn't know about), which is what
|
||||||
`vaultik prune` does. On success the command prints the exact `vaultik
|
`vaultik prune` does. The command prints the `vaultik prune` invocation
|
||||||
prune` invocation to run as a follow-up. An ID that names no snapshot in
|
to run as a follow-up only when it removed the snapshot's metadata from
|
||||||
the local index or on the destination store is an error; with
|
the destination store, and never under `--json`. An ID that names no
|
||||||
`--local-only`, the snapshot must be in the local index. Local row
|
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
|
||||||
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 the destination store is unreachable, the local-DB
|
runs automatically. If removing the snapshot's metadata from the
|
||||||
removal still completes and a warning is emitted; run `vaultik snapshot
|
destination store fails (for example because the store is unreachable,
|
||||||
remove <snapshot-id>` again once the store is reachable to remove the
|
or the local index holds snapshots and the destination has no
|
||||||
snapshot's metadata from it (`vaultik prune` does not). To wipe everything
|
`vaultik-destination.txt`), the local-DB removal still completes and a
|
||||||
on the destination in one go, use `vaultik remote nuke --force`.
|
warning is emitted; run `vaultik snapshot remove <snapshot-id>` again
|
||||||
|
once the store is reachable to remove the snapshot's metadata from it
|
||||||
|
(`vaultik prune` does not). For a snapshot that is only on the
|
||||||
|
destination store, that failure is an error. To wipe everything on the
|
||||||
|
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
|
||||||
@@ -400,8 +416,12 @@ 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 a blob cannot be deleted, the
|
manual entry point for the same work. If the destination store cannot
|
||||||
command (and `snapshot create --prune`) exits non-zero.
|
be listed, or the local index holds snapshots and the destination has
|
||||||
|
no `vaultik-destination.txt` (an unplugged volume's empty mount point),
|
||||||
|
prune fails and deletes nothing. `snapshot purge` does the same. If a
|
||||||
|
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
|
||||||
@@ -425,11 +445,12 @@ 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, leaving the bucket prefix
|
the backup destination store, whatever its name, and then
|
||||||
empty. Destructive and irreversible. This is the single supported way to
|
`vaultik-destination.txt`, leaving the bucket prefix empty. Destructive
|
||||||
wipe the entire destination store. If a listing afterwards still finds
|
and irreversible. This is the single supported way to wipe the entire
|
||||||
anything there, the command names each object left and exits non-zero;
|
destination store. If a listing afterwards still finds anything under
|
||||||
running it again deletes what is left.
|
`metadata/` or `blobs/`, the command names each object left and exits
|
||||||
|
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
|
||||||
@@ -454,7 +475,12 @@ 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 same directory. The directory must exist before the first backup: vaultik
|
||||||
|
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`).
|
||||||
@@ -485,6 +511,7 @@ 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/
|
||||||
@@ -493,6 +520,15 @@ 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. While the local index holds snapshots, a
|
||||||
|
destination without it is treated as not present: `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:
|
Vaultik stores all backup data in an S3-compatible object store. The repository consists of two main components, plus a marker file:
|
||||||
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,6 +12,7 @@ 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]>/
|
||||||
@@ -24,6 +25,10 @@ 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. While the local index holds snapshots, a destination without it is treated as not present: `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,7 +101,8 @@ 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. Returns the config path.
|
// counterpart on the destination store, and gives the destination the
|
||||||
|
// 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()
|
||||||
|
|
||||||
@@ -131,6 +132,11 @@ 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,6 +41,11 @@ 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))
|
||||||
@@ -59,10 +64,12 @@ func TestEntrySnapshotPurgeKeepsMetadataWhenLocalIndexDeleteFails(t *testing.T)
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, db.Close())
|
require.NoError(t, db.Close())
|
||||||
|
|
||||||
code, _, _ := runEntry(t, flagConfig, configPath,
|
code, _, stderr := 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")
|
||||||
}
|
}
|
||||||
@@ -270,8 +270,9 @@ By default, this removes the snapshot from the local index database and
|
|||||||
strips the snapshot's metadata from the backup destination store. Blobs
|
strips the snapshot's metadata from the backup destination store. Blobs
|
||||||
are NOT touched: deleting them requires reading every remaining remote
|
are NOT touched: deleting them requires reading every remaining remote
|
||||||
manifest (the destination store may hold snapshots this host doesn't
|
manifest (the destination store may hold snapshots this host doesn't
|
||||||
know about), which is what 'vaultik prune' does. On success the command
|
know about), which is what 'vaultik prune' does. The command prints the
|
||||||
prints the exact 'vaultik prune' invocation to run as a follow-up.
|
'vaultik prune' invocation to run as a follow-up only when it removed the
|
||||||
|
snapshot's metadata from the destination store, and never under --json.
|
||||||
|
|
||||||
An ID that names no snapshot in the local index or on the destination
|
An ID that names no snapshot in the local index or on the destination
|
||||||
store is an error.
|
store is an error.
|
||||||
@@ -280,10 +281,12 @@ Use --local-only to skip the remote half (e.g. when you want to forget a
|
|||||||
snapshot locally without touching the destination store); the snapshot
|
snapshot locally without touching the destination store); the snapshot
|
||||||
must then be in the local index.
|
must then be in the local index.
|
||||||
|
|
||||||
If the remote is unreachable, the local-database removal still completes
|
If removing the snapshot's metadata from the destination store fails (for
|
||||||
and a warning is emitted; run 'vaultik snapshot remove <snapshot-id>' again
|
example because the remote is unreachable), the local-database removal
|
||||||
once the destination store is reachable to remove the snapshot's metadata
|
still completes and a warning is emitted; run 'vaultik snapshot remove
|
||||||
from it ('vaultik prune' does not).
|
<snapshot-id>' again once the destination store is reachable to remove
|
||||||
|
the snapshot's metadata from it ('vaultik prune' does not). For a
|
||||||
|
snapshot that is only on the destination store, that failure is an error.
|
||||||
|
|
||||||
To wipe the entire destination store and start over, use 'vaultik remote
|
To wipe the entire destination store and start over, use 'vaultik remote
|
||||||
nuke --force' — it is the single supported entry point for that.`,
|
nuke --force' — it is the single supported entry point for that.`,
|
||||||
|
|||||||
@@ -21,9 +21,13 @@ 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 basePath is recorded; the directory is created lazily on first
|
// The backend never creates basePath: the user does, so that when
|
||||||
// write. Write operations (Put/PutWithProgress) call MkdirAll for the
|
// basePath is a directory on an unplugged volume, a write fails instead
|
||||||
// per-blob parent directory, which also covers basePath on first use.
|
// of landing on the disk under the mount point. This does not hold when
|
||||||
|
// 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.
|
||||||
@@ -299,7 +303,19 @@ func (f *FileStorer) writeAtomic(
|
|||||||
path := f.fullPath(key)
|
path := f.fullPath(key)
|
||||||
dir := filepath.Dir(path)
|
dir := filepath.Dir(path)
|
||||||
|
|
||||||
err := f.fs.MkdirAll(dir, storageDirPerm)
|
// Checked on every write, so a destination that disappears part-way
|
||||||
|
// 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,3 +292,43 @@ 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,7 +41,8 @@ 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.
|
||||||
// Parent directories are created automatically for filesystem backends.
|
// The filesystem backend fails when its destination directory does
|
||||||
|
// 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.
|
||||||
|
|||||||
@@ -39,9 +39,9 @@ type blobDiskCacheEntry struct {
|
|||||||
// internally and will fall back to LRU eviction if curBytes exceeds
|
// internally and will fall back to LRU eviction if curBytes exceeds
|
||||||
// maxBytes. Restore passes math.MaxInt64 as maxBytes and drives
|
// maxBytes. Restore passes math.MaxInt64 as maxBytes and drives
|
||||||
// eviction itself via Delete() through restoreSweeper, which deletes
|
// eviction itself via Delete() through restoreSweeper, which deletes
|
||||||
// each blob the moment every file that references its chunks has been
|
// each blob once no file still to be restored in this run needs it.
|
||||||
// written. LRU never fires under that configuration; it is kept as a
|
// LRU never fires under that configuration; it is kept as a safety net
|
||||||
// safety net for callers that don't manage eviction themselves.
|
// for callers that don't manage eviction themselves.
|
||||||
//
|
//
|
||||||
// Get/ReadAt/peak-Len counters are debugging instrumentation used by
|
// Get/ReadAt/peak-Len counters are debugging instrumentation used by
|
||||||
// tests to assert that the restore code path uses ReadAt rather than
|
// tests to assert that the restore code path uses ReadAt rather than
|
||||||
|
|||||||
@@ -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 := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
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 := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
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")
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ func TestDeepVerifyAcceptsHealthyAndRejectsCorruptBlob(t *testing.T) {
|
|||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
dataDir := filepath.Join(tempDir, "source")
|
dataDir := filepath.Join(tempDir, "source")
|
||||||
storeDir := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
chunkSize := int64(64 * 1024)
|
chunkSize := int64(64 * 1024)
|
||||||
@@ -93,7 +93,7 @@ func TestDeepVerifyJSONCountsBlobs(t *testing.T) {
|
|||||||
|
|
||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
storeDir := filepath.Join(t.TempDir(), "remote")
|
storeDir := t.TempDir()
|
||||||
|
|
||||||
cfg, storer, snapshotID, keys := backUpSeveralBlobs(ctx, t, storeDir)
|
cfg, storer, snapshotID, keys := backUpSeveralBlobs(ctx, t, storeDir)
|
||||||
|
|
||||||
@@ -149,7 +149,7 @@ func TestDeepVerifyJSONCountsWrongSizeBlob(t *testing.T) {
|
|||||||
|
|
||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
storeDir := filepath.Join(t.TempDir(), "remote")
|
storeDir := t.TempDir()
|
||||||
|
|
||||||
cfg, storer, snapshotID, keys := backUpSeveralBlobs(ctx, t, storeDir)
|
cfg, storer, snapshotID, keys := backUpSeveralBlobs(ctx, t, storeDir)
|
||||||
|
|
||||||
@@ -178,7 +178,7 @@ func TestDeepVerifyStopsOnBlobThatCannotBeChecked(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
storeDir := filepath.Join(t.TempDir(), "remote")
|
storeDir := t.TempDir()
|
||||||
|
|
||||||
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]}}
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
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,
|
||||||
|
// 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 and the backup destination store has no marker.
|
||||||
|
var errDestinationNotPresent = errors.New("backup destination store is not present")
|
||||||
|
|
||||||
|
// checkDestinationMarker returns errDestinationNotPresent when the local
|
||||||
|
// index holds a snapshot and the backup destination 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.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
_, 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
package vaultik_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"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/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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
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 := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
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 := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -298,10 +298,6 @@ 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)
|
||||||
|
|
||||||
@@ -365,7 +361,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 := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
restoreDir := filepath.Join(tempDir, "restored")
|
restoreDir := filepath.Join(tempDir, "restored")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
@@ -429,7 +425,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 := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -445,6 +441,10 @@ 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,8 +493,14 @@ 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
|
||||||
@@ -511,7 +517,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 := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
restoreDir := filepath.Join(tempDir, "restored")
|
restoreDir := filepath.Join(tempDir, "restored")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
@@ -686,7 +692,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 := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
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 := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
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 := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
restoreDir := filepath.Join(tempDir, "restored")
|
restoreDir := filepath.Join(tempDir, "restored")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
|
|||||||
@@ -24,13 +24,11 @@ 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.
|
||||||
|
|
||||||
// backUpToFileDestination backs up the snapshot named "first" to a
|
// newFileDestinationVaultik returns a Vaultik that backs up the
|
||||||
// file:// destination at storeDir, which need not exist yet. Everything
|
// snapshot named "first" to a file:// destination at storeDir.
|
||||||
// the returned Vaultik prints after the backup goes to the returned
|
func newFileDestinationVaultik(
|
||||||
// buffer.
|
|
||||||
func backUpToFileDestination(
|
|
||||||
ctx context.Context, t *testing.T, storeDir string,
|
ctx context.Context, t *testing.T, storeDir string,
|
||||||
) (*vaultik.Vaultik, *database.Repositories, *bytes.Buffer) {
|
) (*vaultik.Vaultik, *database.Repositories) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
osFs := afero.NewOsFs()
|
osFs := afero.NewOsFs()
|
||||||
@@ -49,8 +47,22 @@ func backUpToFileDestination(
|
|||||||
|
|
||||||
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{}
|
||||||
@@ -75,21 +87,28 @@ func backUpThenUnplug(
|
|||||||
return v, repos, out
|
return v, repos, out
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestFirstBackupCreatesDestinationDirectory checks that a first backup
|
// TestBackupFailsWhenDestinationDirectoryMissing checks that a backup to
|
||||||
// to a destination directory that does not exist yet creates it, and
|
// a file:// destination whose directory does not exist fails before it
|
||||||
// that the destination can be listed afterwards.
|
// records a snapshot, and does not create the directory.
|
||||||
func TestFirstBackupCreatesDestinationDirectory(t *testing.T) {
|
func TestBackupFailsWhenDestinationDirectoryMissing(t *testing.T) {
|
||||||
log.Initialize(log.Config{})
|
log.Initialize(log.Config{})
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
storeDir := filepath.Join(t.TempDir(), "volume", "backup")
|
volume := filepath.Join(t.TempDir(), "volume")
|
||||||
v, _, out := backUpToFileDestination(ctx, t, storeDir)
|
v, repos := newFileDestinationVaultik(ctx, t, filepath.Join(volume, "backup"))
|
||||||
|
|
||||||
require.NoError(t, v.ListSnapshots(false))
|
err := backUp(v, "first")
|
||||||
|
require.ErrorIs(t, err, fs.ErrNotExist)
|
||||||
|
require.ErrorContains(t, err, "create it, or mount the volume it is on")
|
||||||
|
|
||||||
assert.NotContains(t, out.String(), "Could not list backup destination store")
|
_, err = os.Stat(volume)
|
||||||
assert.NotContains(t, out.String(), "not found in backup destination store")
|
require.ErrorIs(t, err, fs.ErrNotExist,
|
||||||
|
"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(filepath.Join(tempDir, "remote"))
|
store, err := storage.NewFileStorer(t.TempDir())
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
db, err := database.New(ctx, dbPath)
|
db, err := database.New(ctx, dbPath)
|
||||||
|
|||||||
@@ -42,9 +42,10 @@ 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. It returns
|
// part-way left under a temporary `.partial` name, and last the
|
||||||
// successfully only when a listing afterwards finds nothing left there,
|
// destination marker. It returns successfully only when a listing
|
||||||
// and the next backup then starts from scratch.
|
// afterwards finds nothing left under metadata/ and blobs/, and the next
|
||||||
|
// 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.
|
||||||
@@ -105,6 +106,13 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
package vaultik //nolint:testpackage // exercises unexported count helpers
|
package vaultik //nolint:testpackage // exercises unexported count helpers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"sneak.berlin/go/vaultik/internal/database"
|
"sneak.berlin/go/vaultik/internal/database"
|
||||||
"sneak.berlin/go/vaultik/internal/log"
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
|
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||||
|
"sneak.berlin/go/vaultik/internal/ui"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestTableCountForReportSurfacesReadFailure is the regression guard for
|
// TestTableCountForReportSurfacesReadFailure is the regression guard for
|
||||||
@@ -76,3 +80,59 @@ func TestCountDiffUnknownWhenEitherSideUnknown(t *testing.T) {
|
|||||||
assert.Nil(t, countDiff(&before, nil), "unknown after yields unknown delta")
|
assert.Nil(t, countDiff(&before, nil), "unknown after yields unknown delta")
|
||||||
assert.Nil(t, countDiff(nil, nil))
|
assert.Nil(t, countDiff(nil, nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestPruneDatabaseSummaryLabelsTotals removes the only incomplete
|
||||||
|
// snapshot from an index that also holds a complete one. The figure in
|
||||||
|
// brackets is every snapshot left in the local index, so the line must
|
||||||
|
// say so rather than read as incomplete snapshots that remain.
|
||||||
|
func TestPruneDatabaseSummaryLabelsTotals(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
db, err := database.New(ctx, ":memory:")
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
|
||||||
|
repos := database.NewRepositories(db)
|
||||||
|
|
||||||
|
startedAt := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
||||||
|
completedAt := startedAt.Add(time.Minute)
|
||||||
|
|
||||||
|
require.NoError(t, repos.Snapshots.Create(ctx, nil, &database.Snapshot{
|
||||||
|
ID: "host_complete_2026-01-02T03:04:05Z",
|
||||||
|
Hostname: "host",
|
||||||
|
StartedAt: startedAt,
|
||||||
|
CompletedAt: &completedAt,
|
||||||
|
}))
|
||||||
|
require.NoError(t, repos.Snapshots.Create(ctx, nil, &database.Snapshot{
|
||||||
|
ID: "host_incomplete_2026-01-02T03:04:05Z",
|
||||||
|
Hostname: "host",
|
||||||
|
StartedAt: startedAt,
|
||||||
|
}))
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
|
||||||
|
v := &Vaultik{
|
||||||
|
DB: db,
|
||||||
|
Repositories: repos,
|
||||||
|
SnapshotManager: snapshot.NewSnapshotManager(
|
||||||
|
snapshot.SnapshotManagerParams{Repos: repos}),
|
||||||
|
UI: ui.NewWithColor(&out, false),
|
||||||
|
}
|
||||||
|
v.SetContext(ctx)
|
||||||
|
|
||||||
|
result, err := v.PruneDatabase()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, int64(1), result.SnapshotsDeleted)
|
||||||
|
|
||||||
|
assert.Contains(t, out.String(), "Incomplete snapshots: 1 removed "+
|
||||||
|
"(total snapshots left in the local index: 1).")
|
||||||
|
assert.Contains(t, out.String(), "Orphaned files: 0 removed "+
|
||||||
|
"(total files left in the local index: 0).")
|
||||||
|
assert.Contains(t, out.String(), "Orphaned chunks: 0 removed "+
|
||||||
|
"(total chunks left in the local index: 0).")
|
||||||
|
assert.Contains(t, out.String(), "Orphaned blobs: 0 removed "+
|
||||||
|
"(total blobs left in the local index: 0).")
|
||||||
|
}
|
||||||
@@ -35,6 +35,9 @@ 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,6 +45,9 @@ 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,6 +96,10 @@ 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)
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
"sneak.berlin/go/vaultik/internal/log"
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
"sneak.berlin/go/vaultik/internal/snapshot"
|
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||||
"sneak.berlin/go/vaultik/internal/storage"
|
"sneak.berlin/go/vaultik/internal/storage"
|
||||||
|
"sneak.berlin/go/vaultik/internal/storage/faultstore"
|
||||||
"sneak.berlin/go/vaultik/internal/types"
|
"sneak.berlin/go/vaultik/internal/types"
|
||||||
"sneak.berlin/go/vaultik/internal/vaultik"
|
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||||
)
|
)
|
||||||
@@ -303,6 +304,10 @@ 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")
|
||||||
|
|
||||||
@@ -392,7 +397,12 @@ func TestRemoveSnapshot_NoMetadataOnDestination(t *testing.T) {
|
|||||||
log.Initialize(log.Config{})
|
log.Initialize(log.Config{})
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
tv := vaultik.NewForTesting(newTestStorer())
|
// A backup wrote to this destination, so it carries the marker.
|
||||||
|
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",
|
||||||
@@ -431,6 +441,40 @@ func TestRemoveSnapshot_NotInLocalIndex(t *testing.T) {
|
|||||||
assert.Contains(t, out, "Removed snapshot metadata from remote storage")
|
assert.Contains(t, out, "Removed snapshot metadata from remote storage")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestRemoveSnapshot_NotInLocalIndexDeleteFails checks that removing a
|
||||||
|
// snapshot only the destination store holds fails when its metadata
|
||||||
|
// cannot be deleted there, under --force and under --json: nothing was
|
||||||
|
// removed, so the command must not succeed.
|
||||||
|
func TestRemoveSnapshot_NotInLocalIndexDeleteFails(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for name, opts := range map[string]vaultik.RemoveOptions{
|
||||||
|
"force": {Force: true},
|
||||||
|
"json": {JSON: true},
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
store := newTestStorer()
|
||||||
|
addManifest(t, store, "snapshot-001", nil)
|
||||||
|
|
||||||
|
faulty := faultstore.New(store)
|
||||||
|
faulty.OnDelete = func(string) bool { return true }
|
||||||
|
|
||||||
|
tv := vaultik.NewForTesting(faulty)
|
||||||
|
tv.Repositories = newLocalIndex(t)
|
||||||
|
|
||||||
|
result, err := tv.RemoveSnapshot("snapshot-001", &opts)
|
||||||
|
|
||||||
|
require.ErrorIs(t, err, faultstore.ErrInjectedDelete)
|
||||||
|
assert.False(t, result.RemoteRemoved)
|
||||||
|
assert.True(t, store.hasKey(remoteKeyPath("snapshot-001")))
|
||||||
|
assert.Empty(t, tv.Stdout.String())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRemoveAllSnapshots_RequiresForce(t *testing.T) {
|
func TestRemoveAllSnapshots_RequiresForce(t *testing.T) {
|
||||||
log.Initialize(log.Config{})
|
log.Initialize(log.Config{})
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|||||||
@@ -323,12 +323,6 @@ func (v *Vaultik) restoreAllFiles(
|
|||||||
_ = blobCache.Close()
|
_ = blobCache.Close()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Per-restore sweep state: every blob_size_limit/100 bytes written,
|
|
||||||
// scan the cache and delete any blob whose remaining file references
|
|
||||||
// are all already restored.
|
|
||||||
sweeper := newRestoreSweeper(v.ctx, repos, blobCache,
|
|
||||||
v.Config.BlobSizeLimit.Int64()/sweepIntervalDivisor)
|
|
||||||
|
|
||||||
blobByHash, blobIDToHash, err := v.buildBlobIndexes(repos)
|
blobByHash, blobIDToHash, err := v.buildBlobIndexes(repos)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -340,6 +334,12 @@ func (v *Vaultik) restoreAllFiles(
|
|||||||
return nil, fmt.Errorf("building restore plan: %w", err)
|
return nil, fmt.Errorf("building restore plan: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-restore sweep state: every blob_size_limit/100 bytes written,
|
||||||
|
// scan the cache and delete any blob that no pending file in the
|
||||||
|
// plan references.
|
||||||
|
sweeper := newRestoreSweeper(plan, blobCache,
|
||||||
|
v.Config.BlobSizeLimit.Int64()/sweepIntervalDivisor)
|
||||||
|
|
||||||
// Ranging over files rather than the map reports them in a stable order.
|
// Ranging over files rather than the map reports them in a stable order.
|
||||||
for _, file := range files {
|
for _, file := range files {
|
||||||
if planErr, ok := unplanned[file.ID]; ok {
|
if planErr, ok := unplanned[file.ID]; ok {
|
||||||
@@ -427,10 +427,8 @@ func (v *Vaultik) runRestoreLoop(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Record the file as restored so the sweeper can free blobs
|
// Drop the file from the plan's indexes so future picks ignore
|
||||||
// once all referencing files are done, and drop it from the
|
// it and the sweeper can free blobs no pending file needs.
|
||||||
// plan's indexes so future picks ignore it.
|
|
||||||
session.sweeper.fileRestored(fileID.String())
|
|
||||||
plan.finishFile(fileID)
|
plan.finishFile(fileID)
|
||||||
|
|
||||||
processed++
|
processed++
|
||||||
|
|||||||
@@ -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 := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
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 := filepath.Join(root, "remote")
|
storeDir := t.TempDir()
|
||||||
restoreDir := filepath.Join(root, "restored")
|
restoreDir := filepath.Join(root, "restored")
|
||||||
dbPath := filepath.Join(root, "index.sqlite")
|
dbPath := filepath.Join(root, "index.sqlite")
|
||||||
|
|
||||||
@@ -74,7 +74,8 @@ func TestRestoreCleansTempDirOnInterrupt(t *testing.T) {
|
|||||||
buildLocalityFixture(t, fs, dataDir)
|
buildLocalityFixture(t, fs, dataDir)
|
||||||
|
|
||||||
cfg, storer, snapshotID := setupLocalityBackup(
|
cfg, storer, snapshotID := setupLocalityBackup(
|
||||||
context.Background(), t, fs, dataDir, storeDir, dbPath)
|
context.Background(), t, fs, dataDir, storeDir, dbPath,
|
||||||
|
localitySourceBytes)
|
||||||
|
|
||||||
// Point the "" temp paths (the blob cache directory and the
|
// Point the "" temp paths (the blob cache directory and the
|
||||||
// snapshot-database directory) at a private directory so the test can
|
// snapshot-database directory) at a private directory so the test can
|
||||||
|
|||||||
@@ -63,6 +63,10 @@ type localityCopy struct {
|
|||||||
data []byte
|
data []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// localitySourceBytes is the size of each source file of the locality
|
||||||
|
// fixture.
|
||||||
|
const localitySourceBytes = 1024 * 1024
|
||||||
|
|
||||||
// buildLocalityFixture writes the adversarial source layout described
|
// buildLocalityFixture writes the adversarial source layout described
|
||||||
// in TestRestoreLocalityAndReadAt: 15 sources of 1 MiB each (which the
|
// in TestRestoreLocalityAndReadAt: 15 sources of 1 MiB each (which the
|
||||||
// backup packs into 3 blobs of 5 chunks) plus 9 interleaved-name copies
|
// backup packs into 3 blobs of 5 chunks) plus 9 interleaved-name copies
|
||||||
@@ -73,7 +77,6 @@ func buildLocalityFixture(
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
const (
|
const (
|
||||||
srcBytes = 1024 * 1024
|
|
||||||
srcCount = 15
|
srcCount = 15
|
||||||
blobsCount = 3
|
blobsCount = 3
|
||||||
perBlob = srcCount / blobsCount
|
perBlob = srcCount / blobsCount
|
||||||
@@ -83,7 +86,7 @@ func buildLocalityFixture(
|
|||||||
for i := range srcCount {
|
for i := range srcCount {
|
||||||
s := &localitySource{
|
s := &localitySource{
|
||||||
path: fmt.Sprintf("src-%03d.bin", i+1),
|
path: fmt.Sprintf("src-%03d.bin", i+1),
|
||||||
data: randomBytes(t, srcBytes),
|
data: randomBytes(t, localitySourceBytes),
|
||||||
}
|
}
|
||||||
sources[i] = s
|
sources[i] = s
|
||||||
require.NoError(t, afero.WriteFile(
|
require.NoError(t, afero.WriteFile(
|
||||||
@@ -115,19 +118,20 @@ func buildLocalityFixture(
|
|||||||
|
|
||||||
// setupLocalityBackup runs the backup half of the locality test: a
|
// setupLocalityBackup runs the backup half of the locality test: a
|
||||||
// snapshot of dataDir into a file storer with sizes tuned so 15 one-chunk
|
// snapshot of dataDir into a file storer with sizes tuned so 15 one-chunk
|
||||||
// files pack into 3 blobs. The index database is closed before returning
|
// files of srcBytes pack into 3 blobs. The index database is closed
|
||||||
// so the restore half runs from remote bytes only.
|
// before returning so the restore half runs from remote bytes only.
|
||||||
func setupLocalityBackup(
|
func setupLocalityBackup(
|
||||||
ctx context.Context, t *testing.T, fs afero.Fs,
|
ctx context.Context, t *testing.T, fs afero.Fs,
|
||||||
dataDir, storeDir, dbPath string,
|
dataDir, storeDir, dbPath string, srcBytes int64,
|
||||||
) (*config.Config, *storage.FileStorer, string) {
|
) (*config.Config, *storage.FileStorer, string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
// chunkSize avg = 4 MiB makes minSize = 1 MiB, so a 1 MiB file
|
// An average chunk size of four times srcBytes makes the minimum
|
||||||
// becomes one chunk. maxBlobSize = 5 MiB packs exactly 5 chunks
|
// chunk srcBytes, so each source file becomes one chunk. A blob limit
|
||||||
// per blob, yielding 3 blobs from 15 source files.
|
// of five times srcBytes packs exactly 5 chunks per blob, yielding 3
|
||||||
chunkSize := int64(4 * 1024 * 1024)
|
// blobs from 15 source files.
|
||||||
maxBlobSize := int64(5 * 1024 * 1024)
|
chunkSize := 4 * srcBytes
|
||||||
|
maxBlobSize := 5 * srcBytes
|
||||||
|
|
||||||
storer, err := storage.NewFileStorer(storeDir)
|
storer, err := storage.NewFileStorer(storeDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -197,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 := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
restoreDir := filepath.Join(tempDir, "restored")
|
restoreDir := filepath.Join(tempDir, "restored")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
@@ -208,7 +212,7 @@ func TestRestoreLocalityAndReadAt(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
cfg, storer, snapshotID := setupLocalityBackup(
|
cfg, storer, snapshotID := setupLocalityBackup(
|
||||||
ctx, t, fs, dataDir, storeDir, dbPath)
|
ctx, t, fs, dataDir, storeDir, dbPath, localitySourceBytes)
|
||||||
|
|
||||||
// Wrap the storer so we can count downloads per blob key.
|
// Wrap the storer so we can count downloads per blob key.
|
||||||
counter := newCountingStorer(storer)
|
counter := newCountingStorer(storer)
|
||||||
|
|||||||
@@ -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 := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
storer, err := storage.NewFileStorer(storeDir)
|
storer, err := storage.NewFileStorer(storeDir)
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ var (
|
|||||||
// with no further downloads.
|
// with no further downloads.
|
||||||
// - blobFiles: for each blob, the set of pending files referencing
|
// - blobFiles: for each blob, the set of pending files referencing
|
||||||
// it. Used to short-circuit "when this blob lands, which files
|
// it. Used to short-circuit "when this blob lands, which files
|
||||||
// become ready" without a global scan.
|
// become ready" without a global scan, and by the sweeper to tell
|
||||||
|
// whether a cached blob is still needed.
|
||||||
type restorePlan struct {
|
type restorePlan struct {
|
||||||
fileBlobs map[types.FileID]map[string]struct{}
|
fileBlobs map[types.FileID]map[string]struct{}
|
||||||
blobFiles map[string]map[types.FileID]struct{}
|
blobFiles map[string]map[types.FileID]struct{}
|
||||||
@@ -237,6 +238,12 @@ func (p *restorePlan) filesReferencingBlob(blobHash string) []types.FileID {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pendingFileUsesBlob reports whether any pending file references the
|
||||||
|
// named blob, whether or not the blob is cached.
|
||||||
|
func (p *restorePlan) pendingFileUsesBlob(blobHash string) bool {
|
||||||
|
return len(p.blobFiles[blobHash]) > 0
|
||||||
|
}
|
||||||
|
|
||||||
// hasPending reports whether any unfinished files remain.
|
// hasPending reports whether any unfinished files remain.
|
||||||
func (p *restorePlan) hasPending() bool {
|
func (p *restorePlan) hasPending() bool {
|
||||||
return len(p.fileBlobs) > 0
|
return len(p.fileBlobs) > 0
|
||||||
|
|||||||
@@ -63,15 +63,6 @@ func TestRunRestoreLoopFailsOnAbandonedFiles(t *testing.T) {
|
|||||||
|
|
||||||
t.Cleanup(func() { _ = cache.Close() })
|
t.Cleanup(func() { _ = cache.Close() })
|
||||||
|
|
||||||
v := &Vaultik{ctx: ctx}
|
|
||||||
session := &restoreSession{
|
|
||||||
v: v,
|
|
||||||
ctx: ctx,
|
|
||||||
repos: repos,
|
|
||||||
sweeper: newRestoreSweeper(ctx, repos, cache, 1),
|
|
||||||
result: &RestoreResult{},
|
|
||||||
}
|
|
||||||
|
|
||||||
// A file that is still pending but whose uncached-blob set is empty
|
// A file that is still pending but whose uncached-blob set is empty
|
||||||
// and which was never queued as ready: the loop can neither restore
|
// and which was never queued as ready: the loop can neither restore
|
||||||
// nor download it. This is the abandonment the guard must catch.
|
// nor download it. This is the abandonment the guard must catch.
|
||||||
@@ -83,6 +74,15 @@ func TestRunRestoreLoopFailsOnAbandonedFiles(t *testing.T) {
|
|||||||
cached: map[string]struct{}{},
|
cached: map[string]struct{}{},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
v := &Vaultik{ctx: ctx}
|
||||||
|
session := &restoreSession{
|
||||||
|
v: v,
|
||||||
|
ctx: ctx,
|
||||||
|
repos: repos,
|
||||||
|
sweeper: newRestoreSweeper(plan, cache, 1),
|
||||||
|
result: &RestoreResult{},
|
||||||
|
}
|
||||||
|
|
||||||
err = v.runRestoreLoop(session, plan, map[types.FileID]*database.File{}, 0)
|
err = v.runRestoreLoop(session, plan, map[types.FileID]*database.File{}, 0)
|
||||||
require.ErrorIs(t, err, errRestoreIncomplete)
|
require.ErrorIs(t, err, errRestoreIncomplete)
|
||||||
}
|
}
|
||||||
@@ -345,7 +345,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(filepath.Join(tempDir, "remote"))
|
storer, err := storage.NewFileStorer(t.TempDir())
|
||||||
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 := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
|
|
||||||
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 := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
|
|
||||||
chunkSize := int64(64 * 1024)
|
chunkSize := int64(64 * 1024)
|
||||||
|
|
||||||
|
|||||||
@@ -1,46 +1,30 @@
|
|||||||
package vaultik
|
package vaultik
|
||||||
|
|
||||||
import (
|
// restoreSweeper frees cached blobs once no file still to be restored in
|
||||||
"context"
|
// this run needs them. It works as follows:
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"sneak.berlin/go/vaultik/internal/database"
|
|
||||||
"sneak.berlin/go/vaultik/internal/log"
|
|
||||||
)
|
|
||||||
|
|
||||||
// restoreSweeper frees cached blobs once all files that reference any of
|
|
||||||
// their chunks have been restored. It works as follows:
|
|
||||||
//
|
//
|
||||||
// 1. Callers add a file's ID to an in-memory restored set via
|
// 1. After each chunk is restored, chunkRestored accumulates a running
|
||||||
// fileRestored once the file is fully written to disk.
|
|
||||||
// 2. After each chunk is restored, chunkRestored accumulates a running
|
|
||||||
// byte count.
|
// byte count.
|
||||||
// 3. When the accumulator crosses a threshold (one hundredth of the
|
// 2. When the accumulator crosses a threshold (one hundredth of the
|
||||||
// configured blob size — so a sweep runs about a hundred times per
|
// configured blob size — so a sweep runs about a hundred times per
|
||||||
// blob's worth of restored bytes), the sweeper iterates every key in
|
// blob's worth of restored bytes), the sweeper iterates every key in
|
||||||
// the cache. For each cached blob it asks the DB which files
|
// the cache and deletes each blob that the restore plan no longer
|
||||||
// reference any chunk in that blob, then compares that list against
|
// needs.
|
||||||
// the in-memory restored set. If any referencing file is missing
|
|
||||||
// from the set the blob is kept; otherwise the cache entry is
|
|
||||||
// deleted.
|
|
||||||
//
|
//
|
||||||
// All DB reads happen against the snapshot's temporary metadata DB,
|
// The plan drops a file once it is restored or has failed, and holds
|
||||||
// which is local, indexed, and not under contention — the queries are
|
// only the files this run restores, so files outside the requested paths
|
||||||
// cheap and run at most once per blob per sweep interval.
|
// never keep a blob.
|
||||||
type restoreSweeper struct {
|
type restoreSweeper struct {
|
||||||
ctx context.Context //nolint:containedctx // ctx bound at construction by design
|
plan *restorePlan
|
||||||
repos *database.Repositories
|
|
||||||
cache *blobDiskCache
|
cache *blobDiskCache
|
||||||
threshold int64
|
threshold int64
|
||||||
bytesAccum int64
|
bytesAccum int64
|
||||||
restored map[string]struct{}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// newRestoreSweeper returns a sweeper that triggers eviction every
|
// newRestoreSweeper returns a sweeper that triggers eviction every
|
||||||
// `threshold` bytes restored. Callers should pass blob_size_limit/100.
|
// `threshold` bytes restored. Callers should pass blob_size_limit/100.
|
||||||
func newRestoreSweeper(
|
func newRestoreSweeper(
|
||||||
ctx context.Context,
|
plan *restorePlan,
|
||||||
repos *database.Repositories,
|
|
||||||
cache *blobDiskCache,
|
cache *blobDiskCache,
|
||||||
threshold int64,
|
threshold int64,
|
||||||
) *restoreSweeper {
|
) *restoreSweeper {
|
||||||
@@ -49,21 +33,12 @@ func newRestoreSweeper(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &restoreSweeper{
|
return &restoreSweeper{
|
||||||
ctx: ctx,
|
plan: plan,
|
||||||
repos: repos,
|
|
||||||
cache: cache,
|
cache: cache,
|
||||||
threshold: threshold,
|
threshold: threshold,
|
||||||
restored: make(map[string]struct{}),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// fileRestored records a file as fully restored. After this call, any
|
|
||||||
// blob whose only remaining references come from files in the restored
|
|
||||||
// set may be evicted on the next sweep.
|
|
||||||
func (s *restoreSweeper) fileRestored(fileID string) {
|
|
||||||
s.restored[fileID] = struct{}{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// chunkRestored accounts n bytes against the sweep threshold and runs a
|
// chunkRestored accounts n bytes against the sweep threshold and runs a
|
||||||
// sweep if the threshold has been crossed since the last sweep.
|
// sweep if the threshold has been crossed since the last sweep.
|
||||||
func (s *restoreSweeper) chunkRestored(n int64) {
|
func (s *restoreSweeper) chunkRestored(n int64) {
|
||||||
@@ -76,60 +51,11 @@ func (s *restoreSweeper) chunkRestored(n int64) {
|
|||||||
s.sweep()
|
s.sweep()
|
||||||
}
|
}
|
||||||
|
|
||||||
// sweep deletes any cached blob whose chunks are no longer referenced
|
// sweep deletes every cached blob that no pending file references.
|
||||||
// by an unrestored file. Per-blob DB failures are logged and the blob
|
|
||||||
// is kept — we'd rather hold a blob longer than risk a re-download.
|
|
||||||
func (s *restoreSweeper) sweep() {
|
func (s *restoreSweeper) sweep() {
|
||||||
for _, blobHash := range s.cache.Keys() {
|
for _, blobHash := range s.cache.Keys() {
|
||||||
needed, err := s.blobStillNeeded(blobHash)
|
if !s.plan.pendingFileUsesBlob(blobHash) {
|
||||||
if err != nil {
|
|
||||||
log.Debug("sweeper referencing-files query failed",
|
|
||||||
"blob_hash", blobHash[:16], "error", err)
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if !needed {
|
|
||||||
s.cache.Delete(blobHash)
|
s.cache.Delete(blobHash)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// blobStillNeeded returns true if any file that references a chunk in
|
|
||||||
// this blob has not yet been restored. On any error the function
|
|
||||||
// returns true — keeping the blob is always the safe answer because we
|
|
||||||
// can't prove we're done with it.
|
|
||||||
func (s *restoreSweeper) blobStillNeeded(blobHash string) (bool, error) {
|
|
||||||
rows, err := s.repos.DB().Conn().QueryContext(s.ctx, `
|
|
||||||
SELECT DISTINCT fc.file_id
|
|
||||||
FROM file_chunks fc
|
|
||||||
JOIN blob_chunks bc ON bc.chunk_hash = fc.chunk_hash
|
|
||||||
JOIN blobs b ON b.id = bc.blob_id
|
|
||||||
WHERE b.blob_hash = ?
|
|
||||||
`, blobHash)
|
|
||||||
if err != nil {
|
|
||||||
return true, fmt.Errorf("querying referencing files: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() { _ = rows.Close() }()
|
|
||||||
|
|
||||||
for rows.Next() {
|
|
||||||
var fileID string
|
|
||||||
|
|
||||||
err := rows.Scan(&fileID)
|
|
||||||
if err != nil {
|
|
||||||
return true, fmt.Errorf("scanning file_id: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, ok := s.restored[fileID]; !ok {
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err = rows.Err()
|
|
||||||
if err != nil {
|
|
||||||
return true, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
@@ -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 := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
restoreDir := filepath.Join(tempDir, "restored")
|
restoreDir := filepath.Join(tempDir, "restored")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
package vaultik //nolint:testpackage // reads the restore blob cache's peak size
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/vaultik/internal/config"
|
||||||
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
|
"sneak.berlin/go/vaultik/internal/storage"
|
||||||
|
"sneak.berlin/go/vaultik/internal/ui"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestRestoreFreesBlobsNoPendingFileNeeds asserts that the restore cache
|
||||||
|
// frees a blob once no file still to be restored in this run needs it.
|
||||||
|
// Every original file has a copy with the same content, so every blob
|
||||||
|
// holds chunks of copies: a restore that leaves the copies out of its
|
||||||
|
// paths, or fails on them, must not keep the blobs for them.
|
||||||
|
func TestRestoreFreesBlobsNoPendingFileNeeds(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Small files keep the backup and both restores quick.
|
||||||
|
const fileBytes = 16 * 1024
|
||||||
|
|
||||||
|
fs := afero.NewOsFs()
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
|
dataDir := filepath.Join(tempDir, "source")
|
||||||
|
originalsDir := filepath.Join(dataDir, "originals")
|
||||||
|
copiesDir := filepath.Join(dataDir, "copies")
|
||||||
|
storeDir := filepath.Join(tempDir, "remote")
|
||||||
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
|
originals := writeFilesWithCopies(t, fs, originalsDir, copiesDir, fileBytes)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
cfg, storer, snapshotID := setupLocalityBackup(
|
||||||
|
ctx, t, fs, dataDir, storeDir, dbPath, fileBytes)
|
||||||
|
|
||||||
|
t.Run("restore by path", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
restoreDir := filepath.Join(tempDir, "by-path")
|
||||||
|
|
||||||
|
peak, err := restorePeakCachedBlobs(ctx, t, fs, cfg, storer,
|
||||||
|
&RestoreOptions{
|
||||||
|
SnapshotID: snapshotID,
|
||||||
|
TargetDir: restoreDir,
|
||||||
|
Paths: []string{originalsDir},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
requireRestoredFiles(t, fs, restoreDir, originals)
|
||||||
|
assert.LessOrEqualf(t, peak, 1,
|
||||||
|
"peak cached blobs was %d; blobs were kept for unselected files",
|
||||||
|
peak)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("restore with failed files", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
restoreDir := filepath.Join(tempDir, "with-failures")
|
||||||
|
|
||||||
|
// A regular file where the copies directory belongs makes every
|
||||||
|
// copy fail to restore.
|
||||||
|
blocker := filepath.Join(restoreDir, copiesDir)
|
||||||
|
require.NoError(t, fs.MkdirAll(filepath.Dir(blocker), 0o755))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, blocker, nil, 0o644))
|
||||||
|
|
||||||
|
peak, err := restorePeakCachedBlobs(ctx, t, fs, cfg, storer,
|
||||||
|
&RestoreOptions{
|
||||||
|
SnapshotID: snapshotID,
|
||||||
|
TargetDir: restoreDir,
|
||||||
|
SkipErrors: true,
|
||||||
|
})
|
||||||
|
require.ErrorIs(t, err, errFilesFailedRestore)
|
||||||
|
|
||||||
|
requireRestoredFiles(t, fs, restoreDir, originals)
|
||||||
|
assert.LessOrEqualf(t, peak, 1,
|
||||||
|
"peak cached blobs was %d; blobs were kept for failed files", peak)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeFilesWithCopies writes 15 random files of fileBytes into
|
||||||
|
// originalsDir and a copy of each into copiesDir. With
|
||||||
|
// setupLocalityBackup given the same size, each file is one chunk and the
|
||||||
|
// 15 chunks fill 3 blobs. Returns the originals' content by path.
|
||||||
|
func writeFilesWithCopies(
|
||||||
|
t *testing.T, fs afero.Fs, originalsDir, copiesDir string, fileBytes int,
|
||||||
|
) map[string][]byte {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
const fileCount = 15
|
||||||
|
|
||||||
|
require.NoError(t, fs.MkdirAll(originalsDir, 0o755))
|
||||||
|
require.NoError(t, fs.MkdirAll(copiesDir, 0o755))
|
||||||
|
|
||||||
|
originals := make(map[string][]byte, fileCount)
|
||||||
|
|
||||||
|
for i := range fileCount {
|
||||||
|
name := fmt.Sprintf("file-%02d.bin", i)
|
||||||
|
data := randomBytes(t, fileBytes)
|
||||||
|
original := filepath.Join(originalsDir, name)
|
||||||
|
originals[original] = data
|
||||||
|
|
||||||
|
require.NoError(t, afero.WriteFile(fs, original, data, 0o644))
|
||||||
|
require.NoError(t, afero.WriteFile(
|
||||||
|
fs, filepath.Join(copiesDir, name), data, 0o644))
|
||||||
|
}
|
||||||
|
|
||||||
|
return originals
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireRestoredFiles requires every file in want to have been restored
|
||||||
|
// under restoreDir with its content intact.
|
||||||
|
func requireRestoredFiles(
|
||||||
|
t *testing.T, fs afero.Fs, restoreDir string, want map[string][]byte,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
for path, data := range want {
|
||||||
|
got, err := afero.ReadFile(fs, filepath.Join(restoreDir, path))
|
||||||
|
require.NoErrorf(t, err, "file missing after restore: %s", path)
|
||||||
|
require.Equalf(t, data, got, "byte mismatch for %s", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// restorePeakCachedBlobs runs a restore and returns the most blobs its
|
||||||
|
// cache held at once, together with the restore's error.
|
||||||
|
func restorePeakCachedBlobs(
|
||||||
|
ctx context.Context, t *testing.T, fs afero.Fs,
|
||||||
|
cfg *config.Config, storer storage.Storer, opts *RestoreOptions,
|
||||||
|
) (int, error) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var cache *blobDiskCache
|
||||||
|
|
||||||
|
v := &Vaultik{
|
||||||
|
Config: cfg,
|
||||||
|
Storage: storer,
|
||||||
|
Fs: fs,
|
||||||
|
Stdout: io.Discard,
|
||||||
|
Stderr: io.Discard,
|
||||||
|
UI: ui.NewWithColor(io.Discard, false),
|
||||||
|
restoreCacheObserver: func(c *blobDiskCache) {
|
||||||
|
cache = c
|
||||||
|
},
|
||||||
|
}
|
||||||
|
v.SetContext(ctx)
|
||||||
|
|
||||||
|
err := v.Restore(opts)
|
||||||
|
|
||||||
|
require.NotNil(t, cache, "restoreCacheObserver must fire during restore")
|
||||||
|
|
||||||
|
return cache.PeakLen(), err
|
||||||
|
}
|
||||||
@@ -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 := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
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 := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
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 := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
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 := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
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 := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
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 := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
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 := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
chunkSize := int64(32 * 1024)
|
chunkSize := int64(32 * 1024)
|
||||||
@@ -110,7 +110,7 @@ func TestShallowVerifyDetectsMissingDatabase(t *testing.T) {
|
|||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
dataDir := filepath.Join(tempDir, "source")
|
dataDir := filepath.Join(tempDir, "source")
|
||||||
storeDir := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
chunkSize := int64(32 * 1024)
|
chunkSize := int64(32 * 1024)
|
||||||
@@ -193,7 +193,7 @@ func TestShallowVerifyReportsStatErrorsApartFromMissing(t *testing.T) {
|
|||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
dataDir := filepath.Join(tempDir, "source")
|
dataDir := filepath.Join(tempDir, "source")
|
||||||
storeDir := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
chunkSize := int64(32 * 1024)
|
chunkSize := int64(32 * 1024)
|
||||||
@@ -282,7 +282,7 @@ func TestShallowVerifyQuietFailsOnStatErrorAlone(t *testing.T) {
|
|||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
dataDir := filepath.Join(tempDir, "source")
|
dataDir := filepath.Join(tempDir, "source")
|
||||||
storeDir := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
chunkSize := int64(32 * 1024)
|
chunkSize := int64(32 * 1024)
|
||||||
|
|||||||
+105
-42
@@ -94,6 +94,13 @@ 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
|
||||||
@@ -116,21 +123,9 @@ func (v *Vaultik) CreateSnapshot(opts *SnapshotCreateOptions) error {
|
|||||||
return fmt.Errorf("prune database: %w", err)
|
return fmt.Errorf("prune database: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine which snapshots to process
|
snapshotNames, err := v.snapshotNamesToCreate(opts.Snapshots)
|
||||||
snapshotNames := opts.Snapshots
|
if err != nil {
|
||||||
if len(snapshotNames) == 0 {
|
return err
|
||||||
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
|
||||||
@@ -170,6 +165,28 @@ 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
|
||||||
@@ -1228,9 +1245,11 @@ const snapshotRemoveCommandHint = "vaultik snapshot remove"
|
|||||||
// destination store. Blobs are NOT touched: removing a snapshot's
|
// destination store. Blobs are NOT touched: removing a snapshot's
|
||||||
// blobs requires reading every remaining remote manifest (the remote
|
// blobs requires reading every remaining remote manifest (the remote
|
||||||
// may hold snapshots this host doesn't know about), which is what
|
// may hold snapshots this host doesn't know about), which is what
|
||||||
// `vaultik prune` is for — the command prints the prune invocation to
|
// `vaultik prune` is for — when metadata was removed from the
|
||||||
// run as a follow-up. When the remote is unreachable the command still
|
// destination store, the text output names the prune invocation to run
|
||||||
// completes the local-DB removal and warns.
|
// as a follow-up. When removing the metadata fails, a snapshot in the
|
||||||
|
// local index is still removed from it, with a warning; a snapshot
|
||||||
|
// only on the destination store is an error.
|
||||||
func (v *Vaultik) RemoveSnapshot(
|
func (v *Vaultik) RemoveSnapshot(
|
||||||
snapshotID string, opts *RemoveOptions,
|
snapshotID string, opts *RemoveOptions,
|
||||||
) (*RemoveResult, error) {
|
) (*RemoveResult, error) {
|
||||||
@@ -1252,10 +1271,18 @@ func (v *Vaultik) RemoveSnapshot(
|
|||||||
return v.removeSnapshotDryRun(result, snapshotID, opts)
|
return v.removeSnapshotDryRun(result, snapshotID, opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !opts.Force && !opts.JSON && !v.confirmRemoveSnapshot(snapshotID, opts) {
|
if !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)
|
||||||
|
|
||||||
@@ -1265,8 +1292,10 @@ func (v *Vaultik) RemoveSnapshot(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !opts.LocalOnly {
|
result.RemoteRemoved, err = v.removeSnapshotRemote(
|
||||||
result.RemoteRemoved = v.removeSnapshotRemote(snapshotID, opts)
|
snapshotID, inLocalIndex, markerErr, opts)
|
||||||
|
if err != nil {
|
||||||
|
return result, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if v.SnapshotManager != nil {
|
if v.SnapshotManager != nil {
|
||||||
@@ -1359,11 +1388,15 @@ func (v *Vaultik) removeSnapshotDryRun(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// confirmRemoveSnapshot prompts for confirmation before removing a
|
// confirmRemoveSnapshot prompts for confirmation before removing a
|
||||||
// snapshot. Returns false (treating EOF/read errors as "no") unless the
|
// snapshot, unless --force or --json is set. Returns false (treating
|
||||||
// user answers "y". The prompt and its answer bypass the UI: they are an
|
// EOF/read errors as "no") unless the user answers "y". The prompt and
|
||||||
// interactive exchange the operator must see, so --quiet does not
|
// its answer bypass the UI: they are an interactive exchange the
|
||||||
// silence them.
|
// operator must see, so --quiet does not 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)
|
||||||
@@ -1391,19 +1424,40 @@ 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, warning and proceeding on failure: the local-DB
|
// destination store. It returns true only when the delete finished
|
||||||
// removal has already happened, so the user is told the remote half
|
// without an error and removed at least one object; under LocalOnly it
|
||||||
// didn't finish and to run `vaultik snapshot remove` for the snapshot
|
// does nothing. markerErr is the result of checkDestinationMarker; when
|
||||||
// again once the destination store is reachable (`vaultik prune` never
|
// it is set, nothing is deleted and it is the failure. When the snapshot
|
||||||
// removes snapshot metadata). Returns true when the remote removal
|
// is only on the destination store, a failure is returned as an error,
|
||||||
// succeeded and deleted something.
|
// because that removal is all the command does. When the snapshot was in
|
||||||
func (v *Vaultik) removeSnapshotRemote(snapshotID string, opts *RemoveOptions) bool {
|
// the local index, its removal there has already happened, so a failure
|
||||||
|
// is only a warning telling the user to run `vaultik snapshot remove`
|
||||||
|
// for the snapshot again once the destination store is reachable
|
||||||
|
// (`vaultik prune` never removes snapshot metadata).
|
||||||
|
func (v *Vaultik) removeSnapshotRemote(
|
||||||
|
snapshotID string, inLocalIndex bool, markerErr error,
|
||||||
|
opts *RemoveOptions,
|
||||||
|
) (bool, error) {
|
||||||
|
if opts.LocalOnly {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
log.Info("Removing snapshot metadata from remote storage",
|
log.Info("Removing snapshot metadata from remote storage",
|
||||||
"snapshot_id", snapshotID)
|
"snapshot_id", snapshotID)
|
||||||
|
|
||||||
remoteKey := snapshot.RemoteSnapshotKey(snapshotID)
|
var deleted int
|
||||||
|
|
||||||
|
err := markerErr
|
||||||
|
if err == nil {
|
||||||
|
deleted, err = v.deleteRemoteSnapshotByKey(
|
||||||
|
snapshot.RemoteSnapshotKey(snapshotID))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil && !inLocalIndex {
|
||||||
|
return false, fmt.Errorf(
|
||||||
|
"removing snapshot metadata from destination store: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
deleted, err := v.deleteRemoteSnapshotByKey(remoteKey)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warn("Could not remove snapshot metadata from remote storage; "+
|
log.Warn("Could not remove snapshot metadata from remote storage; "+
|
||||||
"run '"+snapshotRemoveCommandHint+"' with the snapshot's ID "+
|
"run '"+snapshotRemoveCommandHint+"' with the snapshot's ID "+
|
||||||
@@ -1418,10 +1472,10 @@ func (v *Vaultik) removeSnapshotRemote(snapshotID string, opts *RemoveOptions) b
|
|||||||
err, snapshotRemoveCommandHint, snapshotID)
|
err, snapshotRemoveCommandHint, snapshotID)
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return deleted > 0
|
return deleted > 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveAllSnapshots removes every snapshot known to the local
|
// RemoveAllSnapshots removes every snapshot known to the local
|
||||||
@@ -1507,8 +1561,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; callers must treat that as "no remote info available," not
|
// listed, or is not present by checkDestinationMarker; callers must
|
||||||
// fatal.
|
// treat that as "no remote info available," not 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")
|
||||||
|
|
||||||
@@ -1557,6 +1611,11 @@ func (v *Vaultik) listAllRemoteSnapshotKeys() ([]string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err := v.checkDestinationMarker()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
return keys, nil
|
return keys, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1902,13 +1961,17 @@ func (v *Vaultik) PruneDatabase() (*PruneResult, error) {
|
|||||||
snapshotsRemain := countDiff(snapshotCountBefore, &result.SnapshotsDeleted)
|
snapshotsRemain := countDiff(snapshotCountBefore, &result.SnapshotsDeleted)
|
||||||
|
|
||||||
v.UI.Completef("Pruned local index database.")
|
v.UI.Completef("Pruned local index database.")
|
||||||
v.UI.Detailf("Incomplete snapshots: %s removed (%s remain).",
|
v.UI.Detailf("Incomplete snapshots: %s removed "+
|
||||||
|
"(total snapshots left in the local index: %s).",
|
||||||
countText(&result.SnapshotsDeleted), countText(snapshotsRemain))
|
countText(&result.SnapshotsDeleted), countText(snapshotsRemain))
|
||||||
v.UI.Detailf("Orphaned files: %s removed (%s remain).",
|
v.UI.Detailf("Orphaned files: %s removed "+
|
||||||
|
"(total files left in the local index: %s).",
|
||||||
countText(result.FilesDeleted), countText(fileCountAfter))
|
countText(result.FilesDeleted), countText(fileCountAfter))
|
||||||
v.UI.Detailf("Orphaned chunks: %s removed (%s remain).",
|
v.UI.Detailf("Orphaned chunks: %s removed "+
|
||||||
|
"(total chunks left in the local index: %s).",
|
||||||
countText(result.ChunksDeleted), countText(chunkCountAfter))
|
countText(result.ChunksDeleted), countText(chunkCountAfter))
|
||||||
v.UI.Detailf("Orphaned blobs: %s removed (%s remain).",
|
v.UI.Detailf("Orphaned blobs: %s removed "+
|
||||||
|
"(total blobs left in the local index: %s).",
|
||||||
countText(result.BlobsDeleted), countText(blobCountAfter))
|
countText(result.BlobsDeleted), countText(blobCountAfter))
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ func TestSnapshotSummaryCountsFileNotStoredAsFailed(t *testing.T) {
|
|||||||
"src": {Paths: []string{srcDir}},
|
"src": {Paths: []string{srcDir}},
|
||||||
}
|
}
|
||||||
|
|
||||||
store, err := storage.NewFileStorer(filepath.Join(tempDir, "remote"))
|
store, err := storage.NewFileStorer(t.TempDir())
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
db, err := database.New(ctx, cfg.IndexPath)
|
db, err := database.New(ctx, cfg.IndexPath)
|
||||||
|
|||||||
@@ -122,6 +122,9 @@ 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(filepath.Join(tempDir, "remote"))
|
store, err := storage.NewFileStorer(t.TempDir())
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
db, err := database.New(ctx, cfg.IndexPath)
|
db, err := database.New(ctx, cfg.IndexPath)
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ func newSummaryEnv(t *testing.T) *summaryEnv {
|
|||||||
"split": {Paths: []string{dirOne, dirTwo}},
|
"split": {Paths: []string{dirOne, dirTwo}},
|
||||||
}
|
}
|
||||||
|
|
||||||
inner, err := storage.NewFileStorer(filepath.Join(tempDir, "remote"))
|
inner, err := storage.NewFileStorer(t.TempDir())
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
store := faultstore.New(inner)
|
store := faultstore.New(inner)
|
||||||
|
|||||||
@@ -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 := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
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(filepath.Join(tempDir, "remote"))
|
store, err := storage.NewFileStorer(t.TempDir())
|
||||||
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 := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
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