Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c4af6d0df |
+4
-17
@@ -279,8 +279,8 @@ Key methods:
|
|||||||
- `Scan(ctx, path, snapshotID)` → Main scan operation
|
- `Scan(ctx, path, snapshotID)` → Main scan operation
|
||||||
|
|
||||||
Scan phases:
|
Scan phases:
|
||||||
1. **Phase 1**: Walk directory, identify files needing processing
|
1. **Phase 0**: Detect deleted files from previous snapshots
|
||||||
2. **Phase 1b**: Count as deleted the files that the previous snapshot of the same name lists, the walk did not find, and are gone from disk; a file that is excluded, or inside an excluded directory, is not counted
|
2. **Phase 1**: Walk directory, identify files needing processing
|
||||||
3. **Phase 2**: Process files (chunk → pack → upload)
|
3. **Phase 2**: Process files (chunk → pack → upload)
|
||||||
|
|
||||||
#### SnapshotManager
|
#### SnapshotManager
|
||||||
@@ -311,8 +311,6 @@ Repository interfaces:
|
|||||||
|
|
||||||
```
|
```
|
||||||
CreateSnapshot(opts)
|
CreateSnapshot(opts)
|
||||||
│
|
|
||||||
├─► markDestination() // Refuse a destination without the marker, then write it
|
|
||||||
│
|
│
|
||||||
├─► PruneDatabase() // Critical: avoid dedup errors
|
├─► PruneDatabase() // Critical: avoid dedup errors
|
||||||
│
|
│
|
||||||
@@ -322,13 +320,13 @@ CreateSnapshot(opts)
|
|||||||
│ │
|
│ │
|
||||||
│ ├─► scanner.Scan(ctx, path, snapshotID)
|
│ ├─► scanner.Scan(ctx, path, snapshotID)
|
||||||
│ │ │
|
│ │ │
|
||||||
|
│ │ ├─► Phase 0: detectDeletedFiles()
|
||||||
|
│ │ │
|
||||||
│ │ ├─► Phase 1: scanPhase()
|
│ │ ├─► Phase 1: scanPhase()
|
||||||
│ │ │ Walk directory
|
│ │ │ Walk directory
|
||||||
│ │ │ Check file metadata changes
|
│ │ │ Check file metadata changes
|
||||||
│ │ │ Build list of files to process
|
│ │ │ Build list of files to process
|
||||||
│ │ │
|
│ │ │
|
||||||
│ │ ├─► Phase 1b: detectDeletedFiles()
|
|
||||||
│ │ │
|
|
||||||
│ │ └─► Phase 2: processPhase()
|
│ │ └─► Phase 2: processPhase()
|
||||||
│ │ For each file:
|
│ │ For each file:
|
||||||
│ │ chunker.ChunkReaderStreaming()
|
│ │ chunker.ChunkReaderStreaming()
|
||||||
@@ -369,8 +367,6 @@ CreateSnapshot(opts)
|
|||||||
|
|
||||||
```
|
```
|
||||||
bucket/
|
bucket/
|
||||||
├── vaultik-destination.txt # Marker written by every backup
|
|
||||||
│
|
|
||||||
├── blobs/
|
├── blobs/
|
||||||
│ └── {hash[0:2]}/
|
│ └── {hash[0:2]}/
|
||||||
│ └── {hash[2:4]}/
|
│ └── {hash[2:4]}/
|
||||||
@@ -382,15 +378,6 @@ bucket/
|
|||||||
└── manifest.json.zst # Blob list (for pruning/verification)
|
└── manifest.json.zst # Blob list (for pruning/verification)
|
||||||
```
|
```
|
||||||
|
|
||||||
A destination without `vaultik-destination.txt` is treated as not present
|
|
||||||
while the local index holds snapshots stored there (`checkDestinationMarker`
|
|
||||||
in `internal/vaultik/destination_marker.go`): `snapshot create` refuses to
|
|
||||||
write to it, `prune` and `snapshot purge` fail, and `snapshot list` and
|
|
||||||
`snapshot remove` behave as for a destination that cannot be listed. This
|
|
||||||
keeps those commands from taking an unplugged volume's empty mount point for
|
|
||||||
a destination whose snapshots are all gone. The `file://` backend never
|
|
||||||
creates its destination directory, so a write to a missing one fails.
|
|
||||||
|
|
||||||
The `{remote-key}` directory name is a one-way double SHA-256 hash of the human
|
The `{remote-key}` directory name is a one-way double SHA-256 hash of the human
|
||||||
snapshot ID, so the human ID (hostname, snapshot name, timestamp) is never
|
snapshot ID, so the human ID (hostname, snapshot name, timestamp) is never
|
||||||
written to the store as a directory name. See
|
written to the store as a directory name. See
|
||||||
|
|||||||
@@ -24,10 +24,6 @@ grep 'public key' vaultik_backup_private_key.txt
|
|||||||
vaultik config set age_recipients.0 age1YOUR_PUBLIC_KEY_HERE
|
vaultik config set age_recipients.0 age1YOUR_PUBLIC_KEY_HERE
|
||||||
vaultik config set storage_url "file:///Volumes/usbstick/mybackup"
|
vaultik config set storage_url "file:///Volumes/usbstick/mybackup"
|
||||||
|
|
||||||
# create the destination directory; vaultik never creates it, so a backup
|
|
||||||
# to a volume that is not plugged in fails instead of filling the disk
|
|
||||||
mkdir /Volumes/usbstick/mybackup
|
|
||||||
|
|
||||||
# macOS only: grant your terminal app Full Disk Access first
|
# macOS only: grant your terminal app Full Disk Access first
|
||||||
# (System Settings → Privacy & Security → Full Disk Access), otherwise
|
# (System Settings → Privacy & Security → Full Disk Access), otherwise
|
||||||
# the backup will abort with a permission error on protected directories
|
# the backup will abort with a permission error on protected directories
|
||||||
@@ -182,7 +178,7 @@ vaultik version
|
|||||||
* `--verbose`, `-v`: Enable verbose output (on stderr — see below)
|
* `--verbose`, `-v`: Enable verbose output (on stderr — see below)
|
||||||
* `--debug`: Enable debug output (on stderr — see below)
|
* `--debug`: Enable debug output (on stderr — see below)
|
||||||
* `--quiet`, `-q`: Suppress non-error output (also suppresses startup banner)
|
* `--quiet`, `-q`: Suppress non-error output (also suppresses startup banner)
|
||||||
* `--skip-errors`: Skip files that cannot be read when creating a snapshot, or that cannot be restored when restoring, instead of aborting. Packing and storage errors while creating a snapshot (which would leave a chunk recorded but not stored) still abort the run. A run that skipped anything still finishes but exits non-zero: `snapshot create` keeps the snapshot and counts each file or directory it skipped as failed. A file removed during the backup is also counted as failed but does not change the exit status.
|
* `--skip-errors`: Skip files that cannot be read when creating a snapshot, or that cannot be restored when restoring, instead of aborting. Packing and storage errors while creating a snapshot (which would leave a chunk recorded but not stored) still abort the run.
|
||||||
|
|
||||||
### locking
|
### locking
|
||||||
|
|
||||||
@@ -275,17 +271,8 @@ existing file. Created with mode `0600` since it will contain credentials.
|
|||||||
**`config set`**: Set a scalar config value by dotted YAML path
|
**`config set`**: Set a scalar config value by dotted YAML path
|
||||||
(e.g. `vaultik config set compression_level 9`,
|
(e.g. `vaultik config set compression_level 9`,
|
||||||
`vaultik config set storage_url "file:///mnt/backups"`). Comments and
|
`vaultik config set storage_url "file:///mnt/backups"`). Comments and
|
||||||
formatting in the file are preserved. Missing keys along the path are
|
formatting in the file are preserved; intermediate maps are created as
|
||||||
created: as a list when the next path element is a number (so
|
needed.
|
||||||
`vaultik config set age_recipients.0 age1...` works on a file without
|
|
||||||
`age_recipients`), otherwise as a map. If the config loads, a change
|
|
||||||
after which it would not, such as `vaultik config set compression_level 99`,
|
|
||||||
is refused and the file is left unchanged. A config that does not load,
|
|
||||||
such as the one `config init` writes before `storage_url` is set or one
|
|
||||||
with a `${ENV:...}` reference to a variable not set in this shell, is
|
|
||||||
checked one setting at a time: only a change after which the changed
|
|
||||||
setting cannot be read, such as one giving it the wrong type (a single
|
|
||||||
value where a list belongs or a word where a number belongs), is refused.
|
|
||||||
|
|
||||||
**`snapshot create`**: Perform incremental backup of configured snapshots.
|
**`snapshot create`**: Perform incremental backup of configured snapshots.
|
||||||
* Optional snapshot names argument to create specific snapshots (default: all)
|
* Optional snapshot names argument to create specific snapshots (default: all)
|
||||||
@@ -302,10 +289,6 @@ value where a list belongs or a word where a number belongs), is refused.
|
|||||||
this duration instead of only the latest (e.g. `4w`, `30d`, `6mo`, `1y`).
|
this duration instead of only the latest (e.g. `4w`, `30d`, `6mo`, `1y`).
|
||||||
Without `--prune`, or with a value that is not a valid duration, the
|
Without `--prune`, or with a value that is not a valid duration, the
|
||||||
command fails before the backup starts.
|
command fails before the backup starts.
|
||||||
* Fails before it writes anything when a `file://` destination directory
|
|
||||||
does not exist, or when the local index holds snapshots and the
|
|
||||||
destination has no `vaultik-destination.txt` (see
|
|
||||||
[remote storage layout](#remote-storage-layout))
|
|
||||||
|
|
||||||
**`snapshot list`**: Show every snapshot known to this host — the union
|
**`snapshot list`**: Show every snapshot known to this host — the union
|
||||||
of the local index and the backup destination store — with timestamps
|
of the local index and the backup destination store — with timestamps
|
||||||
@@ -331,27 +314,18 @@ read from the manifest.
|
|||||||
|
|
||||||
Snapshots in the local index with no counterpart on the destination
|
Snapshots in the local index with no counterpart on the destination
|
||||||
store are reported below the table as drift, with the `vaultik prune`
|
store are reported below the table as drift, with the `vaultik prune`
|
||||||
invocation that reconciles them. If the local index is bound to a
|
invocation that reconciles them.
|
||||||
different destination than the configured one (after a change to
|
|
||||||
`storage_url`, or `s3.endpoint`, `s3.bucket` or `s3.prefix`), the
|
|
||||||
command says so instead and gives the same two ways out that `prune`
|
|
||||||
gives: revert the configuration, or run `vaultik database delete`.
|
|
||||||
|
|
||||||
If the destination store cannot be listed (unmounted volume,
|
If the destination store cannot be listed (unmounted volume,
|
||||||
permission denied, network down), the command warns, falls back to the
|
permission denied, network down), the command warns, falls back to the
|
||||||
local index alone, and still exits zero. It does the same when the
|
local index alone, and still exits zero.
|
||||||
destination has no `vaultik-destination.txt` while the local index
|
|
||||||
holds snapshots stored there, as when a volume mounted at the
|
|
||||||
destination is unplugged and leaves its empty mount point behind.
|
|
||||||
* `--json`: Output in JSON format. Each entry carries `locally_tracked`
|
* `--json`: Output in JSON format. Each entry carries `locally_tracked`
|
||||||
(whether the snapshot is in the local index), `remote_key` (the full
|
(whether the snapshot is in the local index), `remote_key` (the full
|
||||||
64-character storage key), and `remote_present` (whether it was seen
|
64-character storage key), and `remote_present` (whether it was seen
|
||||||
on the destination store, or `null` if the destination could not be
|
on the destination store, or `null` if the destination could not be
|
||||||
listed). Warnings about an unlistable destination, a local index
|
listed). Warnings about an unlistable destination, unreadable
|
||||||
bound to a different destination (with both destinations and the
|
manifests, and a truncated listing all go to stderr through the
|
||||||
same two ways out), unreadable manifests, and a truncated listing all
|
logger, so stdout stays a single parseable document.
|
||||||
go to stderr through the logger, so stdout stays a single parseable
|
|
||||||
document.
|
|
||||||
|
|
||||||
**`snapshot verify`**: Verify snapshot integrity.
|
**`snapshot verify`**: Verify snapshot integrity.
|
||||||
* Default (shallow): checks that every blob the manifest lists is present in
|
* Default (shallow): checks that every blob the manifest lists is present in
|
||||||
@@ -365,7 +339,9 @@ destination is unplugged and leaves its empty mount point behind.
|
|||||||
|
|
||||||
**`snapshot purge`**: Remove old snapshots based on criteria. Retention is
|
**`snapshot purge`**: Remove old snapshots based on criteria. Retention is
|
||||||
per-snapshot-name (`--keep-latest` keeps the latest of each name, not the
|
per-snapshot-name (`--keep-latest` keeps the latest of each name, not the
|
||||||
latest globally).
|
latest globally). If a snapshot cannot be removed from the local index, the
|
||||||
|
command stops there, leaves that snapshot's metadata on the destination store
|
||||||
|
and exits non-zero; `snapshot create --prune` fails the same way.
|
||||||
* `--keep-latest`: Keep only the most recent snapshot of each name
|
* `--keep-latest`: Keep only the most recent snapshot of each name
|
||||||
* `--older-than <duration>`: Remove snapshots older than duration (e.g. `30d`,
|
* `--older-than <duration>`: Remove snapshots older than duration (e.g. `30d`,
|
||||||
`4w`, `6mo`, `1y`; `m` is minutes, `mo` is months)
|
`4w`, `6mo`, `1y`; `m` is minutes, `mo` is months)
|
||||||
@@ -377,21 +353,16 @@ 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. The command prints the `vaultik prune` invocation
|
`vaultik prune` does. On success the command prints the exact `vaultik
|
||||||
to run as a follow-up only when it removed the snapshot's metadata from
|
prune` invocation to run as a follow-up. An ID that names no snapshot in
|
||||||
the destination store, and never under `--json`. An ID that names no
|
the local index or on the destination store is an error; with
|
||||||
snapshot in the local index or on the destination store is an error;
|
`--local-only`, the snapshot must be in the local index. Local row
|
||||||
with `--local-only`, the snapshot must be in the local index. Local row
|
|
||||||
cleanup (files, chunks, blobs the snapshot was the last referrer for)
|
cleanup (files, chunks, blobs the snapshot was the last referrer for)
|
||||||
runs automatically. If removing the snapshot's metadata from the
|
runs automatically. If the destination store is unreachable, the local-DB
|
||||||
destination store fails (for example because the store is unreachable,
|
removal still completes and a warning is emitted; run `vaultik snapshot
|
||||||
or the local index holds snapshots and the destination has no
|
remove <snapshot-id>` again once the store is reachable to remove the
|
||||||
`vaultik-destination.txt`), the local-DB removal still completes and a
|
snapshot's metadata from it (`vaultik prune` does not). To wipe everything
|
||||||
warning is emitted; run `vaultik snapshot remove <snapshot-id>` again
|
on the destination in one go, use `vaultik remote nuke --force`.
|
||||||
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
|
||||||
@@ -404,9 +375,7 @@ destination in one go, use `vaultik remote nuke --force`.
|
|||||||
store — its remote key (or an unambiguous leading part of it) as shown
|
store — its remote key (or an unambiguous leading part of it) as shown
|
||||||
by `snapshot list`. See
|
by `snapshot list`. See
|
||||||
[restoring on another machine](#restoring-on-another-machine).
|
[restoring on another machine](#restoring-on-another-machine).
|
||||||
* Optional path arguments to restore specific files/directories (default: all).
|
* Optional path arguments to restore specific files/directories (default: all)
|
||||||
A path that matches nothing in the snapshot is named in a warning, and the
|
|
||||||
command exits non-zero after restoring the paths that match.
|
|
||||||
* Preserves file permissions, timestamps, ownership (ownership requires root),
|
* Preserves file permissions, timestamps, ownership (ownership requires root),
|
||||||
symlinks, and empty directories
|
symlinks, and empty directories
|
||||||
* `--verify`: After restoring, verify every restored file's chunk hashes
|
* `--verify`: After restoring, verify every restored file's chunk hashes
|
||||||
@@ -422,12 +391,8 @@ manifest on the destination store to compute the still-referenced blob
|
|||||||
set and delete any blob not in that set. Step (3) reads all remote
|
set and delete any blob not in that set. Step (3) reads all remote
|
||||||
manifests — network cost scales with the number of snapshots. `snapshot
|
manifests — network cost scales with the number of snapshots. `snapshot
|
||||||
create --prune` runs the same cleanup automatically; this is the
|
create --prune` runs the same cleanup automatically; this is the
|
||||||
manual entry point for the same work. If the destination store cannot
|
manual entry point for the same work. If a blob cannot be deleted, the
|
||||||
be listed, or the local index holds snapshots and the destination has
|
command (and `snapshot create --prune`) exits non-zero.
|
||||||
no `vaultik-destination.txt` (an unplugged volume's empty mount point),
|
|
||||||
prune fails and deletes nothing. `snapshot purge` does the same. If a
|
|
||||||
blob cannot be deleted, the command (and `snapshot create --prune`)
|
|
||||||
exits non-zero.
|
|
||||||
* `--force`: Skip confirmation prompt
|
* `--force`: Skip confirmation prompt
|
||||||
* `--json`: Output stats as JSON. Also skips the confirmation prompt, as
|
* `--json`: Output stats as JSON. Also skips the confirmation prompt, as
|
||||||
`--force` does. A blob that cannot be deleted is counted in
|
`--force` does. A blob that cannot be deleted is counted in
|
||||||
@@ -450,12 +415,10 @@ under `blobs/` whose name is not a blob hash is not counted as a blob,
|
|||||||
since `prune` never deletes it.
|
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 snapshot's metadata and every blob from the
|
||||||
the backup destination store, whatever its name, and then
|
backup destination store, leaving the bucket prefix empty. Destructive and
|
||||||
`vaultik-destination.txt`, leaving the bucket prefix empty. Destructive
|
irreversible. This is the single supported way to wipe the entire
|
||||||
and irreversible. This is the single supported way to wipe the entire
|
destination store. If anything cannot be deleted, the command exits
|
||||||
destination store. If a listing afterwards still finds anything under
|
|
||||||
`metadata/` or `blobs/`, the command names each object left and exits
|
|
||||||
non-zero; running it again deletes what is left.
|
non-zero; running it again deletes what is left.
|
||||||
* `--force`: Required to confirm destruction.
|
* `--force`: Required to confirm destruction.
|
||||||
|
|
||||||
@@ -481,12 +444,7 @@ object store. Credentials are read from `s3.access_key_id` and
|
|||||||
a local or mounted filesystem. Useful for testing or backing up to a NAS. The
|
a local or mounted filesystem. Useful for testing or backing up to a NAS. The
|
||||||
path must be absolute; a relative one is refused when the config loads.
|
path must be absolute; a relative one is refused when the config loads.
|
||||||
Percent-escapes in the path are decoded, so `my%20stick` and `my stick` name
|
Percent-escapes in the path are decoded, so `my%20stick` and `my stick` name
|
||||||
the same directory. The directory must exist before the first backup: vaultik
|
the same directory.
|
||||||
never creates it. A directory on a removable volume is gone while the volume is
|
|
||||||
not mounted, so a backup then fails instead of writing to the disk under the
|
|
||||||
mount point. The mount point itself stays behind when the volume is unplugged:
|
|
||||||
there only `vaultik-destination.txt` stops such a backup, and only while the
|
|
||||||
local index holds snapshots.
|
|
||||||
|
|
||||||
**Rclone** (`rclone://remote/path`): Uses rclone's 70+ supported cloud
|
**Rclone** (`rclone://remote/path`): Uses rclone's 70+ supported cloud
|
||||||
providers. Requires rclone to be configured separately (`rclone config`).
|
providers. Requires rclone to be configured separately (`rclone config`).
|
||||||
@@ -517,7 +475,6 @@ For an implementation-level view of the internals — the data model, the
|
|||||||
|
|
||||||
```
|
```
|
||||||
<bucket>/<prefix>/
|
<bucket>/<prefix>/
|
||||||
├── vaultik-destination.txt # Marker written by every backup
|
|
||||||
├── blobs/
|
├── blobs/
|
||||||
│ └── <aa>/<bb>/<full_blob_hash>
|
│ └── <aa>/<bb>/<full_blob_hash>
|
||||||
└── metadata/
|
└── metadata/
|
||||||
@@ -526,15 +483,6 @@ For an implementation-level view of the internals — the data model, the
|
|||||||
└── manifest.json.zst # Unencrypted blob list (for pruning)
|
└── manifest.json.zst # Unencrypted blob list (for pruning)
|
||||||
```
|
```
|
||||||
|
|
||||||
* `vaultik-destination.txt` holds one fixed line of text, with no host
|
|
||||||
name, path or time. A destination without it is treated as not
|
|
||||||
present while the local index holds snapshots stored there:
|
|
||||||
`snapshot create` refuses to write to it, `prune` and `snapshot purge`
|
|
||||||
fail, and `snapshot list` and `snapshot remove` behave as for a
|
|
||||||
destination that cannot be listed. This keeps an unplugged volume's empty mount point
|
|
||||||
from being taken for a destination whose snapshots are all gone. Mount
|
|
||||||
the volume if it is removable; if the destination was emptied on
|
|
||||||
purpose, run `vaultik database delete` and back up again.
|
|
||||||
* Blobs are two-level directory sharded using the first 4 hex chars of the blob hash
|
* Blobs are two-level directory sharded using the first 4 hex chars of the blob hash
|
||||||
* `db.zst.age` is a binary SQLite database (zstd compressed, age encrypted)
|
* `db.zst.age` is a binary SQLite database (zstd compressed, age encrypted)
|
||||||
containing all file metadata, chunk mappings, and relationships for the snapshot
|
containing all file metadata, chunk mappings, and relationships for the snapshot
|
||||||
@@ -754,10 +702,8 @@ the `NO_COLOR` environment variable is unset (https://no-color.org/).
|
|||||||
reports and tables (`version`, `info`, `remote info`, `snapshot
|
reports and tables (`version`, `info`, `remote info`, `snapshot
|
||||||
verify`, `snapshot list`) but never the machine-consumed `config get`
|
verify`, `snapshot list`) but never the machine-consumed `config get`
|
||||||
value or the `--json` documents, which a script depends on. The
|
value or the `--json` documents, which a script depends on. The
|
||||||
confirmation prompts of `database delete`, `prune`, `snapshot purge`
|
`database delete` confirmation prompt is also written this way and
|
||||||
and `snapshot remove`, each with what it asks to delete, are also
|
always shown: it is an interactive exchange the operator must see.
|
||||||
written this way and always shown: they are an interactive exchange
|
|
||||||
the operator must see.
|
|
||||||
|
|
||||||
`internal/ui` writes to stdout; it is the output the user asked for. The
|
`internal/ui` writes to stdout; it is the output the user asked for. The
|
||||||
exceptions are the startup banner and the error a failed command ends
|
exceptions are the startup banner and the error a failed command ends
|
||||||
|
|||||||
+1
-2
@@ -302,8 +302,7 @@ storage_url: "rclone://myremote/path/to/backups"
|
|||||||
|
|
||||||
# Average chunk size for content-defined chunking
|
# Average chunk size for content-defined chunking
|
||||||
# Smaller chunks = better deduplication but more metadata
|
# Smaller chunks = better deduplication but more metadata
|
||||||
# Minimum 1MB
|
# Supports: 10MB, 5M, 1GB, 500KB, 64MiB, etc.
|
||||||
# Supports: 10MB, 5M, 1GB, 64MiB, etc.
|
|
||||||
# Default: 10MB
|
# Default: 10MB
|
||||||
#chunk_size: 10MB
|
#chunk_size: 10MB
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ This document describes the structure and organization of data stored in the S3
|
|||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
Vaultik stores all backup data in an S3-compatible object store. The repository consists of two main components, plus a marker file:
|
Vaultik stores all backup data in an S3-compatible object store. The repository consists of two main components:
|
||||||
1. **Blobs** - The actual backup data (content-addressed, encrypted)
|
1. **Blobs** - The actual backup data (content-addressed, encrypted)
|
||||||
2. **Metadata** - Snapshot information and manifests (partially encrypted)
|
2. **Metadata** - Snapshot information and manifests (partially encrypted)
|
||||||
|
|
||||||
@@ -12,7 +12,6 @@ Vaultik stores all backup data in an S3-compatible object store. The repository
|
|||||||
|
|
||||||
```
|
```
|
||||||
<bucket>/<prefix>/
|
<bucket>/<prefix>/
|
||||||
├── vaultik-destination.txt
|
|
||||||
├── blobs/
|
├── blobs/
|
||||||
│ └── <hash[0:2]>/
|
│ └── <hash[0:2]>/
|
||||||
│ └── <hash[2:4]>/
|
│ └── <hash[2:4]>/
|
||||||
@@ -25,10 +24,6 @@ Vaultik stores all backup data in an S3-compatible object store. The repository
|
|||||||
|
|
||||||
The metadata subdirectory is named with the **remote key**, a one-way hash of the snapshot ID, not with the human-readable snapshot ID itself. See [Remote Key Derivation](#remote-key-derivation).
|
The metadata subdirectory is named with the **remote key**, a one-way hash of the snapshot ID, not with the human-readable snapshot ID itself. See [Remote Key Derivation](#remote-key-derivation).
|
||||||
|
|
||||||
## Marker File (`vaultik-destination.txt`)
|
|
||||||
|
|
||||||
Every backup writes this file at the root before it uploads anything. It holds one fixed line of plain text and names no host, path or time. A destination without it is treated as not present while the local index holds snapshots stored there: `snapshot create` refuses to write to it, and `prune`, `snapshot purge`, `snapshot remove` and `snapshot list` behave as for a destination that cannot be listed. An unplugged volume leaves an empty directory at its mount point, and without the marker that directory would read as a destination whose snapshots are all gone. `remote nuke` deletes the marker last.
|
|
||||||
|
|
||||||
## Blobs Directory (`blobs/`)
|
## Blobs Directory (`blobs/`)
|
||||||
|
|
||||||
### Structure
|
### Structure
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ require (
|
|||||||
github.com/spf13/cobra v1.10.1
|
github.com/spf13/cobra v1.10.1
|
||||||
github.com/spf13/pflag v1.0.10
|
github.com/spf13/pflag v1.0.10
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
go.uber.org/dig v1.19.0
|
|
||||||
go.uber.org/fx v1.24.0
|
go.uber.org/fx v1.24.0
|
||||||
golang.org/x/sync v0.18.0
|
golang.org/x/sync v0.18.0
|
||||||
golang.org/x/sys v0.38.0
|
golang.org/x/sys v0.38.0
|
||||||
@@ -257,6 +256,7 @@ require (
|
|||||||
go.opentelemetry.io/otel/metric v1.38.0 // indirect
|
go.opentelemetry.io/otel/metric v1.38.0 // indirect
|
||||||
go.opentelemetry.io/otel/trace v1.38.0 // indirect
|
go.opentelemetry.io/otel/trace v1.38.0 // indirect
|
||||||
go.shabbyrobe.org/gocovmerge v0.0.0-20230507111327-fa4f82cfbf4d // indirect
|
go.shabbyrobe.org/gocovmerge v0.0.0-20230507111327-fa4f82cfbf4d // indirect
|
||||||
|
go.uber.org/dig v1.19.0 // indirect
|
||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
go.uber.org/zap v1.27.0 // indirect
|
go.uber.org/zap v1.27.0 // indirect
|
||||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||||
|
|||||||
+10
-6
@@ -8,12 +8,12 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/adrg/xdg"
|
"github.com/adrg/xdg"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"go.uber.org/dig"
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
"sneak.berlin/go/vaultik/internal/config"
|
"sneak.berlin/go/vaultik/internal/config"
|
||||||
"sneak.berlin/go/vaultik/internal/database"
|
"sneak.berlin/go/vaultik/internal/database"
|
||||||
@@ -146,12 +146,16 @@ func (e *startupError) Error() string {
|
|||||||
// could not build arguments for function "Y" (file:line): failed to build U:
|
// could not build arguments for function "Y" (file:line): failed to build U:
|
||||||
// received non-nil error from function "Z" (file:line): <real error>
|
// received non-nil error from function "Z" (file:line): <real error>
|
||||||
//
|
//
|
||||||
// Users care about the real error, not the DI plumbing. dig.RootCause,
|
// Users care about the real error, not the DI plumbing. We strip everything
|
||||||
// from the library fx is built on, unwraps those layers and returns the
|
// up through the last "): " (which is always the close-paren of an fx
|
||||||
// real error whole. Cutting the text at "): " instead would also cut a
|
// function-location annotation followed by the wrapped error).
|
||||||
// real error that contains it, such as the blob_size_limit one.
|
|
||||||
func cleanStartupError(err error) error {
|
func cleanStartupError(err error) error {
|
||||||
return &startupError{msg: dig.RootCause(err).Error()}
|
msg := err.Error()
|
||||||
|
if idx := strings.LastIndex(msg, "): "); idx >= 0 {
|
||||||
|
msg = msg[idx+3:]
|
||||||
|
}
|
||||||
|
|
||||||
|
return &startupError{msg: msg}
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunApp starts the fx application, blocks until it is asked to stop, and
|
// RunApp starts the fx application, blocks until it is asked to stop, and
|
||||||
|
|||||||
+40
-47
@@ -2,65 +2,58 @@ package cli //nolint:testpackage // needs access to unexported cleanStartupError
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"go.uber.org/fx"
|
|
||||||
"sneak.berlin/go/vaultik/internal/config"
|
|
||||||
"sneak.berlin/go/vaultik/internal/pidlock"
|
"sneak.berlin/go/vaultik/internal/pidlock"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestCleanStartupError starts a real fx app whose config has a
|
|
||||||
// blob_size_limit below the minimum. fx puts annotations naming the
|
|
||||||
// functions it was building in front of the config error; only those
|
|
||||||
// may be removed, although the config error itself contains "): ".
|
|
||||||
func TestCleanStartupError(t *testing.T) {
|
func TestCleanStartupError(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
path := filepath.Join(t.TempDir(), "config.yml")
|
tests := []struct {
|
||||||
|
name string
|
||||||
err := os.WriteFile(path, []byte("snapshots:\n"+
|
in string
|
||||||
" test:\n"+
|
want string
|
||||||
" paths: [/tmp/vaultik-test-source]\n"+
|
}{
|
||||||
"storage_url: file:///tmp/vaultik-test-storage\n"+
|
{
|
||||||
"chunk_size: 10MB\n"+
|
name: "real fx error chain",
|
||||||
"blob_size_limit: 30MB\n"), 0o600)
|
in: `could not build arguments for function ` +
|
||||||
if err != nil {
|
`"sneak.berlin/go/vaultik/internal/cli".newSnapshotCreateCommand.func1.1 ` +
|
||||||
t.Fatalf("write config: %v", err)
|
`(/Users/user/dev/vaultik/internal/cli/snapshot.go:71): ` +
|
||||||
|
`failed to build *vaultik.Vaultik: ` +
|
||||||
|
`could not build arguments for function ` +
|
||||||
|
`"sneak.berlin/go/vaultik/internal/vaultik".New ` +
|
||||||
|
`(/Users/user/dev/vaultik/internal/vaultik/vaultik.go:59): ` +
|
||||||
|
`failed to build storage.Storer: ` +
|
||||||
|
`received non-nil error from function ` +
|
||||||
|
`"sneak.berlin/go/vaultik/internal/storage".NewStorer ` +
|
||||||
|
`(/Users/user/dev/vaultik/internal/storage/module.go:23): ` +
|
||||||
|
`creating base path: mkdir /Volumes/BACKUPS: permission denied`,
|
||||||
|
want: `creating base path: mkdir /Volumes/BACKUPS: permission denied`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no fx wrapping",
|
||||||
|
in: "plain error",
|
||||||
|
want: "plain error",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single fx wrapping",
|
||||||
|
in: `received non-nil error from function "foo" (file.go:1): underlying problem`,
|
||||||
|
want: "underlying problem",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
app := fx.New(
|
for _, tt := range tests {
|
||||||
fx.NopLogger,
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
fx.Supply(config.Path(path)),
|
t.Parallel()
|
||||||
config.Module,
|
|
||||||
fx.Invoke(func(*config.Config) {}),
|
|
||||||
)
|
|
||||||
|
|
||||||
err = app.Start(t.Context())
|
//nolint:err113 // test constructs errors from table input
|
||||||
if err == nil {
|
got := cleanStartupError(errors.New(tt.in)).Error()
|
||||||
t.Fatal("app started; want a blob_size_limit error")
|
if got != tt.want {
|
||||||
}
|
t.Errorf("got %q, want %q", got, tt.want)
|
||||||
|
}
|
||||||
want := "failed to load config: invalid config: blob_size_limit must " +
|
})
|
||||||
"be at least the largest chunk the chunker can emit (chunk_size " +
|
|
||||||
"times the FastCDC size spread): need at least 40000000 bytes"
|
|
||||||
|
|
||||||
got := cleanStartupError(err).Error()
|
|
||||||
if got != want {
|
|
||||||
t.Errorf("got %q, want %q", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestCleanStartupErrorNotFromFx checks that an error fx did not wrap is
|
|
||||||
// shown unchanged.
|
|
||||||
func TestCleanStartupErrorNotFromFx(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
//nolint:err113 // a one-off error standing in for a startup failure
|
|
||||||
got := cleanStartupError(errors.New("plain error")).Error()
|
|
||||||
if got != "plain error" {
|
|
||||||
t.Errorf("got %q, want %q", got, "plain error")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+27
-129
@@ -8,7 +8,6 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
"slices"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
@@ -45,9 +44,6 @@ var (
|
|||||||
errNeedNumericIndex = errors.New("key is a list; use a numeric index")
|
errNeedNumericIndex = errors.New("key is a list; use a numeric index")
|
||||||
errIndexOutOfRange = errors.New("index out of range")
|
errIndexOutOfRange = errors.New("index out of range")
|
||||||
errNotMapOrList = errors.New("key is not a map or list")
|
errNotMapOrList = errors.New("key is not a map or list")
|
||||||
errWouldNotLoad = errors.New(
|
|
||||||
"vaultik would not load the config after this change, " +
|
|
||||||
"so the file was not changed")
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const defaultConfigTemplate = `# vaultik configuration
|
const defaultConfigTemplate = `# vaultik configuration
|
||||||
@@ -224,7 +220,7 @@ storage_url: ""
|
|||||||
|
|
||||||
# Average chunk size for content-defined chunking (FastCDC).
|
# Average chunk size for content-defined chunking (FastCDC).
|
||||||
# Smaller = better deduplication but more metadata overhead.
|
# Smaller = better deduplication but more metadata overhead.
|
||||||
# Minimum 1MB. Accepts: 1MB, 10M, 64MiB, etc.
|
# Accepts: 1MB, 10M, 64KB, etc.
|
||||||
# Default: 10MB
|
# Default: 10MB
|
||||||
# chunk_size: 10MB
|
# chunk_size: 10MB
|
||||||
|
|
||||||
@@ -333,15 +329,8 @@ func newConfigEditCommand() *cobra.Command {
|
|||||||
editor = "vi"
|
editor = "vi"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run $EDITOR through the shell, as git does, so a value
|
|
||||||
// with arguments such as "code --wait" works. The path
|
|
||||||
// goes in as an argument ("$@"), not into the command
|
|
||||||
// text, so a path with spaces or quotes arrives whole.
|
|
||||||
// sh -c takes the word after the command as $0, which
|
|
||||||
// is why editor appears twice.
|
|
||||||
//nolint:gosec // G204: launching the operator's own $EDITOR is the point
|
//nolint:gosec // G204: launching the operator's own $EDITOR is the point
|
||||||
ed := exec.CommandContext(cmd.Context(),
|
ed := exec.CommandContext(cmd.Context(), editor, path)
|
||||||
"sh", "-c", editor+` "$@"`, editor, path)
|
|
||||||
ed.Stdin = os.Stdin
|
ed.Stdin = os.Stdin
|
||||||
ed.Stdout = os.Stdout
|
ed.Stdout = os.Stdout
|
||||||
ed.Stderr = os.Stderr
|
ed.Stderr = os.Stderr
|
||||||
@@ -402,17 +391,8 @@ func newConfigSetCommand() *cobra.Command {
|
|||||||
Use: "set <key> <value>",
|
Use: "set <key> <value>",
|
||||||
Short: "Set a config value by dotted path (e.g. compression_level 5)",
|
Short: "Set a config value by dotted path (e.g. compression_level 5)",
|
||||||
Long: `Sets a scalar config value addressed by dotted YAML path and writes
|
Long: `Sets a scalar config value addressed by dotted YAML path and writes
|
||||||
the file back, preserving comments and formatting. Missing keys along
|
the file back, preserving comments and formatting. Intermediate maps
|
||||||
the path are created: as a list when the next path element is a
|
are created as needed.
|
||||||
number, otherwise as a map.
|
|
||||||
|
|
||||||
If the config loads, a change after which it would not, such as
|
|
||||||
compression_level 99, is refused and the file is left unchanged. A
|
|
||||||
config that does not load, such as the one config init writes before
|
|
||||||
storage_url is set or one with a ${ENV:...} reference to a variable
|
|
||||||
not set here, is checked one setting at a time: only a change after
|
|
||||||
which the changed setting cannot be read, such as one giving it the
|
|
||||||
wrong type, is refused.
|
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
vaultik config set storage_url "file:///mnt/backups"
|
vaultik config set storage_url "file:///mnt/backups"
|
||||||
@@ -442,14 +422,7 @@ func writeConfigSet(out *ui.Writer, path, key, value string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
oldData, err := marshalConfigYAML(root)
|
err = yamlPathSet(root, strings.Split(key, "."), value)
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("marshaling config: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
keys := strings.Split(key, ".")
|
|
||||||
|
|
||||||
err = yamlPathSet(root, keys, value)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -459,21 +432,6 @@ func writeConfigSet(out *ui.Writer, path, key, value string) error {
|
|||||||
return fmt.Errorf("marshaling config: %w", err)
|
return fmt.Errorf("marshaling config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
setting, err := yamlPathOnly(root, keys)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
settingData, err := marshalConfigYAML(setting)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("marshaling config: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = checkConfigChange(oldData, data, settingData)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("%w: %w", errWouldNotLoad, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = os.WriteFile(path, data, configFileMode)
|
err = os.WriteFile(path, data, configFileMode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("writing config file: %w", err)
|
return fmt.Errorf("writing config file: %w", err)
|
||||||
@@ -495,58 +453,6 @@ func writeConfigSet(out *ui.Writer, path, key, value string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkConfigChange returns the reason vaultik would not load newData, the
|
|
||||||
// config after a change, if the change brings that failure in. If oldData,
|
|
||||||
// the config before it, loads, the new one must load too. A config that
|
|
||||||
// does not load, such as one still being filled in or one with a ${...}
|
|
||||||
// reference that cannot be filled in here, is checked one setting at a
|
|
||||||
// time: settingData, the changed setting on its own, must decode into the
|
|
||||||
// config type.
|
|
||||||
func checkConfigChange(oldData, newData, settingData []byte) error {
|
|
||||||
oldCfg, err := config.Parse(oldData)
|
|
||||||
if err == nil && oldCfg.Validate() == nil {
|
|
||||||
newCfg, err := config.Parse(newData)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return newCfg.Validate()
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = config.Parse(settingData)
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// yamlPathOnly returns a document holding only the value at the dotted key
|
|
||||||
// path in root, inside the maps and lists that lead to it there.
|
|
||||||
func yamlPathOnly(root *yaml.Node, keys []string) (*yaml.Node, error) {
|
|
||||||
node, err := yamlPathGet(root, keys)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
for i, key := range slices.Backward(keys) {
|
|
||||||
parent, err := yamlPathGet(root, keys[:i])
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if parent.Kind == yaml.SequenceNode {
|
|
||||||
node = &yaml.Node{
|
|
||||||
Kind: yaml.SequenceNode, Content: []*yaml.Node{node},
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
keyNode := &yaml.Node{Kind: yaml.ScalarNode, Value: key}
|
|
||||||
node = &yaml.Node{
|
|
||||||
Kind: yaml.MappingNode, Content: []*yaml.Node{keyNode, node},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return &yaml.Node{Kind: yaml.DocumentNode, Content: []*yaml.Node{node}}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// marshalConfigYAML renders a config document tree with 2-space indentation,
|
// marshalConfigYAML renders a config document tree with 2-space indentation,
|
||||||
// matching defaultConfigTemplate. yaml.Marshal defaults to 4 spaces, which
|
// matching defaultConfigTemplate. yaml.Marshal defaults to 4 spaces, which
|
||||||
// would reindent the whole file on the first `config set` despite the promise
|
// would reindent the whole file on the first `config set` despite the promise
|
||||||
@@ -653,8 +559,8 @@ func yamlPathGet(root *yaml.Node, keys []string) (*yaml.Node, error) {
|
|||||||
return node, nil
|
return node, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// yamlPathSet navigates a dotted key path, creating missing keys as needed,
|
// yamlPathSet navigates a dotted key path, creating intermediate maps as
|
||||||
// and sets the final key to the given scalar value. Numeric path
|
// needed, and sets the final key to the given scalar value. Numeric path
|
||||||
// components index into sequences; an index equal to the sequence length
|
// components index into sequences; an index equal to the sequence length
|
||||||
// appends a new element (e.g. "age_recipients.1" on a 1-element list).
|
// appends a new element (e.g. "age_recipients.1" on a 1-element list).
|
||||||
func yamlPathSet(root *yaml.Node, keys []string, value string) error {
|
func yamlPathSet(root *yaml.Node, keys []string, value string) error {
|
||||||
@@ -667,12 +573,12 @@ func yamlPathSet(root *yaml.Node, keys []string, value string) error {
|
|||||||
node = node.Content[0]
|
node = node.Content[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
for i := range keys {
|
for i, key := range keys {
|
||||||
last := i == len(keys)-1
|
last := i == len(keys)-1
|
||||||
|
|
||||||
switch node.Kind {
|
switch node.Kind {
|
||||||
case yaml.MappingNode:
|
case yaml.MappingNode:
|
||||||
node = yamlSetInMapping(node, keys, i, value, last)
|
node = yamlSetInMapping(node, key, value, last)
|
||||||
case yaml.SequenceNode:
|
case yaml.SequenceNode:
|
||||||
next, err := yamlSetInSequence(node, keys, i, value, last)
|
next, err := yamlSetInSequence(node, keys, i, value, last)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -741,16 +647,14 @@ func yamlField(typ reflect.Type, key string) (reflect.StructField, bool) {
|
|||||||
return reflect.StructField{}, false
|
return reflect.StructField{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// yamlSetInMapping resolves (creating if needed) the value node for the
|
// yamlSetInMapping resolves (creating if needed) the value node for key
|
||||||
// path element keys[i] within a mapping node, setting it to value when it
|
// within a mapping node, setting it to value when it is the final path
|
||||||
// is the final path element, and returns the node to descend into.
|
// element, and returns the node to descend into.
|
||||||
func yamlSetInMapping(
|
func yamlSetInMapping(node *yaml.Node, key, value string, last bool) *yaml.Node {
|
||||||
node *yaml.Node, keys []string, i int, value string, last bool,
|
|
||||||
) *yaml.Node {
|
|
||||||
var valueNode *yaml.Node
|
var valueNode *yaml.Node
|
||||||
|
|
||||||
for j := 0; j+1 < len(node.Content); j += 2 {
|
for j := 0; j+1 < len(node.Content); j += 2 {
|
||||||
if node.Content[j].Value == keys[i] {
|
if node.Content[j].Value == key {
|
||||||
valueNode = node.Content[j+1]
|
valueNode = node.Content[j+1]
|
||||||
|
|
||||||
break
|
break
|
||||||
@@ -758,8 +662,13 @@ func yamlSetInMapping(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if valueNode == nil {
|
if valueNode == nil {
|
||||||
keyNode := &yaml.Node{Kind: yaml.ScalarNode, Value: keys[i]}
|
keyNode := &yaml.Node{Kind: yaml.ScalarNode, Value: key}
|
||||||
valueNode = newPathNode(keys, i, value)
|
|
||||||
|
valueNode = &yaml.Node{Kind: yaml.MappingNode}
|
||||||
|
if last {
|
||||||
|
valueNode = &yaml.Node{Kind: yaml.ScalarNode, Value: value}
|
||||||
|
}
|
||||||
|
|
||||||
node.Content = append(node.Content, keyNode, valueNode)
|
node.Content = append(node.Content, keyNode, valueNode)
|
||||||
} else if last {
|
} else if last {
|
||||||
setScalar(valueNode, value)
|
setScalar(valueNode, value)
|
||||||
@@ -787,7 +696,12 @@ func yamlSetInSequence(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if idx == len(node.Content) {
|
if idx == len(node.Content) {
|
||||||
node.Content = append(node.Content, newPathNode(keys, i, value))
|
newNode := &yaml.Node{Kind: yaml.MappingNode}
|
||||||
|
if last {
|
||||||
|
newNode = &yaml.Node{Kind: yaml.ScalarNode, Value: value}
|
||||||
|
}
|
||||||
|
|
||||||
|
node.Content = append(node.Content, newNode)
|
||||||
} else if last {
|
} else if last {
|
||||||
setScalar(node.Content[idx], value)
|
setScalar(node.Content[idx], value)
|
||||||
}
|
}
|
||||||
@@ -795,22 +709,6 @@ func yamlSetInSequence(
|
|||||||
return node.Content[idx], nil
|
return node.Content[idx], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// newPathNode returns the node to create for the missing path element
|
|
||||||
// keys[i]: the value when it is the final path element, otherwise an empty
|
|
||||||
// list when the next path element is a number and an empty map when not.
|
|
||||||
func newPathNode(keys []string, i int, value string) *yaml.Node {
|
|
||||||
if i == len(keys)-1 {
|
|
||||||
return &yaml.Node{Kind: yaml.ScalarNode, Value: value}
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := strconv.Atoi(keys[i+1])
|
|
||||||
if err == nil {
|
|
||||||
return &yaml.Node{Kind: yaml.SequenceNode}
|
|
||||||
}
|
|
||||||
|
|
||||||
return &yaml.Node{Kind: yaml.MappingNode}
|
|
||||||
}
|
|
||||||
|
|
||||||
// setScalar overwrites a node in place with a plain scalar value.
|
// setScalar overwrites a node in place with a plain scalar value.
|
||||||
func setScalar(n *yaml.Node, value string) {
|
func setScalar(n *yaml.Node, value string) {
|
||||||
n.Kind = yaml.ScalarNode
|
n.Kind = yaml.ScalarNode
|
||||||
|
|||||||
+5
-235
@@ -58,17 +58,14 @@ func TestDefaultConfigTemplateParses(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// testRecipient is a valid age public key.
|
|
||||||
const testRecipient = "age1278m9q7dp3chsh2dcy82qk27v047zywyvtxwnj4cvt0z65jw6a7q5dqhfj"
|
|
||||||
|
|
||||||
// TestConfigSetRecipientOnFreshConfig follows the README quickstart: on the
|
// TestConfigSetRecipientOnFreshConfig follows the README quickstart: on the
|
||||||
// file `config init` writes, `config set age_recipients.0` and
|
// file `config init` writes, `config set age_recipients.0` and
|
||||||
// `config set storage_url` give a config that loads with that recipient.
|
// `config set storage_url` give a config that loads with that recipient.
|
||||||
// Neither is refused, although the config does not load until both are set;
|
|
||||||
// once it loads, a recipient that is not a valid key is.
|
|
||||||
func TestConfigSetRecipientOnFreshConfig(t *testing.T) {
|
func TestConfigSetRecipientOnFreshConfig(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
const recipient = "age1278m9q7dp3chsh2dcy82qk27v047zywyvtxwnj4cvt0z65jw6a7q5dqhfj"
|
||||||
|
|
||||||
path := filepath.Join(t.TempDir(), "config.yml")
|
path := filepath.Join(t.TempDir(), "config.yml")
|
||||||
|
|
||||||
err := os.WriteFile(path, []byte(defaultConfigTemplate), configFileMode)
|
err := os.WriteFile(path, []byte(defaultConfigTemplate), configFileMode)
|
||||||
@@ -78,7 +75,7 @@ func TestConfigSetRecipientOnFreshConfig(t *testing.T) {
|
|||||||
|
|
||||||
out := ui.NewWithColor(&bytes.Buffer{}, false)
|
out := ui.NewWithColor(&bytes.Buffer{}, false)
|
||||||
|
|
||||||
err = writeConfigSet(out, path, "age_recipients.0", testRecipient)
|
err = writeConfigSet(out, path, "age_recipients.0", recipient)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("config set age_recipients.0: %v", err)
|
t.Fatalf("config set age_recipients.0: %v", err)
|
||||||
}
|
}
|
||||||
@@ -93,211 +90,8 @@ func TestConfigSetRecipientOnFreshConfig(t *testing.T) {
|
|||||||
t.Fatalf("config.Load: %v", err)
|
t.Fatalf("config.Load: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(cfg.AgeRecipients) != 1 || cfg.AgeRecipients[0] != testRecipient {
|
if len(cfg.AgeRecipients) != 1 || cfg.AgeRecipients[0] != recipient {
|
||||||
t.Errorf("age_recipients = %v, want [%s]", cfg.AgeRecipients, testRecipient)
|
t.Errorf("age_recipients = %v, want [%s]", cfg.AgeRecipients, recipient)
|
||||||
}
|
|
||||||
|
|
||||||
err = writeConfigSet(out, path, "age_recipients.0", "notakey")
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "not a valid recipient") {
|
|
||||||
t.Errorf("config set age_recipients.0 notakey gives error %v, "+
|
|
||||||
"want one saying it is not a valid recipient", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = config.Load(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("config.Load after the refused change: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// configWithoutRecipients loads, but has no age_recipients key and no
|
|
||||||
// snapshot named work.
|
|
||||||
const configWithoutRecipients = `storage_url: file:///mnt/backups
|
|
||||||
snapshots:
|
|
||||||
home:
|
|
||||||
paths:
|
|
||||||
- "~"
|
|
||||||
`
|
|
||||||
|
|
||||||
// configWithUnsetReference loads only where VAULTIK_TEST_UNSET_SECRET is
|
|
||||||
// set, as a backup job's environment might set it. No test sets it.
|
|
||||||
const configWithUnsetReference = configWithoutRecipients + `s3:
|
|
||||||
secret_access_key: ${ENV:VAULTIK_TEST_UNSET_SECRET}
|
|
||||||
`
|
|
||||||
|
|
||||||
// configWithLevelReference returns configWithoutRecipients with a ${FILE:...}
|
|
||||||
// reference on compression_level to a file holding 9, so that the config
|
|
||||||
// loads once the reference is filled in.
|
|
||||||
func configWithLevelReference(t *testing.T) string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
levelPath := filepath.Join(t.TempDir(), "level")
|
|
||||||
|
|
||||||
err := os.WriteFile(levelPath, []byte("9"), configFileMode)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("write level: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return configWithoutRecipients +
|
|
||||||
"compression_level: ${FILE:" + levelPath + "}\n"
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestConfigSetIndexUnderMissingKey checks that a list index under a key
|
|
||||||
// the file does not have creates a list, and that the result loads.
|
|
||||||
func TestConfigSetIndexUnderMissingKey(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
key string
|
|
||||||
value string
|
|
||||||
field func(cfg *config.Config) []string
|
|
||||||
}{
|
|
||||||
{"age_recipients.0", testRecipient,
|
|
||||||
func(cfg *config.Config) []string { return cfg.AgeRecipients }},
|
|
||||||
{"snapshots.work.paths.0", "/srv/work",
|
|
||||||
func(cfg *config.Config) []string { return cfg.Snapshots["work"].Paths }},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.key, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
path := filepath.Join(t.TempDir(), "config.yml")
|
|
||||||
|
|
||||||
err := os.WriteFile(path, []byte(configWithoutRecipients), configFileMode)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("write config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = writeConfigSet(ui.NewWithColor(&bytes.Buffer{}, false),
|
|
||||||
path, tt.key, tt.value)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("config set %s: %v", tt.key, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg, err := config.Load(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("config.Load: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
got := tt.field(cfg)
|
|
||||||
if len(got) != 1 || got[0] != tt.value {
|
|
||||||
t.Errorf("%s gives %v, want [%s]", tt.key, got, tt.value)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestConfigSetKeepsFileThatWouldNotLoad checks that config set refuses a
|
|
||||||
// change after which the config would not load, with an error giving the
|
|
||||||
// reason, and leaves the file as it was. On the file `config init` writes,
|
|
||||||
// which does not load until storage_url is set, and on one that cannot be
|
|
||||||
// read here, only a value of the wrong type is refused.
|
|
||||||
func TestConfigSetKeepsFileThatWouldNotLoad(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
levelReference := configWithLevelReference(t)
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
config string
|
|
||||||
key string
|
|
||||||
value string
|
|
||||||
reason string
|
|
||||||
}{
|
|
||||||
// Index 1 of the empty list the missing key gives.
|
|
||||||
{configWithoutRecipients, "age_recipients.1", testRecipient,
|
|
||||||
"index out of range"},
|
|
||||||
// A list set to a single value.
|
|
||||||
{configWithoutRecipients, "snapshots.home.paths", "/srv",
|
|
||||||
"into []string"},
|
|
||||||
{configWithoutRecipients, "chunk_size", "fast",
|
|
||||||
"invalid size format"},
|
|
||||||
// Values of the right type that fail validation.
|
|
||||||
{configWithoutRecipients, "compression_level", "99",
|
|
||||||
"compression_level must be between 1 and 19"},
|
|
||||||
{configWithoutRecipients, "chunk_size", "1KB",
|
|
||||||
"chunk_size must be at least 1MB"},
|
|
||||||
// With its reference filled in, the config loads.
|
|
||||||
{levelReference, "blob_size_limit", "1MB",
|
|
||||||
"blob_size_limit must be at least"},
|
|
||||||
// The type check on a config that does not load yet.
|
|
||||||
{defaultConfigTemplate, "snapshots.apps.paths", "/srv",
|
|
||||||
"into []string"},
|
|
||||||
// The type check on a config that cannot be read here.
|
|
||||||
{configWithUnsetReference, "snapshots.home.paths", "/x",
|
|
||||||
"cannot unmarshal !!str `/x`"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.key+" "+tt.value, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
path := filepath.Join(t.TempDir(), "config.yml")
|
|
||||||
|
|
||||||
err := os.WriteFile(path, []byte(tt.config), configFileMode)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("write config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = writeConfigSet(ui.NewWithColor(&bytes.Buffer{}, false),
|
|
||||||
path, tt.key, tt.value)
|
|
||||||
if err == nil || !strings.Contains(err.Error(), tt.reason) {
|
|
||||||
t.Errorf("config set %s %s gives error %v, want one saying %q",
|
|
||||||
tt.key, tt.value, err, tt.reason)
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := os.ReadFile(path) //nolint:gosec // G304: test-controlled path
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if string(data) != tt.config {
|
|
||||||
t.Errorf("config set %s changed the file to:\n%s",
|
|
||||||
tt.key, data)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestConfigSetRefusesOnlyItsOwnFailure checks that setting another key
|
|
||||||
// works on a config with a ${...} reference on compression_level, which
|
|
||||||
// loads because loading fills the reference in, on one whose
|
|
||||||
// compression_level already fails to load, and on one with a reference
|
|
||||||
// that cannot be filled in here.
|
|
||||||
func TestConfigSetRefusesOnlyItsOwnFailure(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
configs := map[string]string{
|
|
||||||
"reference": configWithLevelReference(t),
|
|
||||||
"failing": configWithoutRecipients + "compression_level: fast\n",
|
|
||||||
"unset reference": configWithUnsetReference,
|
|
||||||
}
|
|
||||||
|
|
||||||
for name, configYAML := range configs {
|
|
||||||
t.Run(name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
path := filepath.Join(t.TempDir(), "config.yml")
|
|
||||||
|
|
||||||
err := os.WriteFile(path, []byte(configYAML), configFileMode)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("write config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = writeConfigSet(ui.NewWithColor(&bytes.Buffer{}, false),
|
|
||||||
path, "hostname", "myhost")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("config set hostname: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := os.ReadFile(path) //nolint:gosec // G304: test-controlled path
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !strings.Contains(string(data), "hostname: myhost") {
|
|
||||||
t.Errorf("config set hostname wrote:\n%s", data)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -647,30 +441,6 @@ func TestWriteConfigSetTightensMode(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestConfigEditEditorWithArgument checks that an $EDITOR carrying an
|
|
||||||
// argument works and that a config path with a space reaches it whole.
|
|
||||||
// The "editor" is cp with the replacement file as its argument, so the
|
|
||||||
// config ends up with the replacement's content only if both arrived.
|
|
||||||
func TestConfigEditEditorWithArgument(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
path := seedFile(t, dir, "my config.yml", "compression_level: 3\n")
|
|
||||||
edited := seedFile(t, dir, "edited.yml", "compression_level: 9\n")
|
|
||||||
|
|
||||||
setRootFlags(t, RootFlags{ConfigPath: path})
|
|
||||||
t.Setenv("EDITOR", "cp '"+edited+"'")
|
|
||||||
|
|
||||||
mustExecute(t, newConfigEditCommand())
|
|
||||||
|
|
||||||
data, err := os.ReadFile(path) //nolint:gosec // G304: test-controlled path
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("reading config back: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if string(data) != "compression_level: 9\n" {
|
|
||||||
t.Errorf("config after edit = %q, want the edited content", data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func splitPath(s string) []string {
|
func splitPath(s string) []string {
|
||||||
return strings.Split(s, ".")
|
return strings.Split(s, ".")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,8 +101,7 @@ func TestEntryPruneJSONStdoutIsExactlyOneDocument(t *testing.T) {
|
|||||||
// writeHermeticPruneConfig builds a config over a temp directory with an
|
// writeHermeticPruneConfig builds a config over a temp directory with an
|
||||||
// empty destination directory and, if seedStale is set, creates the
|
// empty destination directory and, if seedStale is set, creates the
|
||||||
// index database up front with one snapshot record that has no
|
// index database up front with one snapshot record that has no
|
||||||
// counterpart on the destination store, and gives the destination the
|
// counterpart on the destination store. Returns the config path.
|
||||||
// marker a backup writes. Returns the config path.
|
|
||||||
func writeHermeticPruneConfig(t *testing.T, seedStale bool) string {
|
func writeHermeticPruneConfig(t *testing.T, seedStale bool) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -132,11 +131,6 @@ func writeHermeticPruneConfig(t *testing.T, seedStale bool) string {
|
|||||||
|
|
||||||
if seedStale {
|
if seedStale {
|
||||||
seedStaleSnapshotRecord(t, indexPath)
|
seedStaleSnapshotRecord(t, indexPath)
|
||||||
|
|
||||||
// While the local index holds a snapshot, prune fails on a
|
|
||||||
// destination without the marker a backup writes.
|
|
||||||
require.NoError(t, os.WriteFile(
|
|
||||||
filepath.Join(storeDir, "vaultik-destination.txt"), nil, 0o600))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return configPath
|
return configPath
|
||||||
|
|||||||
@@ -1,123 +0,0 @@
|
|||||||
package cli //nolint:testpackage // shares runEntry and the hermetic config helpers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
// flagCron is used to build the argument vectors below.
|
|
||||||
const flagCron = "--cron"
|
|
||||||
|
|
||||||
// plantUnreferencedBlob writes a blob that no snapshot references to the
|
|
||||||
// destination store at storeDir, so that a prune deletes it. Returns its
|
|
||||||
// path.
|
|
||||||
func plantUnreferencedBlob(t *testing.T, storeDir string) string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
hash := strings.Repeat("b", 64)
|
|
||||||
path := filepath.Join(storeDir, "blobs", hash[:2], hash[2:4], hash)
|
|
||||||
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o750))
|
|
||||||
require.NoError(t, os.WriteFile(path, []byte("unreferenced"), 0o600))
|
|
||||||
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestEntrySnapshotCreatePruneIsSilent checks that a successful
|
|
||||||
// `snapshot create --prune` under --cron or --quiet prints nothing on
|
|
||||||
// stdout or stderr, whether or not the prune finds a blob to delete.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
|
|
||||||
func TestEntrySnapshotCreatePruneIsSilent(t *testing.T) {
|
|
||||||
for _, testCase := range []struct {
|
|
||||||
name string
|
|
||||||
args []string
|
|
||||||
plantBlob bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "cron, no blob to delete",
|
|
||||||
args: []string{cmdSnapshot, cmdCreate, flagCron, flagPrune},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "cron, blob to delete",
|
|
||||||
args: []string{cmdSnapshot, cmdCreate, flagCron, flagPrune},
|
|
||||||
plantBlob: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "cron with keep-newer-than",
|
|
||||||
args: []string{
|
|
||||||
cmdSnapshot, cmdCreate, flagCron, flagPrune,
|
|
||||||
flagKeepNewerThan, "4w",
|
|
||||||
},
|
|
||||||
plantBlob: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "quiet",
|
|
||||||
args: []string{flagQuiet, cmdSnapshot, cmdCreate, flagPrune},
|
|
||||||
plantBlob: true,
|
|
||||||
},
|
|
||||||
} {
|
|
||||||
t.Run(testCase.name, func(t *testing.T) {
|
|
||||||
configPath, storeDir := writeBackupConfig(t)
|
|
||||||
|
|
||||||
var blob string
|
|
||||||
if testCase.plantBlob {
|
|
||||||
blob = plantUnreferencedBlob(t, storeDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
code, stdout, stderr := runEntry(t,
|
|
||||||
append([]string{flagConfig, configPath}, testCase.args...)...)
|
|
||||||
|
|
||||||
assert.Equal(t, 0, code)
|
|
||||||
assert.Empty(t, stdout)
|
|
||||||
assert.Empty(t, stderr)
|
|
||||||
|
|
||||||
if testCase.plantBlob {
|
|
||||||
assert.NoFileExists(t, blob, "the prune must delete the blob")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestEntryPruneQuietIsSilent checks that a successful `prune --force`
|
|
||||||
// under --quiet prints nothing on stdout or stderr, both when there is
|
|
||||||
// nothing to remove and when it removes a stale local snapshot record
|
|
||||||
// and an unreferenced blob.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
|
|
||||||
func TestEntryPruneQuietIsSilent(t *testing.T) {
|
|
||||||
for _, testCase := range []struct {
|
|
||||||
name string
|
|
||||||
staleRecordAndBlob bool
|
|
||||||
}{
|
|
||||||
{name: "nothing to remove"},
|
|
||||||
{name: "stale record and blob to remove", staleRecordAndBlob: true},
|
|
||||||
} {
|
|
||||||
t.Run(testCase.name, func(t *testing.T) {
|
|
||||||
configPath := writeHermeticPruneConfig(t, testCase.staleRecordAndBlob)
|
|
||||||
|
|
||||||
var blob string
|
|
||||||
if testCase.staleRecordAndBlob {
|
|
||||||
// writeHermeticPruneConfig puts the store beside the config.
|
|
||||||
blob = plantUnreferencedBlob(t,
|
|
||||||
filepath.Join(filepath.Dir(configPath), "store"))
|
|
||||||
}
|
|
||||||
|
|
||||||
code, stdout, stderr := runEntry(t,
|
|
||||||
flagConfig, configPath, flagQuiet, cmdPrune, flagForce)
|
|
||||||
|
|
||||||
assert.Equal(t, 0, code)
|
|
||||||
assert.Empty(t, stdout)
|
|
||||||
assert.Empty(t, stderr)
|
|
||||||
|
|
||||||
if testCase.staleRecordAndBlob {
|
|
||||||
assert.NoFileExists(t, blob, "the prune must delete the blob")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
package cli //nolint:testpackage // shares runEntry and the hermetic config helpers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestEntrySnapshotCreateSkipErrorsExitsNonZeroWhenItSkips backs up,
|
|
||||||
// under --skip-errors, a source directory that does not exist, which the
|
|
||||||
// walk skips. The command exits non-zero and reports the backup as
|
|
||||||
// completed, not as a failed snapshot creation. See
|
|
||||||
// https://git.eeqj.de/sneak/vaultik/issues/310.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
|
|
||||||
func TestEntrySnapshotCreateSkipErrorsExitsNonZeroWhenItSkips(t *testing.T) {
|
|
||||||
// writeBackupConfig would create the source directory.
|
|
||||||
configPath := writeHermeticPruneConfig(t, false)
|
|
||||||
|
|
||||||
code, stdout, stderr := runEntry(t,
|
|
||||||
flagConfig, configPath, "--skip-errors", cmdSnapshot, cmdCreate)
|
|
||||||
|
|
||||||
assert.Equal(t, 1, code)
|
|
||||||
assert.Contains(t, stdout,
|
|
||||||
"Files: 1 examined, 0 backed up, 0 unchanged, 1 failed.")
|
|
||||||
assert.NotContains(t, stdout, "Finished successfully.")
|
|
||||||
assert.Contains(t, stderr,
|
|
||||||
"Backup completed, but 1 file(s) could not be read")
|
|
||||||
assert.NotContains(t, stderr, "Snapshot creation failed")
|
|
||||||
}
|
|
||||||
@@ -2,74 +2,89 @@ package cli //nolint:testpackage // shares runEntry and the hermetic config help
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"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/snapshot"
|
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||||
|
"sneak.berlin/go/vaultik/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Words and flags of `snapshot purge` used to build argument vectors
|
// The `snapshot purge` command word and flags used below.
|
||||||
// below.
|
|
||||||
const (
|
const (
|
||||||
cmdPurge = "purge"
|
cmdPurge = "purge"
|
||||||
flagOlderThan = "--older-than"
|
flagKeepLatest = "--keep-latest"
|
||||||
flagForce = "--force"
|
flagForce = "--force"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestEntrySnapshotPurgeKeepsMetadataWhenLocalIndexDeleteFails makes
|
// TestEntrySnapshotPurgeKeepsMetadataWhenLocalIndexDeleteFails makes
|
||||||
// the local index refuse to delete an old snapshot and checks that
|
// removing a snapshot from the local index fail and checks that
|
||||||
// `snapshot purge` exits non-zero and leaves that snapshot's metadata on
|
// `snapshot purge` exits non-zero and leaves that snapshot's metadata on
|
||||||
// the destination store. It used to log the error, delete the metadata
|
// the destination store. It used to log the error, delete the metadata
|
||||||
// and exit 0.
|
// anyway and exit 0.
|
||||||
//
|
|
||||||
// `snapshot create --prune` runs the same purge, but cannot be driven
|
|
||||||
// this way: the refusal also breaks the backup's metadata export, which
|
|
||||||
// deletes the other snapshots from its copy of the local index.
|
|
||||||
//
|
//
|
||||||
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
|
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
|
||||||
func TestEntrySnapshotPurgeKeepsMetadataWhenLocalIndexDeleteFails(t *testing.T) {
|
func TestEntrySnapshotPurgeKeepsMetadataWhenLocalIndexDeleteFails(t *testing.T) {
|
||||||
configPath := writeHermeticPruneConfig(t, false)
|
|
||||||
dir := filepath.Dir(configPath)
|
|
||||||
indexPath := filepath.Join(dir, "index.sqlite")
|
|
||||||
|
|
||||||
// seedStaleSnapshotRecord writes an old snapshot to the local index
|
|
||||||
// only; the manifest planted here puts it on the destination store
|
|
||||||
// too, so the purge selects it rather than dropping it as stale.
|
|
||||||
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",
|
|
||||||
snapshot.RemoteSnapshotKey(stalePruneSnapshotID))
|
|
||||||
require.NoError(t, os.MkdirAll(manifestDir, 0o750))
|
|
||||||
|
|
||||||
manifestPath := filepath.Join(manifestDir, "manifest.json.zst")
|
|
||||||
require.NoError(t, os.WriteFile(manifestPath, []byte("stub"), 0o600))
|
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
configPath := writeHermeticPruneConfig(t, false)
|
||||||
|
|
||||||
db, err := database.New(ctx, indexPath)
|
// writeHermeticPruneConfig puts the index and the store directory
|
||||||
|
// beside the config file.
|
||||||
|
dir := filepath.Dir(configPath)
|
||||||
|
|
||||||
|
// Two snapshots of the same name: --keep-latest purges the older.
|
||||||
|
olderStart := time.Date(2026, 5, 1, 9, 0, 0, 0, time.UTC)
|
||||||
|
newerStart := olderStart.Add(time.Hour)
|
||||||
|
olderID := "test-host_test_" + olderStart.Format(time.RFC3339)
|
||||||
|
newerID := "test-host_test_" + newerStart.Format(time.RFC3339)
|
||||||
|
|
||||||
|
db, err := database.New(ctx, filepath.Join(dir, "index.sqlite"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
repos := database.NewRepositories(db)
|
||||||
|
|
||||||
|
manifestPaths := make(map[string]string)
|
||||||
|
|
||||||
|
for id, startedAt := range map[string]time.Time{
|
||||||
|
olderID: olderStart,
|
||||||
|
newerID: newerStart,
|
||||||
|
} {
|
||||||
|
completedAt := startedAt.Add(time.Minute)
|
||||||
|
snap := &database.Snapshot{
|
||||||
|
ID: types.SnapshotID(id),
|
||||||
|
Hostname: "test-host",
|
||||||
|
VaultikVersion: "test",
|
||||||
|
StartedAt: startedAt,
|
||||||
|
CompletedAt: &completedAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
err = repos.WithTx(ctx, func(ctx context.Context, tx *sql.Tx) error {
|
||||||
|
return repos.Snapshots.Create(ctx, tx, snap)
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
manifestPaths[id] = filepath.Join(dir, "store", "metadata",
|
||||||
|
snapshot.RemoteSnapshotKey(id), "manifest.json.zst")
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Dir(manifestPaths[id]), 0o750))
|
||||||
|
require.NoError(t, os.WriteFile(manifestPaths[id], []byte("stub"), 0o600))
|
||||||
|
}
|
||||||
|
|
||||||
_, err = db.Conn().ExecContext(ctx, `
|
_, err = db.Conn().ExecContext(ctx, `
|
||||||
CREATE TRIGGER refuse_snapshot_delete BEFORE DELETE ON snapshots
|
CREATE TRIGGER refuse_snapshot_delete BEFORE DELETE ON snapshots
|
||||||
BEGIN SELECT RAISE(ABORT, 'simulated index error'); END`)
|
BEGIN SELECT RAISE(ABORT, 'simulated index error'); END`)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, db.Close())
|
require.NoError(t, db.Close())
|
||||||
|
|
||||||
code, _, stderr := runEntry(t, flagConfig, configPath,
|
code, _, stderr := runEntry(t,
|
||||||
cmdSnapshot, cmdPurge, flagOlderThan, "30d", flagForce)
|
flagConfig, configPath, cmdSnapshot, cmdPurge, flagKeepLatest, flagForce)
|
||||||
|
|
||||||
assert.Equal(t, 1, code)
|
assert.Equal(t, 1, code)
|
||||||
assert.Contains(t, stderr, "simulated index error",
|
assert.Contains(t, stderr, "simulated index error")
|
||||||
"the purge must fail at the local-index delete")
|
assert.FileExists(t, manifestPaths[olderID],
|
||||||
assert.FileExists(t, manifestPath,
|
"a snapshot still in the local index keeps its metadata")
|
||||||
"the snapshot is still in the local index, so its metadata must stay")
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,13 +115,6 @@ on Linux), then /etc/vaultik/config.yml.`,
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if errors.Is(err, vaultik.ErrFilesUnreadable) {
|
|
||||||
log.Error("Backup completed with unreadable files", "error", err)
|
|
||||||
ReportErrorf("Backup completed, but %v", err)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Error("Snapshot creation failed", "error", err)
|
log.Error("Snapshot creation failed", "error", err)
|
||||||
ReportErrorf("Snapshot creation failed: %v", err)
|
ReportErrorf("Snapshot creation failed: %v", err)
|
||||||
})
|
})
|
||||||
@@ -277,9 +270,8 @@ 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. The command prints the
|
know about), which is what 'vaultik prune' does. On success the command
|
||||||
'vaultik prune' invocation to run as a follow-up only when it removed the
|
prints the exact 'vaultik prune' invocation to run as a follow-up.
|
||||||
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.
|
||||||
@@ -288,12 +280,10 @@ 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 removing the snapshot's metadata from the destination store fails (for
|
If the remote is unreachable, the local-database removal still completes
|
||||||
example because the remote is unreachable), the local-database removal
|
and a warning is emitted; run 'vaultik snapshot remove <snapshot-id>' again
|
||||||
still completes and a warning is emitted; run 'vaultik snapshot remove
|
once the destination store is reachable to remove the snapshot's metadata
|
||||||
<snapshot-id>' again once the destination store is reachable to remove
|
from it ('vaultik prune' does not).
|
||||||
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.`,
|
||||||
|
|||||||
@@ -30,8 +30,6 @@ This command will restore files from the specified snapshot to the
|
|||||||
target directory.
|
target directory.
|
||||||
If no paths are specified, all files are restored.
|
If no paths are specified, all files are restored.
|
||||||
If paths are specified, only matching files/directories are restored.
|
If paths are specified, only matching files/directories are restored.
|
||||||
A path that matches nothing in the snapshot is named in a warning, and
|
|
||||||
the command exits non-zero after restoring the paths that match.
|
|
||||||
|
|
||||||
The snapshot may be named by its ID or, when restoring on a host with no
|
The snapshot may be named by its ID or, when restoring on a host with no
|
||||||
local index, by the remote key that 'snapshot list' prints for a
|
local index, by the remote key that 'snapshot list' prints for a
|
||||||
|
|||||||
+28
-47
@@ -4,7 +4,6 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
@@ -30,15 +29,13 @@ const appName = "vaultik"
|
|||||||
//nolint:gosec // G101: marker for detecting a pasted secret key, not a credential
|
//nolint:gosec // G101: marker for detecting a pasted secret key, not a credential
|
||||||
const secretKeyPrefix = "AGE-SECRET-KEY-"
|
const secretKeyPrefix = "AGE-SECRET-KEY-"
|
||||||
|
|
||||||
// Defaults and validation bounds for tunable settings. MB and GB are
|
// Defaults and validation bounds for tunable settings.
|
||||||
// 1000-based, as the size parser reads them; the S3 part sizes are S3's own
|
|
||||||
// limits, which are 1024-based (MiB, GiB).
|
|
||||||
const (
|
const (
|
||||||
defaultBlobSizeLimit = Size(10 * 1000 * 1000 * 1000) // 10GB
|
defaultBlobSizeLimit = Size(10 * 1024 * 1024 * 1024) // 10GB
|
||||||
defaultChunkSize = Size(10 * 1000 * 1000) // 10MB
|
defaultChunkSize = Size(10 * 1024 * 1024) // 10MB
|
||||||
defaultS3PartSize = Size(5 * 1024 * 1024) // 5MiB
|
defaultS3PartSize = Size(5 * 1024 * 1024) // 5MiB
|
||||||
defaultCompressionLevel = 3
|
defaultCompressionLevel = 3
|
||||||
minChunkSize = 1000 * 1000 // 1MB
|
minChunkSize = 1024 * 1024 // 1MB
|
||||||
minCompressionLevel = 1
|
minCompressionLevel = 1
|
||||||
maxCompressionLevel = 19
|
maxCompressionLevel = 19
|
||||||
// S3 accepts a multipart upload part from 5MiB to 5GiB.
|
// S3 accepts a multipart upload part from 5MiB to 5GiB.
|
||||||
@@ -236,48 +233,14 @@ func New(path Path) (*Config, error) {
|
|||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load reads the configuration file at path, parses it with Parse, warns
|
// Load reads and parses the configuration file from the specified path.
|
||||||
// when others can read the file, and validates the configuration.
|
// It applies default values for optional fields, performs environment variable
|
||||||
// Returns an error if the file cannot be read, parsed, or if validation
|
// substitution using smartconfig, and validates the configuration.
|
||||||
// fails.
|
// The configuration file should be in YAML format. Returns an error if the file
|
||||||
|
// cannot be read, parsed, or if validation fails.
|
||||||
func Load(path string) (*Config, error) {
|
func Load(path string) (*Config, error) {
|
||||||
data, err := os.ReadFile(path) //nolint:gosec // G304: config path is operator-supplied
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to load config file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg, err := Parse(data)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check config file permissions (warn if world or group readable)
|
|
||||||
//nolint:gosec // G703: config path is operator-supplied by design
|
|
||||||
info, statErr := os.Stat(path)
|
|
||||||
if statErr == nil {
|
|
||||||
mode := info.Mode().Perm()
|
|
||||||
if mode&0044 != 0 { // group or world readable
|
|
||||||
log.Warn(cfg.readableByOthersWarning(),
|
|
||||||
"path", path,
|
|
||||||
"mode", fmt.Sprintf("%04o", mode),
|
|
||||||
"recommendation", "chmod 600 "+path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err = cfg.Validate()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("invalid config: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return cfg, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse reads YAML config data as Load does, without validating it. It
|
|
||||||
// applies default values for optional fields, fills in ${...} references
|
|
||||||
// using smartconfig, and expands ~ in paths.
|
|
||||||
func Parse(data []byte) (*Config, error) {
|
|
||||||
// Load config using smartconfig for interpolation
|
// Load config using smartconfig for interpolation
|
||||||
sc, err := smartconfig.NewFromReader(bytes.NewReader(data))
|
sc, err := smartconfig.NewFromConfigPath(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to load config file: %w", err)
|
return nil, fmt.Errorf("failed to load config file: %w", err)
|
||||||
}
|
}
|
||||||
@@ -339,6 +302,24 @@ func Parse(data []byte) (*Config, error) {
|
|||||||
cfg.S3.Region = "us-east-1"
|
cfg.S3.Region = "us-east-1"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check config file permissions (warn if world or group readable)
|
||||||
|
//nolint:gosec // G703: config path is operator-supplied by design
|
||||||
|
info, statErr := os.Stat(path)
|
||||||
|
if statErr == nil {
|
||||||
|
mode := info.Mode().Perm()
|
||||||
|
if mode&0044 != 0 { // group or world readable
|
||||||
|
log.Warn(cfg.readableByOthersWarning(),
|
||||||
|
"path", path,
|
||||||
|
"mode", fmt.Sprintf("%04o", mode),
|
||||||
|
"recommendation", "chmod 600 "+path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = cfg.Validate()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -337,81 +337,6 @@ func TestLoadS3PartSize(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestDocumentedSizeDefaults checks that the defaults are the documented
|
|
||||||
// chunk_size 10MB and blob_size_limit 10GB as the parser reads those values
|
|
||||||
// (1000-based), so writing a default out in the config file changes nothing.
|
|
||||||
func TestDocumentedSizeDefaults(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cfg, err := Parse([]byte("chunk_size: 10MB\nblob_size_limit: 10GB\n"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Parse() unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if cfg.ChunkSize != defaultChunkSize {
|
|
||||||
t.Errorf("chunk_size: 10MB is %d bytes, the default is %d",
|
|
||||||
cfg.ChunkSize, defaultChunkSize)
|
|
||||||
}
|
|
||||||
|
|
||||||
if cfg.BlobSizeLimit != defaultBlobSizeLimit {
|
|
||||||
t.Errorf("blob_size_limit: 10GB is %d bytes, the default is %d",
|
|
||||||
cfg.BlobSizeLimit, defaultBlobSizeLimit)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestDocumentedSizeExamples checks the 1MB minimum chunk_size and that
|
|
||||||
// every size the README, config.example.yml and the config init template
|
|
||||||
// list as an example is accepted.
|
|
||||||
func TestDocumentedSizeExamples(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const withoutSizes = "snapshots:\n" +
|
|
||||||
" test:\n" +
|
|
||||||
" paths: [/tmp/vaultik-test-source]\n" +
|
|
||||||
"storage_url: file:///tmp/vaultik-test-storage\n"
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
setting string
|
|
||||||
wantErr error
|
|
||||||
}{
|
|
||||||
{"chunk_size: 1MB", nil},
|
|
||||||
{"chunk_size: 999999", errChunkSizeTooSmall},
|
|
||||||
{"chunk_size: 5M", nil},
|
|
||||||
{"chunk_size: 10M", nil},
|
|
||||||
{"chunk_size: 10MB", nil},
|
|
||||||
{"chunk_size: 64MiB", nil},
|
|
||||||
{"chunk_size: 1GB", nil},
|
|
||||||
// Four times the default chunk_size, the documented minimum.
|
|
||||||
{"blob_size_limit: 40MB", nil},
|
|
||||||
{"blob_size_limit: 500MB", nil},
|
|
||||||
{"blob_size_limit: 1GB", nil},
|
|
||||||
{"blob_size_limit: 1GiB", nil},
|
|
||||||
{"blob_size_limit: 10G", nil},
|
|
||||||
{"blob_size_limit: 10GB", nil},
|
|
||||||
{"s3: {part_size: 5MiB}", nil},
|
|
||||||
{"s3: {part_size: 10MB}", nil},
|
|
||||||
{"s3: {part_size: 16MiB}", nil},
|
|
||||||
{"s3: {part_size: 100MiB}", nil},
|
|
||||||
{"s3: {part_size: 5GiB}", nil},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.setting, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cfg, err := Parse([]byte(withoutSizes + tt.setting + "\n"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Parse() unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = cfg.Validate()
|
|
||||||
if !errors.Is(err, tt.wantErr) {
|
|
||||||
t.Fatalf("Validate() error = %v, want %v", err, tt.wantErr)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestLoadRejectsRelativeFileURL checks that a file:// storage_url with a
|
// TestLoadRejectsRelativeFileURL checks that a file:// storage_url with a
|
||||||
// relative path fails at load. Accepted, it would resolve against the
|
// relative path fails at load. Accepted, it would resolve against the
|
||||||
// working directory, so a backup run from another directory would write
|
// working directory, so a backup run from another directory would write
|
||||||
|
|||||||
@@ -267,45 +267,6 @@ func (r *FileRepository) ListUnderPath(
|
|||||||
return files, rows.Err()
|
return files, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListUnderPathInSnapshot returns the files that ListUnderPath returns
|
|
||||||
// for path and that the snapshot lists, in no particular order.
|
|
||||||
func (r *FileRepository) ListUnderPathInSnapshot(
|
|
||||||
ctx context.Context, path, snapshotID string,
|
|
||||||
) ([]*File, error) {
|
|
||||||
path = strings.TrimRight(path, "/")
|
|
||||||
dirPrefix := path + "/"
|
|
||||||
|
|
||||||
query := `
|
|
||||||
SELECT f.id, f.path, f.source_path, f.mtime, f.mtime_nsec, f.size,
|
|
||||||
f.mode, f.uid, f.gid, f.link_target
|
|
||||||
FROM files f
|
|
||||||
JOIN snapshot_files sf ON sf.file_id = f.id
|
|
||||||
WHERE sf.snapshot_id = ?
|
|
||||||
AND (f.path = ? OR substr(f.path, 1, length(?)) = ?)
|
|
||||||
`
|
|
||||||
|
|
||||||
rows, err := r.db.conn.QueryContext(ctx, query,
|
|
||||||
snapshotID, path, dirPrefix, dirPrefix)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("querying files: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() { _ = rows.Close() }()
|
|
||||||
|
|
||||||
var files []*File
|
|
||||||
|
|
||||||
for rows.Next() {
|
|
||||||
file, err := r.scanFileRows(rows)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("scanning file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
files = append(files, file)
|
|
||||||
}
|
|
||||||
|
|
||||||
return files, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListIDsWithChunksNotInUploadedBlobs returns the IDs of the files whose
|
// ListIDsWithChunksNotInUploadedBlobs returns the IDs of the files whose
|
||||||
// path starts with prefix and that list at least one chunk held by no
|
// path starts with prefix and that list at least one chunk held by no
|
||||||
// blob whose upload has completed (uploaded_ts set). A new snapshot
|
// blob whose upload has completed (uploaded_ts set). A new snapshot
|
||||||
|
|||||||
@@ -212,65 +212,6 @@ func TestFileRepositoryListUnderPath(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListUnderPathInSnapshot leaves out files outside path and files the
|
|
||||||
// snapshot does not list, though another snapshot lists them.
|
|
||||||
func TestFileRepositoryListUnderPathInSnapshot(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
db, cleanup := setupTestDB(t)
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
files := database.NewFileRepository(db)
|
|
||||||
snapshots := database.NewSnapshotRepository(db)
|
|
||||||
|
|
||||||
listed := map[string][]string{
|
|
||||||
"one": {"/src/a.txt", "/src2/c.txt"},
|
|
||||||
"two": {"/src/b.txt"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for snapshotID, paths := range listed {
|
|
||||||
err := snapshots.Create(ctx, nil, &database.Snapshot{
|
|
||||||
ID: types.SnapshotID(snapshotID),
|
|
||||||
Hostname: "host",
|
|
||||||
StartedAt: time.Now(),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to create snapshot %s: %v", snapshotID, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, path := range paths {
|
|
||||||
err := files.Create(ctx, nil, &database.File{
|
|
||||||
Path: types.FilePath(path),
|
|
||||||
MTime: time.Now().Truncate(time.Second),
|
|
||||||
Mode: 0644,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to create %s: %v", path, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = snapshots.AddFile(ctx, nil, snapshotID, path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to add %s to %s: %v", path, snapshotID, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
found, err := files.ListUnderPathInSnapshot(ctx, "/src", "one")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to list files: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
got := make([]string, 0, len(found))
|
|
||||||
for _, f := range found {
|
|
||||||
got = append(got, f.Path.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
if !slices.Equal(got, []string{"/src/a.txt"}) {
|
|
||||||
t.Errorf("got %q, want only /src/a.txt", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFileRepositorySymlink(t *testing.T) {
|
func TestFileRepositorySymlink(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -578,54 +578,3 @@ func TestExcludePatterns_DoubleStarFileAtEveryDepth(t *testing.T) {
|
|||||||
require.Equal(t, 1, result.FilesScanned,
|
require.Equal(t, 1, result.FilesScanned,
|
||||||
"**/ file pattern should match at the top level and nested")
|
"**/ file pattern should match at the top level and nested")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExcludePatterns_AnchoredDoubleStarAtEveryDepth(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// As in gitignore, /**/ also matches directly under the source dir
|
|
||||||
fs := setupAnchoredTestFS(t)
|
|
||||||
|
|
||||||
scanner, repos, cleanup := createTestScanner(t, fs, []string{"/**/projectname"})
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
createSnapshotRecord(ctx, t, repos, "test-snapshot")
|
|
||||||
|
|
||||||
result, err := scanner.Scan(ctx, "/backup", "test-snapshot")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// /**/projectname should exclude BOTH:
|
|
||||||
// - /backup/projectname/file.txt
|
|
||||||
// - /backup/otherproject/projectname/file.txt
|
|
||||||
// Total files: 4, excluded: 2
|
|
||||||
require.Equal(t, 2, result.FilesScanned,
|
|
||||||
"/**/ pattern should match at the top level and nested")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExcludePatterns_DoubleStarInMiddleMatchesZeroDirectories(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// As in gitignore, a/**/b matches a/b as well as a/c/b. The last part
|
|
||||||
// is a glob: with a plain name the glob library already matched a/b.
|
|
||||||
fs := &afero.MemMapFs{}
|
|
||||||
files := []string{"/backup/a/x.log", "/backup/a/c/y.log", "/backup/a/c/kept.txt"}
|
|
||||||
|
|
||||||
for _, path := range files {
|
|
||||||
require.NoError(t, fs.MkdirAll(filepath.Dir(path), 0755))
|
|
||||||
require.NoError(t, afero.WriteFile(fs, path, []byte(path), 0644))
|
|
||||||
}
|
|
||||||
|
|
||||||
scanner, repos, cleanup := createTestScanner(t, fs, []string{"a/**/*.log"})
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
createSnapshotRecord(ctx, t, repos, "test-snapshot")
|
|
||||||
|
|
||||||
result, err := scanner.Scan(ctx, "/backup", "test-snapshot")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// a/**/*.log should exclude /backup/a/x.log and /backup/a/c/y.log
|
|
||||||
// Total files: 3, excluded: 2
|
|
||||||
require.Equal(t, 1, result.FilesScanned,
|
|
||||||
"a/**/*.log should match a/x.log as well as a/c/y.log")
|
|
||||||
}
|
|
||||||
|
|||||||
+11
-15
@@ -16,9 +16,6 @@ type ScannerParams struct {
|
|||||||
Fs afero.Fs
|
Fs afero.Fs
|
||||||
Exclude []string // Exclude patterns (combined global + snapshot-specific)
|
Exclude []string // Exclude patterns (combined global + snapshot-specific)
|
||||||
SkipErrors bool // Skip file read errors (log loudly but continue)
|
SkipErrors bool // Skip file read errors (log loudly but continue)
|
||||||
// PreviousSnapshotID is the most recent completed snapshot of the
|
|
||||||
// same name, "" for none.
|
|
||||||
PreviousSnapshotID string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Module exports backup functionality as an fx module.
|
// Module exports backup functionality as an fx module.
|
||||||
@@ -47,18 +44,17 @@ func provideScannerFactory(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return NewScanner(ScannerConfig{
|
return NewScanner(ScannerConfig{
|
||||||
FS: params.Fs,
|
FS: params.Fs,
|
||||||
ChunkSize: cfg.ChunkSize.Int64(),
|
ChunkSize: cfg.ChunkSize.Int64(),
|
||||||
Repositories: repos,
|
Repositories: repos,
|
||||||
Storage: storer,
|
Storage: storer,
|
||||||
MaxBlobSize: cfg.BlobSizeLimit.Int64(),
|
MaxBlobSize: cfg.BlobSizeLimit.Int64(),
|
||||||
CompressionLevel: cfg.CompressionLevel,
|
CompressionLevel: cfg.CompressionLevel,
|
||||||
AgeRecipients: cfg.AgeRecipients,
|
AgeRecipients: cfg.AgeRecipients,
|
||||||
EnableProgress: params.EnableProgress,
|
EnableProgress: params.EnableProgress,
|
||||||
UI: params.UI,
|
UI: params.UI,
|
||||||
Exclude: excludes,
|
Exclude: excludes,
|
||||||
SkipErrors: params.SkipErrors,
|
SkipErrors: params.SkipErrors,
|
||||||
PreviousSnapshotID: params.PreviousSnapshotID,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+45
-166
@@ -58,10 +58,6 @@ type Scanner struct {
|
|||||||
compressionLevel int
|
compressionLevel int
|
||||||
ageRecipient string
|
ageRecipient string
|
||||||
snapshotID string // Current snapshot being processed
|
snapshotID string // Current snapshot being processed
|
||||||
// previousSnapshotID is the most recent completed snapshot of the
|
|
||||||
// same name, "" for none. Only a file it lists can be counted as
|
|
||||||
// deleted.
|
|
||||||
previousSnapshotID string
|
|
||||||
// currentSourcePath is the source directory being scanned, stored with
|
// currentSourcePath is the source directory being scanned, stored with
|
||||||
// each file record.
|
// each file record.
|
||||||
currentSourcePath string
|
currentSourcePath string
|
||||||
@@ -133,20 +129,15 @@ type ScannerConfig struct {
|
|||||||
// SkipErrors skips files that cannot be opened or read (log loudly but
|
// SkipErrors skips files that cannot be opened or read (log loudly but
|
||||||
// continue); packer, database, encryption, and upload errors still abort.
|
// continue); packer, database, encryption, and upload errors still abort.
|
||||||
SkipErrors bool
|
SkipErrors bool
|
||||||
// PreviousSnapshotID is the most recent completed snapshot of the
|
|
||||||
// same name, "" for none.
|
|
||||||
PreviousSnapshotID string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ScanResult contains the results of a scan operation. Files and bytes
|
// ScanResult contains the results of a scan operation. Files and bytes
|
||||||
// are counted per file: BytesScanned is the size of the new and changed
|
// are counted per file: BytesScanned is the size of the new and changed
|
||||||
// files, BytesSkipped that of the unchanged ones. FilesFailed counts the
|
// files, BytesSkipped that of the unchanged ones. FilesFailed counts the
|
||||||
// new and changed files that phase 2 could not store, each entry removed
|
// new and changed files that phase 2 could not store; FilesScanned
|
||||||
// after the walk listed its directory and, under --skip-errors, each
|
// includes them and BytesScanned does not. ChunksCreated counts the
|
||||||
// entry the walk could not read, such as a directory it could not list;
|
// chunks that were not already stored, and NewChunkSize is their total
|
||||||
// FilesScanned includes them and BytesScanned does not. ChunksCreated
|
// size.
|
||||||
// counts the chunks that were not already stored, and NewChunkSize is
|
|
||||||
// their total size.
|
|
||||||
type ScanResult struct {
|
type ScanResult struct {
|
||||||
FilesScanned int
|
FilesScanned int
|
||||||
FilesSkipped int
|
FilesSkipped int
|
||||||
@@ -163,11 +154,6 @@ type ScanResult struct {
|
|||||||
UploadDuration time.Duration
|
UploadDuration time.Duration
|
||||||
StartTime time.Time
|
StartTime time.Time
|
||||||
EndTime time.Time
|
EndTime time.Time
|
||||||
|
|
||||||
// FilesUnreadable is the part of FilesFailed that --skip-errors
|
|
||||||
// skipped because it could not be read or listed; the rest are files
|
|
||||||
// removed after the walk listed them.
|
|
||||||
FilesUnreadable int
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewScanner creates a new scanner instance
|
// NewScanner creates a new scanner instance
|
||||||
@@ -221,7 +207,6 @@ func NewScanner(cfg ScannerConfig) *Scanner {
|
|||||||
compiledExclude: compiledExclude,
|
compiledExclude: compiledExclude,
|
||||||
progress: progress,
|
progress: progress,
|
||||||
skipErrors: cfg.SkipErrors,
|
skipErrors: cfg.SkipErrors,
|
||||||
previousSnapshotID: cfg.PreviousSnapshotID,
|
|
||||||
ui: uiw,
|
ui: uiw,
|
||||||
pendingChunkHashes: make(map[string]struct{}),
|
pendingChunkHashes: make(map[string]struct{}),
|
||||||
}
|
}
|
||||||
@@ -273,9 +258,8 @@ func (s *Scanner) Scan(
|
|||||||
|
|
||||||
filesToProcess := scanResult.FilesToProcess
|
filesToProcess := scanResult.FilesToProcess
|
||||||
|
|
||||||
// Phase 1b: Detect deleted files by comparing the previous snapshot
|
// Phase 1b: Detect deleted files by comparing DB against scanned files
|
||||||
// of this name against scanned files
|
err = s.detectDeletedFilesFromMap(ctx, knownFiles, existingFiles, result)
|
||||||
err = s.detectDeletedFiles(ctx, path, existingFiles, result)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("detecting deleted files: %w", err)
|
return nil, fmt.Errorf("detecting deleted files: %w", err)
|
||||||
}
|
}
|
||||||
@@ -455,8 +439,9 @@ func (s *Scanner) summarizeScanPhase(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// loadKnownFiles loads the known files at and beneath path from the
|
// loadKnownFiles loads the known files at and beneath path from the
|
||||||
// database into a map for fast lookup. This avoids per-file database
|
// database into a map for fast lookup. Every loaded file the scan does
|
||||||
// queries during the scan phase.
|
// not find is counted as deleted. This avoids per-file database queries
|
||||||
|
// during the scan phase.
|
||||||
func (s *Scanner) loadKnownFiles(
|
func (s *Scanner) loadKnownFiles(
|
||||||
ctx context.Context, path string,
|
ctx context.Context, path string,
|
||||||
) (map[string]*database.File, error) {
|
) (map[string]*database.File, error) {
|
||||||
@@ -889,16 +874,8 @@ func (s *Scanner) scanPhase(
|
|||||||
log.Debug("Starting directory walk", "path", path)
|
log.Debug("Starting directory walk", "path", path)
|
||||||
|
|
||||||
walkFn := func(filePath string, info os.FileInfo, err error) error {
|
walkFn := func(filePath string, info os.FileInfo, err error) error {
|
||||||
// The source path is in no directory listing, so its absence is
|
|
||||||
// an error rather than a removal.
|
|
||||||
if errors.Is(err, os.ErrNotExist) && filePath != path {
|
|
||||||
countRemovedDuringWalk(filePath, existingFiles, result)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return s.handleWalkError(filePath, err, result)
|
return s.handleWalkError(filePath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check context cancellation
|
// Check context cancellation
|
||||||
@@ -974,7 +951,7 @@ func (s *Scanner) recordSpecialEntry(
|
|||||||
if info.Mode()&os.ModeSymlink != 0 {
|
if info.Mode()&os.ModeSymlink != 0 {
|
||||||
file, err := s.buildSymlinkEntry(filePath, info)
|
file, err := s.buildSymlinkEntry(filePath, info)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return true, s.handleWalkError(filePath, err, result)
|
return true, s.handleWalkError(filePath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
existingFiles[filePath] = struct{}{}
|
existingFiles[filePath] = struct{}{}
|
||||||
@@ -997,38 +974,15 @@ func (s *Scanner) recordSpecialEntry(
|
|||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// countRemovedDuringWalk counts an entry that was in its directory's
|
|
||||||
// listing but was gone when the walk looked it up, like a file removed
|
|
||||||
// before phase 2 reads it: as examined and failed, and not as an error,
|
|
||||||
// with or without --skip-errors.
|
|
||||||
func countRemovedDuringWalk(
|
|
||||||
filePath string, existingFiles map[string]struct{}, result *ScanResult,
|
|
||||||
) {
|
|
||||||
log.Warn("File was deleted during the directory walk, skipping",
|
|
||||||
"path", filePath)
|
|
||||||
|
|
||||||
// The walk found it, so it is not also counted as deleted.
|
|
||||||
existingFiles[filePath] = struct{}{}
|
|
||||||
result.FilesScanned++
|
|
||||||
result.FilesFailed++
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleWalkError deals with a filesystem error surfaced by the walk:
|
// handleWalkError deals with a filesystem error surfaced by the walk:
|
||||||
// under --skip-errors the entry is skipped with a UI notice and counted
|
// skipped (with a UI notice) under --skip-errors, fatal otherwise.
|
||||||
// as examined, failed and unreadable, otherwise the error is fatal.
|
func (s *Scanner) handleWalkError(filePath string, err error) error {
|
||||||
func (s *Scanner) handleWalkError(
|
|
||||||
filePath string, err error, result *ScanResult,
|
|
||||||
) error {
|
|
||||||
if s.skipErrors {
|
if s.skipErrors {
|
||||||
log.Error("Failed to access file (skipping due to --skip-errors)",
|
log.Error("Failed to access file (skipping due to --skip-errors)",
|
||||||
"path", filePath, "error", err)
|
"path", filePath, "error", err)
|
||||||
s.ui.Errorf("Failed to access %s: %v. Skipping (--skip-errors).",
|
s.ui.Errorf("Failed to access %s: %v. Skipping (--skip-errors).",
|
||||||
s.ui.Path(filePath), err)
|
s.ui.Path(filePath), err)
|
||||||
|
|
||||||
result.FilesScanned++
|
|
||||||
result.FilesFailed++
|
|
||||||
result.FilesUnreadable++
|
|
||||||
|
|
||||||
return nil // Continue scanning
|
return nil // Continue scanning
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1460,7 +1414,6 @@ func (s *Scanner) processFileWithErrorHandling(
|
|||||||
s.ui.Path(fileToProcess.Path), err)
|
s.ui.Path(fileToProcess.Path), err)
|
||||||
|
|
||||||
countFailedFile(fileToProcess, result)
|
countFailedFile(fileToProcess, result)
|
||||||
result.FilesUnreadable++
|
|
||||||
|
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
@@ -2001,29 +1954,21 @@ func (s *Scanner) queueFileForBatchInsert(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// detectDeletedFiles counts as deleted each file at or beneath path that
|
// detectDeletedFilesFromMap finds files that existed in previous snapshots
|
||||||
// the previous snapshot of this name lists, the walk did not find, and
|
// but no longer exist. Uses pre-loaded maps to avoid any filesystem or
|
||||||
// is gone from disk. A file that is excluded, or inside an excluded
|
// database access.
|
||||||
// directory, is not counted: the walk skips it whether or not it is
|
func (s *Scanner) detectDeletedFilesFromMap(
|
||||||
// still on disk.
|
|
||||||
func (s *Scanner) detectDeletedFiles(
|
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
path string,
|
knownFiles map[string]*database.File,
|
||||||
existingFiles map[string]struct{},
|
existingFiles map[string]struct{},
|
||||||
result *ScanResult,
|
result *ScanResult,
|
||||||
) error {
|
) error {
|
||||||
if s.previousSnapshotID == "" {
|
if len(knownFiles) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
previousFiles, err := s.repos.Files.ListUnderPathInSnapshot(
|
// Check each known file against the enumerated set (no filesystem access needed)
|
||||||
ctx, path, s.previousSnapshotID)
|
for path, file := range knownFiles {
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("listing files of snapshot %s: %w",
|
|
||||||
s.previousSnapshotID, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, file := range previousFiles {
|
|
||||||
// Check context cancellation periodically
|
// Check context cancellation periodically
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
@@ -2031,22 +1976,13 @@ func (s *Scanner) detectDeletedFiles(
|
|||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
|
|
||||||
filePath := file.Path.String()
|
// Check if the file exists in our enumerated set
|
||||||
|
if _, exists := existingFiles[path]; !exists {
|
||||||
_, exists := existingFiles[filePath]
|
// File has been deleted
|
||||||
if exists || s.excludedOrInExcludedDir(filePath, path) {
|
result.FilesDeleted++
|
||||||
continue
|
result.BytesDeleted += file.Size
|
||||||
|
log.Debug("Detected deleted file", "path", path, "size", file.Size)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Under --skip-errors the walk also misses what it cannot reach,
|
|
||||||
// such as the files in a directory it cannot open.
|
|
||||||
if !s.goneFromDisk(filePath) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
result.FilesDeleted++
|
|
||||||
result.BytesDeleted += file.Size
|
|
||||||
log.Debug("Detected deleted file", "path", filePath, "size", file.Size)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.FilesDeleted > 0 {
|
if result.FilesDeleted > 0 {
|
||||||
@@ -2057,36 +1993,6 @@ func (s *Scanner) detectDeletedFiles(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// excludedOrInExcludedDir reports whether filePath, which is at or
|
|
||||||
// beneath rootPath, or any directory between rootPath and it, matches an
|
|
||||||
// exclude pattern. A pattern such as */.local/share/Trash matches the
|
|
||||||
// directory but not the files inside it, which the walk skips too.
|
|
||||||
func (s *Scanner) excludedOrInExcludedDir(filePath, rootPath string) bool {
|
|
||||||
for p := filePath; len(p) > len(rootPath); p = filepath.Dir(p) {
|
|
||||||
if s.shouldExclude(p, rootPath) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// goneFromDisk reports whether looking filePath up on disk finds that
|
|
||||||
// it does not exist. Like the walk, it does not follow a symlink, so a
|
|
||||||
// symlink whose target is gone is still on disk. A file whose directory
|
|
||||||
// was replaced by a regular file is gone too, though the lookup reports
|
|
||||||
// "not a directory".
|
|
||||||
func (s *Scanner) goneFromDisk(filePath string) bool {
|
|
||||||
var err error
|
|
||||||
if lstater, ok := s.fs.(afero.Lstater); ok {
|
|
||||||
_, _, err = lstater.LstatIfPossible(filePath)
|
|
||||||
} else {
|
|
||||||
_, err = s.fs.Stat(filePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
return errors.Is(err, os.ErrNotExist) || errors.Is(err, syscall.ENOTDIR)
|
|
||||||
}
|
|
||||||
|
|
||||||
// wrapPermissionError augments permission errors with platform-specific
|
// wrapPermissionError augments permission errors with platform-specific
|
||||||
// remediation instructions. On macOS, TCC-protected directories (Calendars,
|
// remediation instructions. On macOS, TCC-protected directories (Calendars,
|
||||||
// Reminders, Photos, etc.) return EPERM unless the running application has
|
// Reminders, Photos, etc.) return EPERM unless the running application has
|
||||||
@@ -2129,60 +2035,33 @@ func compileExcludePatterns(patterns []string) []compiledPattern {
|
|||||||
pattern = strings.TrimSuffix(pattern, "/")
|
pattern = strings.TrimSuffix(pattern, "/")
|
||||||
|
|
||||||
// A leading "**/" demands a "/" before the name and so misses an
|
// A leading "**/" demands a "/" before the name and so misses an
|
||||||
// entry directly under the source path. It means "at every depth",
|
// entry directly under the source path. An unanchored pattern
|
||||||
// which is what an unanchored pattern already does, so drop the
|
// already matches at every depth, so drop the prefix: as in
|
||||||
// prefix and match unanchored: as in gitignore, "**/x" and
|
// gitignore, "**/x" then also matches a top-level "x".
|
||||||
// "/**/x" then also match a top-level "x".
|
if !anchored {
|
||||||
if rest, found := strings.CutPrefix(pattern, "**/"); found {
|
pattern = strings.TrimPrefix(pattern, "**/")
|
||||||
pattern = rest
|
|
||||||
anchored = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Likewise "/**/" inside a pattern demands two slashes, so
|
// Compile the glob pattern
|
||||||
// "a/**/*.log" misses "a/x.log". As in gitignore it stands for
|
// For patterns without path separators, we need to match them as components
|
||||||
// zero or more directories, so also compile the pattern with it
|
// e.g., ".git" should match ".git" anywhere in the path
|
||||||
// made a single "/". The glob library's {a,b} is no help: with a
|
g, err := glob.Compile(pattern, '/')
|
||||||
// "**" inside it in mid-pattern, it misses paths it should match.
|
if err != nil {
|
||||||
for _, variant := range doubleStarVariants(pattern) {
|
log.Warn("Invalid exclude pattern, skipping", "pattern", p, "error", err)
|
||||||
// For patterns without path separators, we need to match them as components
|
|
||||||
// e.g., ".git" should match ".git" anywhere in the path
|
|
||||||
g, err := glob.Compile(variant, '/')
|
|
||||||
if err != nil {
|
|
||||||
log.Warn("Invalid exclude pattern, skipping", "pattern", p, "error", err)
|
|
||||||
|
|
||||||
break
|
continue
|
||||||
}
|
|
||||||
|
|
||||||
compiled = append(compiled, compiledPattern{
|
|
||||||
pattern: g,
|
|
||||||
anchored: anchored,
|
|
||||||
original: p,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
compiled = append(compiled, compiledPattern{
|
||||||
|
pattern: g,
|
||||||
|
anchored: anchored,
|
||||||
|
original: p,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return compiled
|
return compiled
|
||||||
}
|
}
|
||||||
|
|
||||||
// doubleStarVariants returns pattern once for every way of keeping each
|
|
||||||
// "/**/" in it or making it a single "/".
|
|
||||||
func doubleStarVariants(pattern string) []string {
|
|
||||||
const doubleStar = "/**/"
|
|
||||||
|
|
||||||
before, after, found := strings.Cut(pattern, doubleStar)
|
|
||||||
if !found {
|
|
||||||
return []string{pattern}
|
|
||||||
}
|
|
||||||
|
|
||||||
var variants []string
|
|
||||||
|
|
||||||
for _, rest := range doubleStarVariants(after) {
|
|
||||||
variants = append(variants, before+doubleStar+rest, before+"/"+rest)
|
|
||||||
}
|
|
||||||
|
|
||||||
return variants
|
|
||||||
}
|
|
||||||
|
|
||||||
// shouldExclude checks if a path should be excluded based on exclude patterns
|
// shouldExclude checks if a path should be excluded based on exclude patterns
|
||||||
// filePath is the full path to the file
|
// filePath is the full path to the file
|
||||||
// rootPath is the root of the backup source directory
|
// rootPath is the root of the backup source directory
|
||||||
|
|||||||
+17
-83
@@ -21,13 +21,9 @@ type FileStorer struct {
|
|||||||
// NewFileStorer creates a new filesystem storage backend.
|
// NewFileStorer creates a new filesystem storage backend.
|
||||||
//
|
//
|
||||||
// Construction is intentionally cheap and does not touch the filesystem.
|
// Construction is intentionally cheap and does not touch the filesystem.
|
||||||
// The backend never creates basePath: the user does, so that when
|
// The basePath is recorded; the directory is created lazily on first
|
||||||
// basePath is a directory on an unplugged volume, a write fails instead
|
// write. Write operations (Put/PutWithProgress) call MkdirAll for the
|
||||||
// of landing on the disk under the mount point. This does not hold when
|
// per-blob parent directory, which also covers basePath on first use.
|
||||||
// basePath is the mount point itself, which stays behind when its volume
|
|
||||||
// is unplugged.
|
|
||||||
// Put and PutWithProgress fail on a missing basePath and create the
|
|
||||||
// directories below it as needed.
|
|
||||||
// Get and Stat report a key under a missing basePath as ErrNotFound.
|
// Get and Stat report a key under a missing basePath as ErrNotFound.
|
||||||
// List and ListStream fail on a missing basePath, because listing it as
|
// List and ListStream fail on a missing basePath, because listing it as
|
||||||
// an empty store would make `prune` drop every local snapshot record.
|
// an empty store would make `prune` drop every local snapshot record.
|
||||||
@@ -124,10 +120,9 @@ func (f *FileStorer) Delete(_ context.Context, key string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// List returns all keys with the given prefix, reading through symlinked
|
// List returns all keys with the given prefix. It fails when the
|
||||||
// directories. It fails when the destination directory is missing, or
|
// destination directory is missing; a missing prefix under it is an
|
||||||
// when the prefix or a directory above it is a symlink whose target is
|
// empty listing.
|
||||||
// missing; a prefix that is truly missing is an empty listing.
|
|
||||||
func (f *FileStorer) List(ctx context.Context, prefix string) ([]string, error) {
|
func (f *FileStorer) List(ctx context.Context, prefix string) ([]string, error) {
|
||||||
var keys []string
|
var keys []string
|
||||||
|
|
||||||
@@ -138,7 +133,8 @@ func (f *FileStorer) List(ctx context.Context, prefix string) ([]string, error)
|
|||||||
|
|
||||||
basePath := f.fullPath(prefix)
|
basePath := f.fullPath(prefix)
|
||||||
|
|
||||||
exists, err := f.prefixExists(prefix)
|
// Check if the prefix exists
|
||||||
|
exists, err := afero.Exists(f.fs, basePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("checking path: %w", err)
|
return nil, fmt.Errorf("checking path: %w", err)
|
||||||
}
|
}
|
||||||
@@ -147,7 +143,7 @@ func (f *FileStorer) List(ctx context.Context, prefix string) ([]string, error)
|
|||||||
return keys, nil // Empty list for non-existent prefix
|
return keys, nil // Empty list for non-existent prefix
|
||||||
}
|
}
|
||||||
|
|
||||||
err = f.walk(basePath, func(path string, info os.FileInfo, err error) error {
|
err = afero.Walk(f.fs, basePath, func(path string, info os.FileInfo, err error) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -180,9 +176,7 @@ func (f *FileStorer) List(ctx context.Context, prefix string) ([]string, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ListStream returns a channel of ObjectInfo for large result sets. Like
|
// ListStream returns a channel of ObjectInfo for large result sets. Like
|
||||||
// List, it reads through symlinked directories and sends an error when
|
// List, it sends an error when the destination directory is missing.
|
||||||
// the destination directory is missing, or when the prefix or a directory
|
|
||||||
// above it is a symlink whose target is missing.
|
|
||||||
func (f *FileStorer) ListStream(ctx context.Context, prefix string) <-chan ObjectInfo {
|
func (f *FileStorer) ListStream(ctx context.Context, prefix string) <-chan ObjectInfo {
|
||||||
ch := make(chan ObjectInfo)
|
ch := make(chan ObjectInfo)
|
||||||
|
|
||||||
@@ -198,7 +192,8 @@ func (f *FileStorer) ListStream(ctx context.Context, prefix string) <-chan Objec
|
|||||||
|
|
||||||
basePath := f.fullPath(prefix)
|
basePath := f.fullPath(prefix)
|
||||||
|
|
||||||
exists, err := f.prefixExists(prefix)
|
// Check if the prefix exists
|
||||||
|
exists, err := afero.Exists(f.fs, basePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ch <- ObjectInfo{Err: fmt.Errorf("checking path: %w", err)}
|
ch <- ObjectInfo{Err: fmt.Errorf("checking path: %w", err)}
|
||||||
|
|
||||||
@@ -209,7 +204,7 @@ func (f *FileStorer) ListStream(ctx context.Context, prefix string) <-chan Objec
|
|||||||
return // Empty channel for non-existent prefix
|
return // Empty channel for non-existent prefix
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = f.walk(basePath, func(path string, info os.FileInfo, err error) error {
|
_ = afero.Walk(f.fs, basePath, func(path string, info os.FileInfo, err error) error {
|
||||||
// Check context cancellation
|
// Check context cancellation
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
@@ -248,12 +243,11 @@ func (f *FileStorer) ListStream(ctx context.Context, prefix string) <-chan Objec
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DeletePartialUploads removes every file under prefix whose name ends in
|
// DeletePartialUploads removes every file under prefix whose name ends in
|
||||||
// tempSuffix. Like List, it reads through symlinked directories and fails
|
// tempSuffix. A missing prefix has none to remove.
|
||||||
// on one whose target is missing. A missing prefix has none to remove.
|
|
||||||
func (f *FileStorer) DeletePartialUploads(ctx context.Context, prefix string) error {
|
func (f *FileStorer) DeletePartialUploads(ctx context.Context, prefix string) error {
|
||||||
basePath := f.fullPath(prefix)
|
basePath := f.fullPath(prefix)
|
||||||
|
|
||||||
exists, err := f.prefixExists(prefix)
|
exists, err := afero.Exists(f.fs, basePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("checking path: %w", err)
|
return fmt.Errorf("checking path: %w", err)
|
||||||
}
|
}
|
||||||
@@ -262,7 +256,7 @@ func (f *FileStorer) DeletePartialUploads(ctx context.Context, prefix string) er
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
err = f.walk(basePath, func(path string, info os.FileInfo, err error) error {
|
err = afero.Walk(f.fs, basePath, func(path string, info os.FileInfo, err error) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -303,19 +297,7 @@ func (f *FileStorer) writeAtomic(
|
|||||||
path := f.fullPath(key)
|
path := f.fullPath(key)
|
||||||
dir := filepath.Dir(path)
|
dir := filepath.Dir(path)
|
||||||
|
|
||||||
// Checked on every write, so a destination that disappears part-way
|
err := f.fs.MkdirAll(dir, storageDirPerm)
|
||||||
// through a backup fails it rather than being recreated by MkdirAll.
|
|
||||||
_, err := f.fs.Stat(f.basePath)
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return fmt.Errorf("destination directory does not exist; "+
|
|
||||||
"create it, or mount the volume it is on: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("checking destination directory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = f.fs.MkdirAll(dir, storageDirPerm)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("creating directories: %w", err)
|
return fmt.Errorf("creating directories: %w", err)
|
||||||
}
|
}
|
||||||
@@ -370,54 +352,6 @@ func (f *FileStorer) writeAtomic(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// walk is afero.Walk, except that it reads through a symlinked directory
|
|
||||||
// the way Get and Stat do, where afero.Walk reports the link as one file.
|
|
||||||
// afero.Walk uses Lstat, which does not follow a link, whenever the
|
|
||||||
// filesystem has one; the struct below carries only the afero.Fs methods,
|
|
||||||
// so the walk uses Stat instead. A link back up the tree is followed until
|
|
||||||
// the operating system refuses the path for too many levels of links, and
|
|
||||||
// the walk then fails.
|
|
||||||
func (f *FileStorer) walk(root string, fn filepath.WalkFunc) error {
|
|
||||||
return afero.Walk(struct{ afero.Fs }{f.fs}, root, fn)
|
|
||||||
}
|
|
||||||
|
|
||||||
// prefixExists reports whether prefix exists under the destination
|
|
||||||
// directory. It checks each directory on the way down to prefix in turn,
|
|
||||||
// and the first one Stat cannot find decides: if it is not there at all,
|
|
||||||
// prefix is missing; if it is a symlink whose target is missing, such as
|
|
||||||
// a link to a volume that is not mounted, prefixExists fails, since
|
|
||||||
// listing it as empty would let prune delete every blob.
|
|
||||||
func (f *FileStorer) prefixExists(prefix string) (bool, error) {
|
|
||||||
lstater, canLstat := f.fs.(afero.Lstater)
|
|
||||||
path := f.basePath
|
|
||||||
|
|
||||||
for name := range strings.SplitSeq(prefix, "/") {
|
|
||||||
path = filepath.Join(path, name)
|
|
||||||
|
|
||||||
_, err := f.fs.Stat(path)
|
|
||||||
if err == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if !os.IsNotExist(err) {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lstat does not follow a symlink at path, so it finds the link
|
|
||||||
// that Stat could not follow.
|
|
||||||
if canLstat {
|
|
||||||
_, _, lstatErr := lstater.LstatIfPossible(path)
|
|
||||||
if lstatErr == nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// fullPath returns the full filesystem path for a key.
|
// fullPath returns the full filesystem path for a key.
|
||||||
func (f *FileStorer) fullPath(key string) string {
|
func (f *FileStorer) fullPath(key string) string {
|
||||||
return filepath.Join(f.basePath, key)
|
return filepath.Join(f.basePath, key)
|
||||||
|
|||||||
@@ -4,10 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"sneak.berlin/go/vaultik/internal/storage"
|
"sneak.berlin/go/vaultik/internal/storage"
|
||||||
@@ -63,272 +60,3 @@ func TestFileStorerListMissingDestination(t *testing.T) {
|
|||||||
t.Errorf("ListStream error = %v, want a not-exist error", streamErr)
|
t.Errorf("ListStream error = %v, want a not-exist error", streamErr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The two directories at the top of a destination, either of which the
|
|
||||||
// tests below replace with a symlink.
|
|
||||||
const (
|
|
||||||
metadataDir = "metadata"
|
|
||||||
blobsDir = "blobs"
|
|
||||||
)
|
|
||||||
|
|
||||||
// moveBehindSymlink moves base/dir to a directory elsewhere and puts a
|
|
||||||
// symlink to it in its place.
|
|
||||||
func moveBehindSymlink(t *testing.T, base, dir string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
linkPath := filepath.Join(base, dir)
|
|
||||||
movedPath := filepath.Join(t.TempDir(), dir)
|
|
||||||
|
|
||||||
err := os.Rename(linkPath, movedPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("moving %s away: %v", dir, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = os.Symlink(movedPath, linkPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("linking %s: %v", dir, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestFileStorerListsThroughSymlinkedDirectory checks that List and
|
|
||||||
// ListStream read through a symlinked metadata/ or blobs/ directory, as Get
|
|
||||||
// and Stat do, instead of listing the link as one key. Listing the whole
|
|
||||||
// destination meets the link part-way through the walk; listing its prefix
|
|
||||||
// starts the walk at the link. A prefix missing behind the link lists as
|
|
||||||
// empty.
|
|
||||||
func TestFileStorerListsThroughSymlinkedDirectory(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, dir := range []string{metadataDir, blobsDir} {
|
|
||||||
t.Run(dir, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
base := t.TempDir()
|
|
||||||
key := dir + "/aa/bb/object"
|
|
||||||
|
|
||||||
s, err := storage.NewFileStorer(base)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewFileStorer: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = s.Put(context.Background(), key, strings.NewReader("data"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Put: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
moveBehindSymlink(t, base, dir)
|
|
||||||
|
|
||||||
want := []string{key}
|
|
||||||
|
|
||||||
for _, prefix := range []string{"", dir + "/"} {
|
|
||||||
got := listSorted(t, s, prefix)
|
|
||||||
if !reflect.DeepEqual(got, want) {
|
|
||||||
t.Errorf("List(%q) = %v, want %v", prefix, got, want)
|
|
||||||
}
|
|
||||||
|
|
||||||
got = listStreamKeys(t, s, prefix)
|
|
||||||
if !reflect.DeepEqual(got, want) {
|
|
||||||
t.Errorf("ListStream(%q) = %v, want %v", prefix, got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
absent := dir + "/absent/"
|
|
||||||
|
|
||||||
if got := listSorted(t, s, absent); len(got) != 0 {
|
|
||||||
t.Errorf("List(%q) = %v, want empty", absent, got)
|
|
||||||
}
|
|
||||||
|
|
||||||
if got := listStreamKeys(t, s, absent); len(got) != 0 {
|
|
||||||
t.Errorf("ListStream(%q) = %v, want empty", absent, got)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestFileStorerListFailsOnSymlinkWithMissingTarget checks that List and
|
|
||||||
// ListStream fail on a symlinked metadata/ or blobs/ directory whose target
|
|
||||||
// is missing, such as a link to a volume that is not mounted, as they do on
|
|
||||||
// a missing destination directory, instead of listing it as empty. That
|
|
||||||
// holds for a prefix below the link too.
|
|
||||||
func TestFileStorerListFailsOnSymlinkWithMissingTarget(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
for _, dir := range []string{metadataDir, blobsDir} {
|
|
||||||
t.Run(dir, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
base := t.TempDir()
|
|
||||||
|
|
||||||
s, err := storage.NewFileStorer(base)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewFileStorer: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
unmounted := filepath.Join(t.TempDir(), "unmounted")
|
|
||||||
|
|
||||||
err = os.Symlink(unmounted, filepath.Join(base, dir))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Symlink: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, prefix := range []string{"", dir + "/", dir + "/aa/"} {
|
|
||||||
keys, err := s.List(ctx, prefix)
|
|
||||||
if !errors.Is(err, fs.ErrNotExist) {
|
|
||||||
t.Errorf("List(%q) = %v, %v; want a not-exist error",
|
|
||||||
prefix, keys, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var streamErr error
|
|
||||||
|
|
||||||
for object := range s.ListStream(ctx, prefix) {
|
|
||||||
if object.Err != nil {
|
|
||||||
streamErr = object.Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !errors.Is(streamErr, fs.ErrNotExist) {
|
|
||||||
t.Errorf("ListStream(%q) error = %v, want a not-exist error",
|
|
||||||
prefix, streamErr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestFileStorerListFailsOnSymlinkLoop checks that a symlink pointing back
|
|
||||||
// at the destination makes List and ListStream fail instead of walking it
|
|
||||||
// forever.
|
|
||||||
func TestFileStorerListFailsOnSymlinkLoop(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
base := t.TempDir()
|
|
||||||
|
|
||||||
s, err := storage.NewFileStorer(base)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewFileStorer: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = s.Put(ctx, testBlobKey, strings.NewReader("data"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Put: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = os.Symlink(base, filepath.Join(base, "blobs", "loop"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Symlink: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
keys, err := s.List(ctx, "blobs/")
|
|
||||||
if err == nil {
|
|
||||||
t.Errorf("List = %d keys and no error, want an error", len(keys))
|
|
||||||
}
|
|
||||||
|
|
||||||
var streamErr error
|
|
||||||
|
|
||||||
for object := range s.ListStream(ctx, "blobs/") {
|
|
||||||
if object.Err != nil {
|
|
||||||
streamErr = object.Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if streamErr == nil {
|
|
||||||
t.Error("ListStream sent no error, want one")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestFileStorerDeletePartialUploadsThroughSymlinkedDirectory checks that
|
|
||||||
// DeletePartialUploads, which remote nuke uses, removes a leftover temp
|
|
||||||
// file behind a symlinked metadata/ or blobs/ directory and keeps the
|
|
||||||
// object at the real key.
|
|
||||||
func TestFileStorerDeletePartialUploadsThroughSymlinkedDirectory(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
for _, dir := range []string{metadataDir, blobsDir} {
|
|
||||||
t.Run(dir, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
base := t.TempDir()
|
|
||||||
key := dir + "/aa/bb/object"
|
|
||||||
|
|
||||||
s, err := storage.NewFileStorer(base)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewFileStorer: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = s.Put(ctx, key, strings.NewReader("data"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Put: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
leftover := filepath.Join(base, key+"-123456.partial")
|
|
||||||
|
|
||||||
err = os.WriteFile(leftover, []byte("half"), 0o600)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("writing leftover temp file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
moveBehindSymlink(t, base, dir)
|
|
||||||
|
|
||||||
err = s.DeletePartialUploads(ctx, dir+"/")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("DeletePartialUploads: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// leftover runs through the link.
|
|
||||||
_, err = os.Stat(leftover)
|
|
||||||
if !os.IsNotExist(err) {
|
|
||||||
t.Errorf("leftover temp file was not removed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = s.Stat(ctx, key)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("Stat of the real key: %v", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -7,12 +7,10 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/rclone/rclone/fs"
|
"github.com/rclone/rclone/fs"
|
||||||
"github.com/rclone/rclone/fs/config"
|
|
||||||
"github.com/rclone/rclone/fs/config/configfile"
|
"github.com/rclone/rclone/fs/config/configfile"
|
||||||
"github.com/rclone/rclone/fs/operations"
|
"github.com/rclone/rclone/fs/operations"
|
||||||
|
|
||||||
@@ -35,15 +33,6 @@ type RcloneStorer struct {
|
|||||||
// The remote parameter is the rclone remote name (as configured via `rclone config`).
|
// The remote parameter is the rclone remote name (as configured via `rclone config`).
|
||||||
// The path parameter is the path within the remote.
|
// The path parameter is the path within the remote.
|
||||||
func NewRcloneStorer(ctx context.Context, remote, path string) (*RcloneStorer, error) {
|
func NewRcloneStorer(ctx context.Context, remote, path string) (*RcloneStorer, error) {
|
||||||
// rclone reads RCLONE_CONFIG only as the value of its --config flag,
|
|
||||||
// which vaultik does not have, so it is applied here as that flag would.
|
|
||||||
if configPath, ok := os.LookupEnv("RCLONE_CONFIG"); ok {
|
|
||||||
err := config.SetConfigPath(configPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("using RCLONE_CONFIG %q: %w", configPath, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Install the default config file handler
|
// Install the default config file handler
|
||||||
configfile.Install()
|
configfile.Install()
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/rclone/rclone/fs"
|
"github.com/rclone/rclone/fs"
|
||||||
"github.com/rclone/rclone/fs/config"
|
|
||||||
"github.com/rclone/rclone/fs/config/configmap"
|
"github.com/rclone/rclone/fs/config/configmap"
|
||||||
"sneak.berlin/go/vaultik/internal/storage"
|
"sneak.berlin/go/vaultik/internal/storage"
|
||||||
)
|
)
|
||||||
@@ -391,41 +390,3 @@ func TestNewRcloneStorerUnknownRemote(t *testing.T) {
|
|||||||
t.Errorf("NewRcloneStorer error = %v, want ErrRemoteNotFound", err)
|
t.Errorf("NewRcloneStorer error = %v, want ErrRemoteNotFound", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestNewRcloneStorerReadsRcloneConfigEnv checks that a remote defined only
|
|
||||||
// in the file RCLONE_CONFIG names is found and written to, as rclone itself
|
|
||||||
// would.
|
|
||||||
func TestNewRcloneStorerReadsRcloneConfigEnv(t *testing.T) {
|
|
||||||
// rclone keeps the config file path in a process-global variable, which
|
|
||||||
// NewRcloneStorer leaves pointing at this test's file.
|
|
||||||
previous := config.GetConfigPath()
|
|
||||||
|
|
||||||
t.Cleanup(func() { _ = config.SetConfigPath(previous) })
|
|
||||||
|
|
||||||
confFile := filepath.Join(t.TempDir(), "rclone.conf")
|
|
||||||
|
|
||||||
err := os.WriteFile(confFile, []byte("[loc]\ntype = local\n"), 0o600)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("writing rclone config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Setenv("RCLONE_CONFIG", confFile)
|
|
||||||
|
|
||||||
dir := t.TempDir()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
s, err := storage.NewRcloneStorer(ctx, "loc", dir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewRcloneStorer: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = s.Put(ctx, testBlobKey, strings.NewReader("blob-bytes"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Put: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = os.Stat(filepath.Join(dir, testBlobKey))
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("object is not in the remote's directory: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -41,8 +41,7 @@ type Info struct {
|
|||||||
// directory for filesystem).
|
// directory for filesystem).
|
||||||
type Storer interface {
|
type Storer interface {
|
||||||
// Put stores data at the specified key.
|
// Put stores data at the specified key.
|
||||||
// The filesystem backend fails when its destination directory does
|
// Parent directories are created automatically for filesystem backends.
|
||||||
// not exist, and creates the directories below it as needed.
|
|
||||||
Put(ctx context.Context, key string, data io.Reader) error
|
Put(ctx context.Context, key string, data io.Reader) error
|
||||||
|
|
||||||
// PutWithProgress stores data with progress reporting.
|
// PutWithProgress stores data with progress reporting.
|
||||||
|
|||||||
+6
-47
@@ -30,12 +30,6 @@ var (
|
|||||||
"storage URL must not carry credentials; " +
|
"storage URL must not carry credentials; " +
|
||||||
"set s3.access_key_id and s3.secret_access_key in the config instead")
|
"set s3.access_key_id and s3.secret_access_key in the config instead")
|
||||||
ErrURLUnknownParam = errors.New("unknown query parameter in storage URL")
|
ErrURLUnknownParam = errors.New("unknown query parameter in storage URL")
|
||||||
ErrURLBadQuery = errors.New(
|
|
||||||
"storage URL query cannot be decoded; " +
|
|
||||||
"check it for a ; or a % not followed by two hex digits")
|
|
||||||
ErrRcloneRemoteParams = errors.New(
|
|
||||||
"rclone URL must not carry parameters in the remote name; " +
|
|
||||||
"set them on the remote with rclone config instead")
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// URL represents a parsed storage URL.
|
// URL represents a parsed storage URL.
|
||||||
@@ -96,9 +90,8 @@ func parseFileURL(rawPath string) (*URL, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// parseS3URL parses an s3://bucket/prefix URL. It rejects credentials in
|
// parseS3URL parses an s3://bucket/prefix URL. It rejects credentials in
|
||||||
// the userinfo or in the endpoint parameter's userinfo, a query that
|
// the userinfo and any query parameter other than endpoint, region and
|
||||||
// cannot be decoded, and any query parameter other than endpoint, region
|
// ssl, so a credential-bearing URL is never stored or echoed.
|
||||||
// and ssl, so a credential-bearing URL is never stored or echoed.
|
|
||||||
func parseS3URL(rawURL string) (*URL, error) {
|
func parseS3URL(rawURL string) (*URL, error) {
|
||||||
u, err := url.Parse(rawURL)
|
u, err := url.Parse(rawURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -114,26 +107,13 @@ func parseS3URL(rawURL string) (*URL, error) {
|
|||||||
return nil, ErrMissingBucket
|
return nil, ErrMissingBucket
|
||||||
}
|
}
|
||||||
|
|
||||||
// u.Query() would silently drop a part it cannot decode, endpoint
|
query := u.Query()
|
||||||
// included. The decode error is not wrapped: it can quote the value.
|
|
||||||
query, err := url.ParseQuery(u.RawQuery)
|
|
||||||
if err != nil {
|
|
||||||
return nil, ErrURLBadQuery
|
|
||||||
}
|
|
||||||
|
|
||||||
err = rejectUnknownParams(query, "endpoint", "region", "ssl")
|
err = rejectUnknownParams(query, "endpoint", "region", "ssl")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// A host name never contains "@", so one in an endpoint, with or
|
|
||||||
// without a scheme, can only introduce userinfo.
|
|
||||||
for _, endpoint := range query["endpoint"] {
|
|
||||||
if strings.Contains(endpoint, "@") {
|
|
||||||
return nil, ErrURLCredentials
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return &URL{
|
return &URL{
|
||||||
Scheme: schemeS3,
|
Scheme: schemeS3,
|
||||||
Bucket: bucket,
|
Bucket: bucket,
|
||||||
@@ -145,23 +125,9 @@ func parseS3URL(rawURL string) (*URL, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// parseRcloneURL parses an rclone://remote/path URL. rclone:// takes no
|
// parseRcloneURL parses an rclone://remote/path URL. rclone:// takes no
|
||||||
// query parameters, so credentials in the userinfo, parameters in the
|
// query parameters, so credentials in the userinfo and any parameter at
|
||||||
// remote name, a query that cannot be decoded and any query parameter at
|
|
||||||
// all are rejected rather than silently ignored.
|
// all are rejected rather than silently ignored.
|
||||||
func parseRcloneURL(rawURL string) (*URL, error) {
|
func parseRcloneURL(rawURL string) (*URL, error) {
|
||||||
// The remote name runs up to the first "/", "?" or "#". It never
|
|
||||||
// contains a comma; one starts rclone's remote,param=value form,
|
|
||||||
// whose values can be credentials. This is checked before url.Parse,
|
|
||||||
// whose error for such a name can print part of it.
|
|
||||||
remote := strings.TrimPrefix(rawURL, "rclone://")
|
|
||||||
if end := strings.IndexAny(remote, "/?#"); end >= 0 {
|
|
||||||
remote = remote[:end]
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.Contains(remote, ",") {
|
|
||||||
return nil, ErrRcloneRemoteParams
|
|
||||||
}
|
|
||||||
|
|
||||||
u, err := url.Parse(rawURL)
|
u, err := url.Parse(rawURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, wrapParseError(err)
|
return nil, wrapParseError(err)
|
||||||
@@ -171,19 +137,12 @@ func parseRcloneURL(rawURL string) (*URL, error) {
|
|||||||
return nil, ErrURLCredentials
|
return nil, ErrURLCredentials
|
||||||
}
|
}
|
||||||
|
|
||||||
remote = u.Host
|
remote := u.Host
|
||||||
if remote == "" {
|
if remote == "" {
|
||||||
return nil, ErrMissingRemote
|
return nil, ErrMissingRemote
|
||||||
}
|
}
|
||||||
|
|
||||||
// As in parseS3URL: u.Query() would drop a part it cannot decode, and
|
err = rejectUnknownParams(u.Query())
|
||||||
// the decode error can quote the value.
|
|
||||||
query, err := url.ParseQuery(u.RawQuery)
|
|
||||||
if err != nil {
|
|
||||||
return nil, ErrURLBadQuery
|
|
||||||
}
|
|
||||||
|
|
||||||
err = rejectUnknownParams(query)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -150,19 +150,6 @@ func TestParseStorageURLRejectsCredentials(t *testing.T) {
|
|||||||
wantErr: storage.ErrURLCredentials,
|
wantErr: storage.ErrURLCredentials,
|
||||||
secrets: []string{key, secret, "mybucket"},
|
secrets: []string{key, secret, "mybucket"},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "s3 userinfo in endpoint",
|
|
||||||
raw: "s3://mybucket?endpoint=https://" +
|
|
||||||
key + ":" + secret + "@minio.example.com",
|
|
||||||
wantErr: storage.ErrURLCredentials,
|
|
||||||
secrets: []string{key, secret},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "s3 userinfo in endpoint without scheme",
|
|
||||||
raw: "s3://mybucket?endpoint=" + key + ":" + secret + "@minio.example.com",
|
|
||||||
wantErr: storage.ErrURLCredentials,
|
|
||||||
secrets: []string{key, secret},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: "s3 unknown query param",
|
name: "s3 unknown query param",
|
||||||
raw: "s3://mybucket?access_key=" + key + "&secret=" + secret,
|
raw: "s3://mybucket?access_key=" + key + "&secret=" + secret,
|
||||||
@@ -216,112 +203,6 @@ func TestParseStorageURLRejectsCredentials(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestParseStorageURLRejectsRcloneRemoteParams checks that an rclone
|
|
||||||
// remote name carrying parameters (rclone's "remote,param=value" form,
|
|
||||||
// which can hold a credential) is rejected without echoing the value,
|
|
||||||
// including the URLs that url.Parse itself refuses.
|
|
||||||
func TestParseStorageURLRejectsRcloneRemoteParams(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
raw string
|
|
||||||
secrets []string // must not appear in the error message
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "configured remote",
|
|
||||||
raw: "rclone://gdrive,client_secret=topsecret/backups",
|
|
||||||
secrets: []string{"topsecret"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "remote defined inline",
|
|
||||||
raw: "rclone://:s3,access_key_id=KEY,secret_access_key=SECRET/bucket",
|
|
||||||
secrets: []string{"KEY", "SECRET"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "colon in a parameter value",
|
|
||||||
raw: "rclone://gdrive,client_secret=ab:cd/backups",
|
|
||||||
secrets: []string{"cd"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
_, err := storage.ParseStorageURL(tc.raw)
|
|
||||||
if !errors.Is(err, storage.ErrRcloneRemoteParams) {
|
|
||||||
t.Fatalf("ParseStorageURL(%q) error = %v, want %v",
|
|
||||||
tc.raw, err, storage.ErrRcloneRemoteParams)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, secret := range tc.secrets {
|
|
||||||
if strings.Contains(err.Error(), secret) {
|
|
||||||
t.Errorf("error message leaked %q: %v", secret, err.Error())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestParseStorageURLRejectsUndecodableQuery checks that an s3 or rclone
|
|
||||||
// URL whose query cannot be decoded is rejected, not read with that part
|
|
||||||
// of the query left out, and that the error does not echo it.
|
|
||||||
func TestParseStorageURLRejectsUndecodableQuery(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const (
|
|
||||||
key = "AKIAKEY"
|
|
||||||
secret = "topsecret"
|
|
||||||
)
|
|
||||||
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
raw string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "semicolon",
|
|
||||||
raw: "s3://mybucket/prefix?endpoint=https://" + key + ":" + secret +
|
|
||||||
"@minio.example.com:9000;region=us-east-1",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "bad percent escape",
|
|
||||||
raw: "s3://mybucket/prefix?endpoint=https://" + key + ":" + secret +
|
|
||||||
"%zz@minio.example.com:9000",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "rclone semicolon",
|
|
||||||
raw: "rclone://gdrive/backups?token=" + secret + ";x=1",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "rclone bad percent escape",
|
|
||||||
raw: "rclone://gdrive/backups?token=" + secret + "%zz",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
_, err := storage.ParseStorageURL(tc.raw)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatalf("ParseStorageURL(%q) returned no error", tc.raw)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !errors.Is(err, storage.ErrURLBadQuery) {
|
|
||||||
t.Errorf("ParseStorageURL(%q) error = %v, want %v",
|
|
||||||
tc.raw, err, storage.ErrURLBadQuery)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, s := range []string{key, secret, "minio.example.com"} {
|
|
||||||
if strings.Contains(err.Error(), s) {
|
|
||||||
t.Errorf("error message leaked %q: %v", s, err.Error())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestParseStorageURLParseFailureHidesURL checks that when url.Parse
|
// TestParseStorageURLParseFailureHidesURL checks that when url.Parse
|
||||||
// itself fails, the wrapped error carries only the inner cause, not the
|
// itself fails, the wrapped error carries only the inner cause, not the
|
||||||
// *url.Error whose text embeds the raw (possibly credential-bearing) URL.
|
// *url.Error whose text embeds the raw (possibly credential-bearing) URL.
|
||||||
|
|||||||
@@ -104,8 +104,7 @@ func (v *Vaultik) FetchAndDecryptBlob(
|
|||||||
|
|
||||||
// FetchBlob downloads a blob and returns a reader for the encrypted data.
|
// FetchBlob downloads a blob and returns a reader for the encrypted data.
|
||||||
// The reader is closed as soon as ctx is cancelled, which ends a read
|
// The reader is closed as soon as ctx is cancelled, which ends a read
|
||||||
// blocked inside it. A storage error is returned as is: restore and deep
|
// blocked inside it.
|
||||||
// verify name the blob in their own errors.
|
|
||||||
func (v *Vaultik) FetchBlob(
|
func (v *Vaultik) FetchBlob(
|
||||||
ctx context.Context, blobHash string,
|
ctx context.Context, blobHash string,
|
||||||
) (io.ReadCloser, error) {
|
) (io.ReadCloser, error) {
|
||||||
@@ -120,7 +119,7 @@ func (v *Vaultik) FetchBlob(
|
|||||||
|
|
||||||
rc, err := v.Storage.Get(ctx, blobPath)
|
rc, err := v.Storage.Get(ctx, blobPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf("downloading blob %s: %w", shortHash(blobHash), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return newCloseOnCancelReader(ctx, rc), nil
|
return newCloseOnCancelReader(ctx, rc), nil
|
||||||
|
|||||||
@@ -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 once no file still to be restored in this run needs it.
|
// each blob the moment every file that references its chunks has been
|
||||||
// LRU never fires under that configuration; it is kept as a safety net
|
// written. LRU never fires under that configuration; it is kept as a
|
||||||
// for callers that don't manage eviction themselves.
|
// safety net 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 := t.TempDir()
|
storeDir := filepath.Join(tempDir, "remote")
|
||||||
restoreDir := filepath.Join(tempDir, "restored")
|
restoreDir := filepath.Join(tempDir, "restored")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
changedPath := filepath.Join(dataDir, "appended.bin")
|
changedPath := filepath.Join(dataDir, "appended.bin")
|
||||||
@@ -184,7 +184,7 @@ func TestBackupAfterInterruptedRunRestoresChangedFile(t *testing.T) {
|
|||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
dataDir := filepath.Join(tempDir, "src")
|
dataDir := filepath.Join(tempDir, "src")
|
||||||
storeDir := t.TempDir()
|
storeDir := filepath.Join(tempDir, "remote")
|
||||||
restoreDir := filepath.Join(tempDir, "restored")
|
restoreDir := filepath.Join(tempDir, "restored")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
changedPath := filepath.Join(dataDir, "appended.bin")
|
changedPath := filepath.Join(dataDir, "appended.bin")
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -35,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 := t.TempDir()
|
storeDir := filepath.Join(tempDir, "remote")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
chunkSize := int64(64 * 1024)
|
chunkSize := int64(64 * 1024)
|
||||||
@@ -94,7 +93,7 @@ func TestDeepVerifyJSONCountsBlobs(t *testing.T) {
|
|||||||
|
|
||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
storeDir := t.TempDir()
|
storeDir := filepath.Join(t.TempDir(), "remote")
|
||||||
|
|
||||||
cfg, storer, snapshotID, keys := backUpSeveralBlobs(ctx, t, storeDir)
|
cfg, storer, snapshotID, keys := backUpSeveralBlobs(ctx, t, storeDir)
|
||||||
|
|
||||||
@@ -143,15 +142,14 @@ func TestDeepVerifyJSONCountsBlobs(t *testing.T) {
|
|||||||
|
|
||||||
// TestDeepVerifyJSONCountsWrongSizeBlob checks that under --deep --json a
|
// TestDeepVerifyJSONCountsWrongSizeBlob checks that under --deep --json a
|
||||||
// blob of the wrong size is counted in mismatched and fails the run, and
|
// blob of the wrong size is counted in mismatched and fails the run, and
|
||||||
// that the check goes on past it to count a missing blob after it. The
|
// that the check goes on past it to count a missing blob after it.
|
||||||
// store is on disk, so the message must not call it S3.
|
|
||||||
func TestDeepVerifyJSONCountsWrongSizeBlob(t *testing.T) {
|
func TestDeepVerifyJSONCountsWrongSizeBlob(t *testing.T) {
|
||||||
log.Initialize(log.Config{})
|
log.Initialize(log.Config{})
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
storeDir := t.TempDir()
|
storeDir := filepath.Join(t.TempDir(), "remote")
|
||||||
|
|
||||||
cfg, storer, snapshotID, keys := backUpSeveralBlobs(ctx, t, storeDir)
|
cfg, storer, snapshotID, keys := backUpSeveralBlobs(ctx, t, storeDir)
|
||||||
|
|
||||||
@@ -170,20 +168,17 @@ func TestDeepVerifyJSONCountsWrongSizeBlob(t *testing.T) {
|
|||||||
require.Equal(t, 1, got.Mismatched)
|
require.Equal(t, 1, got.Mismatched)
|
||||||
require.Equal(t, 1, got.Missing)
|
require.Equal(t, 1, got.Missing)
|
||||||
require.Zero(t, got.Verified)
|
require.Zero(t, got.Verified)
|
||||||
require.Contains(t, got.ErrorMessage, "with the wrong size")
|
|
||||||
require.NotContains(t, got.ErrorMessage, "S3")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestDeepVerifyStopsOnBlobThatCannotBeChecked checks that a blob that is
|
// TestDeepVerifyStopsOnBlobThatCannotBeChecked checks that a blob that is
|
||||||
// present in storage but cannot be checked is not counted as missing:
|
// present in storage but cannot be checked is not counted as missing:
|
||||||
// deep verify stops and fails, saying it could not check the blob, with
|
// deep verify stops and fails with the storage error.
|
||||||
// the storage error.
|
|
||||||
func TestDeepVerifyStopsOnBlobThatCannotBeChecked(t *testing.T) {
|
func TestDeepVerifyStopsOnBlobThatCannotBeChecked(t *testing.T) {
|
||||||
log.Initialize(log.Config{})
|
log.Initialize(log.Config{})
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
storeDir := t.TempDir()
|
storeDir := filepath.Join(t.TempDir(), "remote")
|
||||||
|
|
||||||
cfg, storer, snapshotID, keys := backUpSeveralBlobs(ctx, t, storeDir)
|
cfg, storer, snapshotID, keys := backUpSeveralBlobs(ctx, t, storeDir)
|
||||||
failing := statFailingStorer{Storer: storer, failKeys: []string{keys[0]}}
|
failing := statFailingStorer{Storer: storer, failKeys: []string{keys[0]}}
|
||||||
@@ -192,8 +187,6 @@ func TestDeepVerifyStopsOnBlobThatCannotBeChecked(t *testing.T) {
|
|||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
require.Equal(t, "failed", got.Status)
|
require.Equal(t, "failed", got.Status)
|
||||||
require.Zero(t, got.Missing)
|
require.Zero(t, got.Missing)
|
||||||
require.Contains(t, got.ErrorMessage,
|
|
||||||
"could not check blob "+path.Base(keys[0]))
|
|
||||||
require.Contains(t, got.ErrorMessage, errStatDenied.Error())
|
require.Contains(t, got.ErrorMessage, errStatDenied.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
package vaultik
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"sneak.berlin/go/vaultik/internal/storage"
|
|
||||||
)
|
|
||||||
|
|
||||||
// destinationMarkerKey is the object every backup writes at the root of
|
|
||||||
// the backup destination store. While the local index holds a snapshot
|
|
||||||
// stored there, a destination without it is treated as not present: an
|
|
||||||
// unplugged volume leaves an empty directory at its mount point, which
|
|
||||||
// would otherwise read as a store whose snapshots are all gone.
|
|
||||||
const destinationMarkerKey = "vaultik-destination.txt"
|
|
||||||
|
|
||||||
// destinationMarkerText is the whole content of the marker. It names no
|
|
||||||
// host, path or time.
|
|
||||||
const destinationMarkerText = "This is a vaultik backup destination store.\n"
|
|
||||||
|
|
||||||
// errDestinationNotPresent is returned when the local index holds a
|
|
||||||
// snapshot stored on the backup destination store and the store has no
|
|
||||||
// marker.
|
|
||||||
var errDestinationNotPresent = errors.New("backup destination store is not present")
|
|
||||||
|
|
||||||
// checkDestinationMarker returns errDestinationNotPresent when the local
|
|
||||||
// index holds a snapshot stored on the backup destination store and the
|
|
||||||
// store has no marker. With no snapshot in the local index, as on a
|
|
||||||
// first backup or on another machine, there is nothing to check; nor
|
|
||||||
// when the local index is bound to a different destination than the
|
|
||||||
// configured one, since its snapshots are stored there.
|
|
||||||
func (v *Vaultik) checkDestinationMarker() error {
|
|
||||||
ids, err := v.localSnapshotIDs()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("listing local snapshots: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(ids) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Config is nil only in tests, which then record no binding (see
|
|
||||||
// EnsureStorageBinding).
|
|
||||||
if v.Config != nil {
|
|
||||||
stored, configured, err := v.storageBinding()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if stored != "" && stored != configured {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = v.Storage.Stat(v.ctx, destinationMarkerKey)
|
|
||||||
if errors.Is(err, storage.ErrNotFound) {
|
|
||||||
return fmt.Errorf("%w: %s has no %s, but the local index holds "+
|
|
||||||
"snapshots stored there; mount the volume if it is removable, "+
|
|
||||||
"or if the destination was emptied on purpose, run "+
|
|
||||||
"'vaultik database delete' and back up again",
|
|
||||||
errDestinationNotPresent, v.Storage.Info().Location,
|
|
||||||
destinationMarkerKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("checking for %s: %w", destinationMarkerKey, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// markDestination checks the backup destination store with
|
|
||||||
// checkDestinationMarker and then writes the marker, so a first backup
|
|
||||||
// marks its destination and a later one writes the same text again.
|
|
||||||
func (v *Vaultik) markDestination() error {
|
|
||||||
err := v.checkDestinationMarker()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = v.Storage.Put(v.ctx, destinationMarkerKey,
|
|
||||||
strings.NewReader(destinationMarkerText))
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("writing %s to the backup destination store: %w",
|
|
||||||
destinationMarkerKey, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,278 +0,0 @@
|
|||||||
package vaultik_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"sneak.berlin/go/vaultik/internal/database"
|
|
||||||
"sneak.berlin/go/vaultik/internal/log"
|
|
||||||
"sneak.berlin/go/vaultik/internal/storage"
|
|
||||||
"sneak.berlin/go/vaultik/internal/vaultik"
|
|
||||||
)
|
|
||||||
|
|
||||||
// These tests cover https://git.eeqj.de/sneak/vaultik/issues/239: a
|
|
||||||
// file:// destination at a volume's mount point leaves an empty directory
|
|
||||||
// behind when the volume is unplugged. While the local index holds
|
|
||||||
// snapshots, a destination without vaultik-destination.txt is not present,
|
|
||||||
// so no command may write to it or conclude from it that the local
|
|
||||||
// snapshots are gone.
|
|
||||||
|
|
||||||
// markerFile is the name of the file a backup writes at the root of the
|
|
||||||
// destination.
|
|
||||||
const markerFile = "vaultik-destination.txt"
|
|
||||||
|
|
||||||
// backUpThenUnplugMountPoint backs up to a file:// destination, then
|
|
||||||
// moves the destination directory away and leaves an empty directory in
|
|
||||||
// its place, as unplugging a volume mounted there would. It returns the
|
|
||||||
// path of that empty directory.
|
|
||||||
func backUpThenUnplugMountPoint(
|
|
||||||
ctx context.Context, t *testing.T,
|
|
||||||
) (*vaultik.Vaultik, *database.Repositories, *bytes.Buffer, string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
storeDir := filepath.Join(t.TempDir(), "mnt")
|
|
||||||
v, repos, out := backUpToFileDestination(ctx, t, storeDir)
|
|
||||||
|
|
||||||
require.NoError(t, os.Rename(storeDir, storeDir+"-unplugged"))
|
|
||||||
require.NoError(t, os.Mkdir(storeDir, 0o750))
|
|
||||||
|
|
||||||
return v, repos, out, storeDir
|
|
||||||
}
|
|
||||||
|
|
||||||
// assertUntouched checks that the local index still holds its one
|
|
||||||
// snapshot and that nothing was written to the empty mount point.
|
|
||||||
func assertUntouched(
|
|
||||||
ctx context.Context, t *testing.T,
|
|
||||||
repos *database.Repositories, storeDir string,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
snapshots, err := repos.Snapshots.ListRecent(ctx, listRecentTestLimit)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Len(t, snapshots, 1, "no local snapshot record may be deleted")
|
|
||||||
|
|
||||||
entries, err := os.ReadDir(storeDir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Empty(t, entries, "nothing may be written to the empty mount point")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestFirstBackupWritesMarker checks that a backup writes the marker
|
|
||||||
// file at the root of the destination.
|
|
||||||
func TestFirstBackupWritesMarker(t *testing.T) {
|
|
||||||
log.Initialize(log.Config{})
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
storeDir := t.TempDir()
|
|
||||||
backUpToFileDestination(context.Background(), t, storeDir)
|
|
||||||
|
|
||||||
assert.FileExists(t, filepath.Join(storeDir, markerFile))
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestBackupRefusesDestinationWithoutMarker checks that snapshot create
|
|
||||||
// --prune writes nothing and drops no local snapshot record when the
|
|
||||||
// destination has no marker.
|
|
||||||
func TestBackupRefusesDestinationWithoutMarker(t *testing.T) {
|
|
||||||
log.Initialize(log.Config{})
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
v, repos, _, storeDir := backUpThenUnplugMountPoint(ctx, t)
|
|
||||||
|
|
||||||
err := v.CreateSnapshot(&vaultik.SnapshotCreateOptions{
|
|
||||||
Cron: true,
|
|
||||||
Prune: true,
|
|
||||||
})
|
|
||||||
require.ErrorContains(t, err, markerFile)
|
|
||||||
|
|
||||||
assertUntouched(ctx, t, repos, storeDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestBackupChecksMarkerBeforeDroppingIncompleteSnapshots checks that
|
|
||||||
// snapshot create writes nothing when the local index holds only an
|
|
||||||
// incomplete snapshot and the destination has no marker. The backup drops
|
|
||||||
// incomplete snapshots before it scans; checked after that, the local
|
|
||||||
// index would hold none and nothing would be checked.
|
|
||||||
func TestBackupChecksMarkerBeforeDroppingIncompleteSnapshots(t *testing.T) {
|
|
||||||
log.Initialize(log.Config{})
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
storeDir := t.TempDir()
|
|
||||||
v, repos := newFileDestinationVaultik(ctx, t, storeDir)
|
|
||||||
|
|
||||||
require.NoError(t, repos.Snapshots.Create(ctx, nil, &database.Snapshot{
|
|
||||||
ID: "host_first_2026-01-02T03:04:05Z",
|
|
||||||
Hostname: "host",
|
|
||||||
StartedAt: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC),
|
|
||||||
}))
|
|
||||||
|
|
||||||
err := backUp(v, "first")
|
|
||||||
require.ErrorContains(t, err, markerFile)
|
|
||||||
|
|
||||||
assertUntouched(ctx, t, repos, storeDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestPruneRefusesDestinationWithoutMarker checks that prune fails and
|
|
||||||
// deletes no local snapshot record when the destination has no marker.
|
|
||||||
func TestPruneRefusesDestinationWithoutMarker(t *testing.T) {
|
|
||||||
log.Initialize(log.Config{})
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
v, repos, _, storeDir := backUpThenUnplugMountPoint(ctx, t)
|
|
||||||
|
|
||||||
err := v.Prune(&vaultik.PruneOptions{Force: true})
|
|
||||||
require.ErrorContains(t, err, markerFile)
|
|
||||||
|
|
||||||
assertUntouched(ctx, t, repos, storeDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestPurgeRefusesDestinationWithoutMarker checks that snapshot purge
|
|
||||||
// fails and deletes no local snapshot record when the destination has no
|
|
||||||
// marker.
|
|
||||||
func TestPurgeRefusesDestinationWithoutMarker(t *testing.T) {
|
|
||||||
log.Initialize(log.Config{})
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
v, repos, _, storeDir := backUpThenUnplugMountPoint(ctx, t)
|
|
||||||
|
|
||||||
err := v.PurgeSnapshotsWithOptions(&vaultik.SnapshotPurgeOptions{
|
|
||||||
KeepLatest: true,
|
|
||||||
Force: true,
|
|
||||||
})
|
|
||||||
require.ErrorContains(t, err, markerFile)
|
|
||||||
|
|
||||||
assertUntouched(ctx, t, repos, storeDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRemoveSnapshotWarnsWhenDestinationHasNoMarker checks that snapshot
|
|
||||||
// remove warns that the metadata could not be removed from a destination
|
|
||||||
// without a marker, instead of reporting that it was.
|
|
||||||
func TestRemoveSnapshotWarnsWhenDestinationHasNoMarker(t *testing.T) {
|
|
||||||
log.Initialize(log.Config{})
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
v, repos, out, _ := backUpThenUnplugMountPoint(ctx, t)
|
|
||||||
|
|
||||||
result, err := v.RemoveSnapshot(localSnapshotID(ctx, t, repos, "first"),
|
|
||||||
&vaultik.RemoveOptions{Force: true})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
assert.False(t, result.RemoteRemoved)
|
|
||||||
assert.Contains(t, out.String(),
|
|
||||||
"Could not remove snapshot metadata from remote")
|
|
||||||
assert.Contains(t, out.String(), markerFile)
|
|
||||||
assert.NotContains(t, out.String(),
|
|
||||||
"Removed snapshot metadata from remote storage")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRemoveSnapshotOnlyOnDestinationFailsWithoutMarker checks that
|
|
||||||
// snapshot remove, for a snapshot that is only on the destination, fails
|
|
||||||
// saying the destination is not present when it has no marker, instead
|
|
||||||
// of saying the snapshot does not exist.
|
|
||||||
func TestRemoveSnapshotOnlyOnDestinationFailsWithoutMarker(t *testing.T) {
|
|
||||||
log.Initialize(log.Config{})
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
storeDir := filepath.Join(t.TempDir(), "mnt")
|
|
||||||
v, repos, _ := backUpToFileDestination(ctx, t, storeDir)
|
|
||||||
require.NoError(t, backUp(v, "second"))
|
|
||||||
|
|
||||||
id := localSnapshotID(ctx, t, repos, "first")
|
|
||||||
_, err := v.RemoveSnapshot(id,
|
|
||||||
&vaultik.RemoveOptions{Force: true, LocalOnly: true})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Unplug the volume: an empty directory stays at its mount point.
|
|
||||||
require.NoError(t, os.Rename(storeDir, storeDir+"-unplugged"))
|
|
||||||
require.NoError(t, os.Mkdir(storeDir, 0o750))
|
|
||||||
|
|
||||||
_, err = v.RemoveSnapshot(id, &vaultik.RemoveOptions{Force: true})
|
|
||||||
require.ErrorContains(t, err, markerFile)
|
|
||||||
require.ErrorContains(t, err, "mount the volume")
|
|
||||||
|
|
||||||
assertUntouched(ctx, t, repos, storeDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestListSnapshotsWarnsWhenDestinationHasNoMarker checks that snapshot
|
|
||||||
// list warns and shows the local index alone when the destination has no
|
|
||||||
// marker, without reporting the local snapshot as missing from it.
|
|
||||||
func TestListSnapshotsWarnsWhenDestinationHasNoMarker(t *testing.T) {
|
|
||||||
log.Initialize(log.Config{})
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
v, repos, out, _ := backUpThenUnplugMountPoint(ctx, t)
|
|
||||||
id := localSnapshotID(ctx, t, repos, "first")
|
|
||||||
|
|
||||||
require.NoError(t, v.ListSnapshots(false))
|
|
||||||
|
|
||||||
assert.Contains(t, out.String(), "Could not list backup destination store")
|
|
||||||
assert.Contains(t, out.String(), markerFile)
|
|
||||||
assert.Contains(t, out.String(), id)
|
|
||||||
assert.NotContains(t, out.String(), "not found in backup destination store")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestListSnapshotsAfterStorageURLChangeToEmptyDirectory checks that
|
|
||||||
// snapshot list, after storage_url is changed to an empty directory,
|
|
||||||
// warns only that the local index is bound to a different destination:
|
|
||||||
// the local snapshots are stored at the old one, so the new one's lack
|
|
||||||
// of a marker says nothing. With --json the local row is not on the new
|
|
||||||
// destination, so remote_present is false, not unknown.
|
|
||||||
func TestListSnapshotsAfterStorageURLChangeToEmptyDirectory(t *testing.T) {
|
|
||||||
log.Initialize(log.Config{})
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
v, repos, out := backUpToFileDestination(ctx, t, t.TempDir())
|
|
||||||
id := localSnapshotID(ctx, t, repos, "first")
|
|
||||||
|
|
||||||
newDir := t.TempDir()
|
|
||||||
store, err := storage.NewFileStorer(newDir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
v.Storage = store
|
|
||||||
v.Config.StorageURL = "file://" + newDir
|
|
||||||
|
|
||||||
require.NoError(t, v.ListSnapshots(false))
|
|
||||||
|
|
||||||
assert.Contains(t, out.String(), id)
|
|
||||||
assert.Contains(t, out.String(),
|
|
||||||
"local index is bound to a different backup destination")
|
|
||||||
assert.NotContains(t, out.String(), "Could not list backup destination store")
|
|
||||||
assert.NotContains(t, out.String(), markerFile)
|
|
||||||
|
|
||||||
out.Reset()
|
|
||||||
require.NoError(t, v.ListSnapshots(true))
|
|
||||||
|
|
||||||
rows := decodeListJSON(t, out.String())
|
|
||||||
require.Len(t, rows, 1)
|
|
||||||
require.NotNil(t, rows[0].RemotePresent)
|
|
||||||
assert.False(t, *rows[0].RemotePresent)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestBackupAfterNukeRemoteSucceeds checks that remote nuke leaves the
|
|
||||||
// destination ready for a new first backup, which writes the marker
|
|
||||||
// again.
|
|
||||||
func TestBackupAfterNukeRemoteSucceeds(t *testing.T) {
|
|
||||||
log.Initialize(log.Config{})
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
storeDir := t.TempDir()
|
|
||||||
v, _, _ := backUpToFileDestination(context.Background(), t, storeDir)
|
|
||||||
|
|
||||||
require.NoError(t, v.NukeRemote(true))
|
|
||||||
assert.NoFileExists(t, filepath.Join(storeDir, markerFile))
|
|
||||||
|
|
||||||
require.NoError(t, backUp(v, "second"))
|
|
||||||
assert.FileExists(t, filepath.Join(storeDir, markerFile))
|
|
||||||
}
|
|
||||||
@@ -192,7 +192,7 @@ func assertRestoreRejectsDamagedBlob(
|
|||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
dataDir := filepath.Join(tempDir, "src")
|
dataDir := filepath.Join(tempDir, "src")
|
||||||
storeDir := t.TempDir()
|
storeDir := filepath.Join(tempDir, "remote")
|
||||||
restoreDir := filepath.Join(tempDir, "restored")
|
restoreDir := filepath.Join(tempDir, "restored")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
@@ -238,7 +238,7 @@ func TestDeepVerifyCatchesLyingBackend(t *testing.T) {
|
|||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
dataDir := filepath.Join(tempDir, "src")
|
dataDir := filepath.Join(tempDir, "src")
|
||||||
storeDir := t.TempDir()
|
storeDir := filepath.Join(tempDir, "remote")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -290,7 +290,7 @@ func TestInterruptedBlobUploadRecordsNoUploadedBlob(t *testing.T) {
|
|||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
dataDir := filepath.Join(tempDir, "src")
|
dataDir := filepath.Join(tempDir, "src")
|
||||||
storeDir := t.TempDir()
|
storeDir := filepath.Join(tempDir, "remote")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -298,6 +298,10 @@ func TestInterruptedBlobUploadRecordsNoUploadedBlob(t *testing.T) {
|
|||||||
|
|
||||||
writeFaultSourceTree(t, fs, dataDir)
|
writeFaultSourceTree(t, fs, dataDir)
|
||||||
|
|
||||||
|
// No upload succeeds, so nothing creates the destination directory.
|
||||||
|
// It must exist for the listing below to show that no blob survived.
|
||||||
|
require.NoError(t, os.Mkdir(storeDir, 0o750))
|
||||||
|
|
||||||
inner, err := storage.NewFileStorer(storeDir)
|
inner, err := storage.NewFileStorer(storeDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@@ -361,7 +365,7 @@ func TestBackupRetryAfterInterruptedUploadIsRestorable(t *testing.T) {
|
|||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
dataDir := filepath.Join(tempDir, "src")
|
dataDir := filepath.Join(tempDir, "src")
|
||||||
storeDir := t.TempDir()
|
storeDir := filepath.Join(tempDir, "remote")
|
||||||
restoreDir := filepath.Join(tempDir, "restored")
|
restoreDir := filepath.Join(tempDir, "restored")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
@@ -425,7 +429,7 @@ func TestBackupSurvivesMetadataExportInterruption(t *testing.T) {
|
|||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
dataDir := filepath.Join(tempDir, "src")
|
dataDir := filepath.Join(tempDir, "src")
|
||||||
storeDir := t.TempDir()
|
storeDir := filepath.Join(tempDir, "remote")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -441,10 +445,6 @@ func TestBackupSurvivesMetadataExportInterruption(t *testing.T) {
|
|||||||
|
|
||||||
repos := database.NewRepositories(db)
|
repos := database.NewRepositories(db)
|
||||||
|
|
||||||
// snapshot create writes the marker; this backup runs its steps one
|
|
||||||
// by one, so the marker is written here.
|
|
||||||
require.NoError(t, inner.Put(ctx, markerFile, strings.NewReader("")))
|
|
||||||
|
|
||||||
// Back up and complete with a working backend.
|
// Back up and complete with a working backend.
|
||||||
sm := newFaultSnapshotManager(fs, inner, cfg, repos)
|
sm := newFaultSnapshotManager(fs, inner, cfg, repos)
|
||||||
scanner := newFaultScanner(fs, inner, cfg, repos)
|
scanner := newFaultScanner(fs, inner, cfg, repos)
|
||||||
@@ -493,14 +493,8 @@ func TestBackupSurvivesMetadataExportInterruption(t *testing.T) {
|
|||||||
"shallow verify must report the missing manifest")
|
"shallow verify must report the missing manifest")
|
||||||
|
|
||||||
// snapshot list must not crash on the partial snapshot.
|
// snapshot list must not crash on the partial snapshot.
|
||||||
var listOut strings.Builder
|
|
||||||
|
|
||||||
reader.UI = ui.NewWithColor(&listOut, false)
|
|
||||||
require.NoError(t, reader.ListSnapshots(false),
|
require.NoError(t, reader.ListSnapshots(false),
|
||||||
"snapshot list must tolerate a partially-exported snapshot")
|
"snapshot list must tolerate a partially-exported snapshot")
|
||||||
assert.NotContains(t, listOut.String(),
|
|
||||||
"Could not list backup destination store",
|
|
||||||
"snapshot list must read the partial snapshot from the destination")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scenario 2, repair: the process dies during the metadata export of a
|
// Scenario 2, repair: the process dies during the metadata export of a
|
||||||
@@ -517,7 +511,7 @@ func TestBackupCompletesOnlyAfterMetadataExport(t *testing.T) {
|
|||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
dataDir := filepath.Join(tempDir, "src")
|
dataDir := filepath.Join(tempDir, "src")
|
||||||
storeDir := t.TempDir()
|
storeDir := filepath.Join(tempDir, "remote")
|
||||||
restoreDir := filepath.Join(tempDir, "restored")
|
restoreDir := filepath.Join(tempDir, "restored")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
@@ -667,18 +661,17 @@ func faultScannerFactory(
|
|||||||
) snapshot.ScannerFactory {
|
) snapshot.ScannerFactory {
|
||||||
return func(params snapshot.ScannerParams) *snapshot.Scanner {
|
return func(params snapshot.ScannerParams) *snapshot.Scanner {
|
||||||
return snapshot.NewScanner(snapshot.ScannerConfig{
|
return snapshot.NewScanner(snapshot.ScannerConfig{
|
||||||
FS: params.Fs,
|
FS: params.Fs,
|
||||||
Storage: storer,
|
Storage: storer,
|
||||||
ChunkSize: faultChunkSize,
|
ChunkSize: faultChunkSize,
|
||||||
MaxBlobSize: faultMaxBlobSize,
|
MaxBlobSize: faultMaxBlobSize,
|
||||||
CompressionLevel: cfg.CompressionLevel,
|
CompressionLevel: cfg.CompressionLevel,
|
||||||
AgeRecipients: cfg.AgeRecipients,
|
AgeRecipients: cfg.AgeRecipients,
|
||||||
Repositories: repos,
|
Repositories: repos,
|
||||||
EnableProgress: params.EnableProgress,
|
EnableProgress: params.EnableProgress,
|
||||||
UI: params.UI,
|
UI: params.UI,
|
||||||
Exclude: params.Exclude,
|
Exclude: params.Exclude,
|
||||||
SkipErrors: params.SkipErrors,
|
SkipErrors: params.SkipErrors,
|
||||||
PreviousSnapshotID: params.PreviousSnapshotID,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -693,7 +686,7 @@ func TestRestoreReportsDiskFull(t *testing.T) {
|
|||||||
osFS := afero.NewOsFs()
|
osFS := afero.NewOsFs()
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
dataDir := filepath.Join(tempDir, "src")
|
dataDir := filepath.Join(tempDir, "src")
|
||||||
storeDir := t.TempDir()
|
storeDir := filepath.Join(tempDir, "remote")
|
||||||
restoreDir := filepath.Join(tempDir, "restored")
|
restoreDir := filepath.Join(tempDir, "restored")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
|
|||||||
@@ -771,7 +771,7 @@ func TestEndToEndFileStorage(t *testing.T) {
|
|||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
dataDir := filepath.Join(tempDir, "source")
|
dataDir := filepath.Join(tempDir, "source")
|
||||||
storeDir := t.TempDir()
|
storeDir := filepath.Join(tempDir, "remote")
|
||||||
restoreDir := filepath.Join(tempDir, "restored")
|
restoreDir := filepath.Join(tempDir, "restored")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
@@ -964,7 +964,7 @@ func TestDedupOnlySnapshotRestores(t *testing.T) {
|
|||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
dataDir := filepath.Join(tempDir, "source")
|
dataDir := filepath.Join(tempDir, "source")
|
||||||
storeDir := t.TempDir()
|
storeDir := filepath.Join(tempDir, "remote")
|
||||||
restoreDir := filepath.Join(tempDir, "restored")
|
restoreDir := filepath.Join(tempDir, "restored")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
|
|||||||
@@ -24,11 +24,13 @@ import (
|
|||||||
// that is not plugged in, cannot be listed. It is not an empty store, so
|
// that is not plugged in, cannot be listed. It is not an empty store, so
|
||||||
// no command may conclude from it that the local snapshots are gone.
|
// no command may conclude from it that the local snapshots are gone.
|
||||||
|
|
||||||
// newFileDestinationVaultik returns a Vaultik that backs up the
|
// backUpToFileDestination backs up the snapshot named "first" to a
|
||||||
// snapshot named "first" to a file:// destination at storeDir.
|
// file:// destination at storeDir, which need not exist yet. Everything
|
||||||
func newFileDestinationVaultik(
|
// the returned Vaultik prints after the backup goes to the returned
|
||||||
|
// buffer.
|
||||||
|
func backUpToFileDestination(
|
||||||
ctx context.Context, t *testing.T, storeDir string,
|
ctx context.Context, t *testing.T, storeDir string,
|
||||||
) (*vaultik.Vaultik, *database.Repositories) {
|
) (*vaultik.Vaultik, *database.Repositories, *bytes.Buffer) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
osFs := afero.NewOsFs()
|
osFs := afero.NewOsFs()
|
||||||
@@ -47,22 +49,8 @@ func newFileDestinationVaultik(
|
|||||||
|
|
||||||
repos := database.NewRepositories(db)
|
repos := database.NewRepositories(db)
|
||||||
cfg := changedFileConfig(dataDir, dbPath)
|
cfg := changedFileConfig(dataDir, dbPath)
|
||||||
|
v := newBackupVaultik(ctx, cfg, store, repos, db, osFs)
|
||||||
|
|
||||||
return newBackupVaultik(ctx, cfg, store, repos, db, osFs), repos
|
|
||||||
}
|
|
||||||
|
|
||||||
// backUpToFileDestination creates the directory storeDir, as a user must
|
|
||||||
// before the first backup, and backs up the snapshot named "first" to a
|
|
||||||
// file:// destination there. Everything the returned Vaultik prints
|
|
||||||
// after the backup goes to the returned buffer.
|
|
||||||
func backUpToFileDestination(
|
|
||||||
ctx context.Context, t *testing.T, storeDir string,
|
|
||||||
) (*vaultik.Vaultik, *database.Repositories, *bytes.Buffer) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
require.NoError(t, os.MkdirAll(storeDir, 0o750))
|
|
||||||
|
|
||||||
v, repos := newFileDestinationVaultik(ctx, t, storeDir)
|
|
||||||
require.NoError(t, backUp(v, "first"))
|
require.NoError(t, backUp(v, "first"))
|
||||||
|
|
||||||
out := &bytes.Buffer{}
|
out := &bytes.Buffer{}
|
||||||
@@ -87,28 +75,21 @@ func backUpThenUnplug(
|
|||||||
return v, repos, out
|
return v, repos, out
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestBackupFailsWhenDestinationDirectoryMissing checks that a backup to
|
// TestFirstBackupCreatesDestinationDirectory checks that a first backup
|
||||||
// a file:// destination whose directory does not exist fails before it
|
// to a destination directory that does not exist yet creates it, and
|
||||||
// records a snapshot, and does not create the directory.
|
// that the destination can be listed afterwards.
|
||||||
func TestBackupFailsWhenDestinationDirectoryMissing(t *testing.T) {
|
func TestFirstBackupCreatesDestinationDirectory(t *testing.T) {
|
||||||
log.Initialize(log.Config{})
|
log.Initialize(log.Config{})
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
volume := filepath.Join(t.TempDir(), "volume")
|
storeDir := filepath.Join(t.TempDir(), "volume", "backup")
|
||||||
v, repos := newFileDestinationVaultik(ctx, t, filepath.Join(volume, "backup"))
|
v, _, out := backUpToFileDestination(ctx, t, storeDir)
|
||||||
|
|
||||||
err := backUp(v, "first")
|
require.NoError(t, v.ListSnapshots(false))
|
||||||
require.ErrorIs(t, err, fs.ErrNotExist)
|
|
||||||
require.ErrorContains(t, err, "create it, or mount the volume it is on")
|
|
||||||
|
|
||||||
_, err = os.Stat(volume)
|
assert.NotContains(t, out.String(), "Could not list backup destination store")
|
||||||
require.ErrorIs(t, err, fs.ErrNotExist,
|
assert.NotContains(t, out.String(), "not found in backup destination store")
|
||||||
"the backup must not create the destination directory")
|
|
||||||
|
|
||||||
snapshots, err := repos.Snapshots.ListRecent(ctx, listRecentTestLimit)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Empty(t, snapshots)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestListSnapshotsWarnsWhenDestinationMissing checks that snapshot list
|
// TestListSnapshotsWarnsWhenDestinationMissing checks that snapshot list
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ func TestNewChunkSizeOfChangedFileStaysFixed(t *testing.T) {
|
|||||||
|
|
||||||
appendRandomBytes(t, fs, files, changedPath, appendedFileSize)
|
appendRandomBytes(t, fs, files, changedPath, appendedFileSize)
|
||||||
|
|
||||||
store, err := storage.NewFileStorer(t.TempDir())
|
store, err := storage.NewFileStorer(filepath.Join(tempDir, "remote"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
db, err := database.New(ctx, dbPath)
|
db, err := database.New(ctx, dbPath)
|
||||||
|
|||||||
@@ -18,8 +18,7 @@ import (
|
|||||||
// TestNukeRemoteLeavesNoFiles checks that remote nuke leaves no file
|
// TestNukeRemoteLeavesNoFiles checks that remote nuke leaves no file
|
||||||
// under a file:// destination, including the `.partial` files uploads
|
// under a file:// destination, including the `.partial` files uploads
|
||||||
// killed part-way leave next to a snapshot's metadata and next to where a
|
// killed part-way leave next to a snapshot's metadata and next to where a
|
||||||
// blob would have been, a snapshot's database whose manifest was never
|
// blob would have been.
|
||||||
// uploaded, and a file under blobs/ whose name is not a blob hash.
|
|
||||||
func TestNukeRemoteLeavesNoFiles(t *testing.T) {
|
func TestNukeRemoteLeavesNoFiles(t *testing.T) {
|
||||||
log.Initialize(log.Config{})
|
log.Initialize(log.Config{})
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -39,9 +38,6 @@ func TestNukeRemoteLeavesNoFiles(t *testing.T) {
|
|||||||
"db.zst.age-123456.partial"),
|
"db.zst.age-123456.partial"),
|
||||||
filepath.Join(storeDir, "blobs", hash[:2], hash[2:4],
|
filepath.Join(storeDir, "blobs", hash[:2], hash[2:4],
|
||||||
hash+"-123456.partial"),
|
hash+"-123456.partial"),
|
||||||
filepath.Join(storeDir, "metadata",
|
|
||||||
snapshot.RemoteSnapshotKey("no-manifest"), "db.zst.age"),
|
|
||||||
filepath.Join(storeDir, "blobs", "0b", "01", ".DS_Store"),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, leftover := range leftovers {
|
for _, leftover := range leftovers {
|
||||||
@@ -104,34 +100,6 @@ func TestNukeRemoteFailsWhenADeleteFails(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestNukeRemoteNamesWhatIsLeft checks that remote nuke, when an object
|
|
||||||
// it does not recognise cannot be deleted, does not report the store
|
|
||||||
// empty but names that object and fails.
|
|
||||||
func TestNukeRemoteNamesWhatIsLeft(t *testing.T) {
|
|
||||||
log.Initialize(log.Config{})
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
storeDir := filepath.Join(t.TempDir(), "store")
|
|
||||||
v, _, out := backUpToFileDestination(ctx, t, storeDir)
|
|
||||||
|
|
||||||
const leftKey = "blobs/0b/01/.DS_Store"
|
|
||||||
|
|
||||||
leftPath := filepath.Join(storeDir, filepath.FromSlash(leftKey))
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Dir(leftPath), 0o750))
|
|
||||||
require.NoError(t, os.WriteFile(leftPath, []byte("finder"), 0o600))
|
|
||||||
|
|
||||||
store := faultstore.New(v.Storage)
|
|
||||||
store.OnDelete = func(key string) bool { return key == leftKey }
|
|
||||||
v.Storage = store
|
|
||||||
|
|
||||||
require.Error(t, v.NukeRemote(true))
|
|
||||||
|
|
||||||
assert.Contains(t, out.String(), leftKey)
|
|
||||||
assert.NotContains(t, out.String(), "now empty")
|
|
||||||
assert.Contains(t, out.String(), "vaultik remote nuke --force")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestNukeRemoteRefusesAnotherDestinationWithoutRetryAdvice checks that
|
// TestNukeRemoteRefusesAnotherDestinationWithoutRetryAdvice checks that
|
||||||
// remote nuke, when the local index belongs to a different destination
|
// remote nuke, when the local index belongs to a different destination
|
||||||
// than the configured one, fails without advising to run nuke again:
|
// than the configured one, fails without advising to run nuke again:
|
||||||
|
|||||||
+21
-78
@@ -22,11 +22,6 @@ var errNukeRequiresForce = errors.New(
|
|||||||
// errBlobDeleteFailed reports unreferenced blobs prune could not delete.
|
// errBlobDeleteFailed reports unreferenced blobs prune could not delete.
|
||||||
var errBlobDeleteFailed = errors.New("blob(s) could not be deleted")
|
var errBlobDeleteFailed = errors.New("blob(s) could not be deleted")
|
||||||
|
|
||||||
// errObjectsLeft reports objects remote nuke still finds on the backup
|
|
||||||
// destination store after deleting.
|
|
||||||
var errObjectsLeft = errors.New(
|
|
||||||
"object(s) still on the backup destination store")
|
|
||||||
|
|
||||||
// nukeCommandHint is the command suggested when a remote nuke could not
|
// nukeCommandHint is the command suggested when a remote nuke could not
|
||||||
// delete everything: running it again deletes what is left, which
|
// delete everything: running it again deletes what is left, which
|
||||||
// `vaultik prune` does not do for snapshot metadata.
|
// `vaultik prune` does not do for snapshot metadata.
|
||||||
@@ -40,12 +35,10 @@ const metadataDirName = "metadata"
|
|||||||
// (blobs/<aa>/<bb>/<hash>).
|
// (blobs/<aa>/<bb>/<hash>).
|
||||||
const blobKeyParts = 4
|
const blobKeyParts = 4
|
||||||
|
|
||||||
// NukeRemote deletes every object under metadata/ and blobs/ in remote
|
// NukeRemote deletes every snapshot's metadata and every blob from remote
|
||||||
// storage, whatever its name, along with any object an upload cut off
|
// storage, along with any object an upload cut off part-way left under a
|
||||||
// part-way left under a temporary `.partial` name, and last the
|
// temporary `.partial` name. After this returns successfully the bucket
|
||||||
// destination marker. It returns successfully only when a listing
|
// prefix is empty and the next backup 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.
|
||||||
@@ -75,8 +68,7 @@ func (v *Vaultik) NukeRemote(force bool) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// emptyDestinationStore does the deleting for NukeRemote and stops at
|
// emptyDestinationStore does the deleting for NukeRemote and stops at
|
||||||
// the first step that fails. It also fails when objects are still
|
// the first step that fails.
|
||||||
// listed at the end, naming each one.
|
|
||||||
func (v *Vaultik) emptyDestinationStore() error {
|
func (v *Vaultik) emptyDestinationStore() error {
|
||||||
v.UI.Beginf("Removing all snapshot metadata from backup destination store.")
|
v.UI.Beginf("Removing all snapshot metadata from backup destination store.")
|
||||||
|
|
||||||
@@ -85,69 +77,25 @@ func (v *Vaultik) emptyDestinationStore() error {
|
|||||||
return fmt.Errorf("removing all snapshots: %w", err)
|
return fmt.Errorf("removing all snapshots: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
v.UI.Beginf("Removing everything still present in backup destination store.")
|
v.UI.Beginf("Removing any blobs still present in backup destination store.")
|
||||||
|
|
||||||
var left []string
|
err = v.PruneBlobs(&PruneOptions{Force: true})
|
||||||
|
|
||||||
for _, prefix := range []string{"metadata/", "blobs/"} {
|
|
||||||
keys, err := v.deleteEverythingUnder(prefix)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
left = append(left, keys...)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, key := range left {
|
|
||||||
v.UI.Warningf("Still on the backup destination store: %s", key)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(left) > 0 {
|
|
||||||
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("deleting %s: %w", destinationMarkerKey, err)
|
return fmt.Errorf("pruning blobs: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The file and rclone listings skip `.partial` objects, so the two
|
||||||
|
// steps above never delete them.
|
||||||
|
for _, prefix := range []string{"metadata/", "blobs/"} {
|
||||||
|
err = v.Storage.DeletePartialUploads(v.ctx, prefix)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("deleting partial uploads: %w", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// deleteEverythingUnder deletes every object under prefix, whatever its
|
|
||||||
// name, and returns the keys a listing afterwards still finds. A delete
|
|
||||||
// that fails is logged and the others still run; that object then
|
|
||||||
// shows up in the listing afterwards.
|
|
||||||
func (v *Vaultik) deleteEverythingUnder(prefix string) ([]string, error) {
|
|
||||||
keys, err := v.Storage.List(v.ctx, prefix)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("listing %s: %w", prefix, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, key := range keys {
|
|
||||||
err = v.Storage.Delete(v.ctx, key)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("Failed to delete object", "key", key, "error", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The file and rclone listings skip `.partial` objects, so the
|
|
||||||
// deletes above never reach them.
|
|
||||||
err = v.Storage.DeletePartialUploads(v.ctx, prefix)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("deleting partial uploads: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
left, err := v.Storage.List(v.ctx, prefix)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("listing %s: %w", prefix, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return left, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// PruneBlobsResult contains the result of a blob prune operation
|
// PruneBlobsResult contains the result of a blob prune operation
|
||||||
//
|
//
|
||||||
//nolint:tagliatelle // snake_case is the established JSON output format
|
//nolint:tagliatelle // snake_case is the established JSON output format
|
||||||
@@ -212,7 +160,7 @@ func (v *Vaultik) PruneBlobs(opts *PruneOptions) error {
|
|||||||
return v.outputPruneBlobsJSON(result)
|
return v.outputPruneBlobsJSON(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
v.UI.Infof("No unreferenced blobs to remove.")
|
v.printlnStdout("No unreferenced blobs to remove.")
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -220,17 +168,12 @@ func (v *Vaultik) PruneBlobs(opts *PruneOptions) error {
|
|||||||
log.Info("Found unreferenced blobs",
|
log.Info("Found unreferenced blobs",
|
||||||
"count", len(unreferencedBlobs), "total_size", ubytes(totalSize))
|
"count", len(unreferencedBlobs), "total_size", ubytes(totalSize))
|
||||||
|
|
||||||
if opts.Force && !opts.JSON {
|
if !opts.JSON {
|
||||||
v.UI.Infof("Found %d unreferenced blob(s) totaling %s.",
|
v.stdoutf("Found %d unreferenced blob(s) totaling %s\n",
|
||||||
len(unreferencedBlobs), ubytes(totalSize))
|
len(unreferencedBlobs), ubytes(totalSize))
|
||||||
}
|
}
|
||||||
|
|
||||||
if !opts.Force && !opts.JSON {
|
if !opts.Force && !opts.JSON {
|
||||||
// The count, the prompt and its answer bypass the UI: they are an
|
|
||||||
// interactive exchange the operator must see, so --quiet does not
|
|
||||||
// silence them.
|
|
||||||
v.stdoutf("Found %d unreferenced blob(s) totaling %s.\n",
|
|
||||||
len(unreferencedBlobs), ubytes(totalSize))
|
|
||||||
v.stdoutf("\nDelete %d unreferenced blob(s)? [y/N] ", len(unreferencedBlobs))
|
v.stdoutf("\nDelete %d unreferenced blob(s)? [y/N] ", len(unreferencedBlobs))
|
||||||
|
|
||||||
var confirm string
|
var confirm string
|
||||||
@@ -257,7 +200,7 @@ func (v *Vaultik) PruneBlobs(opts *PruneOptions) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
v.UI.Infof("Deleted %d blob(s) totaling %s.",
|
v.stdoutf("\nDeleted %d blob(s) totaling %s\n",
|
||||||
result.BlobsDeleted, ubytes(result.BytesFreed))
|
result.BlobsDeleted, ubytes(result.BytesFreed))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,13 @@
|
|||||||
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
|
||||||
@@ -80,59 +76,3 @@ 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).")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ 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/types"
|
"sneak.berlin/go/vaultik/internal/types"
|
||||||
"sneak.berlin/go/vaultik/internal/ui"
|
|
||||||
"sneak.berlin/go/vaultik/internal/vaultik"
|
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -35,9 +34,6 @@ func setupConsistencyTest(
|
|||||||
repos := database.NewRepositories(db)
|
repos := database.NewRepositories(db)
|
||||||
mockStorage := NewMockStorer()
|
mockStorage := NewMockStorer()
|
||||||
|
|
||||||
// A backup wrote to this destination, so it carries the marker.
|
|
||||||
require.NoError(t, mockStorage.Put(ctx, markerFile, strings.NewReader("")))
|
|
||||||
|
|
||||||
for _, id := range snapshotIDs {
|
for _, id := range snapshotIDs {
|
||||||
parts := strings.Split(id, "_")
|
parts := strings.Split(id, "_")
|
||||||
startedAt, err := time.Parse(time.RFC3339, parts[len(parts)-1])
|
startedAt, err := time.Parse(time.RFC3339, parts[len(parts)-1])
|
||||||
@@ -62,16 +58,13 @@ func setupConsistencyTest(
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
stdout := &bytes.Buffer{}
|
|
||||||
|
|
||||||
v := &vaultik.Vaultik{
|
v := &vaultik.Vaultik{
|
||||||
Storage: mockStorage,
|
Storage: mockStorage,
|
||||||
Repositories: repos,
|
Repositories: repos,
|
||||||
DB: db,
|
DB: db,
|
||||||
Stdout: stdout,
|
Stdout: &bytes.Buffer{},
|
||||||
Stderr: &bytes.Buffer{},
|
Stderr: &bytes.Buffer{},
|
||||||
Stdin: &bytes.Buffer{},
|
Stdin: &bytes.Buffer{},
|
||||||
UI: ui.NewWithColor(stdout, false),
|
|
||||||
}
|
}
|
||||||
v.SetContext(ctx)
|
v.SetContext(ctx)
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ 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/types"
|
"sneak.berlin/go/vaultik/internal/types"
|
||||||
"sneak.berlin/go/vaultik/internal/ui"
|
|
||||||
"sneak.berlin/go/vaultik/internal/vaultik"
|
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -45,9 +44,6 @@ func setupPurgeTest(
|
|||||||
repos := database.NewRepositories(db)
|
repos := database.NewRepositories(db)
|
||||||
mockStorage := NewMockStorer()
|
mockStorage := NewMockStorer()
|
||||||
|
|
||||||
// A backup wrote to this destination, so it carries the marker.
|
|
||||||
require.NoError(t, mockStorage.Put(ctx, markerFile, strings.NewReader("")))
|
|
||||||
|
|
||||||
// Insert each snapshot into the DB and create remote metadata stubs.
|
// Insert each snapshot into the DB and create remote metadata stubs.
|
||||||
// Use timestamps parsed from snapshot IDs for realistic ordering.
|
// Use timestamps parsed from snapshot IDs for realistic ordering.
|
||||||
for _, id := range snapshotIDs {
|
for _, id := range snapshotIDs {
|
||||||
@@ -90,7 +86,6 @@ func setupPurgeTest(
|
|||||||
Stdout: stdout,
|
Stdout: stdout,
|
||||||
Stderr: stderr,
|
Stderr: stderr,
|
||||||
Stdin: stdin,
|
Stdin: stdin,
|
||||||
UI: ui.NewWithColor(stdout, false),
|
|
||||||
}
|
}
|
||||||
v.SetContext(ctx)
|
v.SetContext(ctx)
|
||||||
|
|
||||||
@@ -284,8 +279,7 @@ func TestPurgeOlderThan_TooLargeDeletesNothing(t *testing.T) {
|
|||||||
OlderThan: "1000y",
|
OlderThan: "1000y",
|
||||||
Force: true,
|
Force: true,
|
||||||
})
|
})
|
||||||
require.EqualError(t, err,
|
require.Error(t, err)
|
||||||
`invalid duration: "1000y" is longer than about 292 years`)
|
|
||||||
|
|
||||||
err = v.PurgeSnapshotsWithOptions(&vaultik.SnapshotPurgeOptions{
|
err = v.PurgeSnapshotsWithOptions(&vaultik.SnapshotPurgeOptions{
|
||||||
OlderThan: "292y",
|
OlderThan: "292y",
|
||||||
|
|||||||
@@ -1,152 +0,0 @@
|
|||||||
package vaultik_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"sneak.berlin/go/vaultik/internal/log"
|
|
||||||
"sneak.berlin/go/vaultik/internal/ui"
|
|
||||||
"sneak.berlin/go/vaultik/internal/vaultik"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestPurgeSnapshots_QuietSuppressesReport checks that `snapshot purge
|
|
||||||
// --keep-latest --force` under --quiet deletes the older snapshot and
|
|
||||||
// prints nothing.
|
|
||||||
func TestPurgeSnapshots_QuietSuppressesReport(t *testing.T) {
|
|
||||||
log.Initialize(log.Config{})
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
v := setupPurgeTest(t, snapHostname, []string{snapHomeT0, snapHomeT1})
|
|
||||||
|
|
||||||
var out bytes.Buffer
|
|
||||||
|
|
||||||
v.Stdout = &out
|
|
||||||
v.UI = ui.NewWithColor(&out, false)
|
|
||||||
v.UI.SetQuiet(true)
|
|
||||||
|
|
||||||
require.NoError(t, v.PurgeSnapshotsWithOptions(&vaultik.SnapshotPurgeOptions{
|
|
||||||
KeepLatest: true,
|
|
||||||
Force: true,
|
|
||||||
}))
|
|
||||||
|
|
||||||
assert.Equal(t, []string{snapHomeT1}, listRemainingSnapshots(t, v))
|
|
||||||
assert.Empty(t, out.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestPurgeSnapshots_QuietPromptListsSnapshots checks that `snapshot
|
|
||||||
// purge --keep-latest` without --force still lists the snapshots its
|
|
||||||
// prompt asks to delete under --quiet.
|
|
||||||
func TestPurgeSnapshots_QuietPromptListsSnapshots(t *testing.T) {
|
|
||||||
log.Initialize(log.Config{})
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
v := setupPurgeTest(t, snapHostname, []string{snapHomeT0, snapHomeT1})
|
|
||||||
|
|
||||||
var out bytes.Buffer
|
|
||||||
|
|
||||||
v.Stdout = &out
|
|
||||||
v.UI = ui.NewWithColor(&out, false)
|
|
||||||
v.UI.SetQuiet(true)
|
|
||||||
|
|
||||||
// Stdin is empty, so the prompt reads no answer and cancels.
|
|
||||||
require.NoError(t, v.PurgeSnapshotsWithOptions(&vaultik.SnapshotPurgeOptions{
|
|
||||||
KeepLatest: true,
|
|
||||||
}))
|
|
||||||
|
|
||||||
assert.Contains(t, out.String(), snapHomeT0)
|
|
||||||
assert.Contains(t, out.String(), "Delete 1 snapshot(s)? [y/N]")
|
|
||||||
assert.Len(t, listRemainingSnapshots(t, v), 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestPruneBlobs_QuietPromptShowsBlobTotal checks that `prune` without
|
|
||||||
// --force still states the count and total size of the blobs its prompt
|
|
||||||
// asks to delete under --quiet.
|
|
||||||
func TestPruneBlobs_QuietPromptShowsBlobTotal(t *testing.T) {
|
|
||||||
log.Initialize(log.Config{})
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := newListEnv(t)
|
|
||||||
env.v.UI.SetQuiet(true)
|
|
||||||
|
|
||||||
// The store holds no manifest, so nothing references this blob.
|
|
||||||
addBlob(t, env.store.testStorer, testBlobHashA)
|
|
||||||
|
|
||||||
// Stdin is empty, so the prompt reads no answer and cancels.
|
|
||||||
require.NoError(t, env.v.PruneBlobs(&vaultik.PruneOptions{}))
|
|
||||||
|
|
||||||
assert.Contains(t, env.stdout.String(),
|
|
||||||
"Found 1 unreferenced blob(s) totaling")
|
|
||||||
assert.Contains(t, env.stdout.String(),
|
|
||||||
"Delete 1 unreferenced blob(s)? [y/N]")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRemoveSnapshot_QuietSuppressesReport checks that `snapshot remove`
|
|
||||||
// under --quiet prints nothing with --dry-run or with --force, and that
|
|
||||||
// the --json document still prints.
|
|
||||||
func TestRemoveSnapshot_QuietSuppressesReport(t *testing.T) {
|
|
||||||
log.Initialize(log.Config{})
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
newQuietRemover := func() (*vaultik.TestVaultik, *testStorer) {
|
|
||||||
store := newTestStorer()
|
|
||||||
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.Repositories = newLocalIndex(t, "snapshot-001")
|
|
||||||
tv.UI.SetQuiet(true)
|
|
||||||
|
|
||||||
return tv, store
|
|
||||||
}
|
|
||||||
|
|
||||||
tv, store := newQuietRemover()
|
|
||||||
|
|
||||||
_, err := tv.RemoveSnapshot("snapshot-001",
|
|
||||||
&vaultik.RemoveOptions{DryRun: true})
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Empty(t, tv.Stdout.String(),
|
|
||||||
"--dry-run must print nothing under --quiet")
|
|
||||||
|
|
||||||
_, err = tv.RemoveSnapshot("snapshot-001",
|
|
||||||
&vaultik.RemoveOptions{Force: true})
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.False(t, store.hasKey(remoteKeyPath("snapshot-001")))
|
|
||||||
assert.Empty(t, tv.Stdout.String(),
|
|
||||||
"--force must print nothing under --quiet")
|
|
||||||
|
|
||||||
tv, _ = newQuietRemover()
|
|
||||||
|
|
||||||
_, err = tv.RemoveSnapshot("snapshot-001",
|
|
||||||
&vaultik.RemoveOptions{JSON: true})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
var result vaultik.RemoveResult
|
|
||||||
|
|
||||||
require.NoError(t, json.Unmarshal(tv.Stdout.Bytes(), &result),
|
|
||||||
"the --json document must still print under --quiet")
|
|
||||||
assert.True(t, result.RemoteRemoved)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestNukeRemote_QuietSuppressesReport checks that `remote nuke --force`
|
|
||||||
// under --quiet prints nothing while it removes a snapshot's metadata
|
|
||||||
// and its blobs.
|
|
||||||
func TestNukeRemote_QuietSuppressesReport(t *testing.T) {
|
|
||||||
log.Initialize(log.Config{})
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
storeDir := filepath.Join(t.TempDir(), "store")
|
|
||||||
v, _, out := backUpToFileDestination(ctx, t, storeDir)
|
|
||||||
v.UI.SetQuiet(true)
|
|
||||||
|
|
||||||
require.NoError(t, v.NukeRemote(true))
|
|
||||||
assert.Empty(t, out.String())
|
|
||||||
}
|
|
||||||
@@ -17,7 +17,6 @@ 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"
|
||||||
)
|
)
|
||||||
@@ -304,10 +303,6 @@ func TestRemoveSnapshot_DefaultRemovesMetadataNotBlobs(t *testing.T) {
|
|||||||
addBlob(t, store, blobUnique)
|
addBlob(t, store, blobUnique)
|
||||||
addBlob(t, store, blobShared)
|
addBlob(t, store, blobShared)
|
||||||
|
|
||||||
// A backup wrote to this destination, so it carries the marker.
|
|
||||||
require.NoError(t, store.Put(context.Background(), markerFile,
|
|
||||||
bytes.NewReader(nil)))
|
|
||||||
|
|
||||||
tv := vaultik.NewForTesting(store)
|
tv := vaultik.NewForTesting(store)
|
||||||
tv.Repositories = newLocalIndex(t, "snapshot-001", "snapshot-002")
|
tv.Repositories = newLocalIndex(t, "snapshot-001", "snapshot-002")
|
||||||
|
|
||||||
@@ -355,7 +350,7 @@ func TestRemoveSnapshot_DryRun(t *testing.T) {
|
|||||||
assert.True(t, store.hasKey("blobs/aa/aa/"+blobA))
|
assert.True(t, store.hasKey("blobs/aa/aa/"+blobA))
|
||||||
assert.True(t, store.hasKey(remoteKeyPath("snapshot-001")))
|
assert.True(t, store.hasKey(remoteKeyPath("snapshot-001")))
|
||||||
|
|
||||||
assert.Contains(t, tv.Stdout.String(), "Dry run: no changes made.")
|
assert.Contains(t, tv.Stdout.String(), "[Dry run - no changes made]")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRemoveSnapshot_UnknownIDIsError checks that an ID naming no
|
// TestRemoveSnapshot_UnknownIDIsError checks that an ID naming no
|
||||||
@@ -377,10 +372,6 @@ func TestRemoveSnapshot_UnknownIDIsError(t *testing.T) {
|
|||||||
store := newTestStorer()
|
store := newTestStorer()
|
||||||
addManifest(t, store, "snapshot-001", nil)
|
addManifest(t, store, "snapshot-001", nil)
|
||||||
|
|
||||||
// A backup wrote to this destination, so it carries the marker.
|
|
||||||
require.NoError(t, store.Put(context.Background(), markerFile,
|
|
||||||
bytes.NewReader(nil)))
|
|
||||||
|
|
||||||
tv := vaultik.NewForTesting(store)
|
tv := vaultik.NewForTesting(store)
|
||||||
tv.Repositories = newLocalIndex(t, "snapshot-001")
|
tv.Repositories = newLocalIndex(t, "snapshot-001")
|
||||||
|
|
||||||
@@ -401,12 +392,7 @@ func TestRemoveSnapshot_NoMetadataOnDestination(t *testing.T) {
|
|||||||
log.Initialize(log.Config{})
|
log.Initialize(log.Config{})
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
// A backup wrote to this destination, so it carries the marker.
|
tv := vaultik.NewForTesting(newTestStorer())
|
||||||
store := newTestStorer()
|
|
||||||
require.NoError(t, store.Put(context.Background(), markerFile,
|
|
||||||
bytes.NewReader(nil)))
|
|
||||||
|
|
||||||
tv := vaultik.NewForTesting(store)
|
|
||||||
tv.Repositories = newLocalIndex(t, "snapshot-001")
|
tv.Repositories = newLocalIndex(t, "snapshot-001")
|
||||||
|
|
||||||
result, err := tv.RemoveSnapshot("snapshot-001",
|
result, err := tv.RemoveSnapshot("snapshot-001",
|
||||||
@@ -445,40 +431,6 @@ 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()
|
||||||
@@ -552,7 +504,7 @@ func TestRemoveAllSnapshots_DryRun(t *testing.T) {
|
|||||||
|
|
||||||
assert.Equal(t, initialCount, store.keyCount())
|
assert.Equal(t, initialCount, store.keyCount())
|
||||||
|
|
||||||
assert.Contains(t, tv.Stdout.String(), "Dry run: no changes made.")
|
assert.Contains(t, tv.Stdout.String(), "[Dry run - no changes made]")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRemoveAllSnapshots_NoSnapshots(t *testing.T) {
|
func TestRemoveAllSnapshots_NoSnapshots(t *testing.T) {
|
||||||
|
|||||||
+39
-105
@@ -12,7 +12,6 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"filippo.io/age"
|
"filippo.io/age"
|
||||||
@@ -28,7 +27,6 @@ import (
|
|||||||
// Sentinel errors for restore failures.
|
// Sentinel errors for restore failures.
|
||||||
var (
|
var (
|
||||||
errFilesFailedRestore = errors.New("file(s) failed to restore")
|
errFilesFailedRestore = errors.New("file(s) failed to restore")
|
||||||
errPathsNotInSnapshot = errors.New("requested path(s) not in the snapshot")
|
|
||||||
errFilesFailedVerify = errors.New("files failed verification")
|
errFilesFailedVerify = errors.New("files failed verification")
|
||||||
errDecryptionKeyRequired = errors.New(
|
errDecryptionKeyRequired = errors.New(
|
||||||
"decryption key required for restore\n\n" +
|
"decryption key required for restore\n\n" +
|
||||||
@@ -152,20 +150,16 @@ func (v *Vaultik) Restore(opts *RestoreOptions) error {
|
|||||||
repos := database.NewRepositories(tempDB)
|
repos := database.NewRepositories(tempDB)
|
||||||
|
|
||||||
// Step 2: Get list of files to restore
|
// Step 2: Get list of files to restore
|
||||||
files, unmatched, err := v.getFilesToRestore(v.ctx, repos, opts.Paths)
|
files, err := v.getFilesToRestore(v.ctx, repos, opts.Paths)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("getting files to restore: %w", err)
|
return fmt.Errorf("getting files to restore: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Named before anything is restored, so the names are shown even
|
|
||||||
// when the restore stops on an error.
|
|
||||||
unmatchedErr := v.reportUnmatchedPaths(unmatched)
|
|
||||||
|
|
||||||
if len(files) == 0 {
|
if len(files) == 0 {
|
||||||
log.Warn("No files found to restore")
|
log.Warn("No files found to restore")
|
||||||
v.UI.Warningf("No files found to restore.")
|
v.UI.Warningf("No files found to restore.")
|
||||||
|
|
||||||
return unmatchedErr
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Info("Found files to restore", "count", len(files))
|
log.Info("Found files to restore", "count", len(files))
|
||||||
@@ -191,20 +185,6 @@ func (v *Vaultik) Restore(opts *RestoreOptions) error {
|
|||||||
|
|
||||||
result.Duration = time.Since(startTime)
|
result.Duration = time.Since(startTime)
|
||||||
|
|
||||||
return v.finishRestore(repos, files, unmatchedErr, opts, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
// finishRestore prints the restore summary and the post-restore
|
|
||||||
// warnings, runs optional verification, and converts any failed-file
|
|
||||||
// count into an error. Otherwise it returns unmatchedErr, the error for
|
|
||||||
// the requested paths that matched nothing (nil when there are none).
|
|
||||||
func (v *Vaultik) finishRestore(
|
|
||||||
repos *database.Repositories,
|
|
||||||
files []*database.File,
|
|
||||||
unmatchedErr error,
|
|
||||||
opts *RestoreOptions,
|
|
||||||
result *RestoreResult,
|
|
||||||
) error {
|
|
||||||
log.Info("Restore complete",
|
log.Info("Restore complete",
|
||||||
"files_restored", result.FilesRestored,
|
"files_restored", result.FilesRestored,
|
||||||
"bytes_restored", ubytes(result.BytesRestored),
|
"bytes_restored", ubytes(result.BytesRestored),
|
||||||
@@ -219,8 +199,21 @@ func (v *Vaultik) finishRestore(
|
|||||||
v.UI.Duration(result.Duration),
|
v.UI.Duration(result.Duration),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return v.finishRestore(repos, files, opts, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// finishRestore emits the post-restore warnings, runs optional
|
||||||
|
// verification, and converts any failed-file count into an error.
|
||||||
|
func (v *Vaultik) finishRestore(
|
||||||
|
repos *database.Repositories,
|
||||||
|
files []*database.File,
|
||||||
|
opts *RestoreOptions,
|
||||||
|
result *RestoreResult,
|
||||||
|
) error {
|
||||||
if os.Geteuid() != 0 {
|
if os.Geteuid() != 0 {
|
||||||
v.warnIfOwnershipNotPreserved(files, opts.TargetDir)
|
v.UI.Warningf("Restore did not preserve file ownership: chown(2) " +
|
||||||
|
"requires root. Re-run as root (e.g. with sudo) if you need " +
|
||||||
|
"original UID/GID preserved.")
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.FilesFailed > 0 {
|
if result.FilesFailed > 0 {
|
||||||
@@ -243,60 +236,7 @@ func (v *Vaultik) finishRestore(
|
|||||||
return fmt.Errorf("%d %w", result.FilesFailed, errFilesFailedRestore)
|
return fmt.Errorf("%d %w", result.FilesFailed, errFilesFailedRestore)
|
||||||
}
|
}
|
||||||
|
|
||||||
return unmatchedErr
|
return nil
|
||||||
}
|
|
||||||
|
|
||||||
// warnIfOwnershipNotPreserved warns once if some entry restored under
|
|
||||||
// targetDir has another owner or group on disk than the snapshot
|
|
||||||
// records. It is meant for a restore by a normal user, which changes no
|
|
||||||
// owner or group: a new entry is owned by that user, and its group is
|
|
||||||
// that user's, or the group of the directory it is created in on macOS
|
|
||||||
// and in a setgid directory on Linux. An entry that is not on disk, or
|
|
||||||
// whose filesystem reports no owner, is skipped.
|
|
||||||
func (v *Vaultik) warnIfOwnershipNotPreserved(
|
|
||||||
files []*database.File, targetDir string,
|
|
||||||
) {
|
|
||||||
for _, file := range files {
|
|
||||||
targetPath, err := containedRestorePath(
|
|
||||||
v.Fs, targetDir, file.Path.String())
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
info, err := lstatIfPossible(v.Fs, targetPath)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
owner, ok := info.Sys().(*syscall.Stat_t)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if owner.Uid != file.UID || owner.Gid != file.GID {
|
|
||||||
v.UI.Warningf("Restore did not preserve file ownership: chown(2) " +
|
|
||||||
"requires root. Re-run as root (e.g. with sudo) if you need " +
|
|
||||||
"original UID/GID preserved.")
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// reportUnmatchedPaths warns, by name, about each requested path that
|
|
||||||
// matched nothing in the snapshot, and returns an error counting them,
|
|
||||||
// or nil when there are none.
|
|
||||||
func (v *Vaultik) reportUnmatchedPaths(unmatched []string) error {
|
|
||||||
if len(unmatched) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, path := range unmatched {
|
|
||||||
log.Warn("Requested path matches nothing in the snapshot", "path", path)
|
|
||||||
v.UI.Warningf("%s matches nothing in the snapshot.", v.UI.Path(path))
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Errorf("%d %w", len(unmatched), errPathsNotInSnapshot)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// restoreIdentities parses the configured age secret key once into every
|
// restoreIdentities parses the configured age secret key once into every
|
||||||
@@ -359,6 +299,12 @@ 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
|
||||||
@@ -370,12 +316,6 @@ 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 {
|
||||||
@@ -463,8 +403,10 @@ func (v *Vaultik) runRestoreLoop(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drop the file from the plan's indexes so future picks ignore
|
// Record the file as restored so the sweeper can free blobs
|
||||||
// it and the sweeper can free blobs no pending file needs.
|
// once all referencing files are done, and drop it from the
|
||||||
|
// plan's indexes so future picks ignore it.
|
||||||
|
session.sweeper.fileRestored(fileID.String())
|
||||||
plan.finishFile(fileID)
|
plan.finishFile(fileID)
|
||||||
|
|
||||||
processed++
|
processed++
|
||||||
@@ -898,34 +840,27 @@ func (v *Vaultik) materializeSnapshotDB(
|
|||||||
return db, tempDir, nil
|
return db, tempDir, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getFilesToRestore returns the list of files to restore based on path
|
// getFilesToRestore returns the list of files to restore based on path filters
|
||||||
// filters, and the filters, as given, that matched no file.
|
|
||||||
func (v *Vaultik) getFilesToRestore(
|
func (v *Vaultik) getFilesToRestore(
|
||||||
ctx context.Context, repos *database.Repositories, pathFilters []string,
|
ctx context.Context, repos *database.Repositories, pathFilters []string,
|
||||||
) ([]*database.File, []string, error) {
|
) ([]*database.File, error) {
|
||||||
// If no filters, get all files
|
// If no filters, get all files
|
||||||
if len(pathFilters) == 0 {
|
if len(pathFilters) == 0 {
|
||||||
files, err := repos.Files.ListAll(ctx)
|
return repos.Files.ListAll(ctx)
|
||||||
|
|
||||||
return files, nil, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get files matching the path filters
|
// Get files matching the path filters
|
||||||
var (
|
var result []*database.File
|
||||||
result []*database.File
|
|
||||||
unmatched []string
|
|
||||||
)
|
|
||||||
|
|
||||||
seen := make(map[string]bool)
|
seen := make(map[string]bool)
|
||||||
|
|
||||||
for _, filter := range pathFilters {
|
for _, filter := range pathFilters {
|
||||||
files, err := repos.Files.ListUnderPath(ctx, filepath.Clean(filter))
|
// Normalize the filter path
|
||||||
if err != nil {
|
filter = filepath.Clean(filter)
|
||||||
return nil, nil, fmt.Errorf("listing files under %s: %w", filter, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(files) == 0 {
|
files, err := repos.Files.ListUnderPath(ctx, filter)
|
||||||
unmatched = append(unmatched, filter)
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("listing files under %s: %w", filter, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, file := range files {
|
for _, file := range files {
|
||||||
@@ -936,7 +871,7 @@ func (v *Vaultik) getFilesToRestore(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result, unmatched, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildChunkToBlobMap creates a mapping from chunk hash to blob information
|
// buildChunkToBlobMap creates a mapping from chunk hash to blob information
|
||||||
@@ -1001,9 +936,8 @@ type restoreSession struct {
|
|||||||
// root can chown a file to an arbitrary UID/GID — non-root chown
|
// root can chown a file to an arbitrary UID/GID — non-root chown
|
||||||
// always fails with EPERM. Attempting it anyway produces N
|
// always fails with EPERM. Attempting it anyway produces N
|
||||||
// guaranteed-failed syscalls + N noisy debug lines, so we skip
|
// guaranteed-failed syscalls + N noisy debug lines, so we skip
|
||||||
// the call entirely as non-root; warnIfOwnershipNotPreserved warns
|
// the call entirely as non-root and emit one warning at the end
|
||||||
// once at the end of the restore if that left any owner or group
|
// of the restore explaining that ownership was not preserved.
|
||||||
// other than recorded.
|
|
||||||
runningAsRoot bool
|
runningAsRoot bool
|
||||||
// directories holds every restored directory, for
|
// directories holds every restored directory, for
|
||||||
// applyDirectoryMetadata to finish after the restore loop.
|
// applyDirectoryMetadata to finish after the restore loop.
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ func TestRestoreOnAnotherMachine(t *testing.T) {
|
|||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
dataDir := filepath.Join(tempDir, "source")
|
dataDir := filepath.Join(tempDir, "source")
|
||||||
storeDir := t.TempDir()
|
storeDir := filepath.Join(tempDir, "remote")
|
||||||
restoreDir := filepath.Join(tempDir, "restored")
|
restoreDir := filepath.Join(tempDir, "restored")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ func TestRestoreCleansTempDirOnInterrupt(t *testing.T) {
|
|||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
|
|
||||||
dataDir := filepath.Join(root, "source")
|
dataDir := filepath.Join(root, "source")
|
||||||
storeDir := t.TempDir()
|
storeDir := filepath.Join(root, "remote")
|
||||||
restoreDir := filepath.Join(root, "restored")
|
restoreDir := filepath.Join(root, "restored")
|
||||||
dbPath := filepath.Join(root, "index.sqlite")
|
dbPath := filepath.Join(root, "index.sqlite")
|
||||||
|
|
||||||
@@ -74,8 +74,7 @@ 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,10 +63,6 @@ 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
|
||||||
@@ -77,6 +73,7 @@ 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
|
||||||
@@ -86,7 +83,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, localitySourceBytes),
|
data: randomBytes(t, srcBytes),
|
||||||
}
|
}
|
||||||
sources[i] = s
|
sources[i] = s
|
||||||
require.NoError(t, afero.WriteFile(
|
require.NoError(t, afero.WriteFile(
|
||||||
@@ -118,20 +115,19 @@ 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 of srcBytes pack into 3 blobs. The index database is closed
|
// files pack into 3 blobs. The index database is closed before returning
|
||||||
// before returning so the restore half runs from remote bytes only.
|
// 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, srcBytes int64,
|
dataDir, storeDir, dbPath string,
|
||||||
) (*config.Config, *storage.FileStorer, string) {
|
) (*config.Config, *storage.FileStorer, string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
// An average chunk size of four times srcBytes makes the minimum
|
// chunkSize avg = 4 MiB makes minSize = 1 MiB, so a 1 MiB file
|
||||||
// chunk srcBytes, so each source file becomes one chunk. A blob limit
|
// becomes one chunk. maxBlobSize = 5 MiB packs exactly 5 chunks
|
||||||
// of five times srcBytes packs exactly 5 chunks per blob, yielding 3
|
// per blob, yielding 3 blobs from 15 source files.
|
||||||
// blobs from 15 source files.
|
chunkSize := int64(4 * 1024 * 1024)
|
||||||
chunkSize := 4 * srcBytes
|
maxBlobSize := int64(5 * 1024 * 1024)
|
||||||
maxBlobSize := 5 * srcBytes
|
|
||||||
|
|
||||||
storer, err := storage.NewFileStorer(storeDir)
|
storer, err := storage.NewFileStorer(storeDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -201,7 +197,7 @@ func TestRestoreLocalityAndReadAt(t *testing.T) {
|
|||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
dataDir := filepath.Join(tempDir, "source")
|
dataDir := filepath.Join(tempDir, "source")
|
||||||
storeDir := t.TempDir()
|
storeDir := filepath.Join(tempDir, "remote")
|
||||||
restoreDir := filepath.Join(tempDir, "restored")
|
restoreDir := filepath.Join(tempDir, "restored")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
@@ -212,7 +208,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, localitySourceBytes)
|
ctx, t, fs, dataDir, storeDir, dbPath)
|
||||||
|
|
||||||
// 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)
|
||||||
|
|||||||
@@ -1,131 +0,0 @@
|
|||||||
package vaultik //nolint:testpackage // calls the unexported ownership check
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"syscall"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/spf13/afero"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"sneak.berlin/go/vaultik/internal/database"
|
|
||||||
"sneak.berlin/go/vaultik/internal/log"
|
|
||||||
"sneak.berlin/go/vaultik/internal/ui"
|
|
||||||
)
|
|
||||||
|
|
||||||
// These tests cover https://git.eeqj.de/sneak/vaultik/issues/316: a
|
|
||||||
// restore by a normal user warns that ownership was not preserved only
|
|
||||||
// when some restored entry has another owner or group on disk than the
|
|
||||||
// snapshot records.
|
|
||||||
|
|
||||||
const ownershipWarning = "did not preserve file ownership"
|
|
||||||
|
|
||||||
// TestOwnershipWarningSilentWhenEntriesMatch checks that a restore whose
|
|
||||||
// entries all end up with their recorded owner and group prints no
|
|
||||||
// warning.
|
|
||||||
func TestOwnershipWarningSilentWhenEntriesMatch(t *testing.T) {
|
|
||||||
log.Initialize(log.Config{})
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
targetDir := t.TempDir()
|
|
||||||
owner := ownerOf(t, targetDir)
|
|
||||||
|
|
||||||
out := restoreAsNormalUser(t, targetDir, []*database.File{
|
|
||||||
{Path: "/a", Mode: plainFileMode, UID: owner.Uid, GID: owner.Gid},
|
|
||||||
{Path: "/b", Mode: plainFileMode, UID: owner.Uid, GID: owner.Gid},
|
|
||||||
})
|
|
||||||
|
|
||||||
assert.Empty(t, out)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestOwnershipWarningWhenOwnerDiffers checks that the warning is printed
|
|
||||||
// when an entry recorded with another owner is restored owned by the
|
|
||||||
// restoring user, even though its group matches the recorded one.
|
|
||||||
func TestOwnershipWarningWhenOwnerDiffers(t *testing.T) {
|
|
||||||
log.Initialize(log.Config{})
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
targetDir := t.TempDir()
|
|
||||||
owner := ownerOf(t, targetDir)
|
|
||||||
|
|
||||||
out := restoreAsNormalUser(t, targetDir, []*database.File{
|
|
||||||
{Path: "/a", Mode: plainFileMode, UID: otherOwnerID, GID: owner.Gid},
|
|
||||||
})
|
|
||||||
|
|
||||||
assert.Equal(t, 1, strings.Count(out, ownershipWarning), out)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestOwnershipWarningWhenDirectoryGivesItsGroup checks that the warning
|
|
||||||
// is printed, once, when entries recorded with the restoring user's
|
|
||||||
// owner and group take another group from the setgid directory they are
|
|
||||||
// restored into, as every new entry takes its directory's group on
|
|
||||||
// macOS.
|
|
||||||
func TestOwnershipWarningWhenDirectoryGivesItsGroup(t *testing.T) {
|
|
||||||
log.Initialize(log.Config{})
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
if os.Geteuid() != 0 {
|
|
||||||
t.Skip("giving a directory to another group needs root")
|
|
||||||
}
|
|
||||||
|
|
||||||
targetDir := t.TempDir()
|
|
||||||
user := ownerOf(t, targetDir)
|
|
||||||
|
|
||||||
require.NoError(t, os.Chown(targetDir, -1, int(otherOwnerID)))
|
|
||||||
require.NoError(t, os.Chmod(targetDir, os.ModeSetgid|writableTestMode))
|
|
||||||
|
|
||||||
out := restoreAsNormalUser(t, targetDir, []*database.File{
|
|
||||||
{Path: "/a", Mode: plainFileMode, UID: user.Uid, GID: user.Gid},
|
|
||||||
{Path: "/b", Mode: plainFileMode, UID: user.Uid, GID: user.Gid},
|
|
||||||
})
|
|
||||||
|
|
||||||
assert.Equal(t, 1, strings.Count(out, ownershipWarning), out)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ownerOf returns the owner and group path has on disk.
|
|
||||||
func ownerOf(t *testing.T, path string) *syscall.Stat_t {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
info, err := os.Lstat(path)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
owner, ok := info.Sys().(*syscall.Stat_t)
|
|
||||||
require.True(t, ok)
|
|
||||||
|
|
||||||
return owner
|
|
||||||
}
|
|
||||||
|
|
||||||
// restoreAsNormalUser restores rows, all empty regular files, into
|
|
||||||
// targetDir without changing any owner or group, as restore does for a
|
|
||||||
// normal user, and returns what warnIfOwnershipNotPreserved prints.
|
|
||||||
func restoreAsNormalUser(
|
|
||||||
t *testing.T, targetDir string, rows []*database.File,
|
|
||||||
) string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
rows, repos := makeFiles(ctx, t, rows)
|
|
||||||
|
|
||||||
var out bytes.Buffer
|
|
||||||
|
|
||||||
v := newContainmentVaultik(ctx, afero.NewOsFs())
|
|
||||||
v.UI = ui.NewWithColor(&out, false)
|
|
||||||
|
|
||||||
session := &restoreSession{
|
|
||||||
v: v,
|
|
||||||
ctx: ctx,
|
|
||||||
repos: repos,
|
|
||||||
opts: &RestoreOptions{TargetDir: targetDir},
|
|
||||||
result: &RestoreResult{},
|
|
||||||
}
|
|
||||||
for _, row := range rows {
|
|
||||||
require.NoError(t, session.restoreFile(row))
|
|
||||||
}
|
|
||||||
|
|
||||||
v.warnIfOwnershipNotPreserved(rows, targetDir)
|
|
||||||
|
|
||||||
return out.String()
|
|
||||||
}
|
|
||||||
@@ -107,7 +107,7 @@ func backupOneFile(
|
|||||||
require.NoError(t, afero.WriteFile(fs, srcPath, content, mode))
|
require.NoError(t, afero.WriteFile(fs, srcPath, content, mode))
|
||||||
require.NoError(t, fs.Chmod(srcPath, mode))
|
require.NoError(t, fs.Chmod(srcPath, mode))
|
||||||
|
|
||||||
storeDir := t.TempDir()
|
storeDir := filepath.Join(tempDir, "remote")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
storer, err := storage.NewFileStorer(storeDir)
|
storer, err := storage.NewFileStorer(storeDir)
|
||||||
|
|||||||
@@ -31,8 +31,7 @@ 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, and by the sweeper to tell
|
// become ready" without a global scan.
|
||||||
// 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{}
|
||||||
@@ -238,12 +237,6 @@ 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,6 +63,15 @@ 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.
|
||||||
@@ -74,15 +83,6 @@ 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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -231,8 +231,7 @@ func (f trailingByteFile) Close() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestRestoreMissingBlobAbortsWithoutSkipErrors checks that a deleted blob
|
// TestRestoreMissingBlobAbortsWithoutSkipErrors checks that a deleted blob
|
||||||
// still ends the restore with an error when SkipErrors is not set, and
|
// still ends the restore with an error when SkipErrors is not set.
|
||||||
// that the error names the blob once.
|
|
||||||
func TestRestoreMissingBlobAbortsWithoutSkipErrors(t *testing.T) {
|
func TestRestoreMissingBlobAbortsWithoutSkipErrors(t *testing.T) {
|
||||||
log.Initialize(log.Config{})
|
log.Initialize(log.Config{})
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -248,8 +247,6 @@ func TestRestoreMissingBlobAbortsWithoutSkipErrors(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
require.ErrorIs(t, err, storage.ErrNotFound)
|
require.ErrorIs(t, err, storage.ErrNotFound)
|
||||||
assert.Equal(t, 1, strings.Count(err.Error(), "downloading blob"),
|
|
||||||
err.Error())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRestoreChunkInNoBlobAbortsWithoutSkipErrors checks that a chunk held
|
// TestRestoreChunkInNoBlobAbortsWithoutSkipErrors checks that a chunk held
|
||||||
@@ -348,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(t.TempDir())
|
storer, err := storage.NewFileStorer(filepath.Join(tempDir, "remote"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
db, err := database.New(ctx, dbPath)
|
db, err := database.New(ctx, dbPath)
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ func TestRestoreAndDeepVerifyRejectSwappedDatabase(t *testing.T) {
|
|||||||
|
|
||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
storeDir := t.TempDir()
|
storeDir := filepath.Join(tempDir, "remote")
|
||||||
|
|
||||||
chunkSize := int64(64 * 1024)
|
chunkSize := int64(64 * 1024)
|
||||||
|
|
||||||
@@ -100,7 +100,7 @@ func TestDeepVerifyRejectsSwappedDatabaseWithEmptyManifest(t *testing.T) {
|
|||||||
|
|
||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
storeDir := t.TempDir()
|
storeDir := filepath.Join(tempDir, "remote")
|
||||||
|
|
||||||
chunkSize := int64(64 * 1024)
|
chunkSize := int64(64 * 1024)
|
||||||
|
|
||||||
|
|||||||
@@ -1,30 +1,46 @@
|
|||||||
package vaultik
|
package vaultik
|
||||||
|
|
||||||
// restoreSweeper frees cached blobs once no file still to be restored in
|
import (
|
||||||
// this run needs them. It works as follows:
|
"context"
|
||||||
|
"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. After each chunk is restored, chunkRestored accumulates a running
|
// 1. Callers add a file's ID to an in-memory restored set via
|
||||||
|
// fileRestored once the file is fully written to disk.
|
||||||
|
// 2. After each chunk is restored, chunkRestored accumulates a running
|
||||||
// byte count.
|
// byte count.
|
||||||
// 2. When the accumulator crosses a threshold (one hundredth of the
|
// 3. 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 and deletes each blob that the restore plan no longer
|
// the cache. For each cached blob it asks the DB which files
|
||||||
// needs.
|
// reference any chunk in that blob, then compares that list against
|
||||||
|
// the in-memory restored set. If any referencing file is missing
|
||||||
|
// from the set the blob is kept; otherwise the cache entry is
|
||||||
|
// deleted.
|
||||||
//
|
//
|
||||||
// The plan drops a file once it is restored or has failed, and holds
|
// All DB reads happen against the snapshot's temporary metadata DB,
|
||||||
// only the files this run restores, so files outside the requested paths
|
// which is local, indexed, and not under contention — the queries are
|
||||||
// never keep a blob.
|
// cheap and run at most once per blob per sweep interval.
|
||||||
type restoreSweeper struct {
|
type restoreSweeper struct {
|
||||||
plan *restorePlan
|
ctx context.Context //nolint:containedctx // ctx bound at construction by design
|
||||||
|
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(
|
||||||
plan *restorePlan,
|
ctx context.Context,
|
||||||
|
repos *database.Repositories,
|
||||||
cache *blobDiskCache,
|
cache *blobDiskCache,
|
||||||
threshold int64,
|
threshold int64,
|
||||||
) *restoreSweeper {
|
) *restoreSweeper {
|
||||||
@@ -33,12 +49,21 @@ func newRestoreSweeper(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &restoreSweeper{
|
return &restoreSweeper{
|
||||||
plan: plan,
|
ctx: ctx,
|
||||||
|
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) {
|
||||||
@@ -51,11 +76,60 @@ func (s *restoreSweeper) chunkRestored(n int64) {
|
|||||||
s.sweep()
|
s.sweep()
|
||||||
}
|
}
|
||||||
|
|
||||||
// sweep deletes every cached blob that no pending file references.
|
// sweep deletes any cached blob whose chunks are no longer referenced
|
||||||
|
// 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() {
|
||||||
if !s.plan.pendingFileUsesBlob(blobHash) {
|
needed, err := s.blobStillNeeded(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 := t.TempDir()
|
storeDir := filepath.Join(tempDir, "remote")
|
||||||
restoreDir := filepath.Join(tempDir, "restored")
|
restoreDir := filepath.Join(tempDir, "restored")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
|
|||||||
@@ -1,163 +0,0 @@
|
|||||||
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 := t.TempDir()
|
|
||||||
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 := t.TempDir()
|
storeDir := filepath.Join(tempDir, "remote")
|
||||||
restoreDir := filepath.Join(tempDir, "restored")
|
restoreDir := filepath.Join(tempDir, "restored")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
|
|||||||
@@ -1,202 +0,0 @@
|
|||||||
package vaultik_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"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/database"
|
|
||||||
"sneak.berlin/go/vaultik/internal/log"
|
|
||||||
"sneak.berlin/go/vaultik/internal/storage"
|
|
||||||
"sneak.berlin/go/vaultik/internal/storage/faultstore"
|
|
||||||
"sneak.berlin/go/vaultik/internal/ui"
|
|
||||||
"sneak.berlin/go/vaultik/internal/vaultik"
|
|
||||||
)
|
|
||||||
|
|
||||||
// These tests cover https://git.eeqj.de/sneak/vaultik/issues/309: a path
|
|
||||||
// given to restore that matches nothing in the snapshot is named in a
|
|
||||||
// warning and makes the restore fail, after the paths that do match are
|
|
||||||
// restored.
|
|
||||||
|
|
||||||
func TestRestoreNamesUnmatchedPathAndRestoresTheMatchingOne(t *testing.T) {
|
|
||||||
log.Initialize(log.Config{})
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewOsFs()
|
|
||||||
tempDir := t.TempDir()
|
|
||||||
dataDir := filepath.Join(tempDir, "src")
|
|
||||||
storeDir := t.TempDir()
|
|
||||||
restoreDir := filepath.Join(tempDir, "restored")
|
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
cfg := faultTestConfig()
|
|
||||||
files := writeFaultSourceTree(t, fs, dataDir)
|
|
||||||
|
|
||||||
store, err := storage.NewFileStorer(storeDir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
db, err := database.New(ctx, dbPath)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
id := fullFaultBackup(ctx, t, fs, store, cfg,
|
|
||||||
database.NewRepositories(db), dataDir, dbPath, "unmatched")
|
|
||||||
require.NoError(t, db.Close())
|
|
||||||
|
|
||||||
matched := filepath.Join(dataDir, "small.txt")
|
|
||||||
unmatched := filepath.Join(dataDir, "typo.txt")
|
|
||||||
|
|
||||||
var out bytes.Buffer
|
|
||||||
|
|
||||||
v := newReaderVaultik(ctx, cfg, store, nil, fs)
|
|
||||||
v.UI = ui.NewWithColor(&out, false)
|
|
||||||
|
|
||||||
err = v.Restore(&vaultik.RestoreOptions{
|
|
||||||
SnapshotID: id,
|
|
||||||
TargetDir: restoreDir,
|
|
||||||
Paths: []string{matched, unmatched},
|
|
||||||
})
|
|
||||||
|
|
||||||
require.Error(t, err, "a path that matches nothing must fail the restore")
|
|
||||||
assert.Contains(t, out.String(), unmatched,
|
|
||||||
"the path that matches nothing must be named")
|
|
||||||
assert.NotContains(t, out.String(), matched,
|
|
||||||
"the path that matches must not be reported")
|
|
||||||
|
|
||||||
got, err := afero.ReadFile(fs, filepath.Join(restoreDir, matched))
|
|
||||||
require.NoError(t, err, "the path that matches must still be restored")
|
|
||||||
assert.Equal(t, files[matched], got)
|
|
||||||
}
|
|
||||||
|
|
||||||
// When no requested path matches, nothing is restored and each path is
|
|
||||||
// named. One path is relative. The other goes through the symlink that
|
|
||||||
// is the snapshot's configured path, while the entries are stored under
|
|
||||||
// the directory the symlink points to.
|
|
||||||
func TestRestoreNamesEveryPathWhenNothingMatches(t *testing.T) {
|
|
||||||
log.Initialize(log.Config{})
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const snapshotName = "linked"
|
|
||||||
|
|
||||||
fs := afero.NewOsFs()
|
|
||||||
tempDir := t.TempDir()
|
|
||||||
dataDir := filepath.Join(tempDir, "src")
|
|
||||||
linkDir := filepath.Join(tempDir, "srclink")
|
|
||||||
storeDir := t.TempDir()
|
|
||||||
restoreDir := filepath.Join(tempDir, "restored")
|
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
writeFaultSourceTree(t, fs, dataDir)
|
|
||||||
require.NoError(t, os.Symlink(dataDir, linkDir))
|
|
||||||
|
|
||||||
cfg := faultTestConfig()
|
|
||||||
cfg.IndexPath = dbPath
|
|
||||||
cfg.ChunkSize = config.Size(faultChunkSize)
|
|
||||||
cfg.Snapshots = map[string]config.SnapshotConfig{
|
|
||||||
snapshotName: {Paths: []string{linkDir}},
|
|
||||||
}
|
|
||||||
|
|
||||||
store, err := storage.NewFileStorer(storeDir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
db, err := database.New(ctx, dbPath)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
repos := database.NewRepositories(db)
|
|
||||||
v := newBackupVaultik(ctx, cfg, store, repos, db, fs)
|
|
||||||
|
|
||||||
require.NoError(t, backUp(v, snapshotName))
|
|
||||||
|
|
||||||
id := localSnapshotID(ctx, t, repos, snapshotName)
|
|
||||||
require.NoError(t, db.Close())
|
|
||||||
|
|
||||||
throughLink := filepath.Join(linkDir, "sub")
|
|
||||||
relative := strings.TrimPrefix(filepath.Join(dataDir, "small.txt"), "/")
|
|
||||||
|
|
||||||
var out bytes.Buffer
|
|
||||||
|
|
||||||
reader := newReaderVaultik(ctx, cfg, store, nil, fs)
|
|
||||||
reader.UI = ui.NewWithColor(&out, false)
|
|
||||||
|
|
||||||
err = reader.Restore(&vaultik.RestoreOptions{
|
|
||||||
SnapshotID: id,
|
|
||||||
TargetDir: restoreDir,
|
|
||||||
Paths: []string{throughLink, relative},
|
|
||||||
})
|
|
||||||
|
|
||||||
require.Error(t, err, "a restore that matches nothing must fail")
|
|
||||||
assert.Contains(t, out.String(), throughLink,
|
|
||||||
"the path through the symlink must be named")
|
|
||||||
assert.Contains(t, out.String(), relative,
|
|
||||||
"the relative path must be named")
|
|
||||||
|
|
||||||
_, err = fs.Stat(restoreDir)
|
|
||||||
assert.True(t, os.IsNotExist(err), "nothing must be restored")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Without --skip-errors a damaged blob stops the restore part way. The
|
|
||||||
// path that matches nothing must still be named.
|
|
||||||
func TestRestoreNamesUnmatchedPathWhenADamagedBlobStopsIt(t *testing.T) {
|
|
||||||
log.Initialize(log.Config{})
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewOsFs()
|
|
||||||
tempDir := t.TempDir()
|
|
||||||
dataDir := filepath.Join(tempDir, "src")
|
|
||||||
storeDir := t.TempDir()
|
|
||||||
restoreDir := filepath.Join(tempDir, "restored")
|
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
cfg := faultTestConfig()
|
|
||||||
|
|
||||||
writeFaultSourceTree(t, fs, dataDir)
|
|
||||||
|
|
||||||
inner, err := storage.NewFileStorer(storeDir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
db, err := database.New(ctx, dbPath)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
id := fullFaultBackup(ctx, t, fs, inner, cfg,
|
|
||||||
database.NewRepositories(db), dataDir, dbPath, "damaged")
|
|
||||||
require.NoError(t, db.Close())
|
|
||||||
|
|
||||||
damaged := faultstore.New(inner)
|
|
||||||
damaged.OnGet = func(key string) faultstore.GetFault {
|
|
||||||
if strings.HasPrefix(key, "blobs/") {
|
|
||||||
return faultstore.GetCorrupt
|
|
||||||
}
|
|
||||||
|
|
||||||
return faultstore.GetNormal
|
|
||||||
}
|
|
||||||
|
|
||||||
matched := filepath.Join(dataDir, "small.txt")
|
|
||||||
unmatched := filepath.Join(dataDir, "typo.txt")
|
|
||||||
|
|
||||||
var out bytes.Buffer
|
|
||||||
|
|
||||||
v := newReaderVaultik(ctx, cfg, damaged, nil, fs)
|
|
||||||
v.UI = ui.NewWithColor(&out, false)
|
|
||||||
|
|
||||||
err = v.Restore(&vaultik.RestoreOptions{
|
|
||||||
SnapshotID: id,
|
|
||||||
TargetDir: restoreDir,
|
|
||||||
Paths: []string{matched, unmatched},
|
|
||||||
})
|
|
||||||
|
|
||||||
require.Error(t, err, "a damaged blob must stop the restore")
|
|
||||||
assert.Contains(t, err.Error(), "blob",
|
|
||||||
"the restore must stop on the damaged blob")
|
|
||||||
assert.Contains(t, out.String(), unmatched,
|
|
||||||
"the path that matches nothing must be named")
|
|
||||||
}
|
|
||||||
@@ -28,7 +28,7 @@ func TestVerifyPassesWhenBackupReusesStoredBlob(t *testing.T) {
|
|||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
dataDir := filepath.Join(tempDir, "source")
|
dataDir := filepath.Join(tempDir, "source")
|
||||||
storeDir := t.TempDir()
|
storeDir := filepath.Join(tempDir, "remote")
|
||||||
secondDBPath := filepath.Join(tempDir, "second.sqlite")
|
secondDBPath := filepath.Join(tempDir, "second.sqlite")
|
||||||
|
|
||||||
chunkSize := int64(64 * 1024)
|
chunkSize := int64(64 * 1024)
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ func TestBackupOfSameSecondRewriteRestoresNewContent(t *testing.T) {
|
|||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
dataDir := filepath.Join(tempDir, "src")
|
dataDir := filepath.Join(tempDir, "src")
|
||||||
storeDir := t.TempDir()
|
storeDir := filepath.Join(tempDir, "remote")
|
||||||
restoreDir := filepath.Join(tempDir, "restored")
|
restoreDir := filepath.Join(tempDir, "restored")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
rewrittenPath := filepath.Join(dataDir, "small.txt")
|
rewrittenPath := filepath.Join(dataDir, "small.txt")
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import (
|
|||||||
// then grows one stored blob so its size no longer matches the manifest.
|
// then grows one stored blob so its size no longer matches the manifest.
|
||||||
// Shallow verify must then fail, count the grown blob as a size mismatch,
|
// Shallow verify must then fail, count the grown blob as a size mismatch,
|
||||||
// and drop it from the verified count rather than continuing to report it
|
// and drop it from the verified count rather than continuing to report it
|
||||||
// as checked, and its error must not repeat the cli layer's prefix.
|
// as checked.
|
||||||
func TestShallowVerifyDetectsWrongBlobSize(t *testing.T) {
|
func TestShallowVerifyDetectsWrongBlobSize(t *testing.T) {
|
||||||
log.Initialize(log.Config{})
|
log.Initialize(log.Config{})
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -36,7 +36,7 @@ func TestShallowVerifyDetectsWrongBlobSize(t *testing.T) {
|
|||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
dataDir := filepath.Join(tempDir, "source")
|
dataDir := filepath.Join(tempDir, "source")
|
||||||
storeDir := t.TempDir()
|
storeDir := filepath.Join(tempDir, "remote")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
chunkSize := int64(32 * 1024)
|
chunkSize := int64(32 * 1024)
|
||||||
@@ -96,12 +96,6 @@ func TestShallowVerifyDetectsWrongBlobSize(t *testing.T) {
|
|||||||
require.Equal(t, 1, bad.Mismatched)
|
require.Equal(t, 1, bad.Mismatched)
|
||||||
require.Equal(t, healthy.BlobCount-1, bad.Verified,
|
require.Equal(t, healthy.BlobCount-1, bad.Verified,
|
||||||
"the wrong-sized blob must not be counted as verified")
|
"the wrong-sized blob must not be counted as verified")
|
||||||
|
|
||||||
// The cli layer prints this error after its own "Verification failed:".
|
|
||||||
err = newVerifier(io.Discard).VerifySnapshotWithOptions(
|
|
||||||
snapshotID, &vaultik.VerifyOptions{})
|
|
||||||
require.EqualError(t, err,
|
|
||||||
"problems found in storage: 1 blobs have the wrong size")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestShallowVerifyDetectsMissingDatabase backs up a real snapshot,
|
// TestShallowVerifyDetectsMissingDatabase backs up a real snapshot,
|
||||||
@@ -116,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 := t.TempDir()
|
storeDir := filepath.Join(tempDir, "remote")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
chunkSize := int64(32 * 1024)
|
chunkSize := int64(32 * 1024)
|
||||||
@@ -199,7 +193,7 @@ func TestShallowVerifyReportsStatErrorsApartFromMissing(t *testing.T) {
|
|||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
dataDir := filepath.Join(tempDir, "source")
|
dataDir := filepath.Join(tempDir, "source")
|
||||||
storeDir := t.TempDir()
|
storeDir := filepath.Join(tempDir, "remote")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
chunkSize := int64(32 * 1024)
|
chunkSize := int64(32 * 1024)
|
||||||
@@ -288,7 +282,7 @@ func TestShallowVerifyQuietFailsOnStatErrorAlone(t *testing.T) {
|
|||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
dataDir := filepath.Join(tempDir, "source")
|
dataDir := filepath.Join(tempDir, "source")
|
||||||
storeDir := t.TempDir()
|
storeDir := filepath.Join(tempDir, "remote")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
chunkSize := int64(32 * 1024)
|
chunkSize := int64(32 * 1024)
|
||||||
|
|||||||
+91
-228
@@ -23,7 +23,6 @@ var (
|
|||||||
errSnapshotNotInConfig = errors.New("snapshot not found in config")
|
errSnapshotNotInConfig = errors.New("snapshot not found in config")
|
||||||
errNoSnapshotsInConfig = errors.New("no snapshots configured")
|
errNoSnapshotsInConfig = errors.New("no snapshots configured")
|
||||||
errSnapshotVerifyFailed = errors.New("verification failed")
|
errSnapshotVerifyFailed = errors.New("verification failed")
|
||||||
errProblemsInStorage = errors.New("problems found in storage")
|
|
||||||
errRemoveAllNeedsForce = errors.New("--all requires --force")
|
errRemoveAllNeedsForce = errors.New("--all requires --force")
|
||||||
errSnapshotNotFound = errors.New("snapshot not found")
|
errSnapshotNotFound = errors.New("snapshot not found")
|
||||||
errSnapshotRemoveFailed = errors.New("snapshot(s) could not be removed")
|
errSnapshotRemoveFailed = errors.New("snapshot(s) could not be removed")
|
||||||
@@ -39,11 +38,6 @@ var (
|
|||||||
// caller can report it apart from a failed backup.
|
// caller can report it apart from a failed backup.
|
||||||
var ErrPostBackupPruneFailed = errors.New("post-backup prune failed")
|
var ErrPostBackupPruneFailed = errors.New("post-backup prune failed")
|
||||||
|
|
||||||
// ErrFilesUnreadable is returned when --skip-errors skipped files or
|
|
||||||
// directories it could not read or list. The snapshot is complete and
|
|
||||||
// kept, so the caller can report it apart from a failed backup.
|
|
||||||
var ErrFilesUnreadable = errors.New("file(s) could not be read")
|
|
||||||
|
|
||||||
// listRecentLimit caps how many snapshot rows are fetched from the
|
// listRecentLimit caps how many snapshot rows are fetched from the
|
||||||
// local index for listing and purge operations.
|
// local index for listing and purge operations.
|
||||||
const listRecentLimit = 10000
|
const listRecentLimit = 10000
|
||||||
@@ -100,13 +94,6 @@ func (v *Vaultik) CreateSnapshot(opts *SnapshotCreateOptions) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Before PruneDatabase: it drops incomplete snapshots, which could
|
|
||||||
// leave the local index without one and so skip the marker check.
|
|
||||||
err = v.markDestination()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up incomplete snapshots FIRST, before any scanning.
|
// Clean up incomplete snapshots FIRST, before any scanning.
|
||||||
// This is critical for data safety; PruneDatabase below does it.
|
// This is critical for data safety; PruneDatabase below does it.
|
||||||
hostname := v.Config.Hostname
|
hostname := v.Config.Hostname
|
||||||
@@ -129,22 +116,30 @@ func (v *Vaultik) CreateSnapshot(opts *SnapshotCreateOptions) error {
|
|||||||
return fmt.Errorf("prune database: %w", err)
|
return fmt.Errorf("prune database: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
snapshotNames, err := v.snapshotNamesToCreate(opts.Snapshots)
|
// Determine which snapshots to process
|
||||||
if err != nil {
|
snapshotNames := opts.Snapshots
|
||||||
return err
|
if len(snapshotNames) == 0 {
|
||||||
|
snapshotNames = v.Config.SnapshotNames()
|
||||||
|
} else {
|
||||||
|
// Validate requested snapshot names exist
|
||||||
|
for _, name := range snapshotNames {
|
||||||
|
if _, ok := v.Config.Snapshots[name]; !ok {
|
||||||
|
return fmt.Errorf("%w: %q", errSnapshotNotInConfig, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(snapshotNames) == 0 {
|
||||||
|
return errNoSnapshotsInConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process each named snapshot
|
// Process each named snapshot
|
||||||
filesUnreadable := 0
|
|
||||||
|
|
||||||
for snapIdx, snapName := range snapshotNames {
|
for snapIdx, snapName := range snapshotNames {
|
||||||
unreadable, err := v.createNamedSnapshot(
|
err = v.createNamedSnapshot(
|
||||||
opts, hostname, snapName, snapIdx+1, len(snapshotNames))
|
opts, hostname, snapName, snapIdx+1, len(snapshotNames))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
filesUnreadable += unreadable
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Print overall summary if multiple snapshots
|
// Print overall summary if multiple snapshots
|
||||||
@@ -160,19 +155,6 @@ func (v *Vaultik) CreateSnapshot(opts *SnapshotCreateOptions) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return v.finishCreateSnapshot(filesUnreadable)
|
|
||||||
}
|
|
||||||
|
|
||||||
// finishCreateSnapshot ends a run whose snapshots are all complete and
|
|
||||||
// kept. Like finishRestore, it returns an error when --skip-errors
|
|
||||||
// skipped anything it could not read or list; otherwise it prints the
|
|
||||||
// closing line. A file removed during the run is counted as failed in
|
|
||||||
// the summary but is not an error, with or without --skip-errors.
|
|
||||||
func (v *Vaultik) finishCreateSnapshot(filesUnreadable int) error {
|
|
||||||
if filesUnreadable > 0 {
|
|
||||||
return fmt.Errorf("%d %w", filesUnreadable, ErrFilesUnreadable)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Terminus must obey the --cron invariant: silent on total
|
// Terminus must obey the --cron invariant: silent on total
|
||||||
// success only. UI.Complete is dropped in cron/quiet mode (that's
|
// success only. UI.Complete is dropped in cron/quiet mode (that's
|
||||||
// the success path), but if any warnings fired during the run we
|
// the success path), but if any warnings fired during the run we
|
||||||
@@ -188,28 +170,6 @@ func (v *Vaultik) finishCreateSnapshot(filesUnreadable int) 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
|
||||||
@@ -262,22 +222,16 @@ type snapshotStats struct {
|
|||||||
totalBlobsUploaded int
|
totalBlobsUploaded int
|
||||||
uploadDuration time.Duration
|
uploadDuration time.Duration
|
||||||
|
|
||||||
// The part of totalFilesFailed that --skip-errors skipped because it
|
|
||||||
// could not be read or listed.
|
|
||||||
totalFilesUnreadable int
|
|
||||||
|
|
||||||
// The sizes of all blobs the snapshot references, set by
|
// The sizes of all blobs the snapshot references, set by
|
||||||
// finalizeSnapshotMetadata once snapshot_blobs is populated.
|
// finalizeSnapshotMetadata once snapshot_blobs is populated.
|
||||||
blobSize int64
|
blobSize int64
|
||||||
blobUncompressedSize int64
|
blobUncompressedSize int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// createNamedSnapshot creates a single named snapshot and returns the
|
// createNamedSnapshot creates a single named snapshot
|
||||||
// number of entries --skip-errors skipped because it could not read or
|
|
||||||
// list them.
|
|
||||||
func (v *Vaultik) createNamedSnapshot(
|
func (v *Vaultik) createNamedSnapshot(
|
||||||
opts *SnapshotCreateOptions, hostname, snapName string, idx, total int,
|
opts *SnapshotCreateOptions, hostname, snapName string, idx, total int,
|
||||||
) (int, error) {
|
) error {
|
||||||
snapshotStartTime := time.Now()
|
snapshotStartTime := time.Now()
|
||||||
|
|
||||||
if total > 1 {
|
if total > 1 {
|
||||||
@@ -286,27 +240,21 @@ func (v *Vaultik) createNamedSnapshot(
|
|||||||
|
|
||||||
resolvedDirs, err := v.resolveSnapshotPaths(snapName)
|
resolvedDirs, err := v.resolveSnapshotPaths(snapName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return err
|
||||||
}
|
|
||||||
|
|
||||||
previousSnapshotID, err := v.previousSnapshotID(snapName)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
scanner := v.ScannerFactory(snapshot.ScannerParams{
|
scanner := v.ScannerFactory(snapshot.ScannerParams{
|
||||||
EnableProgress: !opts.Cron,
|
EnableProgress: !opts.Cron,
|
||||||
UI: v.UI,
|
UI: v.UI,
|
||||||
Fs: v.Fs,
|
Fs: v.Fs,
|
||||||
Exclude: v.Config.GetExcludes(snapName),
|
Exclude: v.Config.GetExcludes(snapName),
|
||||||
SkipErrors: opts.SkipErrors,
|
SkipErrors: opts.SkipErrors,
|
||||||
PreviousSnapshotID: previousSnapshotID,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
snapshotID, err := v.SnapshotManager.CreateSnapshotWithName(
|
snapshotID, err := v.SnapshotManager.CreateSnapshotWithName(
|
||||||
v.ctx, hostname, snapName, v.Globals.Version, v.Globals.Commit)
|
v.ctx, hostname, snapName, v.Globals.Version, v.Globals.Commit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("creating snapshot: %w", err)
|
return fmt.Errorf("creating snapshot: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Info("Beginning snapshot", "snapshot_id", snapshotID, "name", snapName)
|
log.Info("Beginning snapshot", "snapshot_id", snapshotID, "name", snapName)
|
||||||
@@ -314,12 +262,12 @@ func (v *Vaultik) createNamedSnapshot(
|
|||||||
|
|
||||||
stats, err := v.scanAllDirectories(scanner, resolvedDirs, snapshotID)
|
stats, err := v.scanAllDirectories(scanner, resolvedDirs, snapshotID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = v.finalizeSnapshotMetadata(snapshotID, stats)
|
err = v.finalizeSnapshotMetadata(snapshotID, stats)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Info("Snapshot complete",
|
log.Info("Snapshot complete",
|
||||||
@@ -332,25 +280,7 @@ func (v *Vaultik) createNamedSnapshot(
|
|||||||
|
|
||||||
v.printSnapshotSummary(snapshotID, snapshotStartTime, stats)
|
v.printSnapshotSummary(snapshotID, snapshotStartTime, stats)
|
||||||
|
|
||||||
return stats.totalFilesUnreadable, nil
|
return nil
|
||||||
}
|
|
||||||
|
|
||||||
// previousSnapshotID returns the ID of the most recent completed snapshot
|
|
||||||
// named snapName in the local index, or "" when there is none.
|
|
||||||
func (v *Vaultik) previousSnapshotID(snapName string) (string, error) {
|
|
||||||
snapshots, err := v.Repositories.Snapshots.ListRecent(v.ctx, listRecentLimit)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("listing snapshots: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, s := range snapshots {
|
|
||||||
if s.CompletedAt != nil &&
|
|
||||||
parseSnapshotName(s.ID.String(), s.Hostname.String()) == snapName {
|
|
||||||
return s.ID.String(), nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveSnapshotPaths resolves source directories to absolute paths
|
// resolveSnapshotPaths resolves source directories to absolute paths
|
||||||
@@ -418,7 +348,6 @@ func (v *Vaultik) scanAllDirectories(
|
|||||||
stats.totalBlobs += result.BlobsCreated
|
stats.totalBlobs += result.BlobsCreated
|
||||||
stats.totalFilesSkipped += result.FilesSkipped
|
stats.totalFilesSkipped += result.FilesSkipped
|
||||||
stats.totalFilesFailed += result.FilesFailed
|
stats.totalFilesFailed += result.FilesFailed
|
||||||
stats.totalFilesUnreadable += result.FilesUnreadable
|
|
||||||
stats.totalBytesSkipped += result.BytesSkipped
|
stats.totalBytesSkipped += result.BytesSkipped
|
||||||
stats.totalFilesDeleted += result.FilesDeleted
|
stats.totalFilesDeleted += result.FilesDeleted
|
||||||
stats.totalBytesDeleted += result.BytesDeleted
|
stats.totalBytesDeleted += result.BytesDeleted
|
||||||
@@ -645,7 +574,7 @@ func (v *Vaultik) PurgeSnapshotsWithOptions(opts *SnapshotPurgeOptions) error {
|
|||||||
|
|
||||||
if len(toDelete) == 0 {
|
if len(toDelete) == 0 {
|
||||||
if !opts.Quiet {
|
if !opts.Quiet {
|
||||||
v.UI.Infof("No snapshots to delete.")
|
v.printlnStdout("No snapshots to delete")
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -683,7 +612,7 @@ func selectSnapshotsToPurge(
|
|||||||
case opts.OlderThan != "":
|
case opts.OlderThan != "":
|
||||||
duration, err := parseDuration(opts.OlderThan)
|
duration, err := parseDuration(opts.OlderThan)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf("invalid duration: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cutoff := time.Now().UTC().Add(-duration)
|
cutoff := time.Now().UTC().Add(-duration)
|
||||||
@@ -698,23 +627,24 @@ func selectSnapshotsToPurge(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// confirmAndExecutePurge shows deletion candidates, confirms with the
|
// confirmAndExecutePurge shows deletion candidates, confirms with the
|
||||||
// user, and deletes snapshots.
|
// user, and deletes snapshots. It stops with an error at the first
|
||||||
|
// snapshot it cannot delete.
|
||||||
func (v *Vaultik) confirmAndExecutePurge(
|
func (v *Vaultik) confirmAndExecutePurge(
|
||||||
toDelete []SnapshotInfo, force, quiet bool,
|
toDelete []SnapshotInfo, force, quiet bool,
|
||||||
) error {
|
) error {
|
||||||
// Confirm unless --force is used. The list, the prompt and its answer
|
if !quiet {
|
||||||
// bypass the UI: they are an interactive exchange the operator must
|
|
||||||
// see, so --quiet does not silence them.
|
|
||||||
if !force {
|
|
||||||
v.stdoutf("The following snapshots will be deleted:\n\n")
|
v.stdoutf("The following snapshots will be deleted:\n\n")
|
||||||
|
|
||||||
for _, snap := range toDelete {
|
for _, snap := range toDelete {
|
||||||
v.stdoutf(" %s (%s, %s)\n",
|
v.stdoutf(" %s (%s, %s)\n",
|
||||||
snap.ID,
|
snap.ID,
|
||||||
snap.Timestamp.Format(time.DateTime),
|
snap.Timestamp.Format("2006-01-02 15:04:05"),
|
||||||
ubytes(snap.CompressedSize))
|
ubytes(snap.CompressedSize))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm unless --force is used
|
||||||
|
if !force {
|
||||||
v.stdoutf("\nDelete %d snapshot(s)? [y/N] ", len(toDelete))
|
v.stdoutf("\nDelete %d snapshot(s)? [y/N] ", len(toDelete))
|
||||||
|
|
||||||
var confirm string
|
var confirm string
|
||||||
@@ -732,16 +662,7 @@ func (v *Vaultik) confirmAndExecutePurge(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
} else if !quiet {
|
} else if !quiet {
|
||||||
v.UI.Infof("The following snapshots will be deleted:")
|
v.stdoutf("\nDeleting %d snapshot(s) (--force specified)\n", len(toDelete))
|
||||||
|
|
||||||
for _, snap := range toDelete {
|
|
||||||
v.UI.Detailf("%s (%s, %s)",
|
|
||||||
v.UI.Snapshot(snap.ID.String()),
|
|
||||||
snap.Timestamp.Format(time.DateTime),
|
|
||||||
ubytes(snap.CompressedSize))
|
|
||||||
}
|
|
||||||
|
|
||||||
v.UI.Infof("Deleting %d snapshot(s) (--force specified).", len(toDelete))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete snapshots (both local and remote)
|
// Delete snapshots (both local and remote)
|
||||||
@@ -749,8 +670,8 @@ func (v *Vaultik) confirmAndExecutePurge(
|
|||||||
snapshotID := snap.ID.String()
|
snapshotID := snap.ID.String()
|
||||||
log.Info("Deleting snapshot", "id", snapshotID)
|
log.Info("Deleting snapshot", "id", snapshotID)
|
||||||
|
|
||||||
// Stop before the remote delete, so a snapshot still in the local
|
// Stop before the remote delete: a snapshot still in the local
|
||||||
// index keeps its metadata on the destination store.
|
// index must keep its metadata on the destination store.
|
||||||
err := v.deleteSnapshotFromLocalDB(snapshotID)
|
err := v.deleteSnapshotFromLocalDB(snapshotID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("deleting snapshot %s from local index: %w", snapshotID, err)
|
return fmt.Errorf("deleting snapshot %s from local index: %w", snapshotID, err)
|
||||||
@@ -774,9 +695,9 @@ func (v *Vaultik) confirmAndExecutePurge(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !quiet {
|
if !quiet {
|
||||||
v.UI.Infof("Deleted %d snapshot(s).", len(toDelete))
|
v.stdoutf("Deleted %d snapshot(s)\n", len(toDelete))
|
||||||
v.UI.Noticef("Run '%s' to clean up unreferenced remote blobs.",
|
v.printlnStdout(
|
||||||
pruneCommandHint)
|
"\nNote: Run 'vaultik prune' to clean up unreferenced remote blobs.")
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -1012,10 +933,8 @@ func (v *Vaultik) formatVerifyResult(
|
|||||||
v.printVerifySummary(result, failure)
|
v.printVerifySummary(result, failure)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Not errSnapshotVerifyFailed: the cli layer prints this after its own
|
|
||||||
// "Verification failed:".
|
|
||||||
if failure != "" {
|
if failure != "" {
|
||||||
return fmt.Errorf("%w: %s", errProblemsInStorage, failure)
|
return fmt.Errorf("%w: %s", errSnapshotVerifyFailed, failure)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -1151,7 +1070,7 @@ func (v *Vaultik) CleanupLocalSnapshots(opts *PruneOptions) error {
|
|||||||
log.Info("Removing stale local snapshot record", "snapshot_id", id)
|
log.Info("Removing stale local snapshot record", "snapshot_id", id)
|
||||||
|
|
||||||
if !opts.JSON {
|
if !opts.JSON {
|
||||||
v.UI.Infof("Removing stale local record: %s.", v.UI.Snapshot(id))
|
v.stdoutf("Removing stale local record: %s\n", id)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = v.deleteSnapshotFromLocalDB(id)
|
err = v.deleteSnapshotFromLocalDB(id)
|
||||||
@@ -1174,9 +1093,9 @@ func (v *Vaultik) CleanupLocalSnapshots(opts *PruneOptions) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if removed == 0 {
|
if removed == 0 {
|
||||||
v.UI.Infof("No stale local snapshots found.")
|
v.printlnStdout("No stale local snapshots found.")
|
||||||
} else {
|
} else {
|
||||||
v.UI.Infof("Removed %d stale local snapshot record(s).", removed)
|
v.stdoutf("Removed %d stale local snapshot record(s).\n", removed)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -1301,11 +1220,9 @@ 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 — when metadata was removed from the
|
// `vaultik prune` is for — the command prints the prune invocation to
|
||||||
// destination store, the text output names the prune invocation to run
|
// run as a follow-up. When the remote is unreachable the command still
|
||||||
// as a follow-up. When removing the metadata fails, a snapshot in the
|
// completes the local-DB removal and warns.
|
||||||
// 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) {
|
||||||
@@ -1327,18 +1244,10 @@ func (v *Vaultik) RemoveSnapshot(
|
|||||||
return v.removeSnapshotDryRun(result, snapshotID, opts)
|
return v.removeSnapshotDryRun(result, snapshotID, opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !v.confirmRemoveSnapshot(snapshotID, opts) {
|
if !opts.Force && !opts.JSON && !v.confirmRemoveSnapshot(snapshotID, opts) {
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Checked before the local record is deleted: it may be the last
|
|
||||||
// snapshot in the local index, and the check applies only while the
|
|
||||||
// local index holds one.
|
|
||||||
var markerErr error
|
|
||||||
if !opts.LocalOnly {
|
|
||||||
markerErr = v.checkDestinationMarker()
|
|
||||||
}
|
|
||||||
|
|
||||||
if inLocalIndex {
|
if inLocalIndex {
|
||||||
log.Info("Removing snapshot from local database", "snapshot_id", snapshotID)
|
log.Info("Removing snapshot from local database", "snapshot_id", snapshotID)
|
||||||
|
|
||||||
@@ -1348,10 +1257,8 @@ func (v *Vaultik) RemoveSnapshot(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
result.RemoteRemoved, err = v.removeSnapshotRemote(
|
if !opts.LocalOnly {
|
||||||
snapshotID, inLocalIndex, markerErr, opts)
|
result.RemoteRemoved = v.removeSnapshotRemote(snapshotID, opts)
|
||||||
if err != nil {
|
|
||||||
return result, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if v.SnapshotManager != nil {
|
if v.SnapshotManager != nil {
|
||||||
@@ -1367,15 +1274,14 @@ func (v *Vaultik) RemoveSnapshot(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if inLocalIndex {
|
if inLocalIndex {
|
||||||
v.UI.Infof("Removed snapshot '%s' from local database.",
|
v.stdoutf("Removed snapshot '%s' from local database\n", snapshotID)
|
||||||
v.UI.Snapshot(snapshotID))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.RemoteRemoved {
|
if result.RemoteRemoved {
|
||||||
v.UI.Infof("Removed snapshot metadata from remote storage.")
|
v.printlnStdout("Removed snapshot metadata from remote storage")
|
||||||
v.UI.Noticef("The removed snapshot's blobs remain on the remote. "+
|
v.stdoutf("\nNote: The removed snapshot's blobs remain on the remote. "+
|
||||||
"Run '%s' to delete any blobs no longer referenced by any "+
|
"Run '%s' to delete any blobs no longer referenced by any "+
|
||||||
"remaining remote snapshot.", pruneCommandHint)
|
"remaining remote snapshot.\n", pruneCommandHint)
|
||||||
}
|
}
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
@@ -1386,9 +1292,7 @@ func (v *Vaultik) RemoveSnapshot(
|
|||||||
// remove: no row and, unless LocalOnly is set, no metadata on the
|
// remove: no row and, unless LocalOnly is set, no metadata on the
|
||||||
// destination store either. The destination store is consulted only
|
// destination store either. The destination store is consulted only
|
||||||
// when the local index has no row, so a snapshot in the local index can
|
// when the local index has no row, so a snapshot in the local index can
|
||||||
// still be removed from it while the destination store is unreachable,
|
// still be removed from it while the destination store is unreachable.
|
||||||
// and only after checkDestinationMarker passes, so an unplugged volume's
|
|
||||||
// empty mount point is not taken for a store without the snapshot.
|
|
||||||
func (v *Vaultik) findSnapshotToRemove(
|
func (v *Vaultik) findSnapshotToRemove(
|
||||||
snapshotID string, opts *RemoveOptions,
|
snapshotID string, opts *RemoveOptions,
|
||||||
) (bool, error) {
|
) (bool, error) {
|
||||||
@@ -1408,11 +1312,6 @@ func (v *Vaultik) findSnapshotToRemove(
|
|||||||
errSnapshotNotFound, snapshotID)
|
errSnapshotNotFound, snapshotID)
|
||||||
}
|
}
|
||||||
|
|
||||||
err := v.checkDestinationMarker()
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
objects, err := v.remoteSnapshotObjects(snapshot.RemoteSnapshotKey(snapshotID))
|
objects, err := v.remoteSnapshotObjects(snapshot.RemoteSnapshotKey(snapshotID))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("looking up snapshot on destination store: %w", err)
|
return false, fmt.Errorf("looking up snapshot on destination store: %w", err)
|
||||||
@@ -1438,28 +1337,22 @@ func (v *Vaultik) removeSnapshotDryRun(
|
|||||||
return result, v.outputRemoveJSON(result)
|
return result, v.outputRemoveJSON(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
v.UI.Infof("Would remove snapshot: %s.", v.UI.Snapshot(snapshotID))
|
v.stdoutf("Would remove snapshot: %s\n", snapshotID)
|
||||||
|
|
||||||
if !opts.LocalOnly {
|
if !opts.LocalOnly {
|
||||||
v.UI.Infof("Would also remove snapshot metadata from " +
|
v.printlnStdout("Would also remove snapshot metadata from " +
|
||||||
"remote storage (blobs untouched).")
|
"remote storage (blobs untouched)")
|
||||||
}
|
}
|
||||||
|
|
||||||
v.UI.Noticef("Dry run: no changes made.")
|
v.printlnStdout("[Dry run - no changes made]")
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// confirmRemoveSnapshot prompts for confirmation before removing a
|
// confirmRemoveSnapshot prompts for confirmation before removing a
|
||||||
// snapshot, unless --force or --json is set. Returns false (treating
|
// snapshot. Returns false (treating EOF/read errors as "no") unless the
|
||||||
// EOF/read errors as "no") unless the user answers "y". The prompt and
|
// user answers "y".
|
||||||
// its answer bypass the UI: they are an interactive exchange the
|
|
||||||
// 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)
|
||||||
@@ -1487,40 +1380,19 @@ func (v *Vaultik) confirmRemoveSnapshot(snapshotID string, opts *RemoveOptions)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// removeSnapshotRemote strips the snapshot's metadata from the
|
// removeSnapshotRemote strips the snapshot's metadata from the
|
||||||
// destination store. It returns true only when the delete finished
|
// destination store, warning and proceeding on failure: the local-DB
|
||||||
// without an error and removed at least one object; under LocalOnly it
|
// removal has already happened, so the user is told the remote half
|
||||||
// does nothing. markerErr is the result of checkDestinationMarker; when
|
// didn't finish and to run `vaultik snapshot remove` for the snapshot
|
||||||
// it is set, nothing is deleted and it is the failure. When the snapshot
|
// again once the destination store is reachable (`vaultik prune` never
|
||||||
// is only on the destination store, a failure is returned as an error,
|
// removes snapshot metadata). Returns true when the remote removal
|
||||||
// because that removal is all the command does. When the snapshot was in
|
// succeeded and deleted something.
|
||||||
// the local index, its removal there has already happened, so a failure
|
func (v *Vaultik) removeSnapshotRemote(snapshotID string, opts *RemoveOptions) bool {
|
||||||
// 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)
|
||||||
|
|
||||||
var deleted int
|
remoteKey := snapshot.RemoteSnapshotKey(snapshotID)
|
||||||
|
|
||||||
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 "+
|
||||||
@@ -1535,10 +1407,10 @@ func (v *Vaultik) removeSnapshotRemote(
|
|||||||
err, snapshotRemoveCommandHint, snapshotID)
|
err, snapshotRemoveCommandHint, snapshotID)
|
||||||
}
|
}
|
||||||
|
|
||||||
return false, nil
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
return deleted > 0, nil
|
return deleted > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveAllSnapshots removes every snapshot known to the local
|
// RemoveAllSnapshots removes every snapshot known to the local
|
||||||
@@ -1585,7 +1457,7 @@ func (v *Vaultik) RemoveAllSnapshots(opts *RemoveOptions) (*RemoveResult, error)
|
|||||||
|
|
||||||
if len(localSnaps) == 0 && len(orphanRemoteKeys) == 0 {
|
if len(localSnaps) == 0 && len(orphanRemoteKeys) == 0 {
|
||||||
if !opts.JSON {
|
if !opts.JSON {
|
||||||
v.UI.Infof("No snapshots found.")
|
v.printlnStdout("No snapshots found")
|
||||||
}
|
}
|
||||||
|
|
||||||
return &RemoveResult{}, nil
|
return &RemoveResult{}, nil
|
||||||
@@ -1624,8 +1496,8 @@ func (v *Vaultik) localSnapshotIDs() ([]string, error) {
|
|||||||
// listAllRemoteSnapshotKeys collects the hashed remote keys
|
// listAllRemoteSnapshotKeys collects the hashed remote keys
|
||||||
// (subdirectories under metadata/) currently present in the
|
// (subdirectories under metadata/) currently present in the
|
||||||
// destination store. Returns (nil, err) when the store cannot be
|
// destination store. Returns (nil, err) when the store cannot be
|
||||||
// listed, or is not present by checkDestinationMarker; callers must
|
// listed; callers must treat that as "no remote info available," not
|
||||||
// treat that as "no remote info available," not fatal.
|
// fatal.
|
||||||
func (v *Vaultik) listAllRemoteSnapshotKeys() ([]string, error) {
|
func (v *Vaultik) listAllRemoteSnapshotKeys() ([]string, error) {
|
||||||
log.Info("Listing all remote snapshots")
|
log.Info("Listing all remote snapshots")
|
||||||
|
|
||||||
@@ -1674,11 +1546,6 @@ func (v *Vaultik) listAllRemoteSnapshotKeys() ([]string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
err := v.checkDestinationMarker()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return keys, nil
|
return keys, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1695,27 +1562,27 @@ func (v *Vaultik) handleRemoveAllDryRun(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !opts.JSON {
|
if !opts.JSON {
|
||||||
v.UI.Infof("Would remove %d local snapshot(s):", len(localSnaps))
|
v.stdoutf("Would remove %d local snapshot(s):\n", len(localSnaps))
|
||||||
|
|
||||||
for _, id := range localSnaps {
|
for _, id := range localSnaps {
|
||||||
v.UI.Detailf("%s", v.UI.Snapshot(id))
|
v.stdoutf(" %s\n", id)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !opts.LocalOnly {
|
if !opts.LocalOnly {
|
||||||
if len(orphanRemoteKeys) > 0 {
|
if len(orphanRemoteKeys) > 0 {
|
||||||
v.UI.Infof("Would also remove %d orphan remote snapshot key(s):",
|
v.stdoutf("Would also remove %d orphan remote snapshot key(s):\n",
|
||||||
len(orphanRemoteKeys))
|
len(orphanRemoteKeys))
|
||||||
|
|
||||||
for _, key := range orphanRemoteKeys {
|
for _, key := range orphanRemoteKeys {
|
||||||
v.UI.Detailf("%s", key)
|
v.stdoutf(" %s\n", key)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
v.UI.Infof("Would also remove snapshot metadata from " +
|
v.printlnStdout("Would also remove snapshot metadata from " +
|
||||||
"remote storage (blobs untouched).")
|
"remote storage (blobs untouched)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
v.UI.Noticef("Dry run: no changes made.")
|
v.printlnStdout("[Dry run - no changes made]")
|
||||||
}
|
}
|
||||||
|
|
||||||
if opts.JSON {
|
if opts.JSON {
|
||||||
@@ -1764,10 +1631,10 @@ func (v *Vaultik) executeRemoveAll(
|
|||||||
return result, v.outputRemoveJSON(result)
|
return result, v.outputRemoveJSON(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
v.UI.Infof("Removed %d snapshot(s).", len(result.SnapshotsRemoved))
|
v.stdoutf("Removed %d snapshot(s)\n", len(result.SnapshotsRemoved))
|
||||||
|
|
||||||
if !opts.LocalOnly && result.RemoteRemoved {
|
if !opts.LocalOnly && result.RemoteRemoved {
|
||||||
v.UI.Infof("Removed snapshot metadata from remote storage.")
|
v.printlnStdout("Removed snapshot metadata from remote storage")
|
||||||
}
|
}
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
@@ -2024,17 +1891,13 @@ 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 "+
|
v.UI.Detailf("Incomplete snapshots: %s removed (%s remain).",
|
||||||
"(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 "+
|
v.UI.Detailf("Orphaned files: %s removed (%s remain).",
|
||||||
"(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 "+
|
v.UI.Detailf("Orphaned chunks: %s removed (%s remain).",
|
||||||
"(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 "+
|
v.UI.Detailf("Orphaned blobs: %s removed (%s remain).",
|
||||||
"(total blobs left in the local index: %s).",
|
|
||||||
countText(result.BlobsDeleted), countText(blobCountAfter))
|
countText(result.BlobsDeleted), countText(blobCountAfter))
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
|
|||||||
@@ -1,251 +0,0 @@
|
|||||||
package vaultik_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"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/database"
|
|
||||||
"sneak.berlin/go/vaultik/internal/log"
|
|
||||||
"sneak.berlin/go/vaultik/internal/storage"
|
|
||||||
"sneak.berlin/go/vaultik/internal/ui"
|
|
||||||
"sneak.berlin/go/vaultik/internal/vaultik"
|
|
||||||
)
|
|
||||||
|
|
||||||
// These tests cover https://git.eeqj.de/sneak/vaultik/issues/304: a
|
|
||||||
// backup counts a file as deleted only when the previous snapshot of
|
|
||||||
// the same name had it and it is gone, and never counts an excluded
|
|
||||||
// file.
|
|
||||||
|
|
||||||
// deletedCountEnv is a backup setup over the source directory src whose
|
|
||||||
// user-facing output is kept in out. Each test configures its snapshots
|
|
||||||
// with setSnapshot.
|
|
||||||
type deletedCountEnv struct {
|
|
||||||
v *vaultik.Vaultik
|
|
||||||
out *bytes.Buffer
|
|
||||||
src string
|
|
||||||
}
|
|
||||||
|
|
||||||
// newDeletedCountEnv writes files, keyed by path relative to src.
|
|
||||||
func newDeletedCountEnv(t *testing.T, files map[string][]byte) *deletedCountEnv {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
fs := afero.NewOsFs()
|
|
||||||
tempDir := t.TempDir()
|
|
||||||
srcDir := filepath.Join(tempDir, "src")
|
|
||||||
|
|
||||||
for rel, content := range files {
|
|
||||||
path := filepath.Join(srcDir, rel)
|
|
||||||
require.NoError(t, fs.MkdirAll(filepath.Dir(path), 0o755))
|
|
||||||
require.NoError(t, afero.WriteFile(fs, path, content, 0o644))
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg := faultTestConfig()
|
|
||||||
cfg.IndexPath = filepath.Join(tempDir, "index.sqlite")
|
|
||||||
cfg.Snapshots = map[string]config.SnapshotConfig{}
|
|
||||||
|
|
||||||
store, err := storage.NewFileStorer(t.TempDir())
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
db, err := database.New(ctx, cfg.IndexPath)
|
|
||||||
require.NoError(t, err)
|
|
||||||
t.Cleanup(func() { _ = db.Close() })
|
|
||||||
|
|
||||||
repos := database.NewRepositories(db)
|
|
||||||
out := &bytes.Buffer{}
|
|
||||||
v := newBackupVaultik(ctx, cfg, store, repos, db, fs)
|
|
||||||
v.UI = ui.NewWithColor(out, false)
|
|
||||||
|
|
||||||
return &deletedCountEnv{v: v, out: out, src: srcDir}
|
|
||||||
}
|
|
||||||
|
|
||||||
// setSnapshot configures the snapshot name to back up the path rel
|
|
||||||
// beneath src, with the given excludes.
|
|
||||||
func (e *deletedCountEnv) setSnapshot(name, rel string, exclude ...string) {
|
|
||||||
e.v.Config.Snapshots[name] = config.SnapshotConfig{
|
|
||||||
Paths: []string{filepath.Join(e.src, rel)},
|
|
||||||
Exclude: exclude,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// backUp runs a backup of the named snapshot and returns its output.
|
|
||||||
func (e *deletedCountEnv) backUp(t *testing.T, name string) string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
e.out.Reset()
|
|
||||||
require.NoError(t, e.v.CreateSnapshot(&vaultik.SnapshotCreateOptions{
|
|
||||||
Snapshots: []string{name},
|
|
||||||
}))
|
|
||||||
|
|
||||||
return e.out.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// A file this snapshot excludes is not counted as deleted: neither one
|
|
||||||
// that another snapshot backs up, nor one that the previous snapshot of
|
|
||||||
// this name had before the exclude was added.
|
|
||||||
func TestBackupDoesNotCountExcludedFileAsDeleted(t *testing.T) {
|
|
||||||
log.Initialize(log.Config{})
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := newDeletedCountEnv(t, map[string][]byte{
|
|
||||||
"top.txt": []byte("top"),
|
|
||||||
"cache/tmp.bin": []byte("cache"),
|
|
||||||
"photos/a.jpg": []byte("photo a"),
|
|
||||||
"photos/b.jpg": []byte("photo b"),
|
|
||||||
"photos/c.jpg": []byte("photo c"),
|
|
||||||
})
|
|
||||||
env.setSnapshot("photos", "photos")
|
|
||||||
env.setSnapshot("home", "", "/photos")
|
|
||||||
|
|
||||||
env.backUp(t, "photos")
|
|
||||||
assert.Contains(t, env.backUp(t, "home"), filesLine(2, 2, 0))
|
|
||||||
|
|
||||||
env.setSnapshot("home", "", "/photos", "/cache")
|
|
||||||
assert.Contains(t, env.backUp(t, "home"), filesLine(1, 0, 1))
|
|
||||||
}
|
|
||||||
|
|
||||||
// A file inside a directory that an exclude added after a backup
|
|
||||||
// matches is not counted as deleted, though the pattern matches only the
|
|
||||||
// directory and the directory is gone from disk.
|
|
||||||
func TestBackupDoesNotCountFileInExcludedDirectoryAsDeleted(t *testing.T) {
|
|
||||||
log.Initialize(log.Config{})
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := newDeletedCountEnv(t, map[string][]byte{
|
|
||||||
"top.txt": []byte("top"),
|
|
||||||
"user/.local/share/Trash/old.txt": []byte("old"),
|
|
||||||
})
|
|
||||||
env.setSnapshot("home", "")
|
|
||||||
|
|
||||||
assert.Contains(t, env.backUp(t, "home"), filesLine(2, 2, 0))
|
|
||||||
|
|
||||||
require.NoError(t, os.RemoveAll(
|
|
||||||
filepath.Join(env.src, "user/.local/share/Trash")))
|
|
||||||
|
|
||||||
env.setSnapshot("home", "", "*/.local/share/Trash")
|
|
||||||
assert.Contains(t, env.backUp(t, "home"), filesLine(1, 0, 1))
|
|
||||||
}
|
|
||||||
|
|
||||||
// A file whose directory was replaced by a regular file of the same name
|
|
||||||
// is counted as deleted.
|
|
||||||
func TestBackupCountsFileInDirectoryReplacedByFileAsDeleted(t *testing.T) {
|
|
||||||
log.Initialize(log.Config{})
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := newDeletedCountEnv(t, map[string][]byte{
|
|
||||||
"dir/inside.txt": []byte("inside"),
|
|
||||||
})
|
|
||||||
env.setSnapshot("home", "")
|
|
||||||
|
|
||||||
assert.Contains(t, env.backUp(t, "home"), filesLine(1, 1, 0))
|
|
||||||
|
|
||||||
dir := filepath.Join(env.src, "dir")
|
|
||||||
require.NoError(t, os.RemoveAll(dir))
|
|
||||||
require.NoError(t, os.WriteFile(dir, []byte("now a file"), 0o600))
|
|
||||||
|
|
||||||
assert.Contains(t, env.backUp(t, "home"),
|
|
||||||
"Files: 1 examined, 1 backed up, 0 unchanged, 1 deleted.")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Under --skip-errors a file inside a directory that the walk cannot
|
|
||||||
// open is not counted as deleted: it is still on disk.
|
|
||||||
func TestBackupDoesNotCountFileInUnopenableDirectoryAsDeleted(t *testing.T) {
|
|
||||||
log.Initialize(log.Config{})
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := newDeletedCountEnv(t, map[string][]byte{
|
|
||||||
"open.txt": []byte("open"),
|
|
||||||
"locked/inside.txt": []byte("inside"),
|
|
||||||
})
|
|
||||||
env.setSnapshot("work", "")
|
|
||||||
|
|
||||||
assert.Contains(t, env.backUp(t, "work"), filesLine(2, 2, 0))
|
|
||||||
|
|
||||||
// A directory's mode does not lock out root, who runs the tests, so
|
|
||||||
// opening the directory is made to fail instead.
|
|
||||||
env.v.Fs = &openFailFs{
|
|
||||||
path: filepath.Join(env.src, "locked"),
|
|
||||||
err: os.ErrPermission,
|
|
||||||
}
|
|
||||||
|
|
||||||
env.out.Reset()
|
|
||||||
err := env.v.CreateSnapshot(&vaultik.SnapshotCreateOptions{
|
|
||||||
SkipErrors: true,
|
|
||||||
Snapshots: []string{"work"},
|
|
||||||
})
|
|
||||||
require.ErrorIs(t, err, vaultik.ErrFilesUnreadable)
|
|
||||||
assert.Contains(t, env.out.String(),
|
|
||||||
"Files: 2 examined, 0 backed up, 1 unchanged, 1 failed.")
|
|
||||||
}
|
|
||||||
|
|
||||||
// A backup counts only files that the previous snapshot of its own name
|
|
||||||
// had, not one that a later snapshot of another name over the same path
|
|
||||||
// had and that is since removed.
|
|
||||||
func TestBackupIgnoresSnapshotOfAnotherName(t *testing.T) {
|
|
||||||
log.Initialize(log.Config{})
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := newDeletedCountEnv(t, map[string][]byte{
|
|
||||||
"kept.txt": []byte("kept"),
|
|
||||||
})
|
|
||||||
env.setSnapshot("home", "")
|
|
||||||
env.setSnapshot("other", "")
|
|
||||||
|
|
||||||
env.backUp(t, "home")
|
|
||||||
|
|
||||||
// Snapshot start times are kept in whole seconds: wait so that other's
|
|
||||||
// snapshot is strictly the most recent.
|
|
||||||
time.Sleep(time.Second)
|
|
||||||
|
|
||||||
extra := filepath.Join(env.src, "extra.txt")
|
|
||||||
require.NoError(t, os.WriteFile(extra, []byte("extra"), 0o600))
|
|
||||||
env.backUp(t, "other")
|
|
||||||
require.NoError(t, os.Remove(extra))
|
|
||||||
|
|
||||||
assert.Contains(t, env.backUp(t, "home"), filesLine(1, 0, 1))
|
|
||||||
}
|
|
||||||
|
|
||||||
// A file removed from disk is counted as deleted by the next backup of
|
|
||||||
// the snapshot that had it, and not by any later backup of that
|
|
||||||
// snapshot, nor by the first backup of another name over the same path.
|
|
||||||
func TestBackupCountsDeletedFileOnce(t *testing.T) {
|
|
||||||
log.Initialize(log.Config{})
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
aContent := bytes.Repeat([]byte("a"), 1000)
|
|
||||||
bContent := bytes.Repeat([]byte("b"), 2000)
|
|
||||||
keptContent := bytes.Repeat([]byte("k"), 500)
|
|
||||||
|
|
||||||
env := newDeletedCountEnv(t, map[string][]byte{
|
|
||||||
"a.txt": aContent,
|
|
||||||
"b.txt": bContent,
|
|
||||||
"kept.txt": keptContent,
|
|
||||||
})
|
|
||||||
env.setSnapshot("home", "")
|
|
||||||
env.setSnapshot("other", "")
|
|
||||||
|
|
||||||
assert.Contains(t, env.backUp(t, "home"), filesLine(3, 3, 0))
|
|
||||||
|
|
||||||
require.NoError(t, os.Remove(filepath.Join(env.src, "a.txt")))
|
|
||||||
require.NoError(t, os.Remove(filepath.Join(env.src, "b.txt")))
|
|
||||||
|
|
||||||
summary := env.backUp(t, "home")
|
|
||||||
assert.Contains(t, summary,
|
|
||||||
"Files: 1 examined, 0 backed up, 1 unchanged, 2 deleted.")
|
|
||||||
assert.Contains(t, summary,
|
|
||||||
fmt.Sprintf("Data: %s total (%s backed up), %s deleted.",
|
|
||||||
env.v.UI.Size(int64(len(keptContent))), env.v.UI.Size(0),
|
|
||||||
env.v.UI.Size(int64(len(aContent)+len(bContent)))))
|
|
||||||
|
|
||||||
assert.Contains(t, env.backUp(t, "home"), filesLine(1, 0, 1))
|
|
||||||
assert.Contains(t, env.backUp(t, "other"), filesLine(1, 0, 1))
|
|
||||||
}
|
|
||||||
@@ -49,19 +49,13 @@ func TestSnapshotSummaryCountsFileNotStoredAsFailed(t *testing.T) {
|
|||||||
name string
|
name string
|
||||||
openErr error
|
openErr error
|
||||||
skipErrors bool
|
skipErrors bool
|
||||||
wantErr error // nil for none
|
|
||||||
}{
|
}{
|
||||||
// What a normal user gets opening a file with mode 000.
|
// What a normal user gets opening a file with mode 000.
|
||||||
{name: "unopenable under skip-errors",
|
{name: "unopenable under skip-errors",
|
||||||
openErr: os.ErrPermission, skipErrors: true,
|
openErr: os.ErrPermission, skipErrors: true},
|
||||||
wantErr: vaultik.ErrFilesUnreadable},
|
|
||||||
// What opening a file removed after phase 1 gives.
|
// What opening a file removed after phase 1 gives.
|
||||||
{name: "removed between the phases",
|
{name: "removed between the phases",
|
||||||
openErr: os.ErrNotExist, skipErrors: false},
|
openErr: os.ErrNotExist, skipErrors: false},
|
||||||
// A removal is not an error, so --skip-errors does not make the
|
|
||||||
// run fail. See https://git.eeqj.de/sneak/vaultik/issues/310.
|
|
||||||
{name: "removed between the phases under skip-errors",
|
|
||||||
openErr: os.ErrNotExist, skipErrors: true},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@@ -93,7 +87,7 @@ func TestSnapshotSummaryCountsFileNotStoredAsFailed(t *testing.T) {
|
|||||||
"src": {Paths: []string{srcDir}},
|
"src": {Paths: []string{srcDir}},
|
||||||
}
|
}
|
||||||
|
|
||||||
store, err := storage.NewFileStorer(t.TempDir())
|
store, err := storage.NewFileStorer(filepath.Join(tempDir, "remote"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
db, err := database.New(ctx, cfg.IndexPath)
|
db, err := database.New(ctx, cfg.IndexPath)
|
||||||
@@ -105,11 +99,10 @@ func TestSnapshotSummaryCountsFileNotStoredAsFailed(t *testing.T) {
|
|||||||
v := newBackupVaultik(ctx, cfg, store, repos, db, fs)
|
v := newBackupVaultik(ctx, cfg, store, repos, db, fs)
|
||||||
v.UI = ui.NewWithColor(out, false)
|
v.UI = ui.NewWithColor(out, false)
|
||||||
|
|
||||||
err = v.CreateSnapshot(&vaultik.SnapshotCreateOptions{
|
require.NoError(t, v.CreateSnapshot(&vaultik.SnapshotCreateOptions{
|
||||||
SkipErrors: tt.skipErrors,
|
SkipErrors: tt.skipErrors,
|
||||||
Snapshots: []string{"src"},
|
Snapshots: []string{"src"},
|
||||||
})
|
}))
|
||||||
require.ErrorIs(t, err, tt.wantErr)
|
|
||||||
|
|
||||||
summary := out.String()
|
summary := out.String()
|
||||||
assert.Contains(t, summary,
|
assert.Contains(t, summary,
|
||||||
|
|||||||
@@ -61,9 +61,7 @@ const tabPadding = 3
|
|||||||
// attempt is made to recover or fabricate the human ID.
|
// attempt is made to recover or fabricate the human ID.
|
||||||
//
|
//
|
||||||
// Snapshots in the local index with no counterpart on the destination
|
// Snapshots in the local index with no counterpart on the destination
|
||||||
// store are reported as drift below the table, unless the local index
|
// store are reported as drift below the table.
|
||||||
// is bound to a different destination than the configured one: that is
|
|
||||||
// reported instead.
|
|
||||||
//
|
//
|
||||||
// If remote listing fails (unmounted volume, permission denied,
|
// If remote listing fails (unmounted volume, permission denied,
|
||||||
// network), we degrade to local-only with a warning. List never fails
|
// network), we degrade to local-only with a warning. List never fails
|
||||||
@@ -105,26 +103,7 @@ func (v *Vaultik) ListSnapshots(jsonOutput bool) error {
|
|||||||
return snapshots[i].Timestamp.After(snapshots[j].Timestamp)
|
return snapshots[i].Timestamp.After(snapshots[j].Timestamp)
|
||||||
})
|
})
|
||||||
|
|
||||||
stored, configured, err := v.storageBinding()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// A local index bound to another destination describes that one, so
|
|
||||||
// its records are expected to be missing here, and prune refuses to
|
|
||||||
// run. The binding is reported instead of advising prune.
|
|
||||||
boundElsewhere := stored != "" && stored != configured
|
|
||||||
|
|
||||||
if jsonOutput {
|
if jsonOutput {
|
||||||
// stdout holds only the document, so this goes to the logger.
|
|
||||||
if boundElsewhere {
|
|
||||||
log.Warn("Local index is bound to a different backup "+
|
|
||||||
"destination; to proceed, revert storage_url (or the s3.* "+
|
|
||||||
"endpoint, bucket and prefix) to the bound destination, "+
|
|
||||||
"or run 'vaultik database delete'",
|
|
||||||
"bound_to", stored, "configured", configured)
|
|
||||||
}
|
|
||||||
|
|
||||||
if remoteErr == nil {
|
if remoteErr == nil {
|
||||||
v.reportJSONListingLimits(listing)
|
v.reportJSONListingLimits(listing)
|
||||||
}
|
}
|
||||||
@@ -146,13 +125,8 @@ func (v *Vaultik) ListSnapshots(jsonOutput bool) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if boundElsewhere {
|
|
||||||
v.UI.Warningf("%v\n%s", errStorageBindingMismatch,
|
|
||||||
buildBindingMismatchMessage(stored, configured))
|
|
||||||
}
|
|
||||||
|
|
||||||
if remoteErr == nil {
|
if remoteErr == nil {
|
||||||
v.reportListDrift(snapshots, listing, boundElsewhere)
|
v.reportListDrift(snapshots, listing)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -458,9 +432,7 @@ func (v *Vaultik) snapshotInfoFromLocal(ls *database.Snapshot) SnapshotInfo {
|
|||||||
// reportListDrift prints the reconciliation notes the merged table
|
// reportListDrift prints the reconciliation notes the merged table
|
||||||
// cannot express on its own: local records with no counterpart on the
|
// cannot express on its own: local records with no counterpart on the
|
||||||
// destination store, plus counts of remote snapshots that were
|
// destination store, plus counts of remote snapshots that were
|
||||||
// unreadable or omitted. Local records are not reported when
|
// unreadable or omitted.
|
||||||
// boundElsewhere is set: ListSnapshots has already said why they are
|
|
||||||
// missing.
|
|
||||||
//
|
//
|
||||||
// This is what remains of the old reportRemoteDrift, and it no longer
|
// This is what remains of the old reportRemoteDrift, and it no longer
|
||||||
// touches the destination store. Its remote-only half collapsed into
|
// touches the destination store. Its remote-only half collapsed into
|
||||||
@@ -469,7 +441,6 @@ func (v *Vaultik) snapshotInfoFromLocal(ls *database.Snapshot) SnapshotInfo {
|
|||||||
// so `snapshot list` lists the destination exactly once per invocation.
|
// so `snapshot list` lists the destination exactly once per invocation.
|
||||||
func (v *Vaultik) reportListDrift(
|
func (v *Vaultik) reportListDrift(
|
||||||
snapshots []SnapshotInfo, listing *remoteSnapshotListing,
|
snapshots []SnapshotInfo, listing *remoteSnapshotListing,
|
||||||
boundElsewhere bool,
|
|
||||||
) {
|
) {
|
||||||
var localOnly []string
|
var localOnly []string
|
||||||
|
|
||||||
@@ -479,7 +450,7 @@ func (v *Vaultik) reportListDrift(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(localOnly) > 0 && !boundElsewhere {
|
if len(localOnly) > 0 {
|
||||||
v.UI.Warningf("%d local snapshot record(s) not found in backup "+
|
v.UI.Warningf("%d local snapshot record(s) not found in backup "+
|
||||||
"destination store:", len(localOnly))
|
"destination store:", len(localOnly))
|
||||||
|
|
||||||
|
|||||||
@@ -122,9 +122,6 @@ func newListEnv(t *testing.T) *listEnv {
|
|||||||
store := newObservingStorer()
|
store := newObservingStorer()
|
||||||
stdout := &bytes.Buffer{}
|
stdout := &bytes.Buffer{}
|
||||||
|
|
||||||
// A backup wrote to this destination, so it carries the marker.
|
|
||||||
require.NoError(t, store.Put(ctx, markerFile, strings.NewReader("")))
|
|
||||||
|
|
||||||
v := &vaultik.Vaultik{
|
v := &vaultik.Vaultik{
|
||||||
Config: &config.Config{
|
Config: &config.Config{
|
||||||
AgeSecretKey: "",
|
AgeSecretKey: "",
|
||||||
@@ -424,74 +421,6 @@ func TestListSnapshots_LocalOnlyReportedAsDrift(t *testing.T) {
|
|||||||
assert.NotContains(t, out, "snapshot cleanup")
|
assert.NotContains(t, out, "snapshot cleanup")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestListSnapshots_BoundElsewhereDoesNotAdvisePrune covers a changed
|
|
||||||
// storage_url: the local index is still bound to the old destination,
|
|
||||||
// so its records are missing from the new one. prune refuses to run in
|
|
||||||
// that state, so the listing must give the choices prune gives instead
|
|
||||||
// of advising it.
|
|
||||||
func TestListSnapshots_BoundElsewhereDoesNotAdvisePrune(t *testing.T) {
|
|
||||||
log.Initialize(log.Config{})
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const (
|
|
||||||
oldURL = "file:///mnt/backups/old"
|
|
||||||
newURL = "file:///mnt/backups/new"
|
|
||||||
)
|
|
||||||
|
|
||||||
env := newListEnv(t)
|
|
||||||
env.v.Config.StorageURL = newURL
|
|
||||||
require.NoError(t, env.v.Repositories.LocalMeta.Set(context.Background(),
|
|
||||||
database.LocalMetaKeyStorageURL, oldURL))
|
|
||||||
env.addLocal(t, listLocalID, time.Date(2026, 3, 1, 10, 0, 0, 0, time.UTC))
|
|
||||||
|
|
||||||
err := env.v.ListSnapshots(false)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
out := env.stdout.String()
|
|
||||||
|
|
||||||
assert.Contains(t, out, listLocalID)
|
|
||||||
assert.Contains(t, out,
|
|
||||||
"local index is bound to a different backup destination")
|
|
||||||
assert.Contains(t, out, oldURL)
|
|
||||||
assert.Contains(t, out, newURL)
|
|
||||||
assert.Contains(t, out, "revert storage_url")
|
|
||||||
assert.Contains(t, out, "vaultik database delete")
|
|
||||||
assert.NotContains(t, out, "vaultik prune")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestListSnapshots_JSONReportsBoundElsewhere is the --json side of the
|
|
||||||
// test above. Every local row comes out with remote_present false, which
|
|
||||||
// a script reads as stale local records unless it is told, on stderr,
|
|
||||||
// that the local index is bound to a different destination.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // captureProcessStderr replaces os.Stderr
|
|
||||||
func TestListSnapshots_JSONReportsBoundElsewhere(t *testing.T) {
|
|
||||||
const (
|
|
||||||
oldURL = "file:///mnt/backups/old"
|
|
||||||
newURL = "file:///mnt/backups/new"
|
|
||||||
)
|
|
||||||
|
|
||||||
env := newListEnv(t)
|
|
||||||
env.v.Config.StorageURL = newURL
|
|
||||||
require.NoError(t, env.v.Repositories.LocalMeta.Set(context.Background(),
|
|
||||||
database.LocalMetaKeyStorageURL, oldURL))
|
|
||||||
env.addLocal(t, listLocalID, time.Date(2026, 3, 1, 10, 0, 0, 0, time.UTC))
|
|
||||||
|
|
||||||
stderr := captureProcessStderr(t, func() {
|
|
||||||
require.NoError(t, env.v.ListSnapshots(true))
|
|
||||||
})
|
|
||||||
|
|
||||||
rows := decodeListJSON(t, env.stdout.String())
|
|
||||||
require.Len(t, rows, 1)
|
|
||||||
assert.Equal(t, listLocalID, rows[0].ID)
|
|
||||||
|
|
||||||
assert.Contains(t, stderr, "bound to a different backup destination")
|
|
||||||
assert.Contains(t, stderr, oldURL)
|
|
||||||
assert.Contains(t, stderr, newURL)
|
|
||||||
assert.Contains(t, stderr, "revert storage_url")
|
|
||||||
assert.Contains(t, stderr, "vaultik database delete")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestListSnapshots_UnreachableRemoteDegrades covers the promise in the
|
// TestListSnapshots_UnreachableRemoteDegrades covers the promise in the
|
||||||
// doc comment: an unreachable destination is a warning plus local-only
|
// doc comment: an unreachable destination is a warning plus local-only
|
||||||
// output, never a failure.
|
// output, never a failure.
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package vaultik_test
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -46,7 +45,7 @@ func TestSkipErrorsBackupStopsOnIndexErrorRecordingDirectory(t *testing.T) {
|
|||||||
"tree": {Paths: []string{srcDir}},
|
"tree": {Paths: []string{srcDir}},
|
||||||
}
|
}
|
||||||
|
|
||||||
store, err := storage.NewFileStorer(t.TempDir())
|
store, err := storage.NewFileStorer(filepath.Join(tempDir, "remote"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
db, err := database.New(ctx, cfg.IndexPath)
|
db, err := database.New(ctx, cfg.IndexPath)
|
||||||
@@ -74,120 +73,3 @@ func TestSkipErrorsBackupStopsOnIndexErrorRecordingDirectory(t *testing.T) {
|
|||||||
require.Len(t, snapshots, 1)
|
require.Len(t, snapshots, 1)
|
||||||
assert.Nil(t, snapshots[0].CompletedAt)
|
assert.Nil(t, snapshots[0].CompletedAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
// A backup under --skip-errors that skips a file it cannot open, or a
|
|
||||||
// directory it cannot list, keeps the snapshot, counts what it skipped
|
|
||||||
// as failed and returns an error instead of finishing successfully. See
|
|
||||||
// https://git.eeqj.de/sneak/vaultik/issues/310.
|
|
||||||
func TestSkipErrorsBackupKeepsSnapshotAndFailsWhenItSkips(t *testing.T) {
|
|
||||||
log.Initialize(log.Config{})
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
skipped string // relative to the source directory
|
|
||||||
}{
|
|
||||||
{name: "file it cannot open", skipped: "skipdir/file.txt"},
|
|
||||||
{name: "directory it cannot list", skipped: "skipdir"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := newDeletedCountEnv(t, map[string][]byte{
|
|
||||||
"stored.txt": []byte("stored"),
|
|
||||||
"skipdir/file.txt": []byte("in skipdir"),
|
|
||||||
})
|
|
||||||
env.setSnapshot("work", "")
|
|
||||||
|
|
||||||
// A mode does not lock out root, who runs the tests, so
|
|
||||||
// opening the skipped path is made to fail instead.
|
|
||||||
env.v.Fs = &openFailFs{
|
|
||||||
path: filepath.Join(env.src, tt.skipped),
|
|
||||||
err: os.ErrPermission,
|
|
||||||
}
|
|
||||||
|
|
||||||
err := env.v.CreateSnapshot(&vaultik.SnapshotCreateOptions{
|
|
||||||
SkipErrors: true,
|
|
||||||
Snapshots: []string{"work"},
|
|
||||||
})
|
|
||||||
require.ErrorIs(t, err, vaultik.ErrFilesUnreadable)
|
|
||||||
|
|
||||||
assert.Contains(t, env.out.String(),
|
|
||||||
"Files: 2 examined, 1 backed up, 0 unchanged, 1 failed.")
|
|
||||||
assert.NotContains(t, env.out.String(), "Finished successfully.")
|
|
||||||
|
|
||||||
snapshots, err := env.v.Repositories.Snapshots.ListRecent(
|
|
||||||
context.Background(), listRecentTestLimit)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, snapshots, 1)
|
|
||||||
assert.NotNil(t, snapshots[0].CompletedAt, "the snapshot is kept")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// removeOnLookupFs is the real filesystem, except that path is removed
|
|
||||||
// just before it is looked up. The walk looks an entry up after listing
|
|
||||||
// its directory, so path is listed but gone when the walk reaches it.
|
|
||||||
type removeOnLookupFs struct {
|
|
||||||
afero.OsFs
|
|
||||||
|
|
||||||
path string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *removeOnLookupFs) LstatIfPossible(
|
|
||||||
name string,
|
|
||||||
) (os.FileInfo, bool, error) {
|
|
||||||
if name == f.path {
|
|
||||||
_ = os.Remove(name)
|
|
||||||
}
|
|
||||||
|
|
||||||
return f.OsFs.LstatIfPossible(name)
|
|
||||||
}
|
|
||||||
|
|
||||||
// A file removed before the walk reaches it is counted once, as failed,
|
|
||||||
// like a file removed before its data is read, and the backup finishes
|
|
||||||
// successfully, with or without --skip-errors. See
|
|
||||||
// https://git.eeqj.de/sneak/vaultik/issues/310.
|
|
||||||
func TestBackupCountsFileRemovedBeforeWalkReachesItAsFailed(t *testing.T) {
|
|
||||||
log.Initialize(log.Config{})
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
skipErrors bool
|
|
||||||
}{
|
|
||||||
{name: "under skip-errors", skipErrors: true},
|
|
||||||
{name: "without skip-errors", skipErrors: false},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := newDeletedCountEnv(t, map[string][]byte{
|
|
||||||
"stays.txt": []byte("stays"),
|
|
||||||
"removed.txt": []byte("removed"),
|
|
||||||
})
|
|
||||||
env.setSnapshot("walk", "")
|
|
||||||
|
|
||||||
// The previous snapshot has the file, so a removal the walk
|
|
||||||
// also counted as deleted would show.
|
|
||||||
env.backUp(t, "walk")
|
|
||||||
|
|
||||||
env.v.Fs = &removeOnLookupFs{
|
|
||||||
path: filepath.Join(env.src, "removed.txt"),
|
|
||||||
}
|
|
||||||
|
|
||||||
env.out.Reset()
|
|
||||||
require.NoError(t, env.v.CreateSnapshot(&vaultik.SnapshotCreateOptions{
|
|
||||||
SkipErrors: tt.skipErrors,
|
|
||||||
Snapshots: []string{"walk"},
|
|
||||||
}))
|
|
||||||
assert.Contains(t, env.out.String(),
|
|
||||||
"Files: 2 examined, 0 backed up, 1 unchanged, 1 failed.")
|
|
||||||
assert.Contains(t, env.out.String(), "Finished successfully.")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ func newSummaryEnv(t *testing.T) *summaryEnv {
|
|||||||
"split": {Paths: []string{dirOne, dirTwo}},
|
"split": {Paths: []string{dirOne, dirTwo}},
|
||||||
}
|
}
|
||||||
|
|
||||||
inner, err := storage.NewFileStorer(t.TempDir())
|
inner, err := storage.NewFileStorer(filepath.Join(tempDir, "remote"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
store := faultstore.New(inner)
|
store := faultstore.New(inner)
|
||||||
|
|||||||
@@ -45,9 +45,17 @@ func (v *Vaultik) EnsureStorageBinding() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
stored, configured, err := v.storageBinding()
|
configured := v.Config.StorageURL
|
||||||
|
if configured == "" {
|
||||||
|
// The s3.* form: bind on its endpoint, bucket and prefix,
|
||||||
|
// written the way storage_url would name the same destination.
|
||||||
|
s3 := v.Config.S3
|
||||||
|
configured = "s3://" + s3.Bucket + "/" + s3.Prefix + "?endpoint=" + s3.Endpoint
|
||||||
|
}
|
||||||
|
|
||||||
|
stored, err := v.Repositories.LocalMeta.Get(v.ctx, database.LocalMetaKeyStorageURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("reading local storage binding: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if stored == "" {
|
if stored == "" {
|
||||||
@@ -70,27 +78,6 @@ func (v *Vaultik) EnsureStorageBinding() error {
|
|||||||
errStorageBindingMismatch, buildBindingMismatchMessage(stored, configured))
|
errStorageBindingMismatch, buildBindingMismatchMessage(stored, configured))
|
||||||
}
|
}
|
||||||
|
|
||||||
// storageBinding returns the destination the local index is bound to
|
|
||||||
// ("" when no binding is recorded yet), then the configured destination
|
|
||||||
// written the same way. It records nothing, so a read-only command can
|
|
||||||
// call it.
|
|
||||||
func (v *Vaultik) storageBinding() (string, string, error) {
|
|
||||||
configured := v.Config.StorageURL
|
|
||||||
if configured == "" {
|
|
||||||
// The s3.* form: bind on its endpoint, bucket and prefix,
|
|
||||||
// written the way storage_url would name the same destination.
|
|
||||||
s3 := v.Config.S3
|
|
||||||
configured = "s3://" + s3.Bucket + "/" + s3.Prefix + "?endpoint=" + s3.Endpoint
|
|
||||||
}
|
|
||||||
|
|
||||||
stored, err := v.Repositories.LocalMeta.Get(v.ctx, database.LocalMetaKeyStorageURL)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", fmt.Errorf("reading local storage binding: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return stored, configured, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildBindingMismatchMessage assembles the multi-line explanation
|
// buildBindingMismatchMessage assembles the multi-line explanation
|
||||||
// shown when the local index is bound to a different destination than
|
// shown when the local index is bound to a different destination than
|
||||||
// the currently-configured one (the first line lives in the
|
// the currently-configured one (the first line lives in the
|
||||||
|
|||||||
@@ -1,147 +0,0 @@
|
|||||||
package vaultik_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"io/fs"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"sneak.berlin/go/vaultik/internal/log"
|
|
||||||
"sneak.berlin/go/vaultik/internal/vaultik"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestPruneDeletesNothingThroughSymlinkedDirectory checks that prune keeps
|
|
||||||
// every blob and the local snapshot record when the metadata/ or blobs/
|
|
||||||
// directory of a file:// destination is a symlink to a directory elsewhere
|
|
||||||
// (https://git.eeqj.de/sneak/vaultik/issues/296). A backup writes through
|
|
||||||
// such a link, so prune has to list through it too.
|
|
||||||
func TestPruneDeletesNothingThroughSymlinkedDirectory(t *testing.T) {
|
|
||||||
log.Initialize(log.Config{})
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, dir := range []string{"metadata", "blobs"} {
|
|
||||||
t.Run(dir, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
storeDir := filepath.Join(t.TempDir(), "store")
|
|
||||||
v, repos, _ := backUpToFileDestination(ctx, t, storeDir)
|
|
||||||
blobFiles := blobFilesIn(t, storeDir)
|
|
||||||
moveBehindSymlink(t, storeDir, dir)
|
|
||||||
|
|
||||||
require.NoError(t, v.Prune(&vaultik.PruneOptions{Force: true}))
|
|
||||||
|
|
||||||
// When blobs/ is the link, these paths run through it.
|
|
||||||
for _, blobFile := range blobFiles {
|
|
||||||
assert.FileExists(t, blobFile, "prune must delete no blob")
|
|
||||||
}
|
|
||||||
|
|
||||||
snapshots, err := repos.Snapshots.ListRecent(ctx, listRecentTestLimit)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Len(t, snapshots, 1, "prune must delete no local snapshot record")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestPruneFailsOnSymlinkedDirectoryWithMissingTarget checks that prune
|
|
||||||
// fails, deleting no blob and keeping the local snapshot record, when the
|
|
||||||
// metadata/ or blobs/ directory of a file:// destination is a symlink
|
|
||||||
// whose target is missing, as a link to a volume that is not mounted is.
|
|
||||||
func TestPruneFailsOnSymlinkedDirectoryWithMissingTarget(t *testing.T) {
|
|
||||||
log.Initialize(log.Config{})
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, dir := range []string{"metadata", "blobs"} {
|
|
||||||
t.Run(dir, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
storeDir := filepath.Join(t.TempDir(), "store")
|
|
||||||
v, repos, _ := backUpToFileDestination(ctx, t, storeDir)
|
|
||||||
blobFiles := blobFilesIn(t, storeDir)
|
|
||||||
targetPath := moveBehindSymlink(t, storeDir, dir)
|
|
||||||
|
|
||||||
// Unmount the volume the link points to, and mount it again
|
|
||||||
// once prune has run.
|
|
||||||
unmountedPath := targetPath + "-unmounted"
|
|
||||||
require.NoError(t, os.Rename(targetPath, unmountedPath))
|
|
||||||
|
|
||||||
err := v.Prune(&vaultik.PruneOptions{Force: true})
|
|
||||||
require.ErrorIs(t, err, fs.ErrNotExist)
|
|
||||||
|
|
||||||
require.NoError(t, os.Rename(unmountedPath, targetPath))
|
|
||||||
|
|
||||||
// When blobs/ is the link, these paths run through it.
|
|
||||||
for _, blobFile := range blobFiles {
|
|
||||||
assert.FileExists(t, blobFile, "prune must delete no blob")
|
|
||||||
}
|
|
||||||
|
|
||||||
snapshots, err := repos.Snapshots.ListRecent(ctx, listRecentTestLimit)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Len(t, snapshots, 1, "prune must delete no local snapshot record")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRemoveSnapshotWarnsOnSymlinkedMetadataWithMissingTarget checks that
|
|
||||||
// snapshot remove warns that the metadata could not be removed from the
|
|
||||||
// destination when metadata/ is a symlink whose target is missing, as it
|
|
||||||
// does when the destination directory is missing, instead of finding no
|
|
||||||
// metadata to remove.
|
|
||||||
func TestRemoveSnapshotWarnsOnSymlinkedMetadataWithMissingTarget(t *testing.T) {
|
|
||||||
log.Initialize(log.Config{})
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
storeDir := filepath.Join(t.TempDir(), "store")
|
|
||||||
v, repos, out := backUpToFileDestination(ctx, t, storeDir)
|
|
||||||
targetPath := moveBehindSymlink(t, storeDir, "metadata")
|
|
||||||
require.NoError(t, os.Rename(targetPath, targetPath+"-unmounted"))
|
|
||||||
|
|
||||||
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.NotContains(t, out.String(),
|
|
||||||
"Removed snapshot metadata from remote storage")
|
|
||||||
}
|
|
||||||
|
|
||||||
// blobFilesIn returns the paths of the blob files under storeDir/blobs, and
|
|
||||||
// fails the test when there are none.
|
|
||||||
func blobFilesIn(t *testing.T, storeDir string) []string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
var blobFiles []string
|
|
||||||
|
|
||||||
err := filepath.WalkDir(filepath.Join(storeDir, "blobs"),
|
|
||||||
func(path string, entry fs.DirEntry, err error) error {
|
|
||||||
if err == nil && !entry.IsDir() {
|
|
||||||
blobFiles = append(blobFiles, path)
|
|
||||||
}
|
|
||||||
|
|
||||||
return err
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotEmpty(t, blobFiles)
|
|
||||||
|
|
||||||
return blobFiles
|
|
||||||
}
|
|
||||||
|
|
||||||
// moveBehindSymlink moves storeDir/dir to a directory elsewhere, puts a
|
|
||||||
// symlink to it in its place, and returns where it went.
|
|
||||||
func moveBehindSymlink(t *testing.T, storeDir, dir string) string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
linkPath := filepath.Join(storeDir, dir)
|
|
||||||
targetPath := filepath.Join(t.TempDir(), dir)
|
|
||||||
require.NoError(t, os.Rename(linkPath, targetPath))
|
|
||||||
require.NoError(t, os.Symlink(targetPath, linkPath))
|
|
||||||
|
|
||||||
return targetPath
|
|
||||||
}
|
|
||||||
@@ -28,7 +28,7 @@ func TestBackupWithoutCronOfTwoPathSnapshotRestoresBothPaths(t *testing.T) {
|
|||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
firstDir := filepath.Join(tempDir, "first")
|
firstDir := filepath.Join(tempDir, "first")
|
||||||
secondDir := filepath.Join(tempDir, "second")
|
secondDir := filepath.Join(tempDir, "second")
|
||||||
storeDir := t.TempDir()
|
storeDir := filepath.Join(tempDir, "remote")
|
||||||
restoreDir := filepath.Join(tempDir, "restored")
|
restoreDir := filepath.Join(tempDir, "restored")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ func TestUncompressedSizeOfChangedFileStaysFixed(t *testing.T) {
|
|||||||
|
|
||||||
appendRandomBytes(t, fs, files, changedPath, appendedFileSize)
|
appendRandomBytes(t, fs, files, changedPath, appendedFileSize)
|
||||||
|
|
||||||
store, err := storage.NewFileStorer(t.TempDir())
|
store, err := storage.NewFileStorer(filepath.Join(tempDir, "remote"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
db, err := database.New(ctx, dbPath)
|
db, err := database.New(ctx, dbPath)
|
||||||
|
|||||||
@@ -680,7 +680,7 @@ func (v *Vaultik) verifyManifestAgainstDatabase(
|
|||||||
func (v *Vaultik) verifyBlobExistenceFromDB(
|
func (v *Vaultik) verifyBlobExistenceFromDB(
|
||||||
blobs []snapshot.BlobInfo, result *VerifyResult,
|
blobs []snapshot.BlobInfo, result *VerifyResult,
|
||||||
) error {
|
) error {
|
||||||
log.Info("Verifying blob existence in storage", "blob_count", len(blobs))
|
log.Info("Verifying blob existence in S3", "blob_count", len(blobs))
|
||||||
|
|
||||||
for i, blob := range blobs {
|
for i, blob := range blobs {
|
||||||
// The hash is read from the snapshot database, which is not
|
// The hash is read from the snapshot database, which is not
|
||||||
@@ -700,7 +700,7 @@ func (v *Vaultik) verifyBlobExistenceFromDB(
|
|||||||
result.Missing++
|
result.Missing++
|
||||||
result.MissingSize += blob.CompressedSize
|
result.MissingSize += blob.CompressedSize
|
||||||
case err != nil:
|
case err != nil:
|
||||||
return fmt.Errorf("could not check blob %s in storage: %w",
|
return fmt.Errorf("checking blob %s in storage: %w",
|
||||||
blob.Hash, err)
|
blob.Hash, err)
|
||||||
case stat.Size != blob.CompressedSize:
|
case stat.Size != blob.CompressedSize:
|
||||||
log.Warn("Blob has the wrong size in storage",
|
log.Warn("Blob has the wrong size in storage",
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ func TestVerify_QuietSuppressesReport(t *testing.T) {
|
|||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
dataDir := filepath.Join(tempDir, "source")
|
dataDir := filepath.Join(tempDir, "source")
|
||||||
storeDir := t.TempDir()
|
storeDir := filepath.Join(tempDir, "remote")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
chunkSize := int64(32 * 1024)
|
chunkSize := int64(32 * 1024)
|
||||||
|
|||||||
Reference in New Issue
Block a user