Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ad36f5ad8 | ||
|
|
5d8c515192 | ||
|
|
42eb50b151 | ||
|
|
25ee9211d3 | ||
|
|
3361e9ebd9 | ||
|
|
fb8abcd049 | ||
|
|
6aceaaf086 | ||
|
|
3b2b9cec84 | ||
|
|
dfa3bbc9e1 | ||
|
|
740f474c59 | ||
|
|
27e9798da5 | ||
|
|
4193ced1f6 | ||
|
|
e48bafccef | ||
|
|
bffd4578fc | ||
|
|
f4ab1ba5ae | ||
|
|
005927a7fc | ||
|
|
09a0c01c43 | ||
|
|
8c54e0ff75 | ||
|
|
d1213ecb34 | ||
|
|
5f6455ba17 | ||
|
|
0288735aaf | ||
|
|
35eed79b1a | ||
|
|
94412d9ef3 | ||
|
|
167c0a0746 | ||
|
|
07c957f84b | ||
|
|
c9a519c233 | ||
|
|
fa56165fe4 | ||
|
|
34f61fb408 | ||
|
|
05bf73c48a | ||
|
|
79a73fa122 |
@@ -311,6 +311,8 @@ Repository interfaces:
|
||||
|
||||
```
|
||||
CreateSnapshot(opts)
|
||||
│
|
||||
├─► markDestination() // Refuse a destination without the marker, then write it
|
||||
│
|
||||
├─► PruneDatabase() // Critical: avoid dedup errors
|
||||
│
|
||||
@@ -367,6 +369,8 @@ CreateSnapshot(opts)
|
||||
|
||||
```
|
||||
bucket/
|
||||
├── vaultik-destination.txt # Marker written by every backup
|
||||
│
|
||||
├── blobs/
|
||||
│ └── {hash[0:2]}/
|
||||
│ └── {hash[2:4]}/
|
||||
@@ -378,6 +382,15 @@ bucket/
|
||||
└── manifest.json.zst # Blob list (for pruning/verification)
|
||||
```
|
||||
|
||||
While the local index holds snapshots, a destination without
|
||||
`vaultik-destination.txt` is treated as not present
|
||||
(`checkDestinationMarker` in `internal/vaultik/destination_marker.go`):
|
||||
`snapshot create` refuses to write to it, and listing it fails as for a
|
||||
destination that cannot be listed. An unplugged volume's empty mount point
|
||||
is therefore never taken for a destination whose snapshots are all gone.
|
||||
The `file://` backend never creates its destination directory, so a write
|
||||
to a missing one fails.
|
||||
|
||||
The `{remote-key}` directory name is a one-way double SHA-256 hash of the human
|
||||
snapshot ID, so the human ID (hostname, snapshot name, timestamp) is never
|
||||
written to the store as a directory name. See
|
||||
|
||||
+24
-3
@@ -5,7 +5,16 @@
|
||||
FROM golangci/golangci-lint:v2.14.0@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f AS lint
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
# Without the time limit a stalled connection to the module proxy holds
|
||||
# this step, and every build on the host sharing it, until the kernel
|
||||
# drops the connection, which can take an hour. The limit is well above
|
||||
# the three minutes a working download can take on a busy build host.
|
||||
RUN for attempt in 1 2 3; do \
|
||||
timeout 300 go mod download && exit 0; \
|
||||
echo "go mod download attempt $attempt failed or ran over 300s" >&2; \
|
||||
done; \
|
||||
echo "module download failed: giving up after 3 attempts" >&2; \
|
||||
exit 1
|
||||
COPY . .
|
||||
# `golangci-lint run` silently ignores an unknown top-level key in
|
||||
# .golangci.yml, such as a misspelt `linters:`; `config verify` fails on it.
|
||||
@@ -18,7 +27,13 @@ RUN golangci-lint run --config .golangci.yml ./...
|
||||
FROM golang:1.26.1@sha256:cd78d88e00afadbedd272f977d375a6247455f3a4b1178f8ae8bbcb201743a8a AS test
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
# Limited and retried as in the lint phase above.
|
||||
RUN for attempt in 1 2 3; do \
|
||||
timeout 300 go mod download && exit 0; \
|
||||
echo "go mod download attempt $attempt failed or ran over 300s" >&2; \
|
||||
done; \
|
||||
echo "module download failed: giving up after 3 attempts" >&2; \
|
||||
exit 1
|
||||
COPY . .
|
||||
RUN go test -timeout 90s -race -cover ./... || \
|
||||
{ echo "--- Rerunning with -v for details ---"; \
|
||||
@@ -36,7 +51,13 @@ RUN apk add --no-cache git
|
||||
RUN git config --system --add safe.directory /src
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
# Limited and retried as in the lint phase above.
|
||||
RUN for attempt in 1 2 3; do \
|
||||
timeout 300 go mod download && exit 0; \
|
||||
echo "go mod download attempt $attempt failed or ran over 300s" >&2; \
|
||||
done; \
|
||||
echo "module download failed: giving up after 3 attempts" >&2; \
|
||||
exit 1
|
||||
COPY . .
|
||||
|
||||
# The VERSION build arg when one is given, otherwise
|
||||
|
||||
@@ -24,6 +24,10 @@ grep 'public key' vaultik_backup_private_key.txt
|
||||
vaultik config set age_recipients.0 age1YOUR_PUBLIC_KEY_HERE
|
||||
vaultik config set 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
|
||||
# (System Settings → Privacy & Security → Full Disk Access), otherwise
|
||||
# the backup will abort with a permission error on protected directories
|
||||
@@ -286,13 +290,20 @@ needed.
|
||||
remove orphaned blobs from remote storage. By default keeps only the latest
|
||||
snapshot per name; use `--keep-newer-than` for a rolling window.
|
||||
* `--keep-newer-than <duration>`: With `--prune`, keep snapshots newer than
|
||||
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
|
||||
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
|
||||
of the local index and the backup destination store — with timestamps
|
||||
and three sizes per snapshot (compressed remote size; total
|
||||
uncompressed chunk size; size of chunks newly referenced by that
|
||||
snapshot).
|
||||
uncompressed chunk size; size of the chunks that snapshot stored that
|
||||
were not stored before). Both chunk sizes are recorded when the
|
||||
snapshot's backup runs.
|
||||
|
||||
Listing the destination store does **not** require the age secret key,
|
||||
so it works in vaultik's intended configuration, where the backed-up
|
||||
@@ -305,8 +316,8 @@ only the hash, so hostname and snapshot name exist solely in the local
|
||||
index and in the encrypted per-snapshot database. Snapshots found only
|
||||
on the destination store are therefore listed as
|
||||
`<remote only:<abbreviated remote key>>` and show `<remote only>` in
|
||||
the uncompressed and "new chunk" columns, which can only be computed
|
||||
from the local index. Their timestamp and compressed size are real,
|
||||
the uncompressed and "new chunk" columns, which come only from the
|
||||
local index. Their timestamp and compressed size are real,
|
||||
read from the manifest.
|
||||
|
||||
Snapshots in the local index with no counterpart on the destination
|
||||
@@ -315,7 +326,10 @@ invocation that reconciles them.
|
||||
|
||||
If the destination store cannot be listed (unmounted volume,
|
||||
permission denied, network down), the command warns, falls back to the
|
||||
local index alone, and still exits zero.
|
||||
local index alone, and still exits zero. It does the same when the
|
||||
local index holds snapshots and the destination has no
|
||||
`vaultik-destination.txt`, as when a volume mounted at the destination
|
||||
is unplugged and leaves its empty mount point behind.
|
||||
* `--json`: Output in JSON format. Each entry carries `locally_tracked`
|
||||
(whether the snapshot is in the local index), `remote_key` (the full
|
||||
64-character storage key), and `remote_present` (whether it was seen
|
||||
@@ -349,13 +363,17 @@ the backup destination store. Blobs are NOT touched — deleting blobs
|
||||
requires reading every remaining remote manifest (the destination store
|
||||
may hold snapshots this host doesn't know about), which is what
|
||||
`vaultik prune` does. On success the command prints the exact `vaultik
|
||||
prune` invocation to run as a follow-up. Local row cleanup (files,
|
||||
chunks, blobs the snapshot was the last referrer for) runs
|
||||
automatically. If the destination store is unreachable, the local-DB
|
||||
removal still completes and a warning is emitted; run `vaultik snapshot
|
||||
remove <snapshot-id>` again once the store is reachable to remove the
|
||||
snapshot's metadata from it (`vaultik prune` does not). To wipe everything
|
||||
on the destination in one go, use `vaultik remote nuke --force`.
|
||||
prune` invocation to run as a follow-up. An ID that names no snapshot in
|
||||
the local index or on the destination store is an error; with
|
||||
`--local-only`, the snapshot must be in the local index. Local row
|
||||
cleanup (files, chunks, blobs the snapshot was the last referrer for)
|
||||
runs automatically. If the destination store is unreachable, or the
|
||||
local index holds snapshots and the destination has no
|
||||
`vaultik-destination.txt`, the local-DB removal still completes and a
|
||||
warning is emitted; run `vaultik snapshot remove <snapshot-id>` again
|
||||
once the store is reachable to remove the snapshot's metadata from it
|
||||
(`vaultik prune` does not). To wipe everything on the destination in one
|
||||
go, use `vaultik remote nuke --force`.
|
||||
* `--local-only`: Skip remote cleanup; only touch the local index
|
||||
* `--dry-run`: Show what would be deleted without deleting
|
||||
* `--force`: Skip confirmation prompt
|
||||
@@ -371,7 +389,9 @@ on the destination in one go, use `vaultik remote nuke --force`.
|
||||
* Optional path arguments to restore specific files/directories (default: all)
|
||||
* Preserves file permissions, timestamps, ownership (ownership requires root),
|
||||
symlinks, and empty directories
|
||||
* `--verify`: After restoring, verify every file's chunk hashes match
|
||||
* `--verify`: After restoring, verify every restored file's chunk hashes
|
||||
match. A file skipped under `--skip-errors` is reported as failed to
|
||||
restore and is not verified.
|
||||
|
||||
**`prune`**: Tidy up everything that isn't needed. Runs three passes:
|
||||
(1) reconcile the local index against the destination store — any
|
||||
@@ -382,10 +402,16 @@ 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
|
||||
manifests — network cost scales with the number of snapshots. `snapshot
|
||||
create --prune` runs the same cleanup automatically; this is the
|
||||
manual entry point for the same work.
|
||||
manual entry point for the same work. If the destination store cannot
|
||||
be listed, or the local index holds snapshots and the destination has
|
||||
no `vaultik-destination.txt` (an unplugged volume's empty mount point),
|
||||
prune fails and deletes nothing. `snapshot purge` does the same. If a
|
||||
blob cannot be deleted, the command (and `snapshot create --prune`)
|
||||
exits non-zero.
|
||||
* `--force`: Skip confirmation prompt
|
||||
* `--json`: Output stats as JSON. Also skips the confirmation prompt, as
|
||||
`--force` does.
|
||||
`--force` does. A blob that cannot be deleted is counted in
|
||||
`blobs_failed`, and the document is printed before the non-zero exit.
|
||||
|
||||
**`info`**: Display system configuration, storage settings, encryption
|
||||
recipients, and local database statistics.
|
||||
@@ -399,20 +425,25 @@ orphaned blob figures are reported as unknown; `--json` gives them as
|
||||
`null`, lists the remote key of each unreadable manifest in
|
||||
`unreadable_manifests` and counts the manifests under skipped names in
|
||||
`skipped_manifest_count`. An unreadable manifest also leaves its
|
||||
snapshot's blob count and blob size unknown, `null` in `--json`.
|
||||
snapshot's blob count and blob size unknown, `null` in `--json`. A file
|
||||
under `blobs/` whose name is not a blob hash is not counted as a blob,
|
||||
since `prune` never deletes it.
|
||||
* `--json`: Output as JSON
|
||||
|
||||
**`remote nuke`**: Delete every snapshot's metadata and every blob from the
|
||||
backup destination store, leaving the bucket prefix empty. Destructive and
|
||||
irreversible. This is the single supported way to wipe the entire
|
||||
destination store.
|
||||
**`remote nuke`**: Delete every snapshot's metadata, every blob and
|
||||
`vaultik-destination.txt` from the backup destination store, leaving the
|
||||
bucket prefix empty. Destructive and irreversible. This is the single
|
||||
supported way to wipe the entire destination store. If anything cannot
|
||||
be deleted, the command exits non-zero; running it again deletes what is
|
||||
left.
|
||||
* `--force`: Required to confirm destruction.
|
||||
|
||||
**`database delete`**: Delete the local SQLite state database file
|
||||
entirely. Remote storage is unaffected; the next backup will do a full
|
||||
scan and re-deduplicate against existing remote blobs, and the local
|
||||
index will re-bind to the currently configured storage destination.
|
||||
Use this after changing `storage_url` to a different destination.
|
||||
Use this after changing `storage_url`, or `s3.endpoint`, `s3.bucket` or
|
||||
`s3.prefix`, to a different destination.
|
||||
* `--force`: Skip confirmation prompt
|
||||
|
||||
---
|
||||
@@ -426,7 +457,15 @@ object store. Credentials are read from `s3.access_key_id` and
|
||||
`s3.secret_access_key` in the config file.
|
||||
|
||||
**Local filesystem** (`file:///path/to/backup`): Stores blobs and metadata on
|
||||
a local or mounted filesystem. Useful for testing or backing up to a NAS.
|
||||
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.
|
||||
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
|
||||
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
|
||||
providers. Requires rclone to be configured separately (`rclone config`).
|
||||
@@ -457,6 +496,7 @@ For an implementation-level view of the internals — the data model, the
|
||||
|
||||
```
|
||||
<bucket>/<prefix>/
|
||||
├── vaultik-destination.txt # Marker written by every backup
|
||||
├── blobs/
|
||||
│ └── <aa>/<bb>/<full_blob_hash>
|
||||
└── metadata/
|
||||
@@ -465,6 +505,15 @@ For an implementation-level view of the internals — the data model, the
|
||||
└── manifest.json.zst # Unencrypted blob list (for pruning)
|
||||
```
|
||||
|
||||
* `vaultik-destination.txt` holds one fixed line of text, with no host
|
||||
name, path or time. While the local index holds snapshots, a
|
||||
destination without it is treated as not present: `snapshot create`
|
||||
refuses to write to it, `prune` and `snapshot purge` fail, and
|
||||
`snapshot list` and `snapshot remove` warn, 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
|
||||
* `db.zst.age` is a binary SQLite database (zstd compressed, age encrypted)
|
||||
containing all file metadata, chunk mappings, and relationships for the snapshot
|
||||
|
||||
@@ -22,6 +22,76 @@ the tag exists and is exercised; what is left is merging `next` to
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-10-13: Stopped `remote info` counting a file under `blobs/` that
|
||||
is not a blob as an orphaned blob
|
||||
([issue #295](https://git.eeqj.de/sneak/vaultik/issues/295)). A stray
|
||||
file such as `.DS_Store` was reported as an orphaned blob and the
|
||||
report advised running `prune`, which skips such a file and deleted
|
||||
nothing. `remote info` now takes its blob list from the same listing
|
||||
`prune` uses, in the table and in `--json`.
|
||||
|
||||
- 2026-10-13: Made `snapshot remove` fail for a snapshot that does not
|
||||
exist ([issue #293](https://git.eeqj.de/sneak/vaultik/issues/293)).
|
||||
The command never checked the ID: it reported the snapshot removed
|
||||
from the local database and from the destination store and exited 0,
|
||||
under `--json`, `--dry-run` and `--local-only` too. An ID that names
|
||||
no snapshot in the local index or on the destination store is now an
|
||||
error; with `--local-only`, the snapshot must be in the local index.
|
||||
The command now reports a removal from either side only when one
|
||||
happened, and `remote_removed` is true only when metadata was deleted.
|
||||
|
||||
- 2026-10-12: Bound the local index to the destination an `s3.*` config
|
||||
names ([issue #298](https://git.eeqj.de/sneak/vaultik/issues/298)).
|
||||
Only `storage_url` was checked, so after `s3.bucket` or `s3.prefix`
|
||||
changed, the next backup treated every chunk as already stored,
|
||||
uploaded nothing, and completed a snapshot that could not be restored.
|
||||
Without `storage_url`, the local index is now bound to `s3.endpoint`,
|
||||
`s3.bucket` and `s3.prefix`, and a change is refused as a changed
|
||||
`storage_url` is.
|
||||
|
||||
- 2026-10-12: Rejected a retention duration longer than about 292 years
|
||||
([issue #289](https://git.eeqj.de/sneak/vaultik/issues/289)). The
|
||||
number given to `--older-than` or `--keep-newer-than` was multiplied
|
||||
by the length of its unit with no limit, so from `293y`, `15251w`,
|
||||
`3559mo` or `106752d`, or a sum of parts such as `200y200y`, the
|
||||
result wrapped to a negative duration. The cutoff then lay in the
|
||||
future, every snapshot of the name was deleted, the one just made
|
||||
included, and the command exited 0. Such a duration is now rejected
|
||||
as invalid and nothing is deleted.
|
||||
|
||||
- 2026-10-08: Made a backup cancelled under `--skip-errors` stop at once
|
||||
([issue #286](https://git.eeqj.de/sneak/vaultik/issues/286)). After
|
||||
Ctrl-C or SIGTERM, phase 2 treated the cancellation error from each
|
||||
remaining file like an unreadable file: it opened the file, printed an
|
||||
error line for it, counted it as failed and went on to the next one.
|
||||
The run now stops at the first file after the cancellation, and no
|
||||
file is counted as failed because of it.
|
||||
|
||||
- 2026-10-08: Made `remote nuke` delete the `.partial` files that
|
||||
uploads cut off part-way leave on the destination store
|
||||
([issue #281](https://git.eeqj.de/sneak/vaultik/issues/281)). The
|
||||
file and rclone listings skip such a file, so the command left it in
|
||||
place and still reported the store empty. It now removes them under
|
||||
`metadata/` and `blobs/` as its last step.
|
||||
|
||||
- 2026-10-08: Made a local index error while recording a directory or
|
||||
symlink stop a backup under `--skip-errors`
|
||||
([issue #284](https://git.eeqj.de/sneak/vaultik/issues/284)). Phase 2
|
||||
only records such an entry in the local index, since it has no data
|
||||
to open or read, but an error doing so was skipped like an unreadable
|
||||
file. The snapshot then completed without the entry, and a restore
|
||||
did not recreate it. The error now stops the backup, as it does
|
||||
without the flag.
|
||||
|
||||
- 2026-10-08: Counted a file that a backup could not store as failed
|
||||
([issue #280](https://git.eeqj.de/sneak/vaultik/issues/280)). A file
|
||||
that phase 1 counted and phase 2 could not open, because it was
|
||||
unreadable under `--skip-errors` or removed in between, was reported
|
||||
in the summary as unchanged with its bytes as backed up, and the
|
||||
`snapshots` row's `file_count` and `total_size` included it. The
|
||||
summary now counts it as failed, and its data total and the row leave
|
||||
it out.
|
||||
|
||||
- 2026-10-08: Made `remote info` report a snapshot's blob count and
|
||||
blob size as unknown when its manifest cannot be read
|
||||
([issue #272](https://git.eeqj.de/sneak/vaultik/issues/272)). The
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package main_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestDockerfileBoundsEveryModuleDownload fails unless every
|
||||
// `go mod download` in the Dockerfile runs under a time limit, is tried
|
||||
// a fixed number of times, and then fails the build with a message that
|
||||
// names the module download (issue #351). Like the version tests, this
|
||||
// parses the Dockerfile; that a stalled download really is cut off and
|
||||
// retried was checked by hand.
|
||||
func TestDockerfileBoundsEveryModuleDownload(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var downloads []string
|
||||
|
||||
for _, instruction := range instructions(t, productDockerfile) {
|
||||
if strings.Contains(instruction, "go mod download") {
|
||||
downloads = append(downloads, instruction)
|
||||
}
|
||||
}
|
||||
|
||||
require.NotEmpty(t, downloads, "%s must download modules",
|
||||
productDockerfile)
|
||||
|
||||
for _, download := range downloads {
|
||||
assert.Contains(t, download, "timeout 300 go mod download",
|
||||
"%s: a module download must have a time limit: %s",
|
||||
productDockerfile, download)
|
||||
assert.Contains(t, download, "for attempt in 1 2 3",
|
||||
"%s: a module download must be retried: %s",
|
||||
productDockerfile, download)
|
||||
assert.Contains(t, download, "module download failed",
|
||||
"%s: a module download that never succeeds must say so: %s",
|
||||
productDockerfile, download)
|
||||
assert.True(t, strings.HasSuffix(download, "exit 1"),
|
||||
"%s: a module download that never succeeds must fail the"+
|
||||
" build: %s", productDockerfile, download)
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -90,7 +90,7 @@ Stores information about packed, compressed, and encrypted blob files.
|
||||
- `created_ts` (INTEGER NOT NULL) - Creation timestamp
|
||||
- `finished_ts` (INTEGER) - Finalization timestamp (NULL if in progress)
|
||||
- `uncompressed_size` (INTEGER NOT NULL DEFAULT 0) - Total size of chunks before compression
|
||||
- `compressed_size` (INTEGER NOT NULL DEFAULT 0) - Size after compression and encryption
|
||||
- `compressed_size` (INTEGER NOT NULL DEFAULT 0) - Size after compression and encryption. Once uploaded, the size of the object in storage: when the blob was already stored, under another compression level or other recipients, this can differ from the size of the local copy
|
||||
- `uploaded_ts` (INTEGER) - Upload completion timestamp (NULL if not uploaded)
|
||||
|
||||
**Purpose:** Tracks blob lifecycle from creation through upload. The UUID primary key allows immediate association of chunks with blobs.
|
||||
@@ -119,8 +119,10 @@ Tracks backup snapshots.
|
||||
- `completed_at` (INTEGER) - Completion timestamp (NULL if in progress)
|
||||
- `file_count` (INTEGER) - Number of files in snapshot
|
||||
- `chunk_count` (INTEGER) - Number of chunks this snapshot stored that were not stored before
|
||||
- `new_chunk_size` (INTEGER) - Total size of those chunks before compression; `snapshot list` shows it as NEW CHUNK SIZE
|
||||
- `blob_count` (INTEGER) - Number of blobs this snapshot created
|
||||
- `total_size` (INTEGER) - Total size of all files
|
||||
- `uncompressed_chunk_size` (INTEGER) - Total size before compression of the distinct chunks the snapshot's files reference, recorded when its backup ran; `snapshot list` shows it as UNCOMPRESSED SIZE
|
||||
- `blob_size` (INTEGER) - Total compressed size of all referenced blobs
|
||||
- `blob_uncompressed_size` (INTEGER) - Total uncompressed size of all referenced blobs
|
||||
- `compression_ratio` (REAL) - Compression ratio achieved
|
||||
@@ -183,7 +185,7 @@ Tracks blob upload metrics.
|
||||
- `UPDATE blobs SET blob_hash = ?, finished_ts = ?` - Finalize blob after packing
|
||||
|
||||
4. **Upload**
|
||||
- `UPDATE blobs SET uploaded_ts = ?` - Mark blob as uploaded
|
||||
- `UPDATE blobs SET uploaded_ts = ?, compressed_size = ?` - Mark blob as uploaded, with the size of the object in storage (the upload is skipped when the blob is already stored)
|
||||
- `INSERT INTO uploads` - Record upload metrics with snapshot_id
|
||||
- `INSERT INTO snapshot_blobs` - Associate blob with snapshot
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ This document describes the structure and organization of data stored in the S3
|
||||
|
||||
## Overview
|
||||
|
||||
Vaultik stores all backup data in an S3-compatible object store. The repository consists of two main components:
|
||||
Vaultik stores all backup data in an S3-compatible object store. The repository consists of two main components, plus a marker file:
|
||||
1. **Blobs** - The actual backup data (content-addressed, encrypted)
|
||||
2. **Metadata** - Snapshot information and manifests (partially encrypted)
|
||||
|
||||
@@ -12,6 +12,7 @@ Vaultik stores all backup data in an S3-compatible object store. The repository
|
||||
|
||||
```
|
||||
<bucket>/<prefix>/
|
||||
├── vaultik-destination.txt
|
||||
├── blobs/
|
||||
│ └── <hash[0:2]>/
|
||||
│ └── <hash[2:4]>/
|
||||
@@ -24,6 +25,10 @@ Vaultik stores all backup data in an S3-compatible object store. The repository
|
||||
|
||||
The metadata subdirectory is named with the **remote key**, a one-way hash of the snapshot ID, not with the human-readable snapshot ID itself. See [Remote Key Derivation](#remote-key-derivation).
|
||||
|
||||
## Marker File (`vaultik-destination.txt`)
|
||||
|
||||
Every backup writes this file at the root before it uploads anything. It holds one fixed line of plain text and names no host, path or time. While the local index holds snapshots, a destination without it is treated as not present: `snapshot create` refuses to write to it, and `prune`, `snapshot purge`, `snapshot remove` and `snapshot list` behave as for a destination that cannot be listed. An unplugged volume leaves an empty directory at its mount point, and without the marker that directory would read as a destination whose snapshots are all gone. `remote nuke` deletes the marker last.
|
||||
|
||||
## Blobs Directory (`blobs/`)
|
||||
|
||||
### Structure
|
||||
|
||||
@@ -245,6 +245,17 @@ func (p *Packer) Flush(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// DiscardCurrentBlob closes and removes the temp file of the blob being
|
||||
// packed, if any, and drops that blob. A caller that stops before Flush
|
||||
// must call it, or the temp file is left behind. Thread-safe.
|
||||
func (p *Packer) DiscardCurrentBlob() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
p.cleanupTempFile()
|
||||
p.currentBlob = nil
|
||||
}
|
||||
|
||||
// FinalizeBlob finalizes the current blob being assembled.
|
||||
// This compresses the accumulated chunks, encrypts the result, and computes
|
||||
// the content-addressed hash. The finalized blob is either passed to the
|
||||
|
||||
@@ -251,6 +251,12 @@ func NewConfigCommand() *cobra.Command {
|
||||
Use: "config",
|
||||
Short: "Manage the configuration file",
|
||||
Long: "Commands for creating, editing, and querying the vaultik config file.",
|
||||
// cobra checks Args only on a command that has a Run: without
|
||||
// one, a mistyped subcommand printed help and exited 0.
|
||||
Args: cobra.NoArgs,
|
||||
Run: func(cmd *cobra.Command, _ []string) {
|
||||
_ = cmd.Help()
|
||||
},
|
||||
}
|
||||
|
||||
cmd.AddCommand(newConfigInitCommand())
|
||||
|
||||
@@ -15,6 +15,12 @@ func NewDatabaseCommand() *cobra.Command {
|
||||
Use: "database",
|
||||
Short: "Manage the local state database",
|
||||
Long: `Commands for managing the local SQLite state database.`,
|
||||
// cobra checks Args only on a command that has a Run: without
|
||||
// one, a mistyped subcommand printed help and exited 0.
|
||||
Args: cobra.NoArgs,
|
||||
Run: func(cmd *cobra.Command, _ []string) {
|
||||
_ = cmd.Help()
|
||||
},
|
||||
}
|
||||
|
||||
cmd.AddCommand(
|
||||
|
||||
@@ -101,7 +101,8 @@ func TestEntryPruneJSONStdoutIsExactlyOneDocument(t *testing.T) {
|
||||
// writeHermeticPruneConfig builds a config over a temp directory with an
|
||||
// empty destination directory and, if seedStale is set, creates the
|
||||
// index database up front with one snapshot record that has no
|
||||
// counterpart on the destination store. Returns the config path.
|
||||
// counterpart on the destination store, and gives the destination the
|
||||
// marker a backup writes. Returns the config path.
|
||||
func writeHermeticPruneConfig(t *testing.T, seedStale bool) string {
|
||||
t.Helper()
|
||||
|
||||
@@ -131,6 +132,11 @@ func writeHermeticPruneConfig(t *testing.T, seedStale bool) string {
|
||||
|
||||
if seedStale {
|
||||
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
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// Flags of `snapshot create` used to build argument vectors below.
|
||||
const (
|
||||
flagPrune = "--prune"
|
||||
flagKeepNewerThan = "--keep-newer-than"
|
||||
)
|
||||
|
||||
// TestEntrySnapshotCreateRejectsKeepNewerThanBeforeBackup checks that a
|
||||
// --keep-newer-than the post-backup prune could not use stops `snapshot
|
||||
// create` before the backup: the command fails and the destination
|
||||
// store stays empty. It used to be read only by the prune, after the
|
||||
// snapshot was uploaded, and without --prune it was ignored.
|
||||
//
|
||||
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
|
||||
func TestEntrySnapshotCreateRejectsKeepNewerThanBeforeBackup(t *testing.T) {
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{
|
||||
name: "decimal in a day count",
|
||||
args: []string{flagPrune, flagKeepNewerThan, "1.5d"},
|
||||
},
|
||||
{
|
||||
name: "unknown unit",
|
||||
args: []string{flagPrune, flagKeepNewerThan, "4x"},
|
||||
},
|
||||
{
|
||||
name: "without --prune",
|
||||
args: []string{flagKeepNewerThan, "4w"},
|
||||
},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
configPath, storeDir := writeBackupConfig(t)
|
||||
|
||||
code, _, _ := runEntry(t, append(
|
||||
[]string{flagConfig, configPath, cmdSnapshot, cmdCreate},
|
||||
testCase.args...)...)
|
||||
|
||||
assert.Equal(t, 1, code)
|
||||
|
||||
stored, err := os.ReadDir(storeDir)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, stored, "nothing may be uploaded")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEntrySnapshotCreateReportsPostBackupPruneFailure makes the
|
||||
// post-backup prune fail after a good backup, by planting a manifest
|
||||
// that cannot be decoded, and checks that the failure is reported as the
|
||||
// prune's and not as a failed snapshot creation.
|
||||
//
|
||||
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
|
||||
func TestEntrySnapshotCreateReportsPostBackupPruneFailure(t *testing.T) {
|
||||
configPath, storeDir := writeBackupConfig(t)
|
||||
|
||||
// A remote key is 64 lowercase hex characters. The prune reads every
|
||||
// manifest on the store and fails on one it cannot decode.
|
||||
plantedDir := filepath.Join(storeDir, "metadata", strings.Repeat("a", 64))
|
||||
require.NoError(t, os.MkdirAll(plantedDir, 0o750))
|
||||
require.NoError(t, os.WriteFile(
|
||||
filepath.Join(plantedDir, "manifest.json.zst"),
|
||||
[]byte("not a valid manifest"), 0o600))
|
||||
|
||||
code, _, stderr := runEntry(t,
|
||||
flagConfig, configPath, cmdSnapshot, cmdCreate, flagPrune)
|
||||
|
||||
assert.Equal(t, 1, code)
|
||||
assert.Contains(t, stderr, "Backup completed, but post-backup prune failed")
|
||||
assert.NotContains(t, stderr, "Snapshot creation failed")
|
||||
|
||||
snapshots, err := os.ReadDir(filepath.Join(storeDir, "metadata"))
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, snapshots, 2,
|
||||
"the new snapshot is on the store beside the planted one")
|
||||
}
|
||||
|
||||
// writeBackupConfig builds a hermetic config with one small file in its
|
||||
// source directory and an empty destination store directory. Returns the
|
||||
// config path and the store directory.
|
||||
func writeBackupConfig(t *testing.T) (string, string) {
|
||||
t.Helper()
|
||||
|
||||
configPath := writeHermeticPruneConfig(t, false)
|
||||
|
||||
// writeHermeticPruneConfig puts the source and store directories
|
||||
// beside the config file.
|
||||
dir := filepath.Dir(configPath)
|
||||
sourceDir := filepath.Join(dir, "source")
|
||||
|
||||
require.NoError(t, os.Mkdir(sourceDir, 0o750))
|
||||
require.NoError(t, os.WriteFile(
|
||||
filepath.Join(sourceDir, "file.txt"), []byte("hello"), 0o600))
|
||||
|
||||
return configPath, filepath.Join(dir, "store")
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package cli //nolint:testpackage // shares runEntry and the command-word constants
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// TestEntryGroupRejectsUnknownSubcommand runs each group command with a
|
||||
// mistyped subcommand, as a crontab line might carry one. It must fail
|
||||
// with exit 1 and name the word, as `vaultik bogus` does at the top
|
||||
// level, instead of printing the group's help and exiting 0 having done
|
||||
// nothing. The group name alone still prints its help.
|
||||
//
|
||||
//nolint:paralleltest // replaces os.Args, os.Stdout and os.Stderr
|
||||
func TestEntryGroupRejectsUnknownSubcommand(t *testing.T) {
|
||||
for _, testCase := range []struct {
|
||||
group string
|
||||
mistyped string
|
||||
}{
|
||||
{group: cmdSnapshot, mistyped: "craete"},
|
||||
{group: "config", mistyped: "inti"},
|
||||
{group: cmdRemote, mistyped: "ifno"},
|
||||
{group: "database", mistyped: "delte"},
|
||||
{group: "completion", mistyped: "nosuchshell"},
|
||||
} {
|
||||
t.Run(testCase.group, func(t *testing.T) {
|
||||
code, stdout, stderr := runEntry(t,
|
||||
testCase.group, testCase.mistyped)
|
||||
|
||||
assert.Equal(t, 1, code)
|
||||
assert.Empty(t, stdout)
|
||||
assert.Contains(t, stderr,
|
||||
`unknown command "`+testCase.mistyped+`"`)
|
||||
|
||||
code, stdout, _ = runEntry(t, testCase.group)
|
||||
|
||||
assert.Equal(t, 0, code)
|
||||
assert.Contains(t, stdout,
|
||||
programName+" "+testCase.group+" [command]")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,12 @@ func NewRemoteCommand() *cobra.Command {
|
||||
Use: "remote",
|
||||
Short: "Remote storage management commands",
|
||||
Long: "Commands for inspecting and managing remote storage",
|
||||
// cobra checks Args only on a command that has a Run: without
|
||||
// one, a mistyped subcommand printed help and exited 0.
|
||||
Args: cobra.NoArgs,
|
||||
Run: func(cmd *cobra.Command, _ []string) {
|
||||
_ = cmd.Help()
|
||||
},
|
||||
}
|
||||
|
||||
// Add subcommands
|
||||
|
||||
@@ -74,6 +74,21 @@ on the source system.`,
|
||||
NewDatabaseCommand(),
|
||||
)
|
||||
|
||||
// cobra adds the completion command only when Execute runs, and with
|
||||
// no Run, so it skipped its own cobra.NoArgs and a mistyped shell name
|
||||
// printed help and exited 0. Adding it here lets it have a Run. Its
|
||||
// scripts go to os.Stdout as it is now: a later SetOut does not
|
||||
// redirect them.
|
||||
cmd.InitDefaultCompletionCmd()
|
||||
|
||||
for _, sub := range cmd.Commands() {
|
||||
if sub.Name() == "completion" {
|
||||
sub.Run = func(completion *cobra.Command, _ []string) {
|
||||
_ = completion.Help()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,12 @@ func NewSnapshotCommand() *cobra.Command {
|
||||
Use: "snapshot",
|
||||
Short: "Snapshot management commands",
|
||||
Long: "Commands for creating, listing, and managing snapshots",
|
||||
// cobra checks Args only on a command that has a Run: without
|
||||
// one, a mistyped subcommand printed help and exited 0.
|
||||
Args: cobra.NoArgs,
|
||||
Run: func(cmd *cobra.Command, _ []string) {
|
||||
_ = cmd.Help()
|
||||
},
|
||||
}
|
||||
|
||||
// Add subcommands
|
||||
@@ -71,6 +77,11 @@ otherwise from the platform config directory (~/.config/vaultik/config.yml
|
||||
on Linux), then /etc/vaultik/config.yml.`,
|
||||
Args: cobra.ArbitraryArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
err := opts.Validate()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Pass snapshot names from args
|
||||
opts.Snapshots = args
|
||||
// --skip-errors is a global flag on the root command.
|
||||
@@ -97,6 +108,13 @@ on Linux), then /etc/vaultik/config.yml.`,
|
||||
}, func(v *vaultik.Vaultik) error {
|
||||
return v.CreateSnapshot(opts)
|
||||
}, func(err error) {
|
||||
if errors.Is(err, vaultik.ErrPostBackupPruneFailed) {
|
||||
log.Error("Post-backup prune failed", "error", err)
|
||||
ReportErrorf("Backup completed, but %v", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
log.Error("Snapshot creation failed", "error", err)
|
||||
ReportErrorf("Snapshot creation failed: %v", err)
|
||||
})
|
||||
@@ -255,8 +273,12 @@ manifest (the destination store may hold snapshots this host doesn't
|
||||
know about), which is what 'vaultik prune' does. On success the command
|
||||
prints the exact 'vaultik prune' invocation to run as a follow-up.
|
||||
|
||||
An ID that names no snapshot in the local index or on the destination
|
||||
store is an error.
|
||||
|
||||
Use --local-only to skip the remote half (e.g. when you want to forget a
|
||||
snapshot locally without touching the destination store).
|
||||
snapshot locally without touching the destination store); the snapshot
|
||||
must then be in the local index.
|
||||
|
||||
If the remote is unreachable, the local-database removal still completes
|
||||
and a warning is emitted; run 'vaultik snapshot remove <snapshot-id>' again
|
||||
|
||||
@@ -61,6 +61,9 @@ var (
|
||||
errBadS3PartSize = errors.New("s3.part_size must be between 5MiB and 5GiB")
|
||||
errBadStorageScheme = errors.New(
|
||||
"storage_url must start with s3://, file://, or rclone://")
|
||||
errFileURLNotAbsolute = errors.New(
|
||||
"a file:// storage_url needs an absolute path, " +
|
||||
"as in file:///path/to/backup")
|
||||
errStorageNotConfigured = errors.New(
|
||||
"storage not configured; set storage_url or provide s3.endpoint + " +
|
||||
"s3.bucket + credentials")
|
||||
@@ -461,11 +464,18 @@ func (c *Config) validateStorage() error {
|
||||
|
||||
// validateStorageURL validates URL-based storage configuration. File and
|
||||
// rclone URLs need no credentials; S3 URLs require the legacy s3.*
|
||||
// credential fields.
|
||||
// credential fields. A file URL must name an absolute path: a relative one
|
||||
// would resolve against the working directory, so the destination would
|
||||
// move with it.
|
||||
func (c *Config) validateStorageURL() error {
|
||||
switch {
|
||||
case strings.HasPrefix(c.StorageURL, "file://"):
|
||||
// File storage doesn't need S3 credentials
|
||||
// storage.ParseStorageURL applies the same rule, but config
|
||||
// cannot import storage, which imports config.
|
||||
if !strings.HasPrefix(c.StorageURL, "file:///") {
|
||||
return errFileURLNotAbsolute
|
||||
}
|
||||
|
||||
return nil
|
||||
case strings.HasPrefix(c.StorageURL, "rclone://"):
|
||||
// Rclone storage uses rclone's own config
|
||||
|
||||
@@ -337,6 +337,35 @@ func TestLoadS3PartSize(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestLoadRejectsRelativeFileURL checks that a file:// storage_url with a
|
||||
// relative path fails at load. Accepted, it would resolve against the
|
||||
// working directory, so a backup run from another directory would write
|
||||
// to a different destination and still succeed.
|
||||
func TestLoadRejectsRelativeFileURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const relativeConfig = "snapshots:\n" +
|
||||
" test:\n" +
|
||||
" paths: [/tmp/vaultik-test-source]\n" +
|
||||
"storage_url: file://dest\n"
|
||||
|
||||
path := filepath.Join(t.TempDir(), "config.yml")
|
||||
|
||||
err := os.WriteFile(path, []byte(relativeConfig), 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
_, err = Load(path)
|
||||
if !errors.Is(err, errFileURLNotAbsolute) {
|
||||
t.Fatalf("Load() error = %v, want errFileURLNotAbsolute", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "absolute path") {
|
||||
t.Errorf("error %q does not say to use an absolute path", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateAgeRecipients checks that recipients are parsed at config load
|
||||
// (a bad entry fails immediately, not mid-backup) and that no invalid entry —
|
||||
// least of all a pasted secret key — is echoed in the error. An empty list
|
||||
|
||||
@@ -58,12 +58,7 @@ func (r *BlobChunkRepository) GetByBlobID(
|
||||
return nil, fmt.Errorf("querying blob chunks: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err := rows.Close()
|
||||
if err != nil {
|
||||
Fatalf("failed to close rows: %v", err)
|
||||
}
|
||||
}()
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var blobChunks []*BlobChunk
|
||||
|
||||
|
||||
@@ -83,12 +83,7 @@ func (r *BlobRepository) GetAll(ctx context.Context) (map[string]*Blob, error) {
|
||||
return nil, fmt.Errorf("querying blobs: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err := rows.Close()
|
||||
if err != nil {
|
||||
Fatalf("failed to close rows: %v", err)
|
||||
}
|
||||
}()
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
out := make(map[string]*Blob)
|
||||
|
||||
@@ -159,13 +154,16 @@ func (r *BlobRepository) UpdateFinished(
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateUploaded marks a blob as uploaded
|
||||
// UpdateUploaded marks a blob as uploaded and records the size of the
|
||||
// stored object as its compressed size. That size can differ from the
|
||||
// local copy's when an identical blob was already stored with another
|
||||
// compression level or other recipients.
|
||||
func (r *BlobRepository) UpdateUploaded(
|
||||
ctx context.Context, tx *sql.Tx, id string,
|
||||
ctx context.Context, tx *sql.Tx, id string, storedSize int64,
|
||||
) error {
|
||||
query := `
|
||||
UPDATE blobs
|
||||
SET uploaded_ts = ?
|
||||
SET uploaded_ts = ?, compressed_size = ?
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
@@ -173,9 +171,9 @@ func (r *BlobRepository) UpdateUploaded(
|
||||
|
||||
var err error
|
||||
if tx != nil {
|
||||
_, err = tx.ExecContext(ctx, query, now, id)
|
||||
_, err = tx.ExecContext(ctx, query, now, storedSize, id)
|
||||
} else {
|
||||
_, err = r.db.ExecWithLog(ctx, query, now, id)
|
||||
_, err = r.db.ExecWithLog(ctx, query, now, storedSize, id)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
|
||||
@@ -122,8 +122,8 @@ func TestBlobRepositoryUpdates(t *testing.T) {
|
||||
t.Errorf("expected compressed size 500, got %d", updated.CompressedSize)
|
||||
}
|
||||
|
||||
// Test UpdateUploaded
|
||||
err = repo.UpdateUploaded(ctx, nil, blob.ID.String())
|
||||
// Test UpdateUploaded, with a stored object larger than the local copy
|
||||
err = repo.UpdateUploaded(ctx, nil, blob.ID.String(), 600)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to update blob as uploaded: %v", err)
|
||||
}
|
||||
@@ -141,6 +141,10 @@ func TestBlobRepositoryUpdates(t *testing.T) {
|
||||
if uploaded.UploadedTS.Before(now.Add(-1 * time.Second)) {
|
||||
t.Error("uploaded timestamp should be around test time")
|
||||
}
|
||||
|
||||
if uploaded.CompressedSize != 600 {
|
||||
t.Errorf("expected the stored size 600, got %d", uploaded.CompressedSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlobRepositoryDuplicate(t *testing.T) {
|
||||
|
||||
@@ -61,12 +61,7 @@ func (r *ChunkFileRepository) GetByChunkHash(
|
||||
return nil, fmt.Errorf("querying chunk files: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err := rows.Close()
|
||||
if err != nil {
|
||||
Fatalf("failed to close rows: %v", err)
|
||||
}
|
||||
}()
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
return r.scanChunkFiles(rows)
|
||||
}
|
||||
@@ -87,12 +82,7 @@ func (r *ChunkFileRepository) GetByFilePath(
|
||||
return nil, fmt.Errorf("querying chunk files: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err := rows.Close()
|
||||
if err != nil {
|
||||
Fatalf("failed to close rows: %v", err)
|
||||
}
|
||||
}()
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
return r.scanChunkFiles(rows)
|
||||
}
|
||||
@@ -112,12 +102,7 @@ func (r *ChunkFileRepository) GetByFileID(
|
||||
return nil, fmt.Errorf("querying chunk files: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err := rows.Close()
|
||||
if err != nil {
|
||||
Fatalf("failed to close rows: %v", err)
|
||||
}
|
||||
}()
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
return r.scanChunkFiles(rows)
|
||||
}
|
||||
|
||||
@@ -107,12 +107,7 @@ func (r *ChunkRepository) GetByHashes(
|
||||
return nil, fmt.Errorf("querying chunks: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err := rows.Close()
|
||||
if err != nil {
|
||||
Fatalf("failed to close rows: %v", err)
|
||||
}
|
||||
}()
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var chunks []*Chunk
|
||||
|
||||
@@ -152,12 +147,7 @@ func (r *ChunkRepository) ListUnpacked(
|
||||
return nil, fmt.Errorf("querying unpacked chunks: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err := rows.Close()
|
||||
if err != nil {
|
||||
Fatalf("failed to close rows: %v", err)
|
||||
}
|
||||
}()
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var chunks []*Chunk
|
||||
|
||||
|
||||
@@ -38,12 +38,7 @@ func (r *ChunkRepository) list(ctx context.Context, query string) ([]*Chunk, err
|
||||
return nil, fmt.Errorf("querying chunks: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err := rows.Close()
|
||||
if err != nil {
|
||||
Fatalf("failed to close rows: %v", err)
|
||||
}
|
||||
}()
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var chunks []*Chunk
|
||||
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
// Fatalf prints an error message to stderr and exits with status 1
|
||||
func Fatalf(format string, args ...any) {
|
||||
fmt.Fprintf(os.Stderr, "FATAL: "+format+"\n", args...)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -62,12 +62,7 @@ func (r *FileChunkRepository) GetByPath(
|
||||
return nil, fmt.Errorf("querying file chunks: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err := rows.Close()
|
||||
if err != nil {
|
||||
Fatalf("failed to close rows: %v", err)
|
||||
}
|
||||
}()
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
return r.scanFileChunks(rows)
|
||||
}
|
||||
@@ -88,12 +83,7 @@ func (r *FileChunkRepository) GetByFileID(
|
||||
return nil, fmt.Errorf("querying file chunks: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err := rows.Close()
|
||||
if err != nil {
|
||||
Fatalf("failed to close rows: %v", err)
|
||||
}
|
||||
}()
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
return r.scanFileChunks(rows)
|
||||
}
|
||||
@@ -117,12 +107,7 @@ func (r *FileChunkRepository) GetByPathTx(
|
||||
return nil, fmt.Errorf("querying file chunks: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err := rows.Close()
|
||||
if err != nil {
|
||||
Fatalf("failed to close rows: %v", err)
|
||||
}
|
||||
}()
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
fileChunks, err := r.scanFileChunks(rows)
|
||||
LogSQL("GetByPathTx", "Complete", path, "count", len(fileChunks))
|
||||
|
||||
@@ -175,12 +175,7 @@ func (r *FileRepository) ListModifiedSince(
|
||||
return nil, fmt.Errorf("querying files: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err := rows.Close()
|
||||
if err != nil {
|
||||
Fatalf("failed to close rows: %v", err)
|
||||
}
|
||||
}()
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var files []*File
|
||||
|
||||
@@ -256,12 +251,7 @@ func (r *FileRepository) ListUnderPath(
|
||||
return nil, fmt.Errorf("querying files: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err := rows.Close()
|
||||
if err != nil {
|
||||
Fatalf("failed to close rows: %v", err)
|
||||
}
|
||||
}()
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var files []*File
|
||||
|
||||
@@ -304,12 +294,7 @@ func (r *FileRepository) ListIDsWithChunksNotInUploadedBlobs(
|
||||
return nil, fmt.Errorf("querying files: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err := rows.Close()
|
||||
if err != nil {
|
||||
Fatalf("failed to close rows: %v", err)
|
||||
}
|
||||
}()
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var ids []types.FileID
|
||||
|
||||
@@ -340,12 +325,7 @@ func (r *FileRepository) ListAll(ctx context.Context) ([]*File, error) {
|
||||
return nil, fmt.Errorf("querying files: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err := rows.Close()
|
||||
if err != nil {
|
||||
Fatalf("failed to close rows: %v", err)
|
||||
}
|
||||
}()
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var files []*File
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package database
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
@@ -60,22 +61,24 @@ func (r *Repositories) WithTx(ctx context.Context, fn TxFunc) error {
|
||||
|
||||
defer func() {
|
||||
if p := recover(); p != nil {
|
||||
rollbackErr := tx.Rollback()
|
||||
if rollbackErr != nil {
|
||||
Fatalf("failed to rollback transaction: %v", rollbackErr)
|
||||
}
|
||||
// The panic is the failure to report, so a rollback error
|
||||
// is dropped.
|
||||
_ = tx.Rollback()
|
||||
|
||||
panic(p)
|
||||
} else if err != nil {
|
||||
rollbackErr := tx.Rollback()
|
||||
if rollbackErr != nil {
|
||||
Fatalf("failed to rollback transaction: %v", rollbackErr)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
err = fn(ctx, tx)
|
||||
if err != nil {
|
||||
// Once ctx is cancelled, database/sql rolls the transaction
|
||||
// back itself and Rollback returns sql.ErrTxDone.
|
||||
rollbackErr := tx.Rollback()
|
||||
if rollbackErr != nil && !errors.Is(rollbackErr, sql.ErrTxDone) {
|
||||
return fmt.Errorf("%w; rolling back transaction: %w",
|
||||
err, rollbackErr)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -103,22 +106,24 @@ func (r *Repositories) WithReadTx(ctx context.Context, fn TxFunc) error {
|
||||
|
||||
defer func() {
|
||||
if p := recover(); p != nil {
|
||||
rollbackErr := tx.Rollback()
|
||||
if rollbackErr != nil {
|
||||
Fatalf("failed to rollback transaction: %v", rollbackErr)
|
||||
}
|
||||
// The panic is the failure to report, so a rollback error
|
||||
// is dropped.
|
||||
_ = tx.Rollback()
|
||||
|
||||
panic(p)
|
||||
} else if err != nil {
|
||||
rollbackErr := tx.Rollback()
|
||||
if rollbackErr != nil {
|
||||
Fatalf("failed to rollback transaction: %v", rollbackErr)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
err = fn(ctx, tx)
|
||||
if err != nil {
|
||||
// Once ctx is cancelled, database/sql rolls the transaction
|
||||
// back itself and Rollback returns sql.ErrTxDone.
|
||||
rollbackErr := tx.Rollback()
|
||||
if rollbackErr != nil && !errors.Is(rollbackErr, sql.ErrTxDone) {
|
||||
return fmt.Errorf("%w; rolling back transaction: %w",
|
||||
err, rollbackErr)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -235,6 +236,125 @@ func TestRepositoriesTransactionRollback(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// When the context is cancelled during the body, database/sql rolls the
|
||||
// transaction back itself, so WithTx's own rollback finds it already
|
||||
// done. WithTx must return the body's error alone, without that rollback.
|
||||
func TestRepositoriesTransactionContextCancelled(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
repos := database.NewRepositories(db)
|
||||
|
||||
err := repos.WithTx(ctx, func(ctx context.Context, tx *sql.Tx) error {
|
||||
file := &database.File{
|
||||
Path: "/test/cancelled_file.txt",
|
||||
MTime: time.Now().Truncate(time.Second),
|
||||
Size: 1024,
|
||||
Mode: 0644,
|
||||
UID: 1000,
|
||||
GID: 1000,
|
||||
}
|
||||
|
||||
err := repos.Files.Create(ctx, tx, file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cancel()
|
||||
|
||||
// database/sql rolls back from a goroutine of its own; wait
|
||||
// until it has, so the test does not depend on that race.
|
||||
for {
|
||||
_, err = tx.ExecContext(context.WithoutCancel(ctx), "SELECT 1")
|
||||
if errors.Is(err, sql.ErrTxDone) {
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
})
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("expected context.Canceled, got: %v", err)
|
||||
}
|
||||
|
||||
if errors.Is(err, sql.ErrTxDone) {
|
||||
t.Fatalf("the already-done rollback was reported: %v", err)
|
||||
}
|
||||
|
||||
file, err := repos.Files.GetByPath(
|
||||
context.Background(), "/test/cancelled_file.txt",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("error checking for file: %v", err)
|
||||
}
|
||||
|
||||
if file != nil {
|
||||
t.Error("file should not exist after the cancelled transaction")
|
||||
}
|
||||
}
|
||||
|
||||
// A body that ends the transaction itself with ROLLBACK makes WithTx's
|
||||
// own rollback fail. WithTx must return that failure together with the
|
||||
// body's error.
|
||||
func TestRepositoriesTransactionRollbackFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
repos := database.NewRepositories(db)
|
||||
|
||||
err := repos.WithTx(ctx, func(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, "ROLLBACK")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return errIntentionalRollback
|
||||
})
|
||||
if !errors.Is(err, errIntentionalRollback) {
|
||||
t.Fatalf("expected the body's error, got: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "rolling back transaction") {
|
||||
t.Errorf("expected the failed rollback in the error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// When the body ends the transaction itself with ROLLBACK and then
|
||||
// panics, WithTx's own rollback fails; the panic must still reach the
|
||||
// caller.
|
||||
func TestRepositoriesTransactionPanicRollbackFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
repos := database.NewRepositories(db)
|
||||
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Error("expected the body's panic to reach the caller")
|
||||
}
|
||||
}()
|
||||
|
||||
_ = repos.WithTx(ctx, func(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, "ROLLBACK")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
panic(errIntentionalRollback)
|
||||
})
|
||||
}
|
||||
|
||||
func TestRepositoriesReadTransaction(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -291,3 +411,41 @@ func TestRepositoriesReadTransaction(t *testing.T) {
|
||||
t.Error("expected to retrieve file in read transaction")
|
||||
}
|
||||
}
|
||||
|
||||
// When the context is cancelled during a read transaction's body,
|
||||
// database/sql rolls the transaction back itself, so WithReadTx's own
|
||||
// rollback finds it already done. WithReadTx must return the body's error
|
||||
// alone, without that rollback.
|
||||
func TestRepositoriesReadTransactionContextCancelled(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
repos := database.NewRepositories(db)
|
||||
|
||||
err := repos.WithReadTx(ctx, func(ctx context.Context, tx *sql.Tx) error {
|
||||
cancel()
|
||||
|
||||
// database/sql rolls back from a goroutine of its own; wait
|
||||
// until it has, so the test does not depend on that race.
|
||||
for {
|
||||
_, err := tx.ExecContext(context.WithoutCancel(ctx), "SELECT 1")
|
||||
if errors.Is(err, sql.ErrTxDone) {
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
})
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("expected context.Canceled, got: %v", err)
|
||||
}
|
||||
|
||||
if errors.Is(err, sql.ErrTxDone) {
|
||||
t.Fatalf("the already-done rollback was reported: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,8 +86,10 @@ CREATE TABLE IF NOT EXISTS snapshots (
|
||||
completed_at INTEGER,
|
||||
file_count INTEGER NOT NULL DEFAULT 0,
|
||||
chunk_count INTEGER NOT NULL DEFAULT 0,
|
||||
new_chunk_size INTEGER NOT NULL DEFAULT 0,
|
||||
blob_count INTEGER NOT NULL DEFAULT 0,
|
||||
total_size INTEGER NOT NULL DEFAULT 0,
|
||||
uncompressed_chunk_size INTEGER NOT NULL DEFAULT 0,
|
||||
blob_size INTEGER NOT NULL DEFAULT 0,
|
||||
blob_uncompressed_size INTEGER NOT NULL DEFAULT 0,
|
||||
compression_ratio REAL NOT NULL DEFAULT 1.0,
|
||||
|
||||
@@ -120,7 +120,11 @@ func (r *SnapshotRepository) UpdateCounts(
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateExtendedStats updates extended statistics for a snapshot
|
||||
// UpdateExtendedStats updates extended statistics for a snapshot. It also
|
||||
// records the total plaintext size of the distinct chunks the snapshot's
|
||||
// files reference. Only the snapshot's own backup can take that total: a
|
||||
// file's row is shared by every snapshot that lists it, and a later
|
||||
// backup of a changed file rewrites the row with the new chunks.
|
||||
func (r *SnapshotRepository) UpdateExtendedStats(
|
||||
ctx context.Context,
|
||||
tx *sql.Tx,
|
||||
@@ -129,6 +133,7 @@ func (r *SnapshotRepository) UpdateExtendedStats(
|
||||
compressionLevel int,
|
||||
uploadBytes int64,
|
||||
uploadDurationMs int64,
|
||||
newChunkSize int64,
|
||||
) error {
|
||||
compressionRatio, err := r.extendedCompressionRatio(
|
||||
ctx, tx, snapshotID, blobUncompressedSize,
|
||||
@@ -143,18 +148,29 @@ func (r *SnapshotRepository) UpdateExtendedStats(
|
||||
compression_ratio = ?,
|
||||
compression_level = ?,
|
||||
upload_bytes = ?,
|
||||
upload_duration_ms = ?
|
||||
upload_duration_ms = ?,
|
||||
new_chunk_size = ?,
|
||||
uncompressed_chunk_size = (
|
||||
SELECT COALESCE(SUM(c.size), 0)
|
||||
FROM (
|
||||
SELECT DISTINCT fc.chunk_hash
|
||||
FROM snapshot_files sf
|
||||
JOIN file_chunks fc ON sf.file_id = fc.file_id
|
||||
WHERE sf.snapshot_id = snapshots.id
|
||||
) sc
|
||||
JOIN chunks c ON sc.chunk_hash = c.chunk_hash
|
||||
)
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
if tx != nil {
|
||||
_, err = tx.ExecContext(ctx, query,
|
||||
blobUncompressedSize, compressionRatio, compressionLevel,
|
||||
uploadBytes, uploadDurationMs, snapshotID)
|
||||
uploadBytes, uploadDurationMs, newChunkSize, snapshotID)
|
||||
} else {
|
||||
_, err = r.db.ExecWithLog(ctx, query,
|
||||
blobUncompressedSize, compressionRatio, compressionLevel,
|
||||
uploadBytes, uploadDurationMs, snapshotID)
|
||||
uploadBytes, uploadDurationMs, newChunkSize, snapshotID)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
@@ -239,12 +255,7 @@ func (r *SnapshotRepository) GetOnlySnapshot(ctx context.Context) (*Snapshot, er
|
||||
return nil, fmt.Errorf("querying snapshots: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err := rows.Close()
|
||||
if err != nil {
|
||||
Fatalf("failed to close rows: %v", err)
|
||||
}
|
||||
}()
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
snapshots, err := r.scanSnapshotRows(rows)
|
||||
if err != nil {
|
||||
@@ -279,12 +290,7 @@ func (r *SnapshotRepository) ListRecent(
|
||||
return nil, fmt.Errorf("querying snapshots: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err := rows.Close()
|
||||
if err != nil {
|
||||
Fatalf("failed to close rows: %v", err)
|
||||
}
|
||||
}()
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
return r.scanSnapshotRows(rows)
|
||||
}
|
||||
@@ -499,12 +505,7 @@ func (r *SnapshotRepository) GetBlobHashes(
|
||||
return nil, fmt.Errorf("querying blob hashes: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err := rows.Close()
|
||||
if err != nil {
|
||||
Fatalf("failed to close rows: %v", err)
|
||||
}
|
||||
}()
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var blobs []string
|
||||
|
||||
@@ -568,71 +569,42 @@ func (r *SnapshotRepository) GetSnapshotBlobSizes(
|
||||
return compressed, uncompressed, nil
|
||||
}
|
||||
|
||||
// GetSnapshotUncompressedChunkSize returns the sum of plaintext sizes of all unique
|
||||
// chunks referenced by a snapshot (via snapshot_files → file_chunks → chunks).
|
||||
// GetSnapshotUncompressedChunkSize returns the total plaintext size of the
|
||||
// distinct chunks the snapshot's files reference, as the snapshot's backup
|
||||
// recorded it (see UpdateExtendedStats).
|
||||
func (r *SnapshotRepository) GetSnapshotUncompressedChunkSize(
|
||||
ctx context.Context, snapshotID string,
|
||||
) (int64, error) {
|
||||
query := `
|
||||
SELECT COALESCE(SUM(c.size), 0)
|
||||
FROM (
|
||||
SELECT DISTINCT fc.chunk_hash
|
||||
FROM snapshot_files sf
|
||||
JOIN file_chunks fc ON sf.file_id = fc.file_id
|
||||
WHERE sf.snapshot_id = ?
|
||||
) sc
|
||||
JOIN chunks c ON sc.chunk_hash = c.chunk_hash
|
||||
`
|
||||
query := `SELECT uncompressed_chunk_size FROM snapshots WHERE id = ?`
|
||||
|
||||
var totalSize int64
|
||||
var uncompressedChunkSize int64
|
||||
|
||||
err := r.db.conn.QueryRowContext(ctx, query, snapshotID).Scan(&totalSize)
|
||||
err := r.db.conn.QueryRowContext(ctx, query, snapshotID).Scan(&uncompressedChunkSize)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("querying uncompressed chunk size: %w", err)
|
||||
}
|
||||
|
||||
return totalSize, nil
|
||||
return uncompressedChunkSize, nil
|
||||
}
|
||||
|
||||
// GetSnapshotNewChunkSize returns the sum of plaintext sizes of chunks that are
|
||||
// referenced by this snapshot but not by any earlier completed snapshot known to
|
||||
// the local database. The result is the marginal uncompressed data this snapshot
|
||||
// added to the dedup pool — i.e., the delta from prior snapshots.
|
||||
// GetSnapshotNewChunkSize returns the total plaintext size of the chunks the
|
||||
// snapshot's backup stored that were not stored before, as the backup
|
||||
// recorded it. It is not derived from the snapshot's files: a file's row is
|
||||
// shared by every snapshot that lists it and is rewritten when the file
|
||||
// changes, so it shows only the file's latest chunks.
|
||||
func (r *SnapshotRepository) GetSnapshotNewChunkSize(
|
||||
ctx context.Context, snapshotID string,
|
||||
) (int64, error) {
|
||||
query := `
|
||||
WITH this_snap_chunks AS (
|
||||
SELECT DISTINCT fc.chunk_hash
|
||||
FROM snapshot_files sf
|
||||
JOIN file_chunks fc ON sf.file_id = fc.file_id
|
||||
WHERE sf.snapshot_id = ?
|
||||
),
|
||||
prior_chunks AS (
|
||||
SELECT DISTINCT fc.chunk_hash
|
||||
FROM snapshots s
|
||||
JOIN snapshot_files sf ON sf.snapshot_id = s.id
|
||||
JOIN file_chunks fc ON fc.file_id = sf.file_id
|
||||
WHERE s.completed_at IS NOT NULL
|
||||
AND s.id != ?
|
||||
AND s.started_at < (SELECT started_at FROM snapshots WHERE id = ?)
|
||||
)
|
||||
SELECT COALESCE(SUM(c.size), 0)
|
||||
FROM chunks c
|
||||
JOIN this_snap_chunks t ON c.chunk_hash = t.chunk_hash
|
||||
WHERE c.chunk_hash NOT IN (SELECT chunk_hash FROM prior_chunks)
|
||||
`
|
||||
query := `SELECT new_chunk_size FROM snapshots WHERE id = ?`
|
||||
|
||||
var totalSize int64
|
||||
var newChunkSize int64
|
||||
|
||||
err := r.db.conn.QueryRowContext(
|
||||
ctx, query, snapshotID, snapshotID, snapshotID,
|
||||
).Scan(&totalSize)
|
||||
err := r.db.conn.QueryRowContext(ctx, query, snapshotID).Scan(&newChunkSize)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("querying new chunk size: %w", err)
|
||||
}
|
||||
|
||||
return totalSize, nil
|
||||
return newChunkSize, nil
|
||||
}
|
||||
|
||||
// GetIncompleteSnapshots returns all snapshots that haven't been completed
|
||||
@@ -653,12 +625,7 @@ func (r *SnapshotRepository) GetIncompleteSnapshots(
|
||||
return nil, fmt.Errorf("querying incomplete snapshots: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err := rows.Close()
|
||||
if err != nil {
|
||||
Fatalf("failed to close rows: %v", err)
|
||||
}
|
||||
}()
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
return r.scanSnapshotRows(rows)
|
||||
}
|
||||
@@ -681,12 +648,7 @@ func (r *SnapshotRepository) GetIncompleteByHostname(
|
||||
return nil, fmt.Errorf("querying incomplete snapshots: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err := rows.Close()
|
||||
if err != nil {
|
||||
Fatalf("failed to close rows: %v", err)
|
||||
}
|
||||
}()
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
// Same column set as every other multi-row snapshot query, so the
|
||||
// shared scanner applies — including its timestamp normalization.
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/dustin/go-humanize"
|
||||
)
|
||||
|
||||
// groupSeparator joins an open group path to an attribute key. This
|
||||
@@ -304,18 +306,13 @@ func formatDuration(d time.Duration) string {
|
||||
}
|
||||
}
|
||||
|
||||
// formatBytes formats bytes in a human-readable way
|
||||
// formatBytes formats a byte count with humanize.Bytes, as every command
|
||||
// prints one. humanize.Bytes takes no negative count, so one is printed
|
||||
// as it is.
|
||||
func formatBytes(b int64) string {
|
||||
const unit = 1024
|
||||
if b < unit {
|
||||
if b < 0 {
|
||||
return fmt.Sprintf("%d B", b)
|
||||
}
|
||||
|
||||
div, exp := int64(unit), 0
|
||||
for n := b / unit; n >= unit; n /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "KMGTPE"[exp])
|
||||
return humanize.Bytes(uint64(b))
|
||||
}
|
||||
|
||||
@@ -200,7 +200,8 @@ func TestTTYHandlerWithGroupQualifiesKeys(t *testing.T) {
|
||||
func TestTTYHandlerByteFormattingSurvivesGrouping(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const oneAndAHalfKiB = 1536
|
||||
// Printed as "1.0 MB", in powers of 1000 like every other byte count.
|
||||
const oneMegabyte = 1_000_000
|
||||
|
||||
for name, testCase := range map[string]struct {
|
||||
derive func(*slog.Logger) *slog.Logger
|
||||
@@ -223,13 +224,13 @@ func TestTTYHandlerByteFormattingSurvivesGrouping(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
|
||||
logger := slog.New(log.NewTTYHandler(&buf, debugHandlerOptions()))
|
||||
testCase.derive(logger).Info("uploaded", "bytes", oneAndAHalfKiB)
|
||||
testCase.derive(logger).Info("uploaded", "bytes", oneMegabyte)
|
||||
|
||||
line := ansiEscape.ReplaceAllString(buf.String(), "")
|
||||
|
||||
assert.Contains(t, line, testCase.key+"=1.5 KB",
|
||||
assert.Contains(t, line, testCase.key+"=1.0 MB",
|
||||
"a byte count must be human-readable however it is qualified")
|
||||
assert.NotContains(t, line, strconv.Itoa(oneAndAHalfKiB),
|
||||
assert.NotContains(t, line, strconv.Itoa(oneMegabyte),
|
||||
"the raw number must not survive the formatting")
|
||||
})
|
||||
}
|
||||
|
||||
+29
-2
@@ -18,6 +18,13 @@ import (
|
||||
"github.com/aws/smithy-go/logging"
|
||||
)
|
||||
|
||||
// ErrNoContinuationToken is returned by ListObjects and ListObjectsStream
|
||||
// when the server marks a page of a listing truncated but sends no
|
||||
// continuation token to fetch the rest with, as a server answering V2
|
||||
// listings V1-style does.
|
||||
var ErrNoContinuationToken = errors.New(
|
||||
"server marked the listing truncated but sent no continuation token")
|
||||
|
||||
// Client wraps the AWS S3 client for vaultik operations.
|
||||
// It provides a simplified interface for S3 operations with automatic
|
||||
// prefix handling and connection management. All operations are performed
|
||||
@@ -214,7 +221,7 @@ func (c *Client) ListObjects(ctx context.Context, prefix string) ([]string, erro
|
||||
})
|
||||
|
||||
for paginator.HasMorePages() {
|
||||
page, err := paginator.NextPage(ctx)
|
||||
page, err := nextListPage(ctx, paginator)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -235,6 +242,26 @@ func (c *Client) ListObjects(ctx context.Context, prefix string) ([]string, erro
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
// nextListPage fetches the next page of a listing. The SDK paginator takes
|
||||
// a page marked truncated without a continuation token for the last page
|
||||
// and stops without an error, so such a page is ErrNoContinuationToken
|
||||
// here instead.
|
||||
func nextListPage(
|
||||
ctx context.Context, paginator *s3.ListObjectsV2Paginator,
|
||||
) (*s3.ListObjectsV2Output, error) {
|
||||
page, err := paginator.NextPage(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
truncated := aws.ToBool(page.IsTruncated)
|
||||
if truncated && aws.ToString(page.NextContinuationToken) == "" {
|
||||
return nil, ErrNoContinuationToken
|
||||
}
|
||||
|
||||
return page, nil
|
||||
}
|
||||
|
||||
// HeadObject checks if an object exists in S3.
|
||||
// Returns true if the object exists, false otherwise.
|
||||
// The key is automatically prefixed with the configured prefix.
|
||||
@@ -300,7 +327,7 @@ func (c *Client) ListObjectsStream(
|
||||
})
|
||||
|
||||
for paginator.HasMorePages() {
|
||||
page, err := paginator.NextPage(ctx)
|
||||
page, err := nextListPage(ctx, paginator)
|
||||
if err != nil {
|
||||
ch <- ObjectInfo{Err: err}
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
//nolint:testpackage // exercises the unexported cleanupBlobTempFile
|
||||
package snapshot
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"sneak.berlin/go/vaultik/internal/blob"
|
||||
)
|
||||
|
||||
// A blob temp file that cannot be closed or removed must make the cleanup
|
||||
// return an error, not end the process.
|
||||
func TestCleanupBlobTempFileReturnsErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
s := &Scanner{fs: fs}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
// prepare runs before the cleanup and makes its close or remove
|
||||
// fail.
|
||||
prepare func(tempFile afero.File) error
|
||||
want error
|
||||
}{
|
||||
{
|
||||
name: "close fails",
|
||||
prepare: func(tempFile afero.File) error { return tempFile.Close() },
|
||||
want: os.ErrClosed,
|
||||
},
|
||||
{
|
||||
name: "remove fails",
|
||||
prepare: func(tempFile afero.File) error {
|
||||
return fs.Remove(tempFile.Name())
|
||||
},
|
||||
want: os.ErrNotExist,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tempFile, err := afero.TempFile(fs, t.TempDir(), "blob-*.tmp")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = tt.prepare(tempFile)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = s.cleanupBlobTempFile(&blob.WithReader{TempFile: tempFile})
|
||||
if !errors.Is(err, tt.want) {
|
||||
t.Fatalf("expected an error wrapping %v, got: %v", tt.want, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -531,3 +531,50 @@ func TestExcludePatterns_UnanchoredPatternFile(t *testing.T) {
|
||||
require.Equal(t, 1, result.FilesScanned,
|
||||
"Unanchored pattern for file should match anywhere")
|
||||
}
|
||||
|
||||
func TestExcludePatterns_DoubleStarDirectoryAtEveryDepth(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// As in gitignore, a leading **/ 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,
|
||||
"**/ directory pattern should match at the top level and nested")
|
||||
}
|
||||
|
||||
func TestExcludePatterns_DoubleStarFileAtEveryDepth(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// As in gitignore, a leading **/ also matches directly under the source dir
|
||||
fs := setupAnchoredTestFS(t)
|
||||
|
||||
scanner, repos, cleanup := createTestScanner(t, fs, []string{"**/*.txt"})
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
createSnapshotRecord(ctx, t, repos, "test-snapshot")
|
||||
|
||||
result, err := scanner.Scan(ctx, "/backup", "test-snapshot")
|
||||
require.NoError(t, err)
|
||||
|
||||
// **/*.txt should exclude ALL .txt files:
|
||||
// - /backup/file.txt
|
||||
// - /backup/projectname/file.txt
|
||||
// - /backup/otherproject/projectname/file.txt
|
||||
// Total files: 4, excluded: 3
|
||||
require.Equal(t, 1, result.FilesScanned,
|
||||
"**/ file pattern should match at the top level and nested")
|
||||
}
|
||||
|
||||
@@ -133,15 +133,21 @@ type ScannerConfig struct {
|
||||
|
||||
// ScanResult contains the results of a scan operation. Files and bytes
|
||||
// are counted per file: BytesScanned is the size of the new and changed
|
||||
// files, BytesSkipped that of the unchanged ones.
|
||||
// files, BytesSkipped that of the unchanged ones. FilesFailed counts the
|
||||
// new and changed files that phase 2 could not store; FilesScanned
|
||||
// includes them and BytesScanned does not. ChunksCreated counts the
|
||||
// chunks that were not already stored, and NewChunkSize is their total
|
||||
// size.
|
||||
type ScanResult struct {
|
||||
FilesScanned int
|
||||
FilesSkipped int
|
||||
FilesFailed int
|
||||
FilesDeleted int
|
||||
BytesScanned int64
|
||||
BytesSkipped int64
|
||||
BytesDeleted int64
|
||||
ChunksCreated int
|
||||
NewChunkSize int64
|
||||
BlobsCreated int
|
||||
BlobsUploaded int
|
||||
BytesUploaded int64
|
||||
@@ -1292,6 +1298,11 @@ func (s *Scanner) batchAddFilesToSnapshot(
|
||||
func (s *Scanner) processPhase(
|
||||
ctx context.Context, filesToProcess []*FileToProcess, result *ScanResult,
|
||||
) error {
|
||||
// On an error or cancellation the blob being packed is never
|
||||
// finalized, so its temp file must be removed here. After a
|
||||
// successful flush there is no such blob and this does nothing.
|
||||
defer s.packer.DiscardCurrentBlob()
|
||||
|
||||
// Calculate total bytes to process
|
||||
var totalBytes int64
|
||||
for _, f := range filesToProcess {
|
||||
@@ -1309,6 +1320,13 @@ func (s *Scanner) processPhase(
|
||||
|
||||
// Process each file
|
||||
for _, fileToProcess := range filesToProcess {
|
||||
// Check context cancellation
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
// Update progress
|
||||
if s.progress != nil {
|
||||
s.progress.GetStats().CurrentFile.Store(fileToProcess.Path)
|
||||
@@ -1345,13 +1363,32 @@ func (s *Scanner) processPhase(
|
||||
return s.finalizeProcessPhase(ctx, result)
|
||||
}
|
||||
|
||||
// processFileWithErrorHandling wraps processFileStreaming with error recovery for
|
||||
// deleted files and skip-errors mode. Returns (skipped, error).
|
||||
// processFileWithErrorHandling records a directory or symlink, or wraps
|
||||
// processFileStreaming for a regular file with error recovery for deleted
|
||||
// files and skip-errors mode. Returns (skipped, error).
|
||||
func (s *Scanner) processFileWithErrorHandling(
|
||||
ctx context.Context, fileToProcess *FileToProcess, result *ScanResult,
|
||||
) (bool, error) {
|
||||
// A directory or symlink has no data to open or read; it is only
|
||||
// recorded in the local index. An error recording it stops the run
|
||||
// even under --skip-errors, or the snapshot would complete without it.
|
||||
mode := os.FileMode(fileToProcess.File.Mode)
|
||||
if mode&os.ModeSymlink != 0 || mode.IsDir() {
|
||||
err := s.recordNonRegularFile(ctx, fileToProcess)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("processing file %s: %w", fileToProcess.Path, err)
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
err := s.processFileStreaming(ctx, fileToProcess, result)
|
||||
if err != nil {
|
||||
// A cancelled run stops here even under --skip-errors, rather than
|
||||
// counting this file as failed and going on to the next one.
|
||||
if ctx.Err() != nil {
|
||||
return false, fmt.Errorf("processing file %s: %w", fileToProcess.Path, err)
|
||||
}
|
||||
// A packer/database/encryption/upload failure means the chunk's data
|
||||
// may not have been stored. Skipping the file would let the snapshot
|
||||
// record a file whose chunk is in no blob and cannot be restored, so
|
||||
@@ -1365,7 +1402,7 @@ func (s *Scanner) processFileWithErrorHandling(
|
||||
log.Warn("File was deleted during backup, skipping",
|
||||
"path", fileToProcess.Path)
|
||||
|
||||
result.FilesSkipped++
|
||||
countFailedFile(fileToProcess, result)
|
||||
|
||||
return true, nil
|
||||
}
|
||||
@@ -1376,7 +1413,7 @@ func (s *Scanner) processFileWithErrorHandling(
|
||||
s.ui.Errorf("Failed to process %s: %v. Skipping (--skip-errors).",
|
||||
s.ui.Path(fileToProcess.Path), err)
|
||||
|
||||
result.FilesSkipped++
|
||||
countFailedFile(fileToProcess, result)
|
||||
|
||||
return true, nil
|
||||
}
|
||||
@@ -1387,6 +1424,13 @@ func (s *Scanner) processFileWithErrorHandling(
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// countFailedFile counts a file that phase 2 could not store as failed
|
||||
// and takes its size back out of BytesScanned, where phase 1 put it.
|
||||
func countFailedFile(fileToProcess *FileToProcess, result *ScanResult) {
|
||||
result.FilesFailed++
|
||||
result.BytesScanned -= fileToProcess.FileInfo.Size()
|
||||
}
|
||||
|
||||
// printProcessingProgress prints a periodic progress line during the process phase,
|
||||
// showing files processed, bytes transferred, throughput, and ETA
|
||||
func (s *Scanner) printProcessingProgress(
|
||||
@@ -1473,7 +1517,8 @@ func (s *Scanner) finalizeProcessPhase(ctx context.Context, result *ScanResult)
|
||||
// snapshot references has uploaded_ts set, so deduplication and
|
||||
// interrupted-run repair treat these blobs as trustworthy.
|
||||
err = s.repos.WithTx(ctx, func(ctx context.Context, tx *sql.Tx) error {
|
||||
err := s.repos.Blobs.UpdateUploaded(ctx, tx, b.ID)
|
||||
err := s.repos.Blobs.UpdateUploaded(
|
||||
ctx, tx, b.ID, b.Compressed)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marking blob uploaded: %w", err)
|
||||
}
|
||||
@@ -1509,23 +1554,28 @@ func (s *Scanner) handleBlobReady(
|
||||
blobPath := fmt.Sprintf("blobs/%s/%s/%s",
|
||||
finishedBlob.Hash[:2], finishedBlob.Hash[2:4], finishedBlob.Hash)
|
||||
|
||||
blobExists, err := s.uploadBlobIfNeeded(
|
||||
storedSize, blobExists, err := s.uploadBlobIfNeeded(
|
||||
ctx, blobPath, blobWithReader, startTime, result)
|
||||
if err != nil {
|
||||
s.cleanupBlobTempFile(blobWithReader)
|
||||
cleanupErr := s.cleanupBlobTempFile(blobWithReader)
|
||||
|
||||
return fmt.Errorf("uploading blob %s: %w", finishedBlob.Hash, err)
|
||||
return errors.Join(
|
||||
fmt.Errorf("uploading blob %s: %w", finishedBlob.Hash, err), cleanupErr)
|
||||
}
|
||||
|
||||
err = s.recordBlobMetadata(ctx, finishedBlob, blobExists, startTime)
|
||||
err = s.recordBlobMetadata(
|
||||
ctx, finishedBlob, storedSize, blobExists, startTime)
|
||||
if err != nil {
|
||||
s.cleanupBlobTempFile(blobWithReader)
|
||||
cleanupErr := s.cleanupBlobTempFile(blobWithReader)
|
||||
|
||||
return errors.Join(err, cleanupErr)
|
||||
}
|
||||
|
||||
err = s.cleanupBlobTempFile(blobWithReader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.cleanupBlobTempFile(blobWithReader)
|
||||
|
||||
// Chunks from this blob are now committed to DB - remove from pending set
|
||||
s.removePendingChunkHashes(blobWithReader.InsertedChunkHashes)
|
||||
|
||||
@@ -1539,20 +1589,22 @@ func (s *Scanner) handleBlobReady(
|
||||
}
|
||||
|
||||
// uploadBlobIfNeeded uploads the blob to storage if it doesn't already
|
||||
// exist, returns whether it existed
|
||||
// exist. It returns the size of the object in storage, which for an
|
||||
// existing object can differ from the local copy's, and whether it
|
||||
// already existed.
|
||||
func (s *Scanner) uploadBlobIfNeeded(
|
||||
ctx context.Context,
|
||||
blobPath string,
|
||||
blobWithReader *blob.WithReader,
|
||||
startTime time.Time,
|
||||
result *ScanResult,
|
||||
) (bool, error) {
|
||||
) (int64, bool, error) {
|
||||
finishedBlob := blobWithReader.FinishedBlob
|
||||
|
||||
// Check if blob already exists (deduplication after restart)
|
||||
destination := s.storage.Info().Location
|
||||
|
||||
_, err := s.storage.Stat(ctx, blobPath)
|
||||
stored, err := s.storage.Stat(ctx, blobPath)
|
||||
if err == nil {
|
||||
log.Info("Blob already exists in storage, skipping upload",
|
||||
"hash", finishedBlob.Hash,
|
||||
@@ -1561,7 +1613,7 @@ func (s *Scanner) uploadBlobIfNeeded(
|
||||
s.ui.Hex(finishedBlob.Hash), s.ui.Size(finishedBlob.Compressed),
|
||||
s.ui.Path(destination))
|
||||
|
||||
return true, nil
|
||||
return stored.Size, true, nil
|
||||
}
|
||||
|
||||
s.ui.Beginf("Uploading blob %s (%s) to %s.",
|
||||
@@ -1575,7 +1627,7 @@ func (s *Scanner) uploadBlobIfNeeded(
|
||||
if err != nil {
|
||||
log.Error("Failed to upload blob", "hash", finishedBlob.Hash, "error", err)
|
||||
|
||||
return false, fmt.Errorf("uploading blob to storage: %w", err)
|
||||
return 0, false, fmt.Errorf("uploading blob to storage: %w", err)
|
||||
}
|
||||
|
||||
uploadDuration := time.Since(startTime)
|
||||
@@ -1606,7 +1658,7 @@ func (s *Scanner) uploadBlobIfNeeded(
|
||||
stats.BytesUploaded.Add(finishedBlob.Compressed)
|
||||
}
|
||||
|
||||
return false, nil
|
||||
return finishedBlob.Compressed, false, nil
|
||||
}
|
||||
|
||||
// makeUploadProgressCallback creates a progress callback for blob uploads.
|
||||
@@ -1676,6 +1728,7 @@ func (s *Scanner) makeUploadProgressCallback(
|
||||
func (s *Scanner) recordBlobMetadata(
|
||||
ctx context.Context,
|
||||
finishedBlob *blob.FinishedBlob,
|
||||
storedSize int64,
|
||||
blobExists bool,
|
||||
startTime time.Time,
|
||||
) error {
|
||||
@@ -1687,7 +1740,8 @@ func (s *Scanner) recordBlobMetadata(
|
||||
uploadDuration := time.Since(startTime)
|
||||
|
||||
return s.repos.WithTx(ctx, func(txCtx context.Context, tx *sql.Tx) error {
|
||||
err := s.repos.Blobs.UpdateUploaded(txCtx, tx, finishedBlob.ID)
|
||||
err := s.repos.Blobs.UpdateUploaded(
|
||||
txCtx, tx, finishedBlob.ID, storedSize)
|
||||
if err != nil {
|
||||
return fmt.Errorf("updating blob upload timestamp: %w", err)
|
||||
}
|
||||
@@ -1718,20 +1772,22 @@ func (s *Scanner) recordBlobMetadata(
|
||||
}
|
||||
|
||||
// cleanupBlobTempFile closes and removes the blob's temporary file
|
||||
func (s *Scanner) cleanupBlobTempFile(blobWithReader *blob.WithReader) {
|
||||
func (s *Scanner) cleanupBlobTempFile(blobWithReader *blob.WithReader) error {
|
||||
if blobWithReader.TempFile != nil {
|
||||
tempName := blobWithReader.TempFile.Name()
|
||||
|
||||
err := blobWithReader.TempFile.Close()
|
||||
if err != nil {
|
||||
log.Fatal("Failed to close temp file", "file", tempName, "error", err)
|
||||
return fmt.Errorf("closing blob temp file: %w", err)
|
||||
}
|
||||
|
||||
err = s.fs.Remove(tempName)
|
||||
if err != nil {
|
||||
log.Fatal("Failed to remove temp file", "file", tempName, "error", err)
|
||||
return fmt.Errorf("removing blob temp file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// streamingChunkInfo tracks chunk metadata collected during streaming
|
||||
@@ -1755,16 +1811,11 @@ func (e *packerError) Error() string { return e.err.Error() }
|
||||
|
||||
func (e *packerError) Unwrap() error { return e.err }
|
||||
|
||||
// processFileStreaming processes a file by streaming chunks directly to the packer
|
||||
// processFileStreaming processes a regular file by streaming chunks directly
|
||||
// to the packer
|
||||
func (s *Scanner) processFileStreaming(
|
||||
ctx context.Context, fileToProcess *FileToProcess, result *ScanResult,
|
||||
) error {
|
||||
// Symlinks and directories have no data to chunk — just record them in the DB.
|
||||
mode := os.FileMode(fileToProcess.File.Mode)
|
||||
if mode&os.ModeSymlink != 0 || mode.IsDir() {
|
||||
return s.recordNonRegularFile(ctx, fileToProcess)
|
||||
}
|
||||
|
||||
file, err := s.fs.Open(fileToProcess.Path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("opening file: %w", wrapPermissionError(fileToProcess.Path, err))
|
||||
@@ -1830,11 +1881,12 @@ func (s *Scanner) processFileStreaming(
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateChunkStats counts a chunk that was not already stored. The scan
|
||||
// result's file counts, BytesScanned and BytesSkipped are not touched
|
||||
// here: the scan phase counts each file once.
|
||||
// updateChunkStats counts a chunk that was not already stored, and its
|
||||
// size. The scan result's file counts, BytesScanned and BytesSkipped are
|
||||
// not touched here: the scan phase counts each file once.
|
||||
func (s *Scanner) updateChunkStats(chunkSize int64, result *ScanResult) {
|
||||
result.ChunksCreated++
|
||||
result.NewChunkSize += chunkSize
|
||||
|
||||
if s.progress != nil {
|
||||
s.progress.GetStats().ChunksCreated.Add(1)
|
||||
@@ -1982,6 +2034,14 @@ func compileExcludePatterns(patterns []string) []compiledPattern {
|
||||
// Remove trailing slash if present (directory indicator)
|
||||
pattern = strings.TrimSuffix(pattern, "/")
|
||||
|
||||
// A leading "**/" demands a "/" before the name and so misses an
|
||||
// entry directly under the source path. An unanchored pattern
|
||||
// already matches at every depth, so drop the prefix: as in
|
||||
// gitignore, "**/x" then also matches a top-level "x".
|
||||
if !anchored {
|
||||
pattern = strings.TrimPrefix(pattern, "**/")
|
||||
}
|
||||
|
||||
// Compile the glob pattern
|
||||
// For patterns without path separators, we need to match them as components
|
||||
// e.g., ".git" should match ".git" anywhere in the path
|
||||
|
||||
@@ -82,6 +82,32 @@ func (f *readFailFs) Open(name string) (afero.File, error) {
|
||||
return file, nil
|
||||
}
|
||||
|
||||
// cancelOnOpenFs cancels the run when the scanner opens the target file to
|
||||
// back it up, as Ctrl-C partway through processing would, and records each
|
||||
// file opened after that.
|
||||
type cancelOnOpenFs struct {
|
||||
afero.Fs
|
||||
|
||||
target string
|
||||
cancel context.CancelFunc
|
||||
cancelled bool
|
||||
openedAfterCancel []string
|
||||
}
|
||||
|
||||
//nolint:ireturn // afero.Fs.Open is defined to return the interface.
|
||||
func (f *cancelOnOpenFs) Open(name string) (afero.File, error) {
|
||||
if f.cancelled {
|
||||
f.openedAfterCancel = append(f.openedAfterCancel, name)
|
||||
}
|
||||
|
||||
if name == f.target {
|
||||
f.cancel()
|
||||
f.cancelled = true
|
||||
}
|
||||
|
||||
return f.Fs.Open(name)
|
||||
}
|
||||
|
||||
// linkRemovedAfterLstatFs is the real filesystem, except that the symlink at
|
||||
// target is removed right after the walk lstats it, as happens when a link is
|
||||
// deleted during a backup. The scanner's readlink of it then fails.
|
||||
@@ -128,15 +154,16 @@ func writeSkipErrorTestFile(t *testing.T, fs afero.Fs, path, content string) {
|
||||
}
|
||||
}
|
||||
|
||||
// runSkipErrorScan scans source on fs with the given skip-errors setting,
|
||||
// printing user-facing messages to uiw (nil discards them), and returns the
|
||||
// repositories (for inspection) and the scan error.
|
||||
// runSkipErrorScan scans source on fs under ctx with the given skip-errors
|
||||
// setting, printing user-facing messages to uiw (nil discards them), and
|
||||
// returns the repositories (for inspection) and the scan error.
|
||||
func runSkipErrorScan(
|
||||
t *testing.T, fs afero.Fs, source string, skipErrors bool, uiw *ui.Writer,
|
||||
ctx context.Context, t *testing.T, fs afero.Fs, source string,
|
||||
skipErrors bool, uiw *ui.Writer,
|
||||
) (*database.Repositories, error) {
|
||||
t.Helper()
|
||||
|
||||
db, err := database.NewTestDB()
|
||||
db, err := database.New(ctx, ":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("create test db: %v", err)
|
||||
}
|
||||
@@ -161,7 +188,6 @@ func runSkipErrorScan(
|
||||
SkipErrors: skipErrors,
|
||||
})
|
||||
|
||||
ctx := context.Background()
|
||||
snapshotID := "test-snapshot-skip-errors"
|
||||
createTestSnapshotRecord(ctx, t, repos, snapshotID)
|
||||
|
||||
@@ -185,7 +211,7 @@ func TestScannerPackingFailureAbortsUnderSkipErrors(t *testing.T) {
|
||||
writeSkipErrorTestFile(t, fs, "/source/file1.txt", "first file content")
|
||||
writeSkipErrorTestFile(t, fs, "/source/file2.txt", "second file content")
|
||||
|
||||
repos, err := runSkipErrorScan(t, fs, "/source", true, nil)
|
||||
repos, err := runSkipErrorScan(context.Background(), t, fs, "/source", true, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected scan to abort on the packer error, got nil")
|
||||
}
|
||||
@@ -212,7 +238,7 @@ func TestScannerReadErrorAbortsWithoutSkipErrors(t *testing.T) {
|
||||
fs := &readFailFs{Fs: afero.NewMemMapFs(), target: target}
|
||||
writeSkipErrorTestFile(t, fs, target, "content that cannot be read")
|
||||
|
||||
_, err := runSkipErrorScan(t, fs, "/source", false, nil)
|
||||
_, err := runSkipErrorScan(context.Background(), t, fs, "/source", false, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected scan to fail on the read error, got nil")
|
||||
}
|
||||
@@ -228,7 +254,7 @@ func TestScannerReadErrorSkippedWithSkipErrors(t *testing.T) {
|
||||
fs := &readFailFs{Fs: afero.NewMemMapFs(), target: target}
|
||||
writeSkipErrorTestFile(t, fs, target, "content that cannot be read")
|
||||
|
||||
repos, err := runSkipErrorScan(t, fs, "/source", true, nil)
|
||||
repos, err := runSkipErrorScan(context.Background(), t, fs, "/source", true, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("expected scan to complete with --skip-errors, got %v", err)
|
||||
}
|
||||
@@ -267,7 +293,7 @@ func TestScannerUnreadableSymlinkAbortsWithoutSkipErrors(t *testing.T) {
|
||||
sourceDir, linkPath := writeSymlinkSource(t)
|
||||
fs := &linkRemovedAfterLstatFs{t: t, target: linkPath}
|
||||
|
||||
_, err := runSkipErrorScan(t, fs, sourceDir, false, nil)
|
||||
_, err := runSkipErrorScan(context.Background(), t, fs, sourceDir, false, nil)
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("expected scan to fail on the removed symlink, got %v", err)
|
||||
}
|
||||
@@ -283,7 +309,7 @@ func TestScannerUnreadableSymlinkSkippedWithSkipErrors(t *testing.T) {
|
||||
fs := &linkRemovedAfterLstatFs{t: t, target: linkPath}
|
||||
uiw := ui.NewWithColor(io.Discard, false)
|
||||
|
||||
repos, err := runSkipErrorScan(t, fs, sourceDir, true, uiw)
|
||||
repos, err := runSkipErrorScan(context.Background(), t, fs, sourceDir, true, uiw)
|
||||
if err != nil {
|
||||
t.Fatalf("expected scan to complete with --skip-errors, got %v", err)
|
||||
}
|
||||
@@ -302,3 +328,113 @@ func TestScannerUnreadableSymlinkSkippedWithSkipErrors(t *testing.T) {
|
||||
t.Fatalf("expected %s not to be recorded", linkPath)
|
||||
}
|
||||
}
|
||||
|
||||
// TestScannerCancelStopsSkipErrorsRun checks that a --skip-errors backup
|
||||
// cancelled partway through processing returns the cancellation error,
|
||||
// opens no further file, and reports no file as failed.
|
||||
func TestScannerCancelStopsSkipErrorsRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
targetContent string
|
||||
}{
|
||||
// The cancellation lands while the target is being read.
|
||||
{name: "while reading a file", targetContent: "first file content"},
|
||||
// An empty target has no chunks, so the cancellation goes unnoticed
|
||||
// until the run moves on to the next file.
|
||||
{name: "between files", targetContent: ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const target = "/source/a.txt"
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
fs := &cancelOnOpenFs{
|
||||
Fs: afero.NewMemMapFs(), target: target, cancel: cancel,
|
||||
}
|
||||
writeSkipErrorTestFile(t, fs, target, tt.targetContent)
|
||||
writeSkipErrorTestFile(t, fs, "/source/b.txt", "second file content")
|
||||
writeSkipErrorTestFile(t, fs, "/source/c.txt", "third file content")
|
||||
|
||||
uiw := ui.NewWithColor(io.Discard, false)
|
||||
|
||||
_, err := runSkipErrorScan(ctx, t, fs, "/source", true, uiw)
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("expected the cancellation error, got %v", err)
|
||||
}
|
||||
|
||||
if len(fs.openedAfterCancel) != 0 {
|
||||
t.Fatalf("expected no file opened after the cancellation, got %v",
|
||||
fs.openedAfterCancel)
|
||||
}
|
||||
|
||||
if uiw.ErrorCount() != 0 {
|
||||
t.Fatalf("expected no file reported as failed, got %d error lines",
|
||||
uiw.ErrorCount())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestScannerRemovesBlobTempFileWhenRunStops checks that a run stopped
|
||||
// part-way, by a file it cannot read or by a cancellation, removes the
|
||||
// temp file of the blob it was packing.
|
||||
func TestScannerRemovesBlobTempFileWhenRunStops(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// a.txt is backed up first and starts a blob; the run stops at b.txt.
|
||||
const target = "/source/b.txt"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
newFs func(cancel context.CancelFunc) afero.Fs
|
||||
}{
|
||||
{
|
||||
name: "file cannot be read",
|
||||
newFs: func(context.CancelFunc) afero.Fs {
|
||||
return &readFailFs{Fs: afero.NewMemMapFs(), target: target}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "cancelled",
|
||||
newFs: func(cancel context.CancelFunc) afero.Fs {
|
||||
return &cancelOnOpenFs{
|
||||
Fs: afero.NewMemMapFs(), target: target, cancel: cancel,
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
fs := tt.newFs(cancel)
|
||||
writeSkipErrorTestFile(t, fs, "/source/a.txt", "first file content")
|
||||
writeSkipErrorTestFile(t, fs, target, "second file content")
|
||||
|
||||
_, err := runSkipErrorScan(ctx, t, fs, "/source", false, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected the scan to stop with an error, got nil")
|
||||
}
|
||||
|
||||
left, err := afero.Glob(fs, filepath.Join(os.TempDir(), "vaultik-blob-*"))
|
||||
if err != nil {
|
||||
t.Fatalf("listing blob temp files: %v", err)
|
||||
}
|
||||
|
||||
if len(left) != 0 {
|
||||
t.Fatalf("expected no blob temp file left, got %v", left)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,6 +199,7 @@ func (sm *SnapshotManager) UpdateSnapshotStatsExtended(
|
||||
stats.CompressionLevel,
|
||||
stats.BytesUploaded,
|
||||
stats.UploadDurationMs,
|
||||
stats.NewChunkSize,
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -892,7 +893,7 @@ func (sm *SnapshotManager) getFileSize(path string) int64 {
|
||||
// BackupStats contains statistics from a backup operation
|
||||
type BackupStats struct {
|
||||
FilesScanned int
|
||||
TotalSize int64 // Total size of all files examined
|
||||
TotalSize int64 // Total size of the files in the snapshot
|
||||
ChunksCreated int
|
||||
BlobsCreated int
|
||||
BytesUploaded int64
|
||||
@@ -906,6 +907,7 @@ type ExtendedBackupStats struct {
|
||||
BlobUncompressedSize int64 // Total uncompressed size of all referenced blobs
|
||||
CompressionLevel int // Compression level used for this snapshot
|
||||
UploadDurationMs int64 // Total milliseconds spent uploading to S3
|
||||
NewChunkSize int64 // Total size of the chunks counted in ChunksCreated
|
||||
}
|
||||
|
||||
// deleteOtherSnapshots deletes all snapshots except the current one
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
// Package faultstore provides a storage.Storer wrapper that injects
|
||||
// faults on demand, so tests can reproduce the failure modes a real
|
||||
// backend exhibits: an upload that fails partway, a backend that reports
|
||||
// success while storing nothing, and reads that return corrupt or
|
||||
// truncated bytes. It is the seam called for by the fault-injection
|
||||
// tests (sneak/vaultik issue 72) and is meant to be reused by future
|
||||
// tests rather than re-implemented per case.
|
||||
// success while storing nothing, reads that return corrupt or truncated
|
||||
// bytes, and a delete the backend refuses. It is the seam called for by
|
||||
// the fault-injection tests (sneak/vaultik issue 72) and is meant to be
|
||||
// reused by future tests rather than re-implemented per case.
|
||||
//
|
||||
// The wrapper delegates every method to the inner Storer. Two hooks
|
||||
// change that: OnPut decides the fate of each write, and OnGet decides
|
||||
// how each read's bytes are returned. Both are keyed by the object key,
|
||||
// so a test can fault only blobs, only metadata, or a single object.
|
||||
// The wrapper delegates every method to the inner Storer. Three hooks
|
||||
// change that: OnPut decides the fate of each write, OnGet decides how
|
||||
// each read's bytes are returned, and OnDelete decides whether each
|
||||
// delete fails. All are keyed by the object key, so a test can fault
|
||||
// only blobs, only metadata, or a single object.
|
||||
package faultstore
|
||||
|
||||
import (
|
||||
@@ -25,6 +26,10 @@ import (
|
||||
// ErrInjectedUpload is returned by a Put the OnPut hook chose to fail.
|
||||
var ErrInjectedUpload = errors.New("faultstore: injected upload failure")
|
||||
|
||||
// ErrInjectedDelete is returned by a Delete the OnDelete hook chose to
|
||||
// fail.
|
||||
var ErrInjectedDelete = errors.New("faultstore: injected delete failure")
|
||||
|
||||
// PutAction is the disposition OnPut assigns to a write.
|
||||
type PutAction int
|
||||
|
||||
@@ -66,6 +71,11 @@ type Storer struct {
|
||||
// OnGet, when set, is consulted for every Get with the object key
|
||||
// and damages the returned bytes accordingly.
|
||||
OnGet func(key string) GetFault
|
||||
|
||||
// OnDelete, when set, is consulted before every Delete with the
|
||||
// object key. Returning true fails the delete and leaves the object
|
||||
// in place.
|
||||
OnDelete func(key string) bool
|
||||
}
|
||||
|
||||
// New wraps inner. inner must be non-nil.
|
||||
@@ -154,8 +164,12 @@ func (f *Storer) Stat(ctx context.Context, key string) (*storage.ObjectInfo, err
|
||||
return f.inner.Stat(ctx, key)
|
||||
}
|
||||
|
||||
// Delete delegates unchanged.
|
||||
// Delete removes the object unless OnDelete fails the delete.
|
||||
func (f *Storer) Delete(ctx context.Context, key string) error {
|
||||
if f.OnDelete != nil && f.OnDelete(key) {
|
||||
return fmt.Errorf("%w for %q", ErrInjectedDelete, key)
|
||||
}
|
||||
|
||||
return f.inner.Delete(ctx, key)
|
||||
}
|
||||
|
||||
@@ -171,6 +185,11 @@ func (f *Storer) ListStream(
|
||||
return f.inner.ListStream(ctx, prefix)
|
||||
}
|
||||
|
||||
// DeletePartialUploads delegates unchanged.
|
||||
func (f *Storer) DeletePartialUploads(ctx context.Context, prefix string) error {
|
||||
return f.inner.DeletePartialUploads(ctx, prefix)
|
||||
}
|
||||
|
||||
// Info delegates unchanged.
|
||||
func (f *Storer) Info() storage.Info {
|
||||
return f.inner.Info()
|
||||
|
||||
+116
-14
@@ -21,9 +21,13 @@ type FileStorer struct {
|
||||
// NewFileStorer creates a new filesystem storage backend.
|
||||
//
|
||||
// Construction is intentionally cheap and does not touch the filesystem.
|
||||
// The basePath is recorded; the directory is created lazily on first
|
||||
// write. Write operations (Put/PutWithProgress) call MkdirAll for the
|
||||
// per-blob parent directory, which also covers basePath on first use.
|
||||
// The backend never creates basePath: the user does, so that when
|
||||
// basePath is a directory on an unplugged volume, a write fails instead
|
||||
// of landing on the disk under the mount point. This does not hold when
|
||||
// basePath is the mount point itself, which stays behind when its volume
|
||||
// is unplugged.
|
||||
// Put and PutWithProgress fail on a missing basePath and create the
|
||||
// directories below it as needed.
|
||||
// Get and Stat report a key under a missing basePath as ErrNotFound.
|
||||
// List and ListStream fail on a missing basePath, because listing it as
|
||||
// an empty store would make `prune` drop every local snapshot record.
|
||||
@@ -120,9 +124,10 @@ func (f *FileStorer) Delete(_ context.Context, key string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// List returns all keys with the given prefix. It fails when the
|
||||
// destination directory is missing; a missing prefix under it is an
|
||||
// empty listing.
|
||||
// List returns all keys with the given prefix, reading through symlinked
|
||||
// directories. It fails when the destination directory is missing, or
|
||||
// when the prefix or a directory above it is a symlink whose target is
|
||||
// missing; a prefix that is truly missing is an empty listing.
|
||||
func (f *FileStorer) List(ctx context.Context, prefix string) ([]string, error) {
|
||||
var keys []string
|
||||
|
||||
@@ -133,8 +138,7 @@ func (f *FileStorer) List(ctx context.Context, prefix string) ([]string, error)
|
||||
|
||||
basePath := f.fullPath(prefix)
|
||||
|
||||
// Check if the prefix exists
|
||||
exists, err := afero.Exists(f.fs, basePath)
|
||||
exists, err := f.prefixExists(prefix)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("checking path: %w", err)
|
||||
}
|
||||
@@ -143,7 +147,7 @@ func (f *FileStorer) List(ctx context.Context, prefix string) ([]string, error)
|
||||
return keys, nil // Empty list for non-existent prefix
|
||||
}
|
||||
|
||||
err = afero.Walk(f.fs, basePath, func(path string, info os.FileInfo, err error) error {
|
||||
err = f.walk(basePath, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -176,7 +180,9 @@ func (f *FileStorer) List(ctx context.Context, prefix string) ([]string, error)
|
||||
}
|
||||
|
||||
// ListStream returns a channel of ObjectInfo for large result sets. Like
|
||||
// List, it sends an error when the destination directory is missing.
|
||||
// List, it reads through symlinked directories and sends an error when
|
||||
// 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 {
|
||||
ch := make(chan ObjectInfo)
|
||||
|
||||
@@ -192,8 +198,7 @@ func (f *FileStorer) ListStream(ctx context.Context, prefix string) <-chan Objec
|
||||
|
||||
basePath := f.fullPath(prefix)
|
||||
|
||||
// Check if the prefix exists
|
||||
exists, err := afero.Exists(f.fs, basePath)
|
||||
exists, err := f.prefixExists(prefix)
|
||||
if err != nil {
|
||||
ch <- ObjectInfo{Err: fmt.Errorf("checking path: %w", err)}
|
||||
|
||||
@@ -204,7 +209,7 @@ func (f *FileStorer) ListStream(ctx context.Context, prefix string) <-chan Objec
|
||||
return // Empty channel for non-existent prefix
|
||||
}
|
||||
|
||||
_ = afero.Walk(f.fs, basePath, func(path string, info os.FileInfo, err error) error {
|
||||
_ = f.walk(basePath, func(path string, info os.FileInfo, err error) error {
|
||||
// Check context cancellation
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -242,6 +247,43 @@ func (f *FileStorer) ListStream(ctx context.Context, prefix string) <-chan Objec
|
||||
return ch
|
||||
}
|
||||
|
||||
// DeletePartialUploads removes every file under prefix whose name ends in
|
||||
// tempSuffix. Like List, it reads through symlinked directories and fails
|
||||
// on one whose target is missing. A missing prefix has none to remove.
|
||||
func (f *FileStorer) DeletePartialUploads(ctx context.Context, prefix string) error {
|
||||
basePath := f.fullPath(prefix)
|
||||
|
||||
exists, err := f.prefixExists(prefix)
|
||||
if err != nil {
|
||||
return fmt.Errorf("checking path: %w", err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return nil
|
||||
}
|
||||
|
||||
err = f.walk(basePath, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
if info.IsDir() || !strings.HasSuffix(info.Name(), tempSuffix) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return f.fs.Remove(path)
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("walking directory: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Info returns human-readable storage location information.
|
||||
func (f *FileStorer) Info() Info {
|
||||
return Info{
|
||||
@@ -261,7 +303,19 @@ func (f *FileStorer) writeAtomic(
|
||||
path := f.fullPath(key)
|
||||
dir := filepath.Dir(path)
|
||||
|
||||
err := f.fs.MkdirAll(dir, storageDirPerm)
|
||||
// Checked on every write, so a destination that disappears part-way
|
||||
// through a backup fails it rather than being recreated by MkdirAll.
|
||||
_, err := f.fs.Stat(f.basePath)
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("destination directory does not exist; "+
|
||||
"create it, or mount the volume it is on: %w", err)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("checking destination directory: %w", err)
|
||||
}
|
||||
|
||||
err = f.fs.MkdirAll(dir, storageDirPerm)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating directories: %w", err)
|
||||
}
|
||||
@@ -316,6 +370,54 @@ func (f *FileStorer) writeAtomic(
|
||||
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.
|
||||
func (f *FileStorer) fullPath(key string) string {
|
||||
return filepath.Join(f.basePath, key)
|
||||
|
||||
@@ -120,3 +120,45 @@ func TestFileStorer_ListSkipsPartialFiles(t *testing.T) {
|
||||
t.Fatalf("ListStream should return only the real key, got %v", streamed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileStorer_DeletePartialUploads checks that a leftover temp file is
|
||||
// removed and the object at the real key is kept.
|
||||
func TestFileStorer_DeletePartialUploads(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
base := t.TempDir()
|
||||
|
||||
f, err := storage.NewFileStorer(base)
|
||||
if err != nil {
|
||||
t.Fatalf("NewFileStorer: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
err = f.Put(ctx, testBlobKey, strings.NewReader("blob-bytes"))
|
||||
if err != nil {
|
||||
t.Fatalf("Put: %v", err)
|
||||
}
|
||||
|
||||
leftover := filepath.Join(base, testBlobKey+"-123456.partial")
|
||||
|
||||
err = os.WriteFile(leftover, []byte("half"), 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("writing leftover temp file: %v", err)
|
||||
}
|
||||
|
||||
err = f.DeletePartialUploads(ctx, "blobs/")
|
||||
if err != nil {
|
||||
t.Fatalf("DeletePartialUploads: %v", err)
|
||||
}
|
||||
|
||||
_, err = os.Stat(leftover)
|
||||
if !os.IsNotExist(err) {
|
||||
t.Errorf("leftover temp file was not removed: %v", err)
|
||||
}
|
||||
|
||||
_, err = f.Stat(ctx, testBlobKey)
|
||||
if err != nil {
|
||||
t.Errorf("Stat of the real key: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,10 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/vaultik/internal/storage"
|
||||
@@ -60,3 +63,272 @@ func TestFileStorerListMissingDestination(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,6 +213,31 @@ func (r *RcloneStorer) ListStream(
|
||||
return ch
|
||||
}
|
||||
|
||||
// DeletePartialUploads removes every object under prefix whose name ends
|
||||
// in tempSuffix.
|
||||
func (r *RcloneStorer) DeletePartialUploads(ctx context.Context, prefix string) error {
|
||||
var partial []fs.Object
|
||||
|
||||
err := operations.ListFn(ctx, r.fsys, func(obj fs.Object) {
|
||||
key := obj.Remote()
|
||||
if strings.HasPrefix(key, prefix) && strings.HasSuffix(key, tempSuffix) {
|
||||
partial = append(partial, obj)
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("listing objects: %w", err)
|
||||
}
|
||||
|
||||
for _, obj := range partial {
|
||||
err = obj.Remove(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("removing object: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Info returns human-readable storage location information.
|
||||
func (r *RcloneStorer) Info() Info {
|
||||
location := r.remote
|
||||
|
||||
@@ -198,6 +198,47 @@ func TestRcloneStorerListSkipsPartialFiles(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRcloneStorerDeletePartialUploads checks that a temporary file left
|
||||
// by a killed upload is removed and the object at the real key is kept.
|
||||
//
|
||||
//nolint:paralleltest // NewRcloneStorer installs the process-global rclone config
|
||||
func TestRcloneStorerDeletePartialUploads(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ctx := context.Background()
|
||||
|
||||
s, err := storage.NewRcloneStorer(ctx, ":local", 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)
|
||||
}
|
||||
|
||||
leftover := filepath.Join(dir, testBlobKey+"-123456.partial")
|
||||
|
||||
err = os.WriteFile(leftover, []byte("half"), 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("writing leftover temp file: %v", err)
|
||||
}
|
||||
|
||||
err = s.DeletePartialUploads(ctx, "blobs/")
|
||||
if err != nil {
|
||||
t.Fatalf("DeletePartialUploads: %v", err)
|
||||
}
|
||||
|
||||
_, err = os.Stat(leftover)
|
||||
if !os.IsNotExist(err) {
|
||||
t.Errorf("leftover temp file was not removed: %v", err)
|
||||
}
|
||||
|
||||
_, err = s.Stat(ctx, testBlobKey)
|
||||
if err != nil {
|
||||
t.Errorf("Stat of the real key: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// newRcloneStorerOnWrappedLocal registers name as rclone's local backend
|
||||
// wrapped by wrap, and builds an rclone backend on it rooted at a fresh
|
||||
// temp directory. wrap changes the features the local backend reports, so
|
||||
|
||||
@@ -99,6 +99,12 @@ func (s *S3Storer) ListStream(ctx context.Context, prefix string) <-chan ObjectI
|
||||
return ch
|
||||
}
|
||||
|
||||
// DeletePartialUploads has nothing to remove: S3 shows an object only once
|
||||
// its upload has completed, so an upload cut off part-way leaves none.
|
||||
func (s *S3Storer) DeletePartialUploads(_ context.Context, _ string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Info returns human-readable storage location information.
|
||||
func (s *S3Storer) Info() Info {
|
||||
return Info{
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
@@ -22,13 +23,16 @@ import (
|
||||
// s3TestBucket is the bucket created for each in-process S3 server.
|
||||
const s3TestBucket = "test-bucket"
|
||||
|
||||
// Credentials for the tests that build a storer from a config.Config. The
|
||||
// in-process S3 server accepts any.
|
||||
// Credentials for the tests' S3 clients. The test servers accept any.
|
||||
const (
|
||||
s3TestAccessKeyID = "key"
|
||||
s3TestSecretAccessKey = "secret"
|
||||
)
|
||||
|
||||
// s3TestRegion is the region of the S3 clients built here with
|
||||
// s3.NewClient. The test servers ignore it.
|
||||
const s3TestRegion = "us-east-1"
|
||||
|
||||
// newS3Storer builds an s3:// backend backed by a fresh in-process
|
||||
// S3 server. It reuses the same in-memory S3 harness (gofakes3 + s3mem
|
||||
// over httptest) that internal/s3 and the not-found regression test use,
|
||||
@@ -55,7 +59,7 @@ func newS3Storer(t *testing.T) storage.Storer {
|
||||
Bucket: s3TestBucket,
|
||||
AccessKeyID: "test",
|
||||
SecretAccessKey: "test",
|
||||
Region: "us-east-1",
|
||||
Region: s3TestRegion,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("new client: %v", err)
|
||||
@@ -93,6 +97,68 @@ func TestS3StorerMissingKeyMapsToErrNotFound(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestS3ListingCutShortIsAnError checks that List and ListStream fail on a
|
||||
// listing page marked truncated that carries no continuation token, as a
|
||||
// server answering V2 listings V1-style sends it. Taken as the last page,
|
||||
// it would hide every later key, and prune would treat the blobs of the
|
||||
// snapshots listed there as unreferenced.
|
||||
func TestS3ListingCutShortIsAnError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
prefix = "metadata/"
|
||||
truncatedPage = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||
<Name>test-bucket</Name>
|
||||
<Prefix>metadata/</Prefix>
|
||||
<KeyCount>1</KeyCount>
|
||||
<MaxKeys>1</MaxKeys>
|
||||
<IsTruncated>true</IsTruncated>
|
||||
<Contents>
|
||||
<Key>metadata/snap/manifest.json.zst</Key>
|
||||
<Size>8</Size>
|
||||
</Contents>
|
||||
</ListBucketResult>`
|
||||
)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = io.WriteString(w, truncatedPage)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
client, err := s3.NewClient(context.Background(), s3.Config{
|
||||
Endpoint: srv.URL,
|
||||
Bucket: s3TestBucket,
|
||||
AccessKeyID: s3TestAccessKeyID,
|
||||
SecretAccessKey: s3TestSecretAccessKey,
|
||||
Region: s3TestRegion,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("new client: %v", err)
|
||||
}
|
||||
|
||||
storer := storage.NewS3Storer(client)
|
||||
ctx := context.Background()
|
||||
|
||||
keys, err := storer.List(ctx, prefix)
|
||||
if !errors.Is(err, s3.ErrNoContinuationToken) {
|
||||
t.Errorf("List = %q, %v; want ErrNoContinuationToken", keys, err)
|
||||
}
|
||||
|
||||
var streamErr error
|
||||
|
||||
for obj := range storer.ListStream(ctx, prefix) {
|
||||
if obj.Err != nil {
|
||||
streamErr = obj.Err
|
||||
}
|
||||
}
|
||||
|
||||
if !errors.Is(streamErr, s3.ErrNoContinuationToken) {
|
||||
t.Errorf("ListStream error = %v, want ErrNoContinuationToken", streamErr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestS3URLPrefixKeyLayout pins the bucket keys an s3:// URL reads and
|
||||
// writes: the README's remote storage layout, with the prefix joined to
|
||||
// each key by one "/". s3://b/p and s3://b/p/ must be the same
|
||||
|
||||
@@ -41,7 +41,8 @@ type Info struct {
|
||||
// directory for filesystem).
|
||||
type Storer interface {
|
||||
// Put stores data at the specified key.
|
||||
// Parent directories are created automatically for filesystem backends.
|
||||
// The filesystem backend fails when its destination directory does
|
||||
// not exist, and creates the directories below it as needed.
|
||||
Put(ctx context.Context, key string, data io.Reader) error
|
||||
|
||||
// PutWithProgress stores data with progress reporting.
|
||||
@@ -71,6 +72,12 @@ type Storer interface {
|
||||
// If an error occurs during listing, the final item will have Err set.
|
||||
ListStream(ctx context.Context, prefix string) <-chan ObjectInfo
|
||||
|
||||
// DeletePartialUploads removes every object under prefix that an
|
||||
// upload cut off part-way left under a temporary name ending in
|
||||
// `.partial`. The file and rclone backends' List and ListStream skip
|
||||
// such an object.
|
||||
DeletePartialUploads(ctx context.Context, prefix string) error
|
||||
|
||||
// Info returns human-readable storage location information.
|
||||
Info() Info
|
||||
}
|
||||
|
||||
+26
-12
@@ -17,8 +17,10 @@ const (
|
||||
|
||||
// Sentinel errors for storage URL parsing.
|
||||
var (
|
||||
ErrEmptyStorageURL = errors.New("storage URL is empty")
|
||||
ErrEmptyFilePath = errors.New("file URL path is empty")
|
||||
ErrEmptyStorageURL = errors.New("storage URL is empty")
|
||||
ErrEmptyFilePath = errors.New("file URL path is empty")
|
||||
ErrRelativeFilePath = errors.New(
|
||||
"file URL needs an absolute path, as in file:///path/to/backup")
|
||||
ErrMissingBucket = errors.New("s3 URL missing bucket name")
|
||||
ErrMissingRemote = errors.New("rclone URL missing remote name")
|
||||
ErrUnsupportedScheme = errors.New(
|
||||
@@ -51,17 +53,8 @@ func ParseStorageURL(rawURL string) (*URL, error) {
|
||||
return nil, ErrEmptyStorageURL
|
||||
}
|
||||
|
||||
// Handle file:// URLs
|
||||
if after, ok := strings.CutPrefix(rawURL, "file://"); ok {
|
||||
path := after
|
||||
if path == "" {
|
||||
return nil, ErrEmptyFilePath
|
||||
}
|
||||
|
||||
return &URL{
|
||||
Scheme: schemeFile,
|
||||
Prefix: path,
|
||||
}, nil
|
||||
return parseFileURL(after)
|
||||
}
|
||||
|
||||
if strings.HasPrefix(rawURL, "s3://") {
|
||||
@@ -75,6 +68,27 @@ func ParseStorageURL(rawURL string) (*URL, error) {
|
||||
return nil, ErrUnsupportedScheme
|
||||
}
|
||||
|
||||
// parseFileURL parses the path that follows file://. The path must be
|
||||
// absolute: a relative one would resolve against the working directory,
|
||||
// so the destination would move with it. Percent-escapes are decoded, so
|
||||
// my%20stick names the directory "my stick"; a literal space is kept.
|
||||
func parseFileURL(rawPath string) (*URL, error) {
|
||||
if rawPath == "" {
|
||||
return nil, ErrEmptyFilePath
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(rawPath, "/") {
|
||||
return nil, ErrRelativeFilePath
|
||||
}
|
||||
|
||||
path, err := url.PathUnescape(rawPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid file URL path: %w", err)
|
||||
}
|
||||
|
||||
return &URL{Scheme: schemeFile, Prefix: path}, nil
|
||||
}
|
||||
|
||||
// parseS3URL parses an s3://bucket/prefix URL. It rejects credentials in
|
||||
// the userinfo and any query parameter other than endpoint, region and
|
||||
// ssl, so a credential-bearing URL is never stored or echoed.
|
||||
|
||||
@@ -14,7 +14,10 @@ import (
|
||||
func TestParseStorageURLValid(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const bucket = "mybucket"
|
||||
const (
|
||||
bucket = "mybucket"
|
||||
fileScheme = "file"
|
||||
)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -24,7 +27,17 @@ func TestParseStorageURLValid(t *testing.T) {
|
||||
{
|
||||
name: "file absolute path",
|
||||
raw: "file:///var/backups/vaultik",
|
||||
want: &storage.URL{Scheme: "file", Prefix: "/var/backups/vaultik"},
|
||||
want: &storage.URL{Scheme: fileScheme, Prefix: "/var/backups/vaultik"},
|
||||
},
|
||||
{
|
||||
name: "file percent-escaped path is decoded",
|
||||
raw: "file:///Volumes/my%20stick/backup",
|
||||
want: &storage.URL{Scheme: fileScheme, Prefix: "/Volumes/my stick/backup"},
|
||||
},
|
||||
{
|
||||
name: "file path with a literal space",
|
||||
raw: "file:///Volumes/my stick/backup",
|
||||
want: &storage.URL{Scheme: fileScheme, Prefix: "/Volumes/my stick/backup"},
|
||||
},
|
||||
{
|
||||
name: "s3 bucket and prefix, ssl defaults on",
|
||||
@@ -90,6 +103,7 @@ func TestParseStorageURLErrors(t *testing.T) {
|
||||
}{
|
||||
{"empty url", "", storage.ErrEmptyStorageURL},
|
||||
{"file empty path", "file://", storage.ErrEmptyFilePath},
|
||||
{"file relative path", "file://dest", storage.ErrRelativeFilePath},
|
||||
{"s3 missing bucket", "s3://", storage.ErrMissingBucket},
|
||||
{"s3 missing bucket with path", "s3:///justprefix", storage.ErrMissingBucket},
|
||||
{"rclone missing remote", "rclone://", storage.ErrMissingRemote},
|
||||
|
||||
@@ -103,6 +103,8 @@ func (v *Vaultik) FetchAndDecryptBlob(
|
||||
}
|
||||
|
||||
// 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
|
||||
// blocked inside it.
|
||||
func (v *Vaultik) FetchBlob(
|
||||
ctx context.Context, blobHash string,
|
||||
) (io.ReadCloser, error) {
|
||||
@@ -120,5 +122,47 @@ func (v *Vaultik) FetchBlob(
|
||||
return nil, fmt.Errorf("downloading blob %s: %w", shortHash(blobHash), err)
|
||||
}
|
||||
|
||||
return rc, nil
|
||||
return newCloseOnCancelReader(ctx, rc), nil
|
||||
}
|
||||
|
||||
// closeOnCancelReader closes the reader it wraps when ctx is cancelled.
|
||||
// Not every backend's reader stops on cancel by itself (the file
|
||||
// backend's does not), and a read stalled on a slow or hung source would
|
||||
// otherwise outlast the shutdown timeout, so the process would exit
|
||||
// before restore or deep verify removed its decrypted temporary files.
|
||||
type closeOnCancelReader struct {
|
||||
io.ReadCloser
|
||||
|
||||
ctx context.Context //nolint:containedctx // Read reports its error
|
||||
stop func() bool
|
||||
}
|
||||
|
||||
func newCloseOnCancelReader(
|
||||
ctx context.Context, rc io.ReadCloser,
|
||||
) *closeOnCancelReader {
|
||||
return &closeOnCancelReader{
|
||||
ReadCloser: rc,
|
||||
ctx: ctx,
|
||||
stop: context.AfterFunc(ctx, func() { _ = rc.Close() }),
|
||||
}
|
||||
}
|
||||
|
||||
// Read returns the context's error, not the closed reader's, once ctx
|
||||
// has been cancelled.
|
||||
func (r *closeOnCancelReader) Read(p []byte) (int, error) {
|
||||
n, err := r.ReadCloser.Read(p)
|
||||
if err != nil && r.ctx.Err() != nil {
|
||||
return n, r.ctx.Err()
|
||||
}
|
||||
|
||||
return n, err
|
||||
}
|
||||
|
||||
// Close closes the wrapped reader, unless the cancel already closed it.
|
||||
func (r *closeOnCancelReader) Close() error {
|
||||
if !r.stop() {
|
||||
return nil
|
||||
}
|
||||
|
||||
return r.ReadCloser.Close()
|
||||
}
|
||||
|
||||
@@ -137,7 +137,7 @@ func TestBackupAfterRemovingNewestSnapshotRestoresChangedFile(t *testing.T) {
|
||||
fs := afero.NewOsFs()
|
||||
tempDir := t.TempDir()
|
||||
dataDir := filepath.Join(tempDir, "src")
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
storeDir := t.TempDir()
|
||||
restoreDir := filepath.Join(tempDir, "restored")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
changedPath := filepath.Join(dataDir, "appended.bin")
|
||||
@@ -184,7 +184,7 @@ func TestBackupAfterInterruptedRunRestoresChangedFile(t *testing.T) {
|
||||
fs := afero.NewOsFs()
|
||||
tempDir := t.TempDir()
|
||||
dataDir := filepath.Join(tempDir, "src")
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
storeDir := t.TempDir()
|
||||
restoreDir := filepath.Join(tempDir, "restored")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
changedPath := filepath.Join(dataDir, "appended.bin")
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package vaultik_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -9,7 +11,9 @@ import (
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"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"
|
||||
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||
)
|
||||
@@ -30,7 +34,7 @@ func TestDeepVerifyAcceptsHealthyAndRejectsCorruptBlob(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
dataDir := filepath.Join(tempDir, "source")
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
storeDir := t.TempDir()
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
chunkSize := int64(64 * 1024)
|
||||
@@ -77,6 +81,174 @@ func TestDeepVerifyAcceptsHealthyAndRejectsCorruptBlob(t *testing.T) {
|
||||
"deep verify should fail on a corrupted blob")
|
||||
}
|
||||
|
||||
// TestDeepVerifyJSONCountsBlobs checks the verified and missing counts
|
||||
// that deep verify reports under --json: on a healthy snapshot, with a
|
||||
// damaged blob, and with missing blobs. Deep verify stops at the first
|
||||
// damaged blob, so the blobs read before it are the ones verified; when
|
||||
// blobs are missing it reads no blob contents, but counts every missing
|
||||
// blob.
|
||||
func TestDeepVerifyJSONCountsBlobs(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
ctx := context.Background()
|
||||
storeDir := t.TempDir()
|
||||
|
||||
cfg, storer, snapshotID, keys := backUpSeveralBlobs(ctx, t, storeDir)
|
||||
|
||||
healthy, err := deepVerifyJSON(ctx, t, cfg, storer, snapshotID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "ok", healthy.Status)
|
||||
require.Len(t, keys, healthy.BlobCount)
|
||||
require.Equal(t, healthy.BlobCount, healthy.Verified)
|
||||
require.Zero(t, healthy.Missing)
|
||||
|
||||
// Damage the last blob read, keeping its length, so every blob before
|
||||
// it is read and passes first.
|
||||
lastPath := filepath.Join(storeDir, keys[len(keys)-1])
|
||||
blob, err := afero.ReadFile(fs, lastPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
blob[len(blob)/2] ^= 0xff
|
||||
require.NoError(t, afero.WriteFile(fs, lastPath, blob, 0o600))
|
||||
|
||||
got, err := deepVerifyJSON(ctx, t, cfg, storer, snapshotID)
|
||||
require.Error(t, err)
|
||||
require.Equal(t, "failed", got.Status)
|
||||
require.Equal(t, healthy.BlobCount-1, got.Verified)
|
||||
require.Zero(t, got.Missing)
|
||||
|
||||
// Delete the first blob and the damaged last one: both must be
|
||||
// counted as missing, not only the first one found.
|
||||
var deletedSize int64
|
||||
|
||||
for _, key := range []string{keys[0], keys[len(keys)-1]} {
|
||||
info, statErr := storer.Stat(ctx, key)
|
||||
require.NoError(t, statErr)
|
||||
|
||||
deletedSize += info.Size
|
||||
|
||||
require.NoError(t, storer.Delete(ctx, key))
|
||||
}
|
||||
|
||||
got, err = deepVerifyJSON(ctx, t, cfg, storer, snapshotID)
|
||||
require.Error(t, err)
|
||||
require.Equal(t, "failed", got.Status)
|
||||
require.Equal(t, 2, got.Missing)
|
||||
require.Equal(t, deletedSize, got.MissingSize)
|
||||
require.Zero(t, got.Verified)
|
||||
}
|
||||
|
||||
// TestDeepVerifyJSONCountsWrongSizeBlob checks that under --deep --json a
|
||||
// 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.
|
||||
func TestDeepVerifyJSONCountsWrongSizeBlob(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
ctx := context.Background()
|
||||
storeDir := t.TempDir()
|
||||
|
||||
cfg, storer, snapshotID, keys := backUpSeveralBlobs(ctx, t, storeDir)
|
||||
|
||||
// Make the first blob checked longer, and delete the last one.
|
||||
firstPath := filepath.Join(storeDir, keys[0])
|
||||
blob, err := afero.ReadFile(fs, firstPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
blob = append(blob, []byte("extra")...)
|
||||
require.NoError(t, afero.WriteFile(fs, firstPath, blob, 0o600))
|
||||
require.NoError(t, storer.Delete(ctx, keys[len(keys)-1]))
|
||||
|
||||
got, err := deepVerifyJSON(ctx, t, cfg, storer, snapshotID)
|
||||
require.Error(t, err)
|
||||
require.Equal(t, "failed", got.Status)
|
||||
require.Equal(t, 1, got.Mismatched)
|
||||
require.Equal(t, 1, got.Missing)
|
||||
require.Zero(t, got.Verified)
|
||||
}
|
||||
|
||||
// TestDeepVerifyStopsOnBlobThatCannotBeChecked checks that a blob that is
|
||||
// present in storage but cannot be checked is not counted as missing:
|
||||
// deep verify stops and fails with the storage error.
|
||||
func TestDeepVerifyStopsOnBlobThatCannotBeChecked(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
storeDir := t.TempDir()
|
||||
|
||||
cfg, storer, snapshotID, keys := backUpSeveralBlobs(ctx, t, storeDir)
|
||||
failing := statFailingStorer{Storer: storer, failKeys: []string{keys[0]}}
|
||||
|
||||
got, err := deepVerifyJSON(ctx, t, cfg, failing, snapshotID)
|
||||
require.Error(t, err)
|
||||
require.Equal(t, "failed", got.Status)
|
||||
require.Zero(t, got.Missing)
|
||||
require.Contains(t, got.ErrorMessage, errStatDenied.Error())
|
||||
}
|
||||
|
||||
// backUpSeveralBlobs backs up random data, which does not compress or
|
||||
// deduplicate, so it fills at least three blobs in a store on disk at
|
||||
// storeDir. It returns the blobs' storage keys sorted, which is hash
|
||||
// order, the order deep verify reads them in.
|
||||
func backUpSeveralBlobs(
|
||||
ctx context.Context, t *testing.T, storeDir string,
|
||||
) (*config.Config, *storage.FileStorer, string, []string) {
|
||||
t.Helper()
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
tempDir := t.TempDir()
|
||||
dataDir := filepath.Join(tempDir, "source")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
chunkSize := int64(32 * 1024)
|
||||
maxBlobSize := int64(128 * 1024)
|
||||
|
||||
data := make([]byte, maxBlobSize*4)
|
||||
_, err := rand.Read(data)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, fs.MkdirAll(dataDir, 0o755))
|
||||
require.NoError(t, afero.WriteFile(fs,
|
||||
filepath.Join(dataDir, "data.bin"), data, 0o644))
|
||||
|
||||
cfg, storer, snapshotID := runFileStorageBackup(
|
||||
ctx, t, fs, dataDir, storeDir, dbPath, chunkSize, maxBlobSize)
|
||||
|
||||
keys := storedBlobKeys(t, storeDir)
|
||||
require.GreaterOrEqual(t, len(keys), 3)
|
||||
|
||||
return cfg, storer, snapshotID, keys
|
||||
}
|
||||
|
||||
// deepVerifyJSON runs deep verify with --json on snapshotID and returns
|
||||
// the decoded result and the error RunDeepVerify returned.
|
||||
func deepVerifyJSON(
|
||||
ctx context.Context, t *testing.T,
|
||||
cfg *config.Config, storer storage.Storer, snapshotID string,
|
||||
) (vaultik.VerifyResult, error) {
|
||||
t.Helper()
|
||||
|
||||
var out bytes.Buffer
|
||||
|
||||
v := &vaultik.Vaultik{
|
||||
Config: cfg,
|
||||
Storage: storer,
|
||||
Fs: afero.NewOsFs(),
|
||||
Stdout: &out,
|
||||
Stderr: io.Discard,
|
||||
UI: ui.NewWithColor(io.Discard, false),
|
||||
}
|
||||
v.SetContext(ctx)
|
||||
|
||||
err := v.RunDeepVerify(snapshotID,
|
||||
&vaultik.VerifyOptions{Deep: true, JSON: true})
|
||||
|
||||
return decodeVerifyResult(t, out.Bytes()), err
|
||||
}
|
||||
|
||||
// corruptOneBlob flips a middle byte of the first blob file found under
|
||||
// blobsDir, leaving the file length unchanged.
|
||||
func corruptOneBlob(t *testing.T, fs afero.Fs, blobsDir string) {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package vaultik
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"sneak.berlin/go/vaultik/internal/storage"
|
||||
)
|
||||
|
||||
// destinationMarkerKey is the object every backup writes at the root of
|
||||
// the backup destination store. While the local index holds a snapshot,
|
||||
// a destination without it is treated as not present: an unplugged
|
||||
// volume leaves an empty directory at its mount point, which would
|
||||
// otherwise read as a store whose snapshots are all gone.
|
||||
const destinationMarkerKey = "vaultik-destination.txt"
|
||||
|
||||
// destinationMarkerText is the whole content of the marker. It names no
|
||||
// host, path or time.
|
||||
const destinationMarkerText = "This is a vaultik backup destination store.\n"
|
||||
|
||||
// errDestinationNotPresent is returned when the local index holds a
|
||||
// snapshot and the backup destination store has no marker.
|
||||
var errDestinationNotPresent = errors.New("backup destination store is not present")
|
||||
|
||||
// checkDestinationMarker returns errDestinationNotPresent when the local
|
||||
// index holds a snapshot and the backup destination store has no marker.
|
||||
// With no snapshot in the local index, as on a first backup or on
|
||||
// another machine, there is nothing to check.
|
||||
func (v *Vaultik) checkDestinationMarker() error {
|
||||
ids, err := v.localSnapshotIDs()
|
||||
if err != nil {
|
||||
return fmt.Errorf("listing local snapshots: %w", err)
|
||||
}
|
||||
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err = v.Storage.Stat(v.ctx, destinationMarkerKey)
|
||||
if errors.Is(err, storage.ErrNotFound) {
|
||||
return fmt.Errorf("%w: %s has no %s, but the local index holds "+
|
||||
"snapshots stored there; mount the volume if it is removable, "+
|
||||
"or if the destination was emptied on purpose, run "+
|
||||
"'vaultik database delete' and back up again",
|
||||
errDestinationNotPresent, v.Storage.Info().Location,
|
||||
destinationMarkerKey)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("checking for %s: %w", destinationMarkerKey, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// markDestination checks the backup destination store with
|
||||
// checkDestinationMarker and then writes the marker, so a first backup
|
||||
// marks its destination and a later one writes the same text again.
|
||||
func (v *Vaultik) markDestination() error {
|
||||
err := v.checkDestinationMarker()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = v.Storage.Put(v.ctx, destinationMarkerKey,
|
||||
strings.NewReader(destinationMarkerText))
|
||||
if err != nil {
|
||||
return fmt.Errorf("writing %s to the backup destination store: %w",
|
||||
destinationMarkerKey, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package vaultik_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/database"
|
||||
"sneak.berlin/go/vaultik/internal/log"
|
||||
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||
)
|
||||
|
||||
// These tests cover https://git.eeqj.de/sneak/vaultik/issues/239: a
|
||||
// file:// destination at a volume's mount point leaves an empty directory
|
||||
// behind when the volume is unplugged. While the local index holds
|
||||
// snapshots, a destination without vaultik-destination.txt is not present,
|
||||
// so no command may write to it or conclude from it that the local
|
||||
// snapshots are gone.
|
||||
|
||||
// markerFile is the name of the file a backup writes at the root of the
|
||||
// destination.
|
||||
const markerFile = "vaultik-destination.txt"
|
||||
|
||||
// backUpThenUnplugMountPoint backs up to a file:// destination, then
|
||||
// moves the destination directory away and leaves an empty directory in
|
||||
// its place, as unplugging a volume mounted there would. It returns the
|
||||
// path of that empty directory.
|
||||
func backUpThenUnplugMountPoint(
|
||||
ctx context.Context, t *testing.T,
|
||||
) (*vaultik.Vaultik, *database.Repositories, *bytes.Buffer, string) {
|
||||
t.Helper()
|
||||
|
||||
storeDir := filepath.Join(t.TempDir(), "mnt")
|
||||
v, repos, out := backUpToFileDestination(ctx, t, storeDir)
|
||||
|
||||
require.NoError(t, os.Rename(storeDir, storeDir+"-unplugged"))
|
||||
require.NoError(t, os.Mkdir(storeDir, 0o750))
|
||||
|
||||
return v, repos, out, storeDir
|
||||
}
|
||||
|
||||
// assertUntouched checks that the local index still holds its one
|
||||
// snapshot and that nothing was written to the empty mount point.
|
||||
func assertUntouched(
|
||||
ctx context.Context, t *testing.T,
|
||||
repos *database.Repositories, storeDir string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
snapshots, err := repos.Snapshots.ListRecent(ctx, listRecentTestLimit)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, snapshots, 1, "no local snapshot record may be deleted")
|
||||
|
||||
entries, err := os.ReadDir(storeDir)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, entries, "nothing may be written to the empty mount point")
|
||||
}
|
||||
|
||||
// TestFirstBackupWritesMarker checks that a backup writes the marker
|
||||
// file at the root of the destination.
|
||||
func TestFirstBackupWritesMarker(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
storeDir := t.TempDir()
|
||||
backUpToFileDestination(context.Background(), t, storeDir)
|
||||
|
||||
assert.FileExists(t, filepath.Join(storeDir, markerFile))
|
||||
}
|
||||
|
||||
// TestBackupRefusesDestinationWithoutMarker checks that snapshot create
|
||||
// --prune writes nothing and drops no local snapshot record when the
|
||||
// destination has no marker.
|
||||
func TestBackupRefusesDestinationWithoutMarker(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
v, repos, _, storeDir := backUpThenUnplugMountPoint(ctx, t)
|
||||
|
||||
err := v.CreateSnapshot(&vaultik.SnapshotCreateOptions{
|
||||
Cron: true,
|
||||
Prune: true,
|
||||
})
|
||||
require.ErrorContains(t, err, markerFile)
|
||||
|
||||
assertUntouched(ctx, t, repos, storeDir)
|
||||
}
|
||||
|
||||
// TestPruneRefusesDestinationWithoutMarker checks that prune fails and
|
||||
// deletes no local snapshot record when the destination has no marker.
|
||||
func TestPruneRefusesDestinationWithoutMarker(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
v, repos, _, storeDir := backUpThenUnplugMountPoint(ctx, t)
|
||||
|
||||
err := v.Prune(&vaultik.PruneOptions{Force: true})
|
||||
require.ErrorContains(t, err, markerFile)
|
||||
|
||||
assertUntouched(ctx, t, repos, storeDir)
|
||||
}
|
||||
|
||||
// TestPurgeRefusesDestinationWithoutMarker checks that snapshot purge
|
||||
// fails and deletes no local snapshot record when the destination has no
|
||||
// marker.
|
||||
func TestPurgeRefusesDestinationWithoutMarker(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
v, repos, _, storeDir := backUpThenUnplugMountPoint(ctx, t)
|
||||
|
||||
err := v.PurgeSnapshotsWithOptions(&vaultik.SnapshotPurgeOptions{
|
||||
KeepLatest: true,
|
||||
Force: true,
|
||||
})
|
||||
require.ErrorContains(t, err, markerFile)
|
||||
|
||||
assertUntouched(ctx, t, repos, storeDir)
|
||||
}
|
||||
|
||||
// TestRemoveSnapshotWarnsWhenDestinationHasNoMarker checks that snapshot
|
||||
// remove warns that the metadata could not be removed from a destination
|
||||
// without a marker, instead of reporting that it was.
|
||||
func TestRemoveSnapshotWarnsWhenDestinationHasNoMarker(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
v, repos, out, _ := backUpThenUnplugMountPoint(ctx, t)
|
||||
|
||||
result, err := v.RemoveSnapshot(localSnapshotID(ctx, t, repos, "first"),
|
||||
&vaultik.RemoveOptions{Force: true})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.False(t, result.RemoteRemoved)
|
||||
assert.Contains(t, out.String(),
|
||||
"Could not remove snapshot metadata from remote")
|
||||
assert.Contains(t, out.String(), markerFile)
|
||||
assert.NotContains(t, out.String(),
|
||||
"Removed snapshot metadata from remote storage")
|
||||
}
|
||||
|
||||
// TestListSnapshotsWarnsWhenDestinationHasNoMarker checks that snapshot
|
||||
// list warns and shows the local index alone when the destination has no
|
||||
// marker, without reporting the local snapshot as missing from it.
|
||||
func TestListSnapshotsWarnsWhenDestinationHasNoMarker(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
v, repos, out, _ := backUpThenUnplugMountPoint(ctx, t)
|
||||
id := localSnapshotID(ctx, t, repos, "first")
|
||||
|
||||
require.NoError(t, v.ListSnapshots(false))
|
||||
|
||||
assert.Contains(t, out.String(), "Could not list backup destination store")
|
||||
assert.Contains(t, out.String(), markerFile)
|
||||
assert.Contains(t, out.String(), id)
|
||||
assert.NotContains(t, out.String(), "not found in backup destination store")
|
||||
}
|
||||
|
||||
// TestBackupAfterNukeRemoteSucceeds checks that remote nuke leaves the
|
||||
// destination ready for a new first backup, which writes the marker
|
||||
// again.
|
||||
func TestBackupAfterNukeRemoteSucceeds(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
storeDir := t.TempDir()
|
||||
v, _, _ := backUpToFileDestination(context.Background(), t, storeDir)
|
||||
|
||||
require.NoError(t, v.NukeRemote(true))
|
||||
assert.NoFileExists(t, filepath.Join(storeDir, markerFile))
|
||||
|
||||
require.NoError(t, backUp(v, "second"))
|
||||
assert.FileExists(t, filepath.Join(storeDir, markerFile))
|
||||
}
|
||||
@@ -161,13 +161,14 @@ func TestVerifyManifestBlobsRejectsShortHash(t *testing.T) {
|
||||
Blobs: []snapshot.BlobInfo{{Hash: "abc", CompressedSize: 1}},
|
||||
}
|
||||
|
||||
verified, missing, mismatched, missingSize, err :=
|
||||
verified, missing, mismatched, missingSize, unchecked, err :=
|
||||
v.verifyManifestBlobs(manifest, &VerifyOptions{JSON: true})
|
||||
require.ErrorIs(t, err, errInvalidBlobHash)
|
||||
require.Zero(t, verified)
|
||||
require.Zero(t, missing)
|
||||
require.Zero(t, mismatched)
|
||||
require.Zero(t, missingSize)
|
||||
require.Empty(t, unchecked)
|
||||
}
|
||||
|
||||
// TestVerifyBlobChunksRejectsNegativeLength proves a blob_chunks row with a
|
||||
@@ -257,6 +258,10 @@ func (s *stubLister) List(_ context.Context, _ string) ([]string, error) {
|
||||
return nil, errStubUnused
|
||||
}
|
||||
|
||||
func (s *stubLister) DeletePartialUploads(_ context.Context, _ string) error {
|
||||
return errStubUnused
|
||||
}
|
||||
|
||||
func (s *stubLister) Info() storage.Info {
|
||||
return storage.Info{}
|
||||
}
|
||||
|
||||
@@ -192,7 +192,7 @@ func assertRestoreRejectsDamagedBlob(
|
||||
fs := afero.NewOsFs()
|
||||
tempDir := t.TempDir()
|
||||
dataDir := filepath.Join(tempDir, "src")
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
storeDir := t.TempDir()
|
||||
restoreDir := filepath.Join(tempDir, "restored")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
@@ -238,7 +238,7 @@ func TestDeepVerifyCatchesLyingBackend(t *testing.T) {
|
||||
fs := afero.NewOsFs()
|
||||
tempDir := t.TempDir()
|
||||
dataDir := filepath.Join(tempDir, "src")
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
storeDir := t.TempDir()
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
ctx := context.Background()
|
||||
@@ -290,7 +290,7 @@ func TestInterruptedBlobUploadRecordsNoUploadedBlob(t *testing.T) {
|
||||
fs := afero.NewOsFs()
|
||||
tempDir := t.TempDir()
|
||||
dataDir := filepath.Join(tempDir, "src")
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
storeDir := t.TempDir()
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
ctx := context.Background()
|
||||
@@ -298,10 +298,6 @@ func TestInterruptedBlobUploadRecordsNoUploadedBlob(t *testing.T) {
|
||||
|
||||
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)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -365,7 +361,7 @@ func TestBackupRetryAfterInterruptedUploadIsRestorable(t *testing.T) {
|
||||
fs := afero.NewOsFs()
|
||||
tempDir := t.TempDir()
|
||||
dataDir := filepath.Join(tempDir, "src")
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
storeDir := t.TempDir()
|
||||
restoreDir := filepath.Join(tempDir, "restored")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
@@ -429,7 +425,7 @@ func TestBackupSurvivesMetadataExportInterruption(t *testing.T) {
|
||||
fs := afero.NewOsFs()
|
||||
tempDir := t.TempDir()
|
||||
dataDir := filepath.Join(tempDir, "src")
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
storeDir := t.TempDir()
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
ctx := context.Background()
|
||||
@@ -511,7 +507,7 @@ func TestBackupCompletesOnlyAfterMetadataExport(t *testing.T) {
|
||||
fs := afero.NewOsFs()
|
||||
tempDir := t.TempDir()
|
||||
dataDir := filepath.Join(tempDir, "src")
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
storeDir := t.TempDir()
|
||||
restoreDir := filepath.Join(tempDir, "restored")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
@@ -686,7 +682,7 @@ func TestRestoreReportsDiskFull(t *testing.T) {
|
||||
osFS := afero.NewOsFs()
|
||||
tempDir := t.TempDir()
|
||||
dataDir := filepath.Join(tempDir, "src")
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
storeDir := t.TempDir()
|
||||
restoreDir := filepath.Join(tempDir, "restored")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
|
||||
+24
-24
@@ -3,6 +3,7 @@ package vaultik
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -47,6 +48,10 @@ const (
|
||||
year = 365 * day
|
||||
)
|
||||
|
||||
// maxDuration is the largest value a time.Duration can hold, about 292
|
||||
// years.
|
||||
const maxDuration = time.Duration(math.MaxInt64)
|
||||
|
||||
// A snapshot ID split on "_" has at least a hostname and a trailing
|
||||
// timestamp.
|
||||
const minSnapshotIDParts = 2
|
||||
@@ -84,22 +89,6 @@ type SnapshotInfo struct {
|
||||
RemotePresent *bool `json:"remote_present"`
|
||||
}
|
||||
|
||||
// formatBytes formats bytes in a human-readable format
|
||||
func formatBytes(bytes int64) string {
|
||||
const unit = 1024
|
||||
if bytes < unit {
|
||||
return fmt.Sprintf("%d B", bytes)
|
||||
}
|
||||
|
||||
div, exp := int64(unit), 0
|
||||
for n := bytes / unit; n >= unit; n /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
|
||||
}
|
||||
|
||||
// parseSnapshotTimestamp extracts the timestamp from a snapshot ID
|
||||
// Format: hostname_snapshotname_2026-01-12T14:41:15Z
|
||||
func parseSnapshotTimestamp(snapshotID string) (time.Time, error) {
|
||||
@@ -145,10 +134,10 @@ func parseSnapshotName(snapshotID, hostname string) string {
|
||||
// parseDuration parses a duration string with support for human-friendly units:
|
||||
// d/day/days, w/week/weeks, mo/month/months, y/year/years, plus standard Go
|
||||
// duration units. Following Go, m is minutes and mo is months. A bare number,
|
||||
// an unknown unit, and a negative value are all rejected. Outside the Go
|
||||
// units the input must be whole numbers each followed directly by a unit
|
||||
// (2w3d), so 1.0y and 30 days are errors; decimals work only in Go units
|
||||
// (1.5h).
|
||||
// an unknown unit, a negative value, and a total longer than maxDuration are
|
||||
// all rejected. Outside the Go units the input must be whole numbers each
|
||||
// followed directly by a unit (2w3d), so 1.0y and 30 days are errors;
|
||||
// decimals work only in Go units (1.5h).
|
||||
func parseDuration(s string) (time.Duration, error) {
|
||||
if strings.HasPrefix(strings.TrimSpace(s), "-") {
|
||||
return 0, errNegativeDuration
|
||||
@@ -181,19 +170,30 @@ func parseDuration(s string) (time.Duration, error) {
|
||||
return 0, fmt.Errorf("invalid number %q: %w", match[1], err)
|
||||
}
|
||||
|
||||
var unitLength time.Duration
|
||||
|
||||
unit := strings.ToLower(match[2])
|
||||
switch unit {
|
||||
case "d", "day", "days":
|
||||
total += time.Duration(n) * day
|
||||
unitLength = day
|
||||
case "w", "week", "weeks":
|
||||
total += time.Duration(n) * week
|
||||
unitLength = week
|
||||
case "mo", "month", "months":
|
||||
total += time.Duration(n) * month
|
||||
unitLength = month
|
||||
case "y", "year", "years":
|
||||
total += time.Duration(n) * year
|
||||
unitLength = year
|
||||
default:
|
||||
return 0, fmt.Errorf("%w %q", errUnknownTimeUnit, unit)
|
||||
}
|
||||
|
||||
// Past maxDuration the sum wraps to a negative value, which would
|
||||
// put the purge cutoff in the future.
|
||||
if time.Duration(n) > (maxDuration-total)/unitLength {
|
||||
return 0, fmt.Errorf("%w: %q is longer than about 292 years",
|
||||
errInvalidDuration, s)
|
||||
}
|
||||
|
||||
total += time.Duration(n) * unitLength
|
||||
}
|
||||
|
||||
return total, nil
|
||||
|
||||
@@ -111,6 +111,17 @@ func TestParseDuration(t *testing.T) {
|
||||
{"30 days", 0, true},
|
||||
{"30 days ago", 0, true},
|
||||
{"x7d", 0, true},
|
||||
// The largest time.Duration is about 292 years. A larger value
|
||||
// must be rejected, not wrapped to a negative one, which would
|
||||
// put the cutoff in the future and select every snapshot.
|
||||
{"292y", 292 * 365 * 24 * time.Hour, false},
|
||||
{"293y", 0, true},
|
||||
{"3559mo", 0, true},
|
||||
{"15251w", 0, true},
|
||||
{"106752d", 0, true},
|
||||
{"2562048h", 0, true},
|
||||
{"200y200y", 0, true},
|
||||
{"292y6mo", 0, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -167,10 +167,6 @@ const (
|
||||
// metadataKeyParts is the minimum "/"-separated segment count of a
|
||||
// metadata object key (metadata/<snapshot-id>/<filename>).
|
||||
metadataKeyParts = 3
|
||||
|
||||
// blobKeyParts is the minimum "/"-separated segment count of a blob
|
||||
// object key (blobs/<aa>/<bb>/<hash>).
|
||||
blobKeyParts = 4
|
||||
)
|
||||
|
||||
// SnapshotMetadataInfo contains information about a single snapshot's metadata
|
||||
@@ -454,23 +450,16 @@ func (v *Vaultik) scanRemoteBlobStorage(
|
||||
v.stdoutf("Scanning blobs...\n")
|
||||
}
|
||||
|
||||
blobCh := v.Storage.ListStream(v.ctx, "blobs/")
|
||||
allBlobs := make(map[string]int64)
|
||||
// Use prune's listing, so a file under blobs/ that prune would never
|
||||
// delete is not counted as a blob, and so not as an orphaned one.
|
||||
allBlobs, err := v.listAllRemoteBlobs()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for obj := range blobCh {
|
||||
if obj.Err != nil {
|
||||
return fmt.Errorf("listing blobs: %w", obj.Err)
|
||||
}
|
||||
|
||||
parts := strings.Split(obj.Key, "/")
|
||||
if len(parts) < blobKeyParts {
|
||||
continue
|
||||
}
|
||||
|
||||
hash := parts[3]
|
||||
allBlobs[hash] = obj.Size
|
||||
for _, size := range allBlobs {
|
||||
result.TotalBlobCount++
|
||||
result.TotalBlobSize += obj.Size
|
||||
result.TotalBlobSize += size
|
||||
}
|
||||
|
||||
// A blob named only by a manifest that could not be read, or by one
|
||||
|
||||
@@ -155,6 +155,12 @@ func (m *MockStorer) ListStream(
|
||||
return ch
|
||||
}
|
||||
|
||||
// DeletePartialUploads has nothing to remove: Put stores each object
|
||||
// under its key at once.
|
||||
func (m *MockStorer) DeletePartialUploads(_ context.Context, _ string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockStorer) Info() storage.Info {
|
||||
return storage.Info{
|
||||
Type: "mock",
|
||||
@@ -765,7 +771,7 @@ func TestEndToEndFileStorage(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
dataDir := filepath.Join(tempDir, "source")
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
storeDir := t.TempDir()
|
||||
restoreDir := filepath.Join(tempDir, "restored")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
@@ -958,7 +964,7 @@ func TestDedupOnlySnapshotRestores(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
dataDir := filepath.Join(tempDir, "source")
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
storeDir := t.TempDir()
|
||||
restoreDir := filepath.Join(tempDir, "restored")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
|
||||
@@ -24,13 +24,11 @@ import (
|
||||
// that is not plugged in, cannot be listed. It is not an empty store, so
|
||||
// no command may conclude from it that the local snapshots are gone.
|
||||
|
||||
// backUpToFileDestination backs up the snapshot named "first" to a
|
||||
// file:// destination at storeDir, which need not exist yet. Everything
|
||||
// the returned Vaultik prints after the backup goes to the returned
|
||||
// buffer.
|
||||
func backUpToFileDestination(
|
||||
// newFileDestinationVaultik returns a Vaultik that backs up the
|
||||
// snapshot named "first" to a file:// destination at storeDir.
|
||||
func newFileDestinationVaultik(
|
||||
ctx context.Context, t *testing.T, storeDir string,
|
||||
) (*vaultik.Vaultik, *database.Repositories, *bytes.Buffer) {
|
||||
) (*vaultik.Vaultik, *database.Repositories) {
|
||||
t.Helper()
|
||||
|
||||
osFs := afero.NewOsFs()
|
||||
@@ -49,8 +47,22 @@ func backUpToFileDestination(
|
||||
|
||||
repos := database.NewRepositories(db)
|
||||
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"))
|
||||
|
||||
out := &bytes.Buffer{}
|
||||
@@ -75,21 +87,28 @@ func backUpThenUnplug(
|
||||
return v, repos, out
|
||||
}
|
||||
|
||||
// TestFirstBackupCreatesDestinationDirectory checks that a first backup
|
||||
// to a destination directory that does not exist yet creates it, and
|
||||
// that the destination can be listed afterwards.
|
||||
func TestFirstBackupCreatesDestinationDirectory(t *testing.T) {
|
||||
// TestBackupFailsWhenDestinationDirectoryMissing checks that a backup to
|
||||
// a file:// destination whose directory does not exist fails before it
|
||||
// records a snapshot, and does not create the directory.
|
||||
func TestBackupFailsWhenDestinationDirectoryMissing(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
storeDir := filepath.Join(t.TempDir(), "volume", "backup")
|
||||
v, _, out := backUpToFileDestination(ctx, t, storeDir)
|
||||
volume := filepath.Join(t.TempDir(), "volume")
|
||||
v, repos := newFileDestinationVaultik(ctx, t, filepath.Join(volume, "backup"))
|
||||
|
||||
require.NoError(t, v.ListSnapshots(false))
|
||||
err := backUp(v, "first")
|
||||
require.ErrorIs(t, err, fs.ErrNotExist)
|
||||
require.ErrorContains(t, err, "create it, or mount the volume it is on")
|
||||
|
||||
assert.NotContains(t, out.String(), "Could not list backup destination store")
|
||||
assert.NotContains(t, out.String(), "not found in backup destination store")
|
||||
_, err = os.Stat(volume)
|
||||
require.ErrorIs(t, err, fs.ErrNotExist,
|
||||
"the backup must not create the destination directory")
|
||||
|
||||
snapshots, err := repos.Snapshots.ListRecent(ctx, listRecentTestLimit)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, snapshots)
|
||||
}
|
||||
|
||||
// TestListSnapshotsWarnsWhenDestinationMissing checks that snapshot list
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
package vaultik_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"maps"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/chunker"
|
||||
"sneak.berlin/go/vaultik/internal/database"
|
||||
"sneak.berlin/go/vaultik/internal/log"
|
||||
"sneak.berlin/go/vaultik/internal/storage"
|
||||
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||
)
|
||||
|
||||
// TestNewChunkSizeOfChangedFileStaysFixed covers
|
||||
// https://git.eeqj.de/sneak/vaultik/issues/311. A backup whose only change
|
||||
// is a modified file lists the size of the chunks that change added as its
|
||||
// NEW CHUNK SIZE, and a later backup that changes the file again leaves
|
||||
// every earlier snapshot's figure as it was. The file's row in the local
|
||||
// index is shared by all three snapshots and rewritten by each backup.
|
||||
func TestNewChunkSizeOfChangedFileStaysFixed(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
tempDir := t.TempDir()
|
||||
dataDir := filepath.Join(tempDir, "src")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
changedPath := filepath.Join(dataDir, "changed.bin")
|
||||
|
||||
ctx := context.Background()
|
||||
files := writeFaultSourceTree(t, fs, dataDir)
|
||||
cfg := changedFileConfig(dataDir, dbPath)
|
||||
|
||||
appendRandomBytes(t, fs, files, changedPath, appendedFileSize)
|
||||
|
||||
store, err := storage.NewFileStorer(t.TempDir())
|
||||
require.NoError(t, err)
|
||||
|
||||
db, err := database.New(ctx, dbPath)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
repos := database.NewRepositories(db)
|
||||
v := newBackupVaultik(ctx, cfg, store, repos, db, fs)
|
||||
|
||||
require.NoError(t, backUp(v, "first"))
|
||||
|
||||
firstFiles := maps.Clone(files)
|
||||
firstID := localSnapshotID(ctx, t, repos, "first")
|
||||
|
||||
appendRandomBytes(t, fs, files, changedPath, faultChunkSize)
|
||||
require.NoError(t, backUp(v, "second"))
|
||||
|
||||
secondID := localSnapshotID(ctx, t, repos, "second")
|
||||
secondAdded := sizeOfNewChunks(t, files, firstFiles)
|
||||
require.Positive(t, secondAdded)
|
||||
assert.Equal(t, secondAdded, listedNewChunkSizes(t, v)[secondID],
|
||||
"the second snapshot must count the chunks the change added")
|
||||
|
||||
appendRandomBytes(t, fs, files, changedPath, faultChunkSize)
|
||||
require.NoError(t, backUp(v, "third"))
|
||||
|
||||
sizes := listedNewChunkSizes(t, v)
|
||||
assert.Equal(t, sizeOfNewChunks(t, firstFiles, nil), sizes[firstID],
|
||||
"a later backup must not change the first snapshot's figure")
|
||||
assert.Equal(t, secondAdded, sizes[secondID],
|
||||
"a later backup must not change the second snapshot's figure")
|
||||
}
|
||||
|
||||
// sizeOfNewChunks returns the total size of the distinct chunks of the
|
||||
// files in after that no file in before has, cut the way the backup cuts
|
||||
// them.
|
||||
func sizeOfNewChunks(t *testing.T, after, before map[string][]byte) int64 {
|
||||
t.Helper()
|
||||
|
||||
known := make(map[string]bool)
|
||||
for _, chunk := range chunksOf(t, before) {
|
||||
known[chunk.Hash] = true
|
||||
}
|
||||
|
||||
var total int64
|
||||
|
||||
for _, chunk := range chunksOf(t, after) {
|
||||
if !known[chunk.Hash] {
|
||||
known[chunk.Hash] = true
|
||||
total += chunk.Size
|
||||
}
|
||||
}
|
||||
|
||||
return total
|
||||
}
|
||||
|
||||
// chunksOf returns the chunks of every file in files.
|
||||
func chunksOf(t *testing.T, files map[string][]byte) []chunker.Chunk {
|
||||
t.Helper()
|
||||
|
||||
c := chunker.NewChunker(faultChunkSize)
|
||||
|
||||
var all []chunker.Chunk
|
||||
|
||||
for _, content := range files {
|
||||
chunks, err := c.ChunkReader(bytes.NewReader(content))
|
||||
require.NoError(t, err)
|
||||
|
||||
all = append(all, chunks...)
|
||||
}
|
||||
|
||||
return all
|
||||
}
|
||||
|
||||
// listedNewChunkSizes runs `snapshot list --json` and returns each listed
|
||||
// snapshot's new_chunk_size by snapshot ID.
|
||||
func listedNewChunkSizes(t *testing.T, v *vaultik.Vaultik) map[string]int64 {
|
||||
t.Helper()
|
||||
|
||||
var out bytes.Buffer
|
||||
|
||||
v.Stdout = &out
|
||||
require.NoError(t, v.ListSnapshots(true))
|
||||
|
||||
sizes := make(map[string]int64)
|
||||
for _, row := range decodeListJSON(t, out.String()) {
|
||||
sizes[row.ID] = row.NewChunkSize
|
||||
}
|
||||
|
||||
return sizes
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package vaultik_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/log"
|
||||
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||
"sneak.berlin/go/vaultik/internal/storage/faultstore"
|
||||
)
|
||||
|
||||
// TestNukeRemoteLeavesNoFiles checks that remote nuke leaves no file
|
||||
// under a file:// destination, including the `.partial` files uploads
|
||||
// killed part-way leave next to a snapshot's metadata and next to where a
|
||||
// blob would have been.
|
||||
func TestNukeRemoteLeavesNoFiles(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)
|
||||
|
||||
snapshots, err := repos.Snapshots.ListRecent(ctx, listRecentTestLimit)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, snapshots, 1)
|
||||
|
||||
snapshotKey := snapshot.RemoteSnapshotKey(snapshots[0].ID.String())
|
||||
hash := testBlobHashA
|
||||
leftovers := []string{
|
||||
filepath.Join(storeDir, "metadata", snapshotKey,
|
||||
"db.zst.age-123456.partial"),
|
||||
filepath.Join(storeDir, "blobs", hash[:2], hash[2:4],
|
||||
hash+"-123456.partial"),
|
||||
}
|
||||
|
||||
for _, leftover := range leftovers {
|
||||
require.NoError(t, os.MkdirAll(filepath.Dir(leftover), 0o750))
|
||||
require.NoError(t, os.WriteFile(leftover, []byte("half an upload"), 0o600))
|
||||
}
|
||||
|
||||
require.NoError(t, v.NukeRemote(true))
|
||||
|
||||
var files []string
|
||||
|
||||
err = filepath.WalkDir(storeDir,
|
||||
func(path string, entry fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !entry.IsDir() {
|
||||
files = append(files, path)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, files)
|
||||
|
||||
// Nuke deletes the blobs itself, so advice to run prune is wrong.
|
||||
assert.Contains(t, out.String(), "Backup destination store is now empty.")
|
||||
assert.NotContains(t, out.String(), "vaultik prune")
|
||||
}
|
||||
|
||||
// TestNukeRemoteFailsWhenADeleteFails checks that remote nuke returns an
|
||||
// error when a snapshot's metadata or a blob cannot be deleted, does not
|
||||
// report the store empty, and advises running nuke again: prune deletes
|
||||
// no metadata.
|
||||
func TestNukeRemoteFailsWhenADeleteFails(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
for _, refusedPrefix := range []string{"metadata/", "blobs/"} {
|
||||
t.Run(refusedPrefix, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
storeDir := filepath.Join(t.TempDir(), "store")
|
||||
v, _, out := backUpToFileDestination(ctx, t, storeDir)
|
||||
|
||||
store := faultstore.New(v.Storage)
|
||||
store.OnDelete = func(key string) bool {
|
||||
return strings.HasPrefix(key, refusedPrefix)
|
||||
}
|
||||
v.Storage = store
|
||||
|
||||
require.Error(t, v.NukeRemote(true))
|
||||
|
||||
assert.NotContains(t, out.String(), "now empty")
|
||||
assert.Contains(t, out.String(), "vaultik remote nuke --force")
|
||||
assert.NotContains(t, out.String(), "vaultik prune")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNukeRemoteRefusesAnotherDestinationWithoutRetryAdvice checks that
|
||||
// remote nuke, when the local index belongs to a different destination
|
||||
// than the configured one, fails without advising to run nuke again:
|
||||
// that run would fail the same way.
|
||||
func TestNukeRemoteRefusesAnotherDestinationWithoutRetryAdvice(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.Config.StorageURL = "file://" + filepath.Join(t.TempDir(), "elsewhere")
|
||||
|
||||
err := v.NukeRemote(true)
|
||||
|
||||
require.ErrorContains(t, err, "bound to a different backup destination")
|
||||
assert.NotContains(t, out.String(), "vaultik remote nuke --force")
|
||||
}
|
||||
|
||||
// TestNukeRemoteFailsWhenALocalSnapshotCannotBeRemoved checks that a
|
||||
// snapshot remote nuke cannot remove from the local index fails the
|
||||
// run: nuke then leaves that snapshot's metadata on the destination
|
||||
// store.
|
||||
func TestNukeRemoteFailsWhenALocalSnapshotCannotBeRemoved(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
storeDir := filepath.Join(t.TempDir(), "store")
|
||||
v, _, out := backUpToFileDestination(ctx, t, storeDir)
|
||||
|
||||
_, err := v.DB.Conn().ExecContext(ctx, `
|
||||
CREATE TRIGGER refuse_snapshot_delete BEFORE DELETE ON snapshots
|
||||
BEGIN SELECT RAISE(ABORT, 'simulated index error'); END`)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Error(t, v.NukeRemote(true))
|
||||
|
||||
assert.NotContains(t, out.String(), "now empty")
|
||||
assert.Contains(t, out.String(), "vaultik remote nuke --force")
|
||||
}
|
||||
@@ -19,13 +19,27 @@ type PruneOptions struct {
|
||||
var errNukeRequiresForce = errors.New(
|
||||
"nuke requires --force (this deletes ALL remote snapshots and blobs)")
|
||||
|
||||
// errBlobDeleteFailed reports unreferenced blobs prune could not delete.
|
||||
var errBlobDeleteFailed = errors.New("blob(s) could not be deleted")
|
||||
|
||||
// nukeCommandHint is the command suggested when a remote nuke could not
|
||||
// delete everything: running it again deletes what is left, which
|
||||
// `vaultik prune` does not do for snapshot metadata.
|
||||
const nukeCommandHint = "vaultik remote nuke --force"
|
||||
|
||||
// metadataDirName is the top-level remote directory holding snapshot
|
||||
// metadata.
|
||||
const metadataDirName = "metadata"
|
||||
|
||||
// blobKeyParts is the "/"-separated segment count of a blob object key
|
||||
// (blobs/<aa>/<bb>/<hash>).
|
||||
const blobKeyParts = 4
|
||||
|
||||
// NukeRemote deletes every snapshot's metadata and every blob from remote
|
||||
// storage. After this returns successfully the bucket prefix is empty and
|
||||
// the next backup starts from scratch.
|
||||
// storage, along with any object an upload cut off part-way left under a
|
||||
// temporary `.partial` name, and the destination marker. After this
|
||||
// returns successfully the bucket prefix is empty and the next backup
|
||||
// starts from scratch.
|
||||
//
|
||||
// Refuses to run unless force is true. The caller is responsible for
|
||||
// confirming with the user.
|
||||
@@ -34,6 +48,29 @@ func (v *Vaultik) NukeRemote(force bool) error {
|
||||
return errNukeRequiresForce
|
||||
}
|
||||
|
||||
// Checked before anything is deleted: running nuke again would fail
|
||||
// the same way, so the advice below must not follow this error.
|
||||
err := v.EnsureStorageBinding()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = v.emptyDestinationStore()
|
||||
if err != nil {
|
||||
v.UI.Warningf("Run '%s' again to delete what is left on the "+
|
||||
"backup destination store.", nukeCommandHint)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
v.UI.Completef("Backup destination store is now empty.")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// emptyDestinationStore does the deleting for NukeRemote and stops at
|
||||
// the first step that fails.
|
||||
func (v *Vaultik) emptyDestinationStore() error {
|
||||
v.UI.Beginf("Removing all snapshot metadata from backup destination store.")
|
||||
|
||||
_, err := v.RemoveAllSnapshots(&RemoveOptions{Force: true})
|
||||
@@ -48,7 +85,21 @@ func (v *Vaultik) NukeRemote(force bool) error {
|
||||
return fmt.Errorf("pruning blobs: %w", err)
|
||||
}
|
||||
|
||||
v.UI.Completef("Backup destination store is now empty.")
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// Last, once the local snapshot records are gone: the next backup
|
||||
// then has none and writes the marker again.
|
||||
err = v.Storage.Delete(v.ctx, destinationMarkerKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("deleting %s: %w", destinationMarkerKey, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -152,14 +203,17 @@ func (v *Vaultik) PruneBlobs(opts *PruneOptions) error {
|
||||
v.deleteUnreferencedBlobs(unreferencedBlobs, allBlobs, result)
|
||||
|
||||
if opts.JSON {
|
||||
return v.outputPruneBlobsJSON(result)
|
||||
err = v.outputPruneBlobsJSON(result)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
v.stdoutf("\nDeleted %d blob(s) totaling %s\n",
|
||||
result.BlobsDeleted, ubytes(result.BytesFreed))
|
||||
}
|
||||
|
||||
v.stdoutf("\nDeleted %d blob(s) totaling %s\n",
|
||||
result.BlobsDeleted, ubytes(result.BytesFreed))
|
||||
|
||||
if result.BlobsFailed > 0 {
|
||||
v.stdoutf("Failed to delete %d blob(s)\n", result.BlobsFailed)
|
||||
return fmt.Errorf("%d %w", result.BlobsFailed, errBlobDeleteFailed)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package vaultik_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/log"
|
||||
"sneak.berlin/go/vaultik/internal/storage/faultstore"
|
||||
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||
)
|
||||
|
||||
// refuseBlobDeletes is an OnDelete hook that fails every blob delete.
|
||||
func refuseBlobDeletes(key string) bool {
|
||||
return strings.HasPrefix(key, "blobs/")
|
||||
}
|
||||
|
||||
// TestPruneBlobs_FailedDeleteReturnsError checks that prune returns an
|
||||
// error, so the command exits non-zero, when an unreferenced blob cannot
|
||||
// be deleted.
|
||||
func TestPruneBlobs_FailedDeleteReturnsError(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
env := newListEnv(t)
|
||||
|
||||
// The store holds no manifest, so nothing references this blob.
|
||||
addBlob(t, env.store.testStorer, testBlobHashA)
|
||||
|
||||
store := faultstore.New(env.store)
|
||||
store.OnDelete = refuseBlobDeletes
|
||||
env.v.Storage = store
|
||||
|
||||
err := env.v.PruneBlobs(&vaultik.PruneOptions{Force: true})
|
||||
|
||||
require.ErrorContains(t, err, "1 blob(s) could not be deleted")
|
||||
}
|
||||
|
||||
// TestPruneBlobs_JSONFailedDeleteReturnsError checks that prune --json
|
||||
// prints its document and then returns an error when an unreferenced
|
||||
// blob cannot be deleted.
|
||||
func TestPruneBlobs_JSONFailedDeleteReturnsError(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
env := newListEnv(t)
|
||||
addBlob(t, env.store.testStorer, testBlobHashA)
|
||||
|
||||
store := faultstore.New(env.store)
|
||||
store.OnDelete = refuseBlobDeletes
|
||||
env.v.Storage = store
|
||||
|
||||
err := env.v.PruneBlobs(&vaultik.PruneOptions{JSON: true})
|
||||
|
||||
require.Error(t, err, "prune must fail when a blob cannot be deleted")
|
||||
|
||||
var result vaultik.PruneBlobsResult
|
||||
|
||||
require.NoError(t, json.Unmarshal(env.stdout.Bytes(), &result),
|
||||
"stdout must hold the JSON document, got:\n%s", env.stdout.String())
|
||||
assert.Equal(t, 1, result.BlobsFailed)
|
||||
assert.Equal(t, 0, result.BlobsDeleted)
|
||||
}
|
||||
|
||||
// TestCreateSnapshotPrune_FailedBlobDeleteReturnsError checks that the
|
||||
// prune step of snapshot create --prune fails the run when an
|
||||
// unreferenced blob cannot be deleted.
|
||||
func TestCreateSnapshotPrune_FailedBlobDeleteReturnsError(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
storeDir := filepath.Join(t.TempDir(), "store")
|
||||
v, _, _ := backUpToFileDestination(ctx, t, storeDir)
|
||||
|
||||
// No snapshot references this blob, so the prune step deletes it.
|
||||
orphan := filepath.Join(storeDir, "blobs", testBlobHashA[:2],
|
||||
testBlobHashA[2:4], testBlobHashA)
|
||||
require.NoError(t, os.MkdirAll(filepath.Dir(orphan), 0o750))
|
||||
require.NoError(t, os.WriteFile(orphan, []byte("orphan"), 0o600))
|
||||
|
||||
store := faultstore.New(v.Storage)
|
||||
store.OnDelete = refuseBlobDeletes
|
||||
v.Storage = store
|
||||
|
||||
err := v.CreateSnapshot(&vaultik.SnapshotCreateOptions{
|
||||
Cron: true,
|
||||
Prune: true,
|
||||
})
|
||||
|
||||
require.ErrorContains(t, err, "1 blob(s) could not be deleted")
|
||||
assert.FileExists(t, orphan)
|
||||
}
|
||||
@@ -34,6 +34,9 @@ func setupConsistencyTest(
|
||||
repos := database.NewRepositories(db)
|
||||
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 {
|
||||
parts := strings.Split(id, "_")
|
||||
startedAt, err := time.Parse(time.RFC3339, parts[len(parts)-1])
|
||||
|
||||
@@ -44,6 +44,9 @@ func setupPurgeTest(
|
||||
repos := database.NewRepositories(db)
|
||||
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.
|
||||
// Use timestamps parsed from snapshot IDs for realistic ordering.
|
||||
for _, id := range snapshotIDs {
|
||||
@@ -267,6 +270,29 @@ func TestPurgeOlderThan_WithNameFilter(t *testing.T) {
|
||||
assert.Contains(t, remaining, snapHomeT0)
|
||||
}
|
||||
|
||||
func TestPurgeOlderThan_TooLargeDeletesNothing(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
snapshotIDs := []string{snapHomeT0, snapHomeT1}
|
||||
|
||||
v := setupPurgeTest(t, snapHostname, snapshotIDs)
|
||||
|
||||
err := v.PurgeSnapshotsWithOptions(&vaultik.SnapshotPurgeOptions{
|
||||
OlderThan: "1000y",
|
||||
Force: true,
|
||||
})
|
||||
require.Error(t, err)
|
||||
|
||||
err = v.PurgeSnapshotsWithOptions(&vaultik.SnapshotPurgeOptions{
|
||||
OlderThan: "292y",
|
||||
Force: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.ElementsMatch(t, snapshotIDs, listRemainingSnapshots(t, v))
|
||||
}
|
||||
|
||||
func TestPurgeKeepLatest_ThreeNames(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
@@ -185,6 +185,39 @@ func TestRemoteInfo_SkipsNonConformingMetadataName(t *testing.T) {
|
||||
assert.NotContains(t, doc, "unreadable_manifests")
|
||||
}
|
||||
|
||||
// TestRemoteInfo_DoesNotCountNonBlobFileUnderBlobs checks that a file
|
||||
// under blobs/ whose name is not a blob hash is not counted as a blob.
|
||||
// prune never deletes such a file, so counting it as an orphaned blob
|
||||
// would advise a prune that removes nothing.
|
||||
func TestRemoteInfo_DoesNotCountNonBlobFileUnderBlobs(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
env := newListEnv(t)
|
||||
env.addRemote(t, listRemoteID, time.Date(2026, 3, 2, 0, 0, 0, 0, time.UTC))
|
||||
addBlob(t, env.store.testStorer, testBlobHashA)
|
||||
require.NoError(t, env.store.Put(context.Background(),
|
||||
"blobs/"+testBlobHashA[:2]+"/"+testBlobHashA[2:4]+"/.DS_Store",
|
||||
bytes.NewReader([]byte("junk"))))
|
||||
|
||||
require.NoError(t, env.v.RemoteInfo(false))
|
||||
|
||||
text := env.stdout.String()
|
||||
assert.Contains(t, text, "Total blobs on remote: 1 (")
|
||||
assert.Contains(t, text, "Orphaned (unreferenced): 0 (")
|
||||
assert.NotContains(t, text, "vaultik prune")
|
||||
|
||||
env.stdout.Reset()
|
||||
require.NoError(t, env.v.RemoteInfo(true))
|
||||
|
||||
var doc map[string]any
|
||||
|
||||
require.NoError(t, json.Unmarshal(env.stdout.Bytes(), &doc))
|
||||
assert.InDelta(t, 1, doc["total_blob_count"], 0)
|
||||
assert.InDelta(t, 0, doc["orphaned_blob_count"], 0)
|
||||
assert.InDelta(t, 0, doc["orphaned_blob_size"], 0)
|
||||
}
|
||||
|
||||
// TestRemoteInfo_DirectoryWithoutManifestLeavesOrphansKnown checks that
|
||||
// a directory under metadata/ holding no manifest.json.zst, such as one
|
||||
// left by a backup interrupted before its manifest upload, leaves the
|
||||
|
||||
@@ -3,17 +3,21 @@ package vaultik_test
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/klauspost/compress/zstd"
|
||||
"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/snapshot"
|
||||
"sneak.berlin/go/vaultik/internal/storage"
|
||||
"sneak.berlin/go/vaultik/internal/types"
|
||||
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||
)
|
||||
|
||||
@@ -125,6 +129,12 @@ func (s *testStorer) ListStream(
|
||||
return ch
|
||||
}
|
||||
|
||||
// DeletePartialUploads has nothing to remove: Put stores each object
|
||||
// under its key at once.
|
||||
func (s *testStorer) DeletePartialUploads(_ context.Context, _ string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *testStorer) Info() storage.Info {
|
||||
return storage.Info{
|
||||
Type: testLabel,
|
||||
@@ -208,6 +218,37 @@ func addBlob(t *testing.T, store *testStorer, hash string) {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// newLocalIndex returns an in-memory local index holding a completed
|
||||
// snapshot row for each of ids.
|
||||
func newLocalIndex(t *testing.T, ids ...string) *database.Repositories {
|
||||
t.Helper()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
db, err := database.New(ctx, ":memory:")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
repos := database.NewRepositories(db)
|
||||
|
||||
for _, id := range ids {
|
||||
completedAt := time.Now().UTC()
|
||||
snap := &database.Snapshot{
|
||||
ID: types.SnapshotID(id),
|
||||
Hostname: snapHostname,
|
||||
StartedAt: completedAt,
|
||||
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)
|
||||
}
|
||||
|
||||
return repos
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Unit Tests for RemoveSnapshot
|
||||
// ============================================================================
|
||||
@@ -227,6 +268,7 @@ func TestRemoveSnapshot_LocalOnly_PreservesRemote(t *testing.T) {
|
||||
addBlob(t, store, blobA)
|
||||
|
||||
tv := vaultik.NewForTesting(store)
|
||||
tv.Repositories = newLocalIndex(t, "snapshot-001")
|
||||
|
||||
opts := &vaultik.RemoveOptions{Force: true, LocalOnly: true}
|
||||
result, err := tv.RemoveSnapshot("snapshot-001", opts)
|
||||
@@ -261,7 +303,12 @@ func TestRemoveSnapshot_DefaultRemovesMetadataNotBlobs(t *testing.T) {
|
||||
addBlob(t, store, blobUnique)
|
||||
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.Repositories = newLocalIndex(t, "snapshot-001", "snapshot-002")
|
||||
|
||||
opts := &vaultik.RemoveOptions{Force: true}
|
||||
result, err := tv.RemoveSnapshot("snapshot-001", opts)
|
||||
@@ -310,6 +357,89 @@ func TestRemoveSnapshot_DryRun(t *testing.T) {
|
||||
assert.Contains(t, tv.Stdout.String(), "[Dry run - no changes made]")
|
||||
}
|
||||
|
||||
// TestRemoveSnapshot_UnknownIDIsError checks that an ID naming no
|
||||
// snapshot in the local index or on the destination store is an error
|
||||
// under every option, and that nothing is reported as removed.
|
||||
func TestRemoveSnapshot_UnknownIDIsError(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
for name, opts := range map[string]vaultik.RemoveOptions{
|
||||
"force": {Force: true},
|
||||
"json": {JSON: true},
|
||||
"dry-run": {Force: true, DryRun: true},
|
||||
"local-only": {Force: true, LocalOnly: true},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := newTestStorer()
|
||||
addManifest(t, store, "snapshot-001", nil)
|
||||
|
||||
tv := vaultik.NewForTesting(store)
|
||||
tv.Repositories = newLocalIndex(t, "snapshot-001")
|
||||
|
||||
result, err := tv.RemoveSnapshot("snapshot-999", &opts)
|
||||
|
||||
require.ErrorContains(t, err, "snapshot not found")
|
||||
assert.False(t, result.RemoteRemoved)
|
||||
assert.Empty(t, tv.Stdout.String())
|
||||
assert.True(t, store.hasKey(remoteKeyPath("snapshot-001")))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRemoveSnapshot_NoMetadataOnDestination checks that removing a
|
||||
// snapshot whose metadata is not on the destination store does not
|
||||
// report metadata removed from it.
|
||||
func TestRemoveSnapshot_NoMetadataOnDestination(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
// A backup wrote to this destination, so it carries the marker.
|
||||
store := newTestStorer()
|
||||
require.NoError(t, store.Put(context.Background(), markerFile,
|
||||
bytes.NewReader(nil)))
|
||||
|
||||
tv := vaultik.NewForTesting(store)
|
||||
tv.Repositories = newLocalIndex(t, "snapshot-001")
|
||||
|
||||
result, err := tv.RemoveSnapshot("snapshot-001",
|
||||
&vaultik.RemoveOptions{Force: true})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.False(t, result.RemoteRemoved)
|
||||
|
||||
out := tv.Stdout.String()
|
||||
assert.Contains(t, out, "Removed snapshot 'snapshot-001' from local database")
|
||||
assert.NotContains(t, out, "Removed snapshot metadata from remote storage")
|
||||
}
|
||||
|
||||
// TestRemoveSnapshot_NotInLocalIndex checks that removing a snapshot
|
||||
// only the destination store holds removes its metadata there without
|
||||
// reporting a removal from the local database.
|
||||
func TestRemoveSnapshot_NotInLocalIndex(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
store := newTestStorer()
|
||||
addManifest(t, store, "snapshot-001", nil)
|
||||
|
||||
tv := vaultik.NewForTesting(store)
|
||||
tv.Repositories = newLocalIndex(t)
|
||||
|
||||
result, err := tv.RemoveSnapshot("snapshot-001",
|
||||
&vaultik.RemoveOptions{Force: true})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.RemoteRemoved)
|
||||
assert.False(t, store.hasKey(remoteKeyPath("snapshot-001")))
|
||||
|
||||
out := tv.Stdout.String()
|
||||
assert.NotContains(t, out, "from local database")
|
||||
assert.Contains(t, out, "Removed snapshot metadata from remote storage")
|
||||
}
|
||||
|
||||
func TestRemoveAllSnapshots_RequiresForce(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
@@ -355,7 +485,8 @@ func TestRemoveAllSnapshots_WithForce(t *testing.T) {
|
||||
out := tv.Stdout.String()
|
||||
assert.Contains(t, out, "Removed 2 snapshot(s)")
|
||||
assert.Contains(t, out, "Removed snapshot metadata from remote storage")
|
||||
assert.Contains(t, out, "vaultik prune")
|
||||
// Remote nuke, the only caller, deletes the blobs next itself.
|
||||
assert.NotContains(t, out, "vaultik prune")
|
||||
}
|
||||
|
||||
func TestRemoveAllSnapshots_DryRun(t *testing.T) {
|
||||
|
||||
+41
-13
@@ -103,11 +103,12 @@ type RestoreResult struct {
|
||||
BlobsDownloaded int
|
||||
BytesDownloaded int64
|
||||
Duration time.Duration
|
||||
FilesFailed int
|
||||
FailedFiles []string // Paths of files that failed to restore
|
||||
// Verification results (only populated if Verify option is set)
|
||||
FilesVerified int
|
||||
BytesVerified int64
|
||||
FilesFailed int
|
||||
FailedFiles []string // Paths of files that failed verification
|
||||
FilesVerified int
|
||||
BytesVerified int64
|
||||
VerifyFailedFiles []string // Paths of restored files that failed verification
|
||||
}
|
||||
|
||||
// Restore restores files from a snapshot to the target directory
|
||||
@@ -309,11 +310,22 @@ func (v *Vaultik) restoreAllFiles(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
plan, err := newRestorePlan(v.ctx, repos, files, chunkToBlobMap, blobIDToHash)
|
||||
plan, unplanned, err := newRestorePlan(
|
||||
v.ctx, repos, files, chunkToBlobMap, blobIDToHash)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("building restore plan: %w", err)
|
||||
}
|
||||
|
||||
// Ranging over files rather than the map reports them in a stable order.
|
||||
for _, file := range files {
|
||||
if planErr, ok := unplanned[file.ID]; ok {
|
||||
err = v.handleRestoreFileError(plan, opts, result, file, file.ID, planErr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
filesByID, totalBytesExpected := indexRestoreFiles(files)
|
||||
|
||||
v.UI.Beginf("Restoring %s files (%s) to %s.",
|
||||
@@ -647,20 +659,34 @@ func (v *Vaultik) handleRestoreVerification(
|
||||
opts *RestoreOptions,
|
||||
result *RestoreResult,
|
||||
) error {
|
||||
err := v.verifyRestoredFiles(v.ctx, repos, files, opts.TargetDir, result)
|
||||
// A file that failed to restore was not written and is already
|
||||
// reported as such, so it is not verified.
|
||||
failedToRestore := make(map[string]bool, len(result.FailedFiles))
|
||||
for _, path := range result.FailedFiles {
|
||||
failedToRestore[path] = true
|
||||
}
|
||||
|
||||
restored := make([]*database.File, 0, len(files))
|
||||
for _, file := range files {
|
||||
if !failedToRestore[file.Path.String()] {
|
||||
restored = append(restored, file)
|
||||
}
|
||||
}
|
||||
|
||||
err := v.verifyRestoredFiles(v.ctx, repos, restored, opts.TargetDir, result)
|
||||
if err != nil {
|
||||
return fmt.Errorf("verification failed: %w", err)
|
||||
}
|
||||
|
||||
if result.FilesFailed > 0 {
|
||||
if len(result.VerifyFailedFiles) > 0 {
|
||||
v.UI.Errorf("Verification failed: %s files did not match expected checksums.",
|
||||
v.UI.Count(result.FilesFailed))
|
||||
v.UI.Count(len(result.VerifyFailedFiles)))
|
||||
|
||||
for _, path := range result.FailedFiles {
|
||||
for _, path := range result.VerifyFailedFiles {
|
||||
v.UI.Detailf("%s", v.UI.Path(path))
|
||||
}
|
||||
|
||||
return fmt.Errorf("%d %w", result.FilesFailed, errFilesFailedVerify)
|
||||
return fmt.Errorf("%d %w", len(result.VerifyFailedFiles), errFilesFailedVerify)
|
||||
}
|
||||
|
||||
v.UI.Completef("Verified %s files (%s).",
|
||||
@@ -691,6 +717,8 @@ func (v *Vaultik) downloadSnapshotDB(
|
||||
return nil, "", fmt.Errorf("downloading %s: %w", dbKey, err)
|
||||
}
|
||||
|
||||
reader = newCloseOnCancelReader(v.ctx, reader)
|
||||
|
||||
defer func() { _ = reader.Close() }()
|
||||
|
||||
// Decrypt and decompress straight from the storage stream, then stream
|
||||
@@ -1420,8 +1448,8 @@ func (v *Vaultik) verifyRestoredFiles(
|
||||
if err != nil {
|
||||
log.Error("File verification failed", "path", file.Path, "error", err)
|
||||
|
||||
result.FilesFailed++
|
||||
result.FailedFiles = append(result.FailedFiles, file.Path.String())
|
||||
result.VerifyFailedFiles = append(
|
||||
result.VerifyFailedFiles, file.Path.String())
|
||||
}
|
||||
|
||||
bytesProcessed += file.Size
|
||||
@@ -1437,7 +1465,7 @@ func (v *Vaultik) verifyRestoredFiles(
|
||||
log.Info("Verification complete",
|
||||
"files_verified", result.FilesVerified,
|
||||
"bytes_verified", ubytes(result.BytesVerified),
|
||||
"files_failed", result.FilesFailed,
|
||||
"files_failed", len(result.VerifyFailedFiles),
|
||||
)
|
||||
|
||||
return nil
|
||||
|
||||
@@ -42,7 +42,7 @@ func TestRestoreOnAnotherMachine(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
dataDir := filepath.Join(tempDir, "source")
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
storeDir := t.TempDir()
|
||||
restoreDir := filepath.Join(tempDir, "restored")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ func TestRestoreCleansTempDirOnInterrupt(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
|
||||
dataDir := filepath.Join(root, "source")
|
||||
storeDir := filepath.Join(root, "remote")
|
||||
storeDir := t.TempDir()
|
||||
restoreDir := filepath.Join(root, "restored")
|
||||
dbPath := filepath.Join(root, "index.sqlite")
|
||||
|
||||
@@ -142,14 +142,14 @@ func TestRestoreCleansTempDirOnInterrupt(t *testing.T) {
|
||||
}
|
||||
|
||||
// scratchEntries returns the vaultik blob-cache and snapshot-database
|
||||
// scratch entries currently present in dir.
|
||||
// scratch entries (restore's and deep verify's) currently present in dir.
|
||||
func scratchEntries(t *testing.T, dir string) []string {
|
||||
t.Helper()
|
||||
|
||||
var matches []string
|
||||
|
||||
for _, pattern := range []string{
|
||||
"vaultik-blobcache-*", "vaultik-restore-*",
|
||||
"vaultik-blobcache-*", "vaultik-restore-*", "vaultik-verify-*",
|
||||
} {
|
||||
found, err := filepath.Glob(filepath.Join(dir, pattern))
|
||||
require.NoError(t, err)
|
||||
@@ -206,3 +206,141 @@ func TestRestoreSkipErrorsCancelDuringBlobDownload(t *testing.T) {
|
||||
assert.NotContains(t, out.String(), srcPath,
|
||||
"the cancel was reported as a failed file")
|
||||
}
|
||||
|
||||
// stallingStorer wraps a Storer so that each download of a key chosen by
|
||||
// stalls hands over the first half of the object and then stalls,
|
||||
// ignoring the context, as a file:// read on a hung USB stick or network
|
||||
// share does. Only closing the returned reader ends the stall.
|
||||
type stallingStorer struct {
|
||||
storage.Storer
|
||||
|
||||
stalls func(key string) bool
|
||||
once sync.Once
|
||||
stalled chan struct{}
|
||||
}
|
||||
|
||||
func newStallingStorer(
|
||||
inner storage.Storer, stalls func(key string) bool,
|
||||
) *stallingStorer {
|
||||
return &stallingStorer{
|
||||
Storer: inner,
|
||||
stalls: stalls,
|
||||
stalled: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *stallingStorer) Get(
|
||||
ctx context.Context, key string,
|
||||
) (io.ReadCloser, error) {
|
||||
rc, err := s.Storer.Get(ctx, key)
|
||||
if err != nil || !s.stalls(key) {
|
||||
return rc, err
|
||||
}
|
||||
|
||||
object, err := io.ReadAll(rc)
|
||||
_ = rc.Close()
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// The writer is never closed, so once the first half has been read
|
||||
// the next Read blocks until the reader is closed.
|
||||
pr, pw := io.Pipe()
|
||||
|
||||
go func() {
|
||||
_, _ = pw.Write(object[:len(object)/2])
|
||||
|
||||
s.once.Do(func() { close(s.stalled) })
|
||||
}()
|
||||
|
||||
return pr, nil
|
||||
}
|
||||
|
||||
// TestInterruptDuringStalledReadCleansTempDir stops a restore and a deep
|
||||
// verify while a read of a blob or of the snapshot database is stalled
|
||||
// on a source that ignores the context. Stopping must end that read, so
|
||||
// the operation unwinds and removes its decrypted scratch files well
|
||||
// within the shutdown timeout (issues #325 and #356).
|
||||
//
|
||||
// Not parallel: it points TMPDIR at a private directory (via t.Setenv)
|
||||
// so it can assert on exactly the scratch files the operation created.
|
||||
func TestInterruptDuringStalledReadCleansTempDir(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
|
||||
stalledReads := map[string]func(key string) bool{
|
||||
"blob": func(key string) bool {
|
||||
return strings.HasPrefix(key, "blobs/")
|
||||
},
|
||||
"snapshot database": func(key string) bool {
|
||||
return strings.HasSuffix(key, "/db.zst.age")
|
||||
},
|
||||
}
|
||||
|
||||
operations := map[string]func(v *Vaultik, snapshotID, targetDir string) error{
|
||||
"restore": func(v *Vaultik, snapshotID, targetDir string) error {
|
||||
return v.Restore(&RestoreOptions{
|
||||
SnapshotID: snapshotID,
|
||||
TargetDir: targetDir,
|
||||
})
|
||||
},
|
||||
"deep verify": func(v *Vaultik, snapshotID, _ string) error {
|
||||
return v.RunDeepVerify(snapshotID, &VerifyOptions{Deep: true})
|
||||
},
|
||||
}
|
||||
|
||||
for readName, stalls := range stalledReads {
|
||||
for opName, op := range operations {
|
||||
t.Run(readName+" read during "+opName, func(t *testing.T) {
|
||||
fs := afero.NewOsFs()
|
||||
root := t.TempDir()
|
||||
|
||||
// Random content, so the blob stays large enough to stall
|
||||
// part-way through its compressed, encrypted body.
|
||||
cfg, storer, snapshotID, _ := backupOneFile(context.Background(),
|
||||
t, fs, root, "a.bin", randomBytes(t, 256*1024), 0o644)
|
||||
|
||||
scratch := filepath.Join(root, "scratch")
|
||||
require.NoError(t, fs.MkdirAll(scratch, 0o755))
|
||||
t.Setenv("TMPDIR", scratch)
|
||||
|
||||
stalling := newStallingStorer(storer, stalls)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
v := newRestoreVaultik(ctx, cfg, stalling, fs)
|
||||
v.cancel = cancel
|
||||
|
||||
var opErr error
|
||||
|
||||
stop := v.StartOperation(func() {
|
||||
opErr = op(v, snapshotID, filepath.Join(root, "restored"))
|
||||
})
|
||||
|
||||
select {
|
||||
case <-stalling.stalled:
|
||||
case <-time.After(30 * time.Second):
|
||||
t.Fatal("the operation never reached the stalled read")
|
||||
}
|
||||
|
||||
// The snapshot database's temp directory is created just
|
||||
// after its read starts, so it can appear after the stall.
|
||||
require.Eventually(t, func() bool {
|
||||
return len(scratchEntries(t, scratch)) > 0
|
||||
}, 10*time.Second, 10*time.Millisecond,
|
||||
"expected decrypted scratch files to exist mid-read")
|
||||
|
||||
stopCtx, stopCancel := context.WithTimeout(
|
||||
context.Background(), 10*time.Second)
|
||||
defer stopCancel()
|
||||
|
||||
require.True(t, stop(stopCtx),
|
||||
"stop timed out; the stalled read was not interrupted")
|
||||
require.ErrorIs(t, opErr, context.Canceled)
|
||||
require.Empty(t, scratchEntries(t, scratch),
|
||||
"decrypted scratch files remained after the interrupt")
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,7 +197,7 @@ func TestRestoreLocalityAndReadAt(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
dataDir := filepath.Join(tempDir, "source")
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
storeDir := t.TempDir()
|
||||
restoreDir := filepath.Join(tempDir, "restored")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
|
||||
@@ -61,8 +61,8 @@ func craftedFile(
|
||||
}
|
||||
|
||||
// TestRestoreShortChunkHashInNoBlob proves a file whose short chunk hash
|
||||
// has no blob_chunks row fails restore planning and the chunk write with
|
||||
// an error.
|
||||
// has no blob_chunks row is left out of the restore plan with an error,
|
||||
// and fails the chunk write with an error.
|
||||
func TestRestoreShortChunkHashInNoBlob(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -77,9 +77,11 @@ func TestRestoreShortChunkHashInNoBlob(t *testing.T) {
|
||||
chunkToBlobMap, err := v.buildChunkToBlobMap(ctx, repos)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = newRestorePlan(ctx, repos, []*database.File{file},
|
||||
plan, unplanned, err := newRestorePlan(ctx, repos, []*database.File{file},
|
||||
chunkToBlobMap, map[string]string{})
|
||||
require.ErrorIs(t, err, errPlanChunkMissing)
|
||||
require.NoError(t, err)
|
||||
require.ErrorIs(t, unplanned[file.ID], errPlanChunkMissing)
|
||||
require.False(t, plan.hasPending())
|
||||
|
||||
fileChunks, err := repos.FileChunks.GetByFileID(ctx, file.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -107,7 +107,7 @@ func backupOneFile(
|
||||
require.NoError(t, afero.WriteFile(fs, srcPath, content, mode))
|
||||
require.NoError(t, fs.Chmod(srcPath, mode))
|
||||
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
storeDir := t.TempDir()
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
storer, err := storage.NewFileStorer(storeDir)
|
||||
|
||||
@@ -41,20 +41,25 @@ type restorePlan struct {
|
||||
|
||||
// newRestorePlan builds the file→blob index for the given files. Files
|
||||
// whose chunks reference no blobs (symlinks, directories) start in the
|
||||
// ready queue immediately.
|
||||
// ready queue immediately. A file with a chunk that no blob holds cannot
|
||||
// be restored: it is left out of the plan and returned, by ID, with the
|
||||
// reason.
|
||||
func newRestorePlan(
|
||||
ctx context.Context,
|
||||
repos *database.Repositories,
|
||||
files []*database.File,
|
||||
chunkToBlobMap map[string]*database.BlobChunk,
|
||||
blobIDToHash map[string]string,
|
||||
) (*restorePlan, error) {
|
||||
) (*restorePlan, map[types.FileID]error, error) {
|
||||
p := &restorePlan{
|
||||
fileBlobs: make(map[types.FileID]map[string]struct{}, len(files)),
|
||||
blobFiles: make(map[string]map[types.FileID]struct{}),
|
||||
ready: make([]types.FileID, 0, len(files)),
|
||||
cached: make(map[string]struct{}),
|
||||
}
|
||||
unplanned := make(map[types.FileID]error)
|
||||
|
||||
nextFile:
|
||||
for _, f := range files {
|
||||
if f.IsSymlink() || f.Mode&uint32(os.ModeDir) != 0 {
|
||||
// No chunks to fetch — restore can run immediately.
|
||||
@@ -66,7 +71,7 @@ func newRestorePlan(
|
||||
|
||||
fileChunks, err := repos.FileChunks.GetByFileID(ctx, f.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("planning %s: %w", f.Path, err)
|
||||
return nil, nil, fmt.Errorf("planning %s: %w", f.Path, err)
|
||||
}
|
||||
|
||||
blobs := make(map[string]struct{})
|
||||
@@ -74,14 +79,18 @@ func newRestorePlan(
|
||||
for _, fc := range fileChunks {
|
||||
bc, ok := chunkToBlobMap[fc.ChunkHash.String()]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("planning %s: %w: %s",
|
||||
f.Path, errPlanChunkMissing, shortHash(fc.ChunkHash.String()))
|
||||
unplanned[f.ID] = fmt.Errorf("%w: %s",
|
||||
errPlanChunkMissing, shortHash(fc.ChunkHash.String()))
|
||||
|
||||
continue nextFile
|
||||
}
|
||||
|
||||
hash, ok := blobIDToHash[bc.BlobID.String()]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("planning %s: %w: %s",
|
||||
f.Path, errPlanBlobIDMissing, bc.BlobID)
|
||||
unplanned[f.ID] = fmt.Errorf("%w: %s",
|
||||
errPlanBlobIDMissing, bc.BlobID)
|
||||
|
||||
continue nextFile
|
||||
}
|
||||
|
||||
blobs[hash] = struct{}{}
|
||||
@@ -103,7 +112,7 @@ func newRestorePlan(
|
||||
}
|
||||
}
|
||||
|
||||
return p, nil
|
||||
return p, unplanned, nil
|
||||
}
|
||||
|
||||
// markBlobCached records that the named blob is now resident in the
|
||||
|
||||
@@ -4,7 +4,9 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
@@ -28,7 +30,8 @@ const (
|
||||
)
|
||||
|
||||
// missingBlobBackup is a snapshot of single-chunk files spread over two
|
||||
// blobs, with one of those blobs deleted from the store.
|
||||
// blobs, with one of those blobs lost: deleted from the store, or missing
|
||||
// from the snapshot metadata.
|
||||
type missingBlobBackup struct {
|
||||
fs afero.Fs
|
||||
cfg *config.Config
|
||||
@@ -37,20 +40,39 @@ type missingBlobBackup struct {
|
||||
restoreDir string
|
||||
// files holds the original content by source path.
|
||||
files map[string][]byte
|
||||
// lost holds the source paths whose chunk is in the deleted blob.
|
||||
// lost holds the source paths whose chunk is in the lost blob.
|
||||
lost map[string]bool
|
||||
}
|
||||
|
||||
// TestRestoreSkipErrorsSkipsFilesOfMissingBlob restores with SkipErrors
|
||||
// after one blob of a two-blob snapshot was deleted. Every file stored in
|
||||
// that blob must be reported as failed and left absent, every other file
|
||||
// must be restored intact, and Restore must still return an error.
|
||||
// after one blob of a two-blob snapshot was deleted from the store.
|
||||
func TestRestoreSkipErrorsSkipsFilesOfMissingBlob(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
backup := backupThenDeleteOneBlob(ctx, t)
|
||||
assertRestoreSkipsLostFiles(ctx, t, backupThenDeleteOneBlob(ctx, t))
|
||||
}
|
||||
|
||||
// TestRestoreSkipErrorsSkipsFilesWithChunkInNoBlob restores with
|
||||
// SkipErrors a snapshot whose metadata lists chunks that no blob holds, as
|
||||
// earlier backup defects left behind.
|
||||
func TestRestoreSkipErrorsSkipsFilesWithChunkInNoBlob(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
assertRestoreSkipsLostFiles(ctx, t, backupThenDropOneBlobFromMetadata(ctx, t))
|
||||
}
|
||||
|
||||
// assertRestoreSkipsLostFiles restores backup with SkipErrors. Every file
|
||||
// stored in the lost blob must be reported as failed and left absent,
|
||||
// every other file must be restored intact, and Restore must still return
|
||||
// an error.
|
||||
func assertRestoreSkipsLostFiles(
|
||||
ctx context.Context, t *testing.T, backup *missingBlobBackup,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
var out bytes.Buffer
|
||||
|
||||
@@ -72,18 +94,142 @@ func TestRestoreSkipErrorsSkipsFilesOfMissingBlob(t *testing.T) {
|
||||
|
||||
if backup.lost[path] {
|
||||
assert.Containsf(t, out.String(), path,
|
||||
"%s needs the deleted blob and must be reported", path)
|
||||
"%s needs the lost blob and must be reported", path)
|
||||
assert.NoFileExists(t, restored)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
got, err := afero.ReadFile(backup.fs, restored)
|
||||
require.NoErrorf(t, err, "%s does not need the deleted blob", path)
|
||||
require.NoErrorf(t, err, "%s does not need the lost blob", path)
|
||||
assert.Equalf(t, content, got, "%s restored with wrong content", path)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRestoreSkipErrorsVerifyChecksOnlyRestoredFiles restores with
|
||||
// SkipErrors and Verify after one blob of a two-blob snapshot was
|
||||
// deleted. Verification must check only the files that were written, so
|
||||
// a file of the deleted blob is reported as failed to restore and not
|
||||
// also as failing verification, and Restore must still return an error.
|
||||
func TestRestoreSkipErrorsVerifyChecksOnlyRestoredFiles(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
backup := backupThenDeleteOneBlob(ctx, t)
|
||||
|
||||
var out bytes.Buffer
|
||||
|
||||
v := newReaderVaultik(ctx, backup.cfg, backup.storer, nil, backup.fs)
|
||||
v.UI = ui.NewWithColor(&out, false)
|
||||
|
||||
err := v.Restore(&vaultik.RestoreOptions{
|
||||
SnapshotID: backup.snapshotID,
|
||||
TargetDir: backup.restoreDir,
|
||||
Verify: true,
|
||||
SkipErrors: true,
|
||||
})
|
||||
|
||||
require.Error(t, err, "restore must fail when files were skipped")
|
||||
assert.Contains(t, err.Error(),
|
||||
fmt.Sprintf("%d file(s) failed to restore", len(backup.lost)))
|
||||
|
||||
written := len(backup.files) - len(backup.lost)
|
||||
|
||||
assert.Contains(t, out.String(), fmt.Sprintf("Verifying %d files", written))
|
||||
assert.Contains(t, out.String(), fmt.Sprintf("Verified %d files", written))
|
||||
assert.NotContains(t, out.String(), "Verification failed")
|
||||
}
|
||||
|
||||
// TestRestoreSkipErrorsVerifyReportsFailuresApart restores with
|
||||
// SkipErrors and Verify after one blob of a two-blob snapshot was
|
||||
// deleted, while one file that does restore is written one byte too long.
|
||||
// The restore warning must list only the files of the deleted blob, the
|
||||
// verification summary only the long file, each path once, and Restore
|
||||
// must return an error.
|
||||
func TestRestoreSkipErrorsVerifyReportsFailuresApart(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
backup := backupThenDeleteOneBlob(ctx, t)
|
||||
|
||||
var long string
|
||||
|
||||
for path := range backup.files {
|
||||
if !backup.lost[path] {
|
||||
long = path
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
|
||||
fs := trailingByteFs{Fs: backup.fs, suffix: long}
|
||||
v := newReaderVaultik(ctx, backup.cfg, backup.storer, nil, fs)
|
||||
v.UI = ui.NewWithColor(&out, false)
|
||||
|
||||
err := v.Restore(&vaultik.RestoreOptions{
|
||||
SnapshotID: backup.snapshotID,
|
||||
TargetDir: backup.restoreDir,
|
||||
Verify: true,
|
||||
SkipErrors: true,
|
||||
})
|
||||
require.Error(t, err, "restore must fail when files failed")
|
||||
|
||||
_, afterWarning, found := strings.Cut(out.String(),
|
||||
fmt.Sprintf("%d file(s) failed to restore:", len(backup.lost)))
|
||||
require.Truef(t, found, "no restore warning in:\n%s", out.String())
|
||||
|
||||
warning, summary, found := strings.Cut(afterWarning,
|
||||
"Verification failed: 1 files did not match")
|
||||
require.Truef(t, found, "no verification summary in:\n%s", out.String())
|
||||
|
||||
for path := range backup.lost {
|
||||
assert.Equalf(t, 1, strings.Count(warning, path),
|
||||
"%s must be listed once as failed to restore", path)
|
||||
assert.NotContainsf(t, summary, path,
|
||||
"%s was not restored and must not fail verification", path)
|
||||
}
|
||||
|
||||
assert.NotContainsf(t, warning, long,
|
||||
"%s was restored and must not be listed as failed to restore", long)
|
||||
assert.Equalf(t, 1, strings.Count(summary, long),
|
||||
"%s must be listed once as failing verification", long)
|
||||
}
|
||||
|
||||
// trailingByteFs appends one byte, on close, to the file it opens whose
|
||||
// path ends in suffix, so that file is restored one byte too long and
|
||||
// fails verification.
|
||||
type trailingByteFs struct {
|
||||
afero.Fs
|
||||
|
||||
suffix string
|
||||
}
|
||||
|
||||
//nolint:ireturn // afero.Fs.OpenFile is defined to return the interface
|
||||
func (fs trailingByteFs) OpenFile(
|
||||
name string, flag int, perm os.FileMode,
|
||||
) (afero.File, error) {
|
||||
f, err := fs.Fs.OpenFile(name, flag, perm)
|
||||
if err != nil || !strings.HasSuffix(name, fs.suffix) {
|
||||
return f, err
|
||||
}
|
||||
|
||||
return trailingByteFile{File: f}, nil
|
||||
}
|
||||
|
||||
type trailingByteFile struct {
|
||||
afero.File
|
||||
}
|
||||
|
||||
func (f trailingByteFile) Close() error {
|
||||
_, _ = f.Write([]byte{'x'})
|
||||
|
||||
return f.File.Close()
|
||||
}
|
||||
|
||||
// TestRestoreMissingBlobAbortsWithoutSkipErrors checks that a deleted blob
|
||||
// still ends the restore with an error when SkipErrors is not set.
|
||||
func TestRestoreMissingBlobAbortsWithoutSkipErrors(t *testing.T) {
|
||||
@@ -103,6 +249,30 @@ func TestRestoreMissingBlobAbortsWithoutSkipErrors(t *testing.T) {
|
||||
require.ErrorIs(t, err, storage.ErrNotFound)
|
||||
}
|
||||
|
||||
// TestRestoreChunkInNoBlobAbortsWithoutSkipErrors checks that a chunk held
|
||||
// by no blob still ends the restore, before any file is written, when
|
||||
// SkipErrors is not set.
|
||||
func TestRestoreChunkInNoBlobAbortsWithoutSkipErrors(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
backup := backupThenDropOneBlobFromMetadata(ctx, t)
|
||||
|
||||
v := newReaderVaultik(ctx, backup.cfg, backup.storer, nil, backup.fs)
|
||||
|
||||
err := v.Restore(&vaultik.RestoreOptions{
|
||||
SnapshotID: backup.snapshotID,
|
||||
TargetDir: backup.restoreDir,
|
||||
})
|
||||
|
||||
require.ErrorContains(t, err, "chunk missing from blob map")
|
||||
|
||||
for path := range backup.files {
|
||||
assert.NoFileExists(t, filepath.Join(backup.restoreDir, path))
|
||||
}
|
||||
}
|
||||
|
||||
// backupThenDeleteOneBlob backs up missingBlobFileCount single-chunk
|
||||
// files, then deletes from the store the blob holding the first of them.
|
||||
func backupThenDeleteOneBlob(
|
||||
@@ -110,6 +280,54 @@ func backupThenDeleteOneBlob(
|
||||
) *missingBlobBackup {
|
||||
t.Helper()
|
||||
|
||||
backup, db, lostBlob := backupSingleChunkFiles(ctx, t)
|
||||
require.NoError(t, db.Close())
|
||||
|
||||
require.NoError(t, backup.storer.Delete(ctx, fmt.Sprintf(
|
||||
"blobs/%s/%s/%s", lostBlob[:2], lostBlob[2:4], lostBlob)))
|
||||
|
||||
return backup
|
||||
}
|
||||
|
||||
// backupThenDropOneBlobFromMetadata backs up missingBlobFileCount
|
||||
// single-chunk files, then removes the blob holding the first of them from
|
||||
// the snapshot and exports the snapshot metadata again. The exported
|
||||
// metadata then lists chunks that no blob holds.
|
||||
func backupThenDropOneBlobFromMetadata(
|
||||
ctx context.Context, t *testing.T,
|
||||
) *missingBlobBackup {
|
||||
t.Helper()
|
||||
|
||||
backup, db, lostBlob := backupSingleChunkFiles(ctx, t)
|
||||
|
||||
// The uploads row refers to the blob row, so it would keep the export
|
||||
// from deleting it.
|
||||
for _, query := range []string{
|
||||
"DELETE FROM snapshot_blobs WHERE blob_hash = ?",
|
||||
"DELETE FROM uploads WHERE blob_hash = ?",
|
||||
} {
|
||||
_, err := db.Conn().ExecContext(ctx, query, lostBlob)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
sm := newFaultSnapshotManager(
|
||||
backup.fs, backup.storer, backup.cfg, database.NewRepositories(db))
|
||||
require.NoError(t,
|
||||
sm.ExportSnapshotMetadata(ctx, db.Path(), backup.snapshotID))
|
||||
require.NoError(t, db.Close())
|
||||
|
||||
return backup
|
||||
}
|
||||
|
||||
// backupSingleChunkFiles backs up missingBlobFileCount single-chunk files.
|
||||
// It returns the backup, with lost naming the files stored in the same
|
||||
// blob as the first of them, the still open local index, and the hash of
|
||||
// that blob.
|
||||
func backupSingleChunkFiles(
|
||||
ctx context.Context, t *testing.T,
|
||||
) (*missingBlobBackup, *database.DB, string) {
|
||||
t.Helper()
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
tempDir := t.TempDir()
|
||||
dataDir := filepath.Join(tempDir, "src")
|
||||
@@ -127,7 +345,7 @@ func backupThenDeleteOneBlob(
|
||||
require.NoError(t, afero.WriteFile(fs, path, files[path], 0o644))
|
||||
}
|
||||
|
||||
storer, err := storage.NewFileStorer(filepath.Join(tempDir, "remote"))
|
||||
storer, err := storage.NewFileStorer(t.TempDir())
|
||||
require.NoError(t, err)
|
||||
|
||||
db, err := database.New(ctx, dbPath)
|
||||
@@ -143,16 +361,11 @@ func backupThenDeleteOneBlob(
|
||||
blobOfFile[path] = blobHashOfSingleChunkFile(ctx, t, repos, path)
|
||||
}
|
||||
|
||||
require.NoError(t, db.Close())
|
||||
|
||||
deleted := blobOfFile[filepath.Join(dataDir, "file-00.bin")]
|
||||
require.NoError(t, storer.Delete(ctx, fmt.Sprintf(
|
||||
"blobs/%s/%s/%s", deleted[:2], deleted[2:4], deleted)))
|
||||
|
||||
lostBlob := blobOfFile[filepath.Join(dataDir, "file-00.bin")]
|
||||
lost := make(map[string]bool)
|
||||
|
||||
for path, hash := range blobOfFile {
|
||||
if hash == deleted {
|
||||
if hash == lostBlob {
|
||||
lost[path] = true
|
||||
}
|
||||
}
|
||||
@@ -168,7 +381,7 @@ func backupThenDeleteOneBlob(
|
||||
restoreDir: filepath.Join(tempDir, "restored"),
|
||||
files: files,
|
||||
lost: lost,
|
||||
}
|
||||
}, db, lostBlob
|
||||
}
|
||||
|
||||
// blobHashOfSingleChunkFile returns the hash of the blob holding the one
|
||||
|
||||
@@ -29,7 +29,7 @@ func TestRestoreAndDeepVerifyRejectSwappedDatabase(t *testing.T) {
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
tempDir := t.TempDir()
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
storeDir := t.TempDir()
|
||||
|
||||
chunkSize := int64(64 * 1024)
|
||||
|
||||
@@ -100,7 +100,7 @@ func TestDeepVerifyRejectsSwappedDatabaseWithEmptyManifest(t *testing.T) {
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
tempDir := t.TempDir()
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
storeDir := t.TempDir()
|
||||
|
||||
chunkSize := int64(64 * 1024)
|
||||
|
||||
|
||||
@@ -186,7 +186,7 @@ func TestRestoreSweeperEvictsBlobs(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
dataDir := filepath.Join(tempDir, "source")
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
storeDir := t.TempDir()
|
||||
restoreDir := filepath.Join(tempDir, "restored")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ func TestRestoreRejectsTruncatedMetadataDB(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
dataDir := filepath.Join(tempDir, "source")
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
storeDir := t.TempDir()
|
||||
restoreDir := filepath.Join(tempDir, "restored")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package vaultik_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/log"
|
||||
"sneak.berlin/go/vaultik/internal/ui"
|
||||
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||
)
|
||||
|
||||
// TestVerifyPassesWhenBackupReusesStoredBlob backs up a file, then backs it
|
||||
// up again from an empty local index, as after `database delete`, with an
|
||||
// extra age recipient. The second backup builds the same blob, finds it
|
||||
// already stored and skips the upload, but its own copy is larger, because
|
||||
// age writes a header entry for each recipient. The second snapshot must
|
||||
// record the size of the stored object, so shallow and deep verify pass.
|
||||
func TestVerifyPassesWhenBackupReusesStoredBlob(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
tempDir := t.TempDir()
|
||||
|
||||
dataDir := filepath.Join(tempDir, "source")
|
||||
storeDir := t.TempDir()
|
||||
secondDBPath := filepath.Join(tempDir, "second.sqlite")
|
||||
|
||||
chunkSize := int64(64 * 1024)
|
||||
maxBlobSize := int64(512 * 1024)
|
||||
|
||||
require.NoError(t, fs.MkdirAll(dataDir, 0o755))
|
||||
require.NoError(t, afero.WriteFile(fs,
|
||||
filepath.Join(dataDir, "data.bin"),
|
||||
bytesPattern("reuse-", int(chunkSize*3)), 0o644))
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
runFileStorageBackup(ctx, t, fs, dataDir, storeDir,
|
||||
filepath.Join(tempDir, "first.sqlite"), chunkSize, maxBlobSize)
|
||||
|
||||
env := setupDedupBackupEnv(
|
||||
ctx, t, fs, storeDir, secondDBPath, chunkSize, maxBlobSize)
|
||||
|
||||
defer func() { _ = env.db.Close() }()
|
||||
|
||||
extra, err := age.GenerateX25519Identity()
|
||||
require.NoError(t, err)
|
||||
|
||||
env.cfg.AgeRecipients = append(env.cfg.AgeRecipients,
|
||||
extra.Recipient().String())
|
||||
|
||||
snapshotID, result := runDedupSnapshot(ctx, t, env.sm, env.makeScanner(),
|
||||
env.cfg.Hostname, "second", dataDir, secondDBPath)
|
||||
require.Positive(t, result.BlobsCreated)
|
||||
require.Zero(t, result.BlobsUploaded,
|
||||
"the second backup should find every blob already stored")
|
||||
|
||||
require.NoError(t, env.db.Close())
|
||||
|
||||
verifier := &vaultik.Vaultik{
|
||||
Config: env.cfg,
|
||||
Storage: env.storer,
|
||||
Fs: fs,
|
||||
Stdout: io.Discard,
|
||||
Stderr: io.Discard,
|
||||
UI: ui.NewWithColor(io.Discard, false),
|
||||
}
|
||||
verifier.SetContext(ctx)
|
||||
|
||||
require.NoError(t,
|
||||
verifier.VerifySnapshotWithOptions(snapshotID, &vaultik.VerifyOptions{}),
|
||||
"shallow verify should pass")
|
||||
require.NoError(t,
|
||||
verifier.VerifySnapshotWithOptions(snapshotID,
|
||||
&vaultik.VerifyOptions{Deep: true}),
|
||||
"deep verify should pass")
|
||||
}
|
||||
@@ -25,7 +25,7 @@ func TestBackupOfSameSecondRewriteRestoresNewContent(t *testing.T) {
|
||||
fs := afero.NewOsFs()
|
||||
tempDir := t.TempDir()
|
||||
dataDir := filepath.Join(tempDir, "src")
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
storeDir := t.TempDir()
|
||||
restoreDir := filepath.Join(tempDir, "restored")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
rewrittenPath := filepath.Join(dataDir, "small.txt")
|
||||
|
||||
@@ -4,15 +4,20 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/log"
|
||||
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||
"sneak.berlin/go/vaultik/internal/storage"
|
||||
"sneak.berlin/go/vaultik/internal/ui"
|
||||
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||
)
|
||||
@@ -31,7 +36,7 @@ func TestShallowVerifyDetectsWrongBlobSize(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
dataDir := filepath.Join(tempDir, "source")
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
storeDir := t.TempDir()
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
chunkSize := int64(32 * 1024)
|
||||
@@ -105,7 +110,7 @@ func TestShallowVerifyDetectsMissingDatabase(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
dataDir := filepath.Join(tempDir, "source")
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
storeDir := t.TempDir()
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
chunkSize := int64(32 * 1024)
|
||||
@@ -151,6 +156,224 @@ func TestShallowVerifyDetectsMissingDatabase(t *testing.T) {
|
||||
"shallow verify must fail when db.zst.age is absent")
|
||||
}
|
||||
|
||||
// errStatDenied stands in for any storage error other than not-found: a
|
||||
// permission error, an S3 403, a dropped connection.
|
||||
var errStatDenied = errors.New("injected stat failure")
|
||||
|
||||
// statFailingStorer fails Stat with errStatDenied for the keys in
|
||||
// failKeys and passes every other call to the wrapped Storer.
|
||||
type statFailingStorer struct {
|
||||
storage.Storer
|
||||
|
||||
failKeys []string
|
||||
}
|
||||
|
||||
func (s statFailingStorer) Stat(
|
||||
ctx context.Context, key string,
|
||||
) (*storage.ObjectInfo, error) {
|
||||
if slices.Contains(s.failKeys, key) {
|
||||
return nil, fmt.Errorf("stat %q: %w", key, errStatDenied)
|
||||
}
|
||||
|
||||
return s.Storer.Stat(ctx, key)
|
||||
}
|
||||
|
||||
// TestShallowVerifyReportsStatErrorsApartFromMissing backs up a real
|
||||
// snapshot, deletes one blob, and makes storage fail Stat with an error
|
||||
// other than not-found for a second blob and for the encrypted database.
|
||||
// Only the deleted blob may be counted as missing. The second blob and
|
||||
// the database must be reported as could not be checked, with the error
|
||||
// storage returned, and verify must fail, in the text output and under
|
||||
// --json.
|
||||
func TestShallowVerifyReportsStatErrorsApartFromMissing(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
tempDir := t.TempDir()
|
||||
|
||||
dataDir := filepath.Join(tempDir, "source")
|
||||
storeDir := t.TempDir()
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
chunkSize := int64(32 * 1024)
|
||||
maxBlobSize := int64(128 * 1024)
|
||||
|
||||
// Random content, so the snapshot spans several blobs.
|
||||
require.NoError(t, fs.MkdirAll(dataDir, 0o755))
|
||||
appendRandomBytes(t, fs, map[string][]byte{},
|
||||
filepath.Join(dataDir, "data.bin"), maxBlobSize*4)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
cfg, storer, snapshotID := runFileStorageBackup(
|
||||
ctx, t, fs, dataDir, storeDir, dbPath, chunkSize, maxBlobSize)
|
||||
|
||||
blobKeys := storedBlobKeys(t, storeDir)
|
||||
require.GreaterOrEqual(t, len(blobKeys), 2, "expected at least two blobs")
|
||||
|
||||
missingKey, uncheckedKey := blobKeys[0], blobKeys[1]
|
||||
missingHash, uncheckedHash := path.Base(missingKey), path.Base(uncheckedKey)
|
||||
|
||||
require.NoError(t, os.Remove(filepath.Join(storeDir, missingKey)))
|
||||
|
||||
dbKey := "metadata/" + snapshot.RemoteSnapshotKey(snapshotID) + "/db.zst.age"
|
||||
failing := statFailingStorer{
|
||||
Storer: storer,
|
||||
failKeys: []string{uncheckedKey, dbKey},
|
||||
}
|
||||
|
||||
newVerifier := func(out io.Writer) *vaultik.Vaultik {
|
||||
v := &vaultik.Vaultik{
|
||||
Config: cfg,
|
||||
Storage: failing,
|
||||
Fs: fs,
|
||||
Stdout: out,
|
||||
Stderr: io.Discard,
|
||||
UI: ui.NewWithColor(io.Discard, false),
|
||||
}
|
||||
v.SetContext(ctx)
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
|
||||
err := newVerifier(&out).VerifySnapshotWithOptions(
|
||||
snapshotID, &vaultik.VerifyOptions{JSON: true})
|
||||
require.Error(t, err)
|
||||
|
||||
result := decodeVerifyResult(t, out.Bytes())
|
||||
require.Equal(t, "failed", result.Status)
|
||||
require.Equal(t, 1, result.Missing, "only the deleted blob is missing")
|
||||
require.Equal(t, result.BlobCount-2, result.Verified)
|
||||
require.Len(t, result.Unchecked, 1)
|
||||
require.Equal(t, uncheckedHash, result.Unchecked[0].Hash)
|
||||
require.Contains(t, result.Unchecked[0].Error, errStatDenied.Error())
|
||||
require.False(t, result.DatabaseMissing,
|
||||
"a database that could not be checked is not missing")
|
||||
require.Contains(t, result.DatabaseCheckError, errStatDenied.Error())
|
||||
|
||||
out.Reset()
|
||||
err = newVerifier(&out).VerifySnapshotWithOptions(
|
||||
snapshotID, &vaultik.VerifyOptions{})
|
||||
require.Error(t, err)
|
||||
|
||||
text := out.String()
|
||||
require.Contains(t, text, "Missing: "+missingHash)
|
||||
require.NotContains(t, text, "Missing: "+uncheckedHash)
|
||||
require.Contains(t, text, "Could not check: "+uncheckedHash)
|
||||
require.Contains(t, text, "Encrypted database: could not be checked")
|
||||
require.Contains(t, text, errStatDenied.Error())
|
||||
require.Contains(t, text, "FAILED")
|
||||
}
|
||||
|
||||
// TestShallowVerifyQuietFailsOnStatErrorAlone backs up a healthy snapshot,
|
||||
// then makes storage fail Stat, with an error other than not-found, for
|
||||
// one blob alone and then for the encrypted database alone. Each run must
|
||||
// fail verify, and under --quiet, which drops the report, the error
|
||||
// storage returned must still reach stderr.
|
||||
//
|
||||
//nolint:paralleltest // captureProcessStderr replaces os.Stderr
|
||||
func TestShallowVerifyQuietFailsOnStatErrorAlone(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
tempDir := t.TempDir()
|
||||
|
||||
dataDir := filepath.Join(tempDir, "source")
|
||||
storeDir := t.TempDir()
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
chunkSize := int64(32 * 1024)
|
||||
maxBlobSize := int64(128 * 1024)
|
||||
|
||||
require.NoError(t, fs.MkdirAll(dataDir, 0o755))
|
||||
require.NoError(t, afero.WriteFile(fs,
|
||||
filepath.Join(dataDir, "data.bin"),
|
||||
bytesPattern("shallow-quiet-", int(maxBlobSize*2)), 0o644))
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
cfg, storer, snapshotID := runFileStorageBackup(
|
||||
ctx, t, fs, dataDir, storeDir, dbPath, chunkSize, maxBlobSize)
|
||||
|
||||
newQuietVerifier := func(s storage.Storer) *vaultik.Vaultik {
|
||||
v := &vaultik.Vaultik{
|
||||
Config: cfg,
|
||||
Storage: s,
|
||||
Fs: fs,
|
||||
Stdout: io.Discard,
|
||||
Stderr: io.Discard,
|
||||
UI: ui.NewWithColor(io.Discard, false),
|
||||
}
|
||||
v.SetContext(ctx)
|
||||
v.UI.SetQuiet(true)
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
require.NoError(t,
|
||||
newQuietVerifier(storer).VerifySnapshotWithOptions(
|
||||
snapshotID, &vaultik.VerifyOptions{}),
|
||||
"shallow verify should pass on a healthy snapshot")
|
||||
|
||||
blobKeys := storedBlobKeys(t, storeDir)
|
||||
require.NotEmpty(t, blobKeys)
|
||||
|
||||
dbKey := "metadata/" + snapshot.RemoteSnapshotKey(snapshotID) + "/db.zst.age"
|
||||
|
||||
for _, failKey := range []string{blobKeys[0], dbKey} {
|
||||
v := newQuietVerifier(statFailingStorer{
|
||||
Storer: storer,
|
||||
failKeys: []string{failKey},
|
||||
})
|
||||
|
||||
var err error
|
||||
|
||||
stderr := captureProcessStderr(t, func() {
|
||||
err = v.VerifySnapshotWithOptions(
|
||||
snapshotID, &vaultik.VerifyOptions{})
|
||||
})
|
||||
|
||||
require.Error(t, err,
|
||||
"verify must fail when %s could not be checked", failKey)
|
||||
require.Contains(t, stderr, errStatDenied.Error(),
|
||||
"under --quiet the error for %s must still reach stderr", failKey)
|
||||
}
|
||||
}
|
||||
|
||||
// storedBlobKeys returns the storage key of every blob file under
|
||||
// storeDir, sorted.
|
||||
func storedBlobKeys(t *testing.T, storeDir string) []string {
|
||||
t.Helper()
|
||||
|
||||
var keys []string
|
||||
|
||||
err := filepath.Walk(filepath.Join(storeDir, "blobs"),
|
||||
func(p string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
rel, err := filepath.Rel(storeDir, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
keys = append(keys, filepath.ToSlash(rel))
|
||||
|
||||
return nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
return keys
|
||||
}
|
||||
|
||||
// growOneBlob appends bytes to the first blob file found under blobsDir,
|
||||
// changing its on-disk size so it no longer matches the manifest.
|
||||
func growOneBlob(t *testing.T, fs afero.Fs, blobsDir string) {
|
||||
|
||||
+289
-101
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
"sneak.berlin/go/vaultik/internal/log"
|
||||
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||
"sneak.berlin/go/vaultik/internal/storage"
|
||||
"sneak.berlin/go/vaultik/internal/types"
|
||||
)
|
||||
|
||||
@@ -23,12 +24,20 @@ var (
|
||||
errNoSnapshotsInConfig = errors.New("no snapshots configured")
|
||||
errSnapshotVerifyFailed = errors.New("verification failed")
|
||||
errRemoveAllNeedsForce = errors.New("--all requires --force")
|
||||
errSnapshotNotFound = errors.New("snapshot not found")
|
||||
errSnapshotRemoveFailed = errors.New("snapshot(s) could not be removed")
|
||||
errInvalidTableName = errors.New("invalid table name")
|
||||
errNoAgeRecipients = errors.New(
|
||||
"creating a snapshot needs at least one public key in " +
|
||||
"age_recipients (generate a keypair with: age-keygen)")
|
||||
errKeepNewerThanNeedsPrune = errors.New("--keep-newer-than requires --prune")
|
||||
)
|
||||
|
||||
// ErrPostBackupPruneFailed wraps a failure of the post-backup prune,
|
||||
// which runs only after every snapshot was created and uploaded, so the
|
||||
// caller can report it apart from a failed backup.
|
||||
var ErrPostBackupPruneFailed = errors.New("post-backup prune failed")
|
||||
|
||||
// listRecentLimit caps how many snapshot rows are fetched from the
|
||||
// local index for listing and purge operations.
|
||||
const listRecentLimit = 10000
|
||||
@@ -44,6 +53,26 @@ type SnapshotCreateOptions struct {
|
||||
Snapshots []string // Optional list of snapshot names to process (empty = all)
|
||||
}
|
||||
|
||||
// Validate fails when KeepNewerThan is set without Prune or is not a
|
||||
// valid duration. Only the post-backup prune reads KeepNewerThan, after
|
||||
// the backup, so the caller checks it before starting one.
|
||||
func (opts *SnapshotCreateOptions) Validate() error {
|
||||
if opts.KeepNewerThan == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !opts.Prune {
|
||||
return errKeepNewerThanNeedsPrune
|
||||
}
|
||||
|
||||
_, err := parseDuration(opts.KeepNewerThan)
|
||||
if err != nil {
|
||||
return fmt.Errorf("--keep-newer-than: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateSnapshot executes the snapshot creation operation
|
||||
func (v *Vaultik) CreateSnapshot(opts *SnapshotCreateOptions) error {
|
||||
// config.Load accepts an empty list, since listing, verifying and
|
||||
@@ -65,6 +94,13 @@ func (v *Vaultik) CreateSnapshot(opts *SnapshotCreateOptions) error {
|
||||
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.
|
||||
// This is critical for data safety; PruneDatabase below does it.
|
||||
hostname := v.Config.Hostname
|
||||
@@ -87,21 +123,9 @@ func (v *Vaultik) CreateSnapshot(opts *SnapshotCreateOptions) error {
|
||||
return fmt.Errorf("prune database: %w", err)
|
||||
}
|
||||
|
||||
// Determine which snapshots to process
|
||||
snapshotNames := opts.Snapshots
|
||||
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
|
||||
snapshotNames, err := v.snapshotNamesToCreate(opts.Snapshots)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Process each named snapshot
|
||||
@@ -122,7 +146,7 @@ func (v *Vaultik) CreateSnapshot(opts *SnapshotCreateOptions) error {
|
||||
if opts.Prune {
|
||||
err = v.runPostBackupPrune(snapshotNames, opts.KeepNewerThan)
|
||||
if err != nil {
|
||||
return fmt.Errorf("post-backup prune: %w", err)
|
||||
return fmt.Errorf("%w: %w", ErrPostBackupPruneFailed, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,6 +165,28 @@ func (v *Vaultik) CreateSnapshot(opts *SnapshotCreateOptions) error {
|
||||
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
|
||||
// orphan blobs from remote storage. If keepNewerThan is set (e.g. "4w"),
|
||||
// snapshots newer than that duration are kept. Otherwise only the latest
|
||||
@@ -182,9 +228,11 @@ type snapshotStats struct {
|
||||
totalFiles int
|
||||
totalBytes int64
|
||||
totalChunks int
|
||||
newChunkSize int64
|
||||
totalBlobs int
|
||||
totalBytesSkipped int64
|
||||
totalFilesSkipped int
|
||||
totalFilesFailed int
|
||||
totalFilesDeleted int
|
||||
totalBytesDeleted int64
|
||||
totalBytesUploaded int64
|
||||
@@ -313,8 +361,10 @@ func (v *Vaultik) scanAllDirectories(
|
||||
stats.totalFiles += result.FilesScanned
|
||||
stats.totalBytes += result.BytesScanned
|
||||
stats.totalChunks += result.ChunksCreated
|
||||
stats.newChunkSize += result.NewChunkSize
|
||||
stats.totalBlobs += result.BlobsCreated
|
||||
stats.totalFilesSkipped += result.FilesSkipped
|
||||
stats.totalFilesFailed += result.FilesFailed
|
||||
stats.totalBytesSkipped += result.BytesSkipped
|
||||
stats.totalFilesDeleted += result.FilesDeleted
|
||||
stats.totalBytesDeleted += result.BytesDeleted
|
||||
@@ -326,6 +376,7 @@ func (v *Vaultik) scanAllDirectories(
|
||||
"path", dir,
|
||||
"files", result.FilesScanned,
|
||||
"files_skipped", result.FilesSkipped,
|
||||
"files_failed", result.FilesFailed,
|
||||
"bytes", result.BytesScanned,
|
||||
"bytes_skipped", result.BytesSkipped,
|
||||
"chunks", result.ChunksCreated,
|
||||
@@ -359,9 +410,11 @@ func (v *Vaultik) finalizeSnapshotMetadata(
|
||||
return fmt.Errorf("getting snapshot blob sizes: %w", err)
|
||||
}
|
||||
|
||||
// file_count and total_size leave out the files that could not be
|
||||
// stored; stats.totalBytes already does.
|
||||
extStats := snapshot.ExtendedBackupStats{
|
||||
BackupStats: snapshot.BackupStats{
|
||||
FilesScanned: stats.totalFiles,
|
||||
FilesScanned: stats.totalFiles - stats.totalFilesFailed,
|
||||
TotalSize: stats.totalBytes + stats.totalBytesSkipped,
|
||||
ChunksCreated: stats.totalChunks,
|
||||
BlobsCreated: stats.totalBlobs,
|
||||
@@ -371,6 +424,7 @@ func (v *Vaultik) finalizeSnapshotMetadata(
|
||||
BlobUncompressedSize: stats.blobUncompressedSize,
|
||||
CompressionLevel: v.Config.CompressionLevel,
|
||||
UploadDurationMs: stats.uploadDuration.Milliseconds(),
|
||||
NewChunkSize: stats.newChunkSize,
|
||||
}
|
||||
|
||||
err = v.SnapshotManager.UpdateSnapshotStatsExtended(v.ctx, snapshotID, extStats)
|
||||
@@ -409,7 +463,8 @@ func (v *Vaultik) printSnapshotSummary(
|
||||
snapshotID string, startTime time.Time, stats *snapshotStats,
|
||||
) {
|
||||
snapshotDuration := time.Since(startTime)
|
||||
totalFilesChanged := stats.totalFiles - stats.totalFilesSkipped
|
||||
totalFilesChanged := stats.totalFiles - stats.totalFilesSkipped -
|
||||
stats.totalFilesFailed
|
||||
totalBytesAll := stats.totalBytes + stats.totalBytesSkipped
|
||||
|
||||
var compressionRatio float64
|
||||
@@ -426,6 +481,10 @@ func (v *Vaultik) printSnapshotSummary(
|
||||
v.UI.Count(stats.totalFiles),
|
||||
v.UI.Count(totalFilesChanged),
|
||||
v.UI.Count(stats.totalFilesSkipped))
|
||||
if stats.totalFilesFailed > 0 {
|
||||
filesMsg += fmt.Sprintf(", %s failed", v.UI.Count(stats.totalFilesFailed))
|
||||
}
|
||||
|
||||
if stats.totalFilesDeleted > 0 {
|
||||
filesMsg += fmt.Sprintf(", %s deleted", v.UI.Count(stats.totalFilesDeleted))
|
||||
}
|
||||
@@ -596,7 +655,7 @@ func (v *Vaultik) confirmAndExecutePurge(
|
||||
v.stdoutf(" %s (%s, %s)\n",
|
||||
snap.ID,
|
||||
snap.Timestamp.Format("2006-01-02 15:04:05"),
|
||||
formatBytes(snap.CompressedSize))
|
||||
ubytes(snap.CompressedSize))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -633,7 +692,7 @@ func (v *Vaultik) confirmAndExecutePurge(
|
||||
"snapshot_id", snapshotID, "error", err)
|
||||
}
|
||||
|
||||
err = v.deleteRemoteSnapshotByKey(snapshot.RemoteSnapshotKey(snapshotID))
|
||||
_, err = v.deleteRemoteSnapshotByKey(snapshot.RemoteSnapshotKey(snapshotID))
|
||||
if err != nil {
|
||||
return fmt.Errorf("deleting snapshot %s from remote: %w", snapshotID, err)
|
||||
}
|
||||
@@ -737,18 +796,10 @@ func (v *Vaultik) VerifySnapshotWithOptions(
|
||||
v.stdoutf("Checking blob presence and sizes...\n")
|
||||
}
|
||||
|
||||
// A snapshot is only restorable if its encrypted database is present
|
||||
// alongside the blobs. Shallow verify checks that the object exists; it
|
||||
// does not decrypt it (that is deep verify's job).
|
||||
dbPath := fmt.Sprintf("metadata/%s/db.zst.age", remoteKey)
|
||||
v.checkSnapshotDatabase(remoteKey, result)
|
||||
|
||||
_, dbErr := v.Storage.Stat(v.ctx, dbPath)
|
||||
if dbErr != nil {
|
||||
result.DatabaseMissing = true
|
||||
}
|
||||
|
||||
result.Verified, result.Missing, result.Mismatched, result.MissingSize, err =
|
||||
v.verifyManifestBlobs(manifest, opts)
|
||||
result.Verified, result.Missing, result.Mismatched, result.MissingSize,
|
||||
result.Unchecked, err = v.verifyManifestBlobs(manifest, opts)
|
||||
if err != nil {
|
||||
if opts.JSON {
|
||||
result.Status = verifyStatusFailed
|
||||
@@ -785,25 +836,46 @@ func (v *Vaultik) printVerifyHeader(snapshotID string, opts *VerifyOptions) {
|
||||
}
|
||||
}
|
||||
|
||||
// checkSnapshotDatabase records in result whether the snapshot's
|
||||
// encrypted database is missing or could not be checked. A snapshot is
|
||||
// only restorable if its database is present alongside the blobs.
|
||||
// Shallow verify checks that the object exists; it does not decrypt it
|
||||
// (that is deep verify's job).
|
||||
func (v *Vaultik) checkSnapshotDatabase(remoteKey string, result *VerifyResult) {
|
||||
dbPath := fmt.Sprintf("metadata/%s/db.zst.age", remoteKey)
|
||||
|
||||
_, err := v.Storage.Stat(v.ctx, dbPath)
|
||||
|
||||
switch {
|
||||
case errors.Is(err, storage.ErrNotFound):
|
||||
result.DatabaseMissing = true
|
||||
case err != nil:
|
||||
result.DatabaseCheckError = err.Error()
|
||||
}
|
||||
}
|
||||
|
||||
// verifyManifestBlobs checks that each blob in the manifest is present in
|
||||
// storage with the size the manifest records, returning the counts of
|
||||
// blobs that were present with the right size, absent, and present but the
|
||||
// wrong size, plus the total bytes of the absent blobs. It does not read
|
||||
// blob contents; deep verification (RunDeepVerify) does that. The size
|
||||
// comparison matches the deep path (see verifyBlobExistenceFromDB).
|
||||
// wrong size, the total bytes of the absent blobs, and the blobs it could
|
||||
// not check because storage returned an error other than not-found. It
|
||||
// does not read blob contents; deep verification (RunDeepVerify) does
|
||||
// that. The size comparison matches the deep path (see
|
||||
// verifyBlobExistenceFromDB).
|
||||
func (v *Vaultik) verifyManifestBlobs(
|
||||
manifest *snapshot.Manifest, opts *VerifyOptions,
|
||||
) (int, int, int, int64, error) {
|
||||
) (int, int, int, int64, []UncheckedBlob, error) {
|
||||
var (
|
||||
verified, missing, mismatched int
|
||||
missingSize int64
|
||||
unchecked []UncheckedBlob
|
||||
)
|
||||
|
||||
for _, blob := range manifest.Blobs {
|
||||
// The manifest is unauthenticated, so its blob hashes are checked
|
||||
// before being spliced into a storage path.
|
||||
if !isBlobHash(blob.Hash) {
|
||||
return 0, 0, 0, 0, fmt.Errorf("%w: %s",
|
||||
return 0, 0, 0, 0, nil, fmt.Errorf("%w: %s",
|
||||
errInvalidBlobHash, shortHash(blob.Hash))
|
||||
}
|
||||
|
||||
@@ -812,7 +884,7 @@ func (v *Vaultik) verifyManifestBlobs(
|
||||
|
||||
stat, err := v.Storage.Stat(v.ctx, blobPath)
|
||||
switch {
|
||||
case err != nil:
|
||||
case errors.Is(err, storage.ErrNotFound):
|
||||
if !opts.JSON && !v.UI.Quiet() {
|
||||
v.stdoutf(" Missing: %s (%s)\n",
|
||||
blob.Hash, ubytes(blob.CompressedSize))
|
||||
@@ -820,6 +892,13 @@ func (v *Vaultik) verifyManifestBlobs(
|
||||
|
||||
missing++
|
||||
missingSize += blob.CompressedSize
|
||||
case err != nil:
|
||||
if !opts.JSON && !v.UI.Quiet() {
|
||||
v.stdoutf(" Could not check: %s (%v)\n", blob.Hash, err)
|
||||
}
|
||||
|
||||
unchecked = append(unchecked,
|
||||
UncheckedBlob{Hash: blob.Hash, Error: err.Error()})
|
||||
case stat.Size != blob.CompressedSize:
|
||||
if !opts.JSON && !v.UI.Quiet() {
|
||||
v.stdoutf(" Wrong size: %s (store has %s, manifest lists %s)\n",
|
||||
@@ -832,7 +911,7 @@ func (v *Vaultik) verifyManifestBlobs(
|
||||
}
|
||||
}
|
||||
|
||||
return verified, missing, mismatched, missingSize, nil
|
||||
return verified, missing, mismatched, missingSize, unchecked, nil
|
||||
}
|
||||
|
||||
// formatVerifyResult outputs the final verification results as JSON or
|
||||
@@ -853,9 +932,19 @@ func (v *Vaultik) formatVerifyResult(
|
||||
return v.outputVerifyJSON(result)
|
||||
}
|
||||
|
||||
// Under --quiet a failure is still returned, and the cli layer
|
||||
// prints it on stderr.
|
||||
if !v.UI.Quiet() {
|
||||
// --quiet drops the report but not the errors storage returned, which
|
||||
// are logged on stderr. A failure is still returned either way, and
|
||||
// the cli layer prints it on stderr.
|
||||
if v.UI.Quiet() {
|
||||
for _, blob := range result.Unchecked {
|
||||
log.Warn("Could not check blob", "blob", blob.Hash, "error", blob.Error)
|
||||
}
|
||||
|
||||
if result.DatabaseCheckError != "" {
|
||||
log.Warn("Could not check the encrypted database",
|
||||
"snapshot", result.SnapshotID, "error", result.DatabaseCheckError)
|
||||
}
|
||||
} else {
|
||||
v.printVerifySummary(result, failure)
|
||||
}
|
||||
|
||||
@@ -881,10 +970,19 @@ func (v *Vaultik) printVerifySummary(result *VerifyResult, failure string) {
|
||||
v.stdoutf(" Wrong size: %d blobs\n", result.Mismatched)
|
||||
}
|
||||
|
||||
if len(result.Unchecked) > 0 {
|
||||
v.stdoutf(" Could not check: %d blobs\n", len(result.Unchecked))
|
||||
}
|
||||
|
||||
if result.DatabaseMissing {
|
||||
v.stdoutf(" Encrypted database: missing\n")
|
||||
}
|
||||
|
||||
if result.DatabaseCheckError != "" {
|
||||
v.stdoutf(" Encrypted database: could not be checked (%s)\n",
|
||||
result.DatabaseCheckError)
|
||||
}
|
||||
|
||||
v.stdoutf(" Status: ")
|
||||
|
||||
if failure != "" {
|
||||
@@ -912,10 +1010,19 @@ func shallowVerifyFailure(result *VerifyResult) string {
|
||||
fmt.Sprintf("%d blobs have the wrong size", result.Mismatched))
|
||||
}
|
||||
|
||||
if len(result.Unchecked) > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d blobs could not be checked",
|
||||
len(result.Unchecked)))
|
||||
}
|
||||
|
||||
if result.DatabaseMissing {
|
||||
parts = append(parts, "the encrypted database is missing")
|
||||
}
|
||||
|
||||
if result.DatabaseCheckError != "" {
|
||||
parts = append(parts, "the encrypted database could not be checked")
|
||||
}
|
||||
|
||||
return strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
@@ -1143,23 +1250,38 @@ func (v *Vaultik) RemoveSnapshot(
|
||||
return result, err
|
||||
}
|
||||
|
||||
inLocalIndex, err := v.findSnapshotToRemove(snapshotID, opts)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
if opts.DryRun {
|
||||
return v.removeSnapshotDryRun(result, snapshotID, opts)
|
||||
}
|
||||
|
||||
if !opts.Force && !opts.JSON && !v.confirmRemoveSnapshot(snapshotID, opts) {
|
||||
if !v.confirmRemoveSnapshot(snapshotID, opts) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
log.Info("Removing snapshot from local database", "snapshot_id", snapshotID)
|
||||
// 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()
|
||||
}
|
||||
|
||||
err = v.deleteSnapshotFromLocalDB(snapshotID)
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("removing from local database: %w", err)
|
||||
if inLocalIndex {
|
||||
log.Info("Removing snapshot from local database", "snapshot_id", snapshotID)
|
||||
|
||||
err = v.deleteSnapshotFromLocalDB(snapshotID)
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("removing from local database: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if !opts.LocalOnly {
|
||||
result.RemoteRemoved = v.removeSnapshotRemote(snapshotID, opts)
|
||||
result.RemoteRemoved = v.removeSnapshotRemote(snapshotID, markerErr, opts)
|
||||
}
|
||||
|
||||
if v.SnapshotManager != nil {
|
||||
@@ -1174,9 +1296,11 @@ func (v *Vaultik) RemoveSnapshot(
|
||||
return result, v.outputRemoveJSON(result)
|
||||
}
|
||||
|
||||
v.stdoutf("Removed snapshot '%s' from local database\n", snapshotID)
|
||||
if inLocalIndex {
|
||||
v.stdoutf("Removed snapshot '%s' from local database\n", snapshotID)
|
||||
}
|
||||
|
||||
if !opts.LocalOnly && result.RemoteRemoved {
|
||||
if result.RemoteRemoved {
|
||||
v.printlnStdout("Removed snapshot metadata from remote storage")
|
||||
v.stdoutf("\nNote: The removed snapshot's blobs remain on the remote. "+
|
||||
"Run '%s' to delete any blobs no longer referenced by any "+
|
||||
@@ -1186,6 +1310,45 @@ func (v *Vaultik) RemoveSnapshot(
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// findSnapshotToRemove reports whether the local index has a row for
|
||||
// snapshotID, and returns errSnapshotNotFound when there is nothing to
|
||||
// remove: no row and, unless LocalOnly is set, no metadata on the
|
||||
// destination store either. The destination store is consulted only
|
||||
// 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.
|
||||
func (v *Vaultik) findSnapshotToRemove(
|
||||
snapshotID string, opts *RemoveOptions,
|
||||
) (bool, error) {
|
||||
if v.Repositories != nil {
|
||||
snap, err := v.Repositories.Snapshots.GetByID(v.ctx, snapshotID)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("looking up snapshot in local index: %w", err)
|
||||
}
|
||||
|
||||
if snap != nil {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
if opts.LocalOnly {
|
||||
return false, fmt.Errorf("%w in the local index: %s",
|
||||
errSnapshotNotFound, snapshotID)
|
||||
}
|
||||
|
||||
objects, err := v.remoteSnapshotObjects(snapshot.RemoteSnapshotKey(snapshotID))
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("looking up snapshot on destination store: %w", err)
|
||||
}
|
||||
|
||||
if len(objects) == 0 {
|
||||
return false, fmt.Errorf(
|
||||
"%w in the local index or on the destination store: %s",
|
||||
errSnapshotNotFound, snapshotID)
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// removeSnapshotDryRun reports what RemoveSnapshot would do without
|
||||
// making changes.
|
||||
func (v *Vaultik) removeSnapshotDryRun(
|
||||
@@ -1210,9 +1373,13 @@ func (v *Vaultik) removeSnapshotDryRun(
|
||||
}
|
||||
|
||||
// confirmRemoveSnapshot prompts for confirmation before removing a
|
||||
// snapshot. Returns false (treating EOF/read errors as "no") unless the
|
||||
// user answers "y".
|
||||
// snapshot, unless --force or --json is set. Returns false (treating
|
||||
// EOF/read errors as "no") unless the user answers "y".
|
||||
func (v *Vaultik) confirmRemoveSnapshot(snapshotID string, opts *RemoveOptions) bool {
|
||||
if opts.Force || opts.JSON {
|
||||
return true
|
||||
}
|
||||
|
||||
if opts.LocalOnly {
|
||||
v.stdoutf("Remove snapshot '%s' from local database "+
|
||||
"(remote untouched)? [y/N] ", snapshotID)
|
||||
@@ -1244,15 +1411,24 @@ func (v *Vaultik) confirmRemoveSnapshot(snapshotID string, opts *RemoveOptions)
|
||||
// removal has already happened, so the user is told the remote half
|
||||
// didn't finish and to run `vaultik snapshot remove` for the snapshot
|
||||
// again once the destination store is reachable (`vaultik prune` never
|
||||
// removes snapshot metadata). Returns true when the remote removal
|
||||
// succeeded.
|
||||
func (v *Vaultik) removeSnapshotRemote(snapshotID string, opts *RemoveOptions) bool {
|
||||
// removes snapshot metadata). markerErr is the result of
|
||||
// checkDestinationMarker; when it is set, nothing is deleted and it is
|
||||
// the failure reported. Returns true when the remote removal succeeded
|
||||
// and deleted something.
|
||||
func (v *Vaultik) removeSnapshotRemote(
|
||||
snapshotID string, markerErr error, opts *RemoveOptions,
|
||||
) bool {
|
||||
log.Info("Removing snapshot metadata from remote storage",
|
||||
"snapshot_id", snapshotID)
|
||||
|
||||
remoteKey := snapshot.RemoteSnapshotKey(snapshotID)
|
||||
var deleted int
|
||||
|
||||
err := markerErr
|
||||
if err == nil {
|
||||
deleted, err = v.deleteRemoteSnapshotByKey(
|
||||
snapshot.RemoteSnapshotKey(snapshotID))
|
||||
}
|
||||
|
||||
err := v.deleteRemoteSnapshotByKey(remoteKey)
|
||||
if err != nil {
|
||||
log.Warn("Could not remove snapshot metadata from remote storage; "+
|
||||
"run '"+snapshotRemoveCommandHint+"' with the snapshot's ID "+
|
||||
@@ -1270,7 +1446,7 @@ func (v *Vaultik) removeSnapshotRemote(snapshotID string, opts *RemoveOptions) b
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
return deleted > 0
|
||||
}
|
||||
|
||||
// RemoveAllSnapshots removes every snapshot known to the local
|
||||
@@ -1356,8 +1532,8 @@ func (v *Vaultik) localSnapshotIDs() ([]string, error) {
|
||||
// listAllRemoteSnapshotKeys collects the hashed remote keys
|
||||
// (subdirectories under metadata/) currently present in the
|
||||
// destination store. Returns (nil, err) when the store cannot be
|
||||
// listed; callers must treat that as "no remote info available," not
|
||||
// fatal.
|
||||
// listed, or is not present by checkDestinationMarker; callers must
|
||||
// treat that as "no remote info available," not fatal.
|
||||
func (v *Vaultik) listAllRemoteSnapshotKeys() ([]string, error) {
|
||||
log.Info("Listing all remote snapshots")
|
||||
|
||||
@@ -1406,6 +1582,11 @@ func (v *Vaultik) listAllRemoteSnapshotKeys() ([]string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
err := v.checkDestinationMarker()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
@@ -1454,8 +1635,8 @@ func (v *Vaultik) handleRemoveAllDryRun(
|
||||
|
||||
// executeRemoveAll deletes every local snapshot and, unless LocalOnly
|
||||
// is set, every corresponding remote metadata directory plus any
|
||||
// orphan remote keys. Blobs are not touched; the printed prune-command
|
||||
// hint is the next step.
|
||||
// orphan remote keys. Blobs are not touched. It returns an error when
|
||||
// any snapshot could not be removed.
|
||||
func (v *Vaultik) executeRemoveAll(
|
||||
localSnaps, orphanRemoteKeys []string, opts *RemoveOptions,
|
||||
) (*RemoveResult, error) {
|
||||
@@ -1469,18 +1650,11 @@ func (v *Vaultik) executeRemoveAll(
|
||||
|
||||
result := &RemoveResult{}
|
||||
|
||||
remoteErrors := v.removeAllLocalSnapshots(localSnaps, opts, result)
|
||||
failures := v.removeAllLocalSnapshots(localSnaps, opts, result)
|
||||
|
||||
if !opts.LocalOnly {
|
||||
remoteErrors += v.removeAllOrphanRemotes(orphanRemoteKeys, result)
|
||||
|
||||
if remoteErrors == 0 {
|
||||
result.RemoteRemoved = true
|
||||
} else if v.UI != nil {
|
||||
v.UI.Warningf("Some remote metadata deletions failed. Run '%s' "+
|
||||
"once the remote is healthy to clean up unreferenced blobs.",
|
||||
pruneCommandHint)
|
||||
}
|
||||
failures += v.removeAllOrphanRemotes(orphanRemoteKeys, result)
|
||||
result.RemoteRemoved = failures == 0
|
||||
}
|
||||
|
||||
if v.SnapshotManager != nil {
|
||||
@@ -1490,6 +1664,10 @@ func (v *Vaultik) executeRemoveAll(
|
||||
}
|
||||
}
|
||||
|
||||
if failures > 0 {
|
||||
return result, fmt.Errorf("%d %w", failures, errSnapshotRemoveFailed)
|
||||
}
|
||||
|
||||
if opts.JSON {
|
||||
return result, v.outputRemoveJSON(result)
|
||||
}
|
||||
@@ -1498,9 +1676,6 @@ func (v *Vaultik) executeRemoveAll(
|
||||
|
||||
if !opts.LocalOnly && result.RemoteRemoved {
|
||||
v.printlnStdout("Removed snapshot metadata from remote storage")
|
||||
v.stdoutf("\nNote: Removed snapshots' blobs remain on the remote. "+
|
||||
"Run '%s' to delete any blobs no longer referenced by any "+
|
||||
"remaining remote snapshot.\n", pruneCommandHint)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
@@ -1543,12 +1718,13 @@ func (v *Vaultik) deleteIncompleteSnapshot(snapshotIDStr string, id any) bool {
|
||||
}
|
||||
|
||||
// removeAllLocalSnapshots removes each known local snapshot (and, unless
|
||||
// --local-only, its remote metadata), returning the number of remote
|
||||
// deletion failures.
|
||||
// --local-only, its remote metadata), returning the number of snapshots
|
||||
// it could not remove. A snapshot left in the local index keeps its
|
||||
// remote metadata too, since it is not an orphan remote key.
|
||||
func (v *Vaultik) removeAllLocalSnapshots(
|
||||
localSnaps []string, opts *RemoveOptions, result *RemoveResult,
|
||||
) int {
|
||||
remoteErrors := 0
|
||||
failures := 0
|
||||
|
||||
for _, snapshotID := range localSnaps {
|
||||
log.Info("Removing snapshot", "snapshot_id", snapshotID)
|
||||
@@ -1558,24 +1734,26 @@ func (v *Vaultik) removeAllLocalSnapshots(
|
||||
log.Error("Failed to remove from local database",
|
||||
"snapshot_id", snapshotID, "error", err)
|
||||
|
||||
failures++
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if !opts.LocalOnly {
|
||||
err = v.deleteRemoteSnapshotByKey(
|
||||
_, err = v.deleteRemoteSnapshotByKey(
|
||||
snapshot.RemoteSnapshotKey(snapshotID))
|
||||
if err != nil {
|
||||
log.Warn("Failed to remove snapshot metadata from remote",
|
||||
"snapshot_id", snapshotID, "error", err)
|
||||
|
||||
remoteErrors++
|
||||
failures++
|
||||
}
|
||||
}
|
||||
|
||||
result.SnapshotsRemoved = append(result.SnapshotsRemoved, snapshotID)
|
||||
}
|
||||
|
||||
return remoteErrors
|
||||
return failures
|
||||
}
|
||||
|
||||
// removeAllOrphanRemotes deletes remote snapshot metadata that has no
|
||||
@@ -1588,7 +1766,7 @@ func (v *Vaultik) removeAllOrphanRemotes(
|
||||
for _, key := range orphanRemoteKeys {
|
||||
log.Info("Removing orphan remote snapshot", "remote_key", key)
|
||||
|
||||
err := v.deleteRemoteSnapshotByKey(key)
|
||||
_, err := v.deleteRemoteSnapshotByKey(key)
|
||||
if err != nil {
|
||||
log.Warn("Failed to remove orphan from remote",
|
||||
"remote_key", key, "error", err)
|
||||
@@ -1635,34 +1813,44 @@ func (v *Vaultik) deleteSnapshotFromLocalDB(snapshotID string) error {
|
||||
}
|
||||
|
||||
// deleteRemoteSnapshotByKey removes everything under
|
||||
// metadata/<remoteKey>/ on the destination store. The argument is a
|
||||
// remote key (double-SHA256 derivation), not a human snapshot ID;
|
||||
// callers that have a human ID must hash via snapshot.RemoteSnapshotKey
|
||||
// first.
|
||||
func (v *Vaultik) deleteRemoteSnapshotByKey(remoteKey string) error {
|
||||
prefix := fmt.Sprintf("metadata/%s/", remoteKey)
|
||||
objectCh := v.Storage.ListStream(v.ctx, prefix)
|
||||
|
||||
var objectsToDelete []string
|
||||
|
||||
for object := range objectCh {
|
||||
if object.Err != nil {
|
||||
return fmt.Errorf("listing objects: %w", object.Err)
|
||||
}
|
||||
|
||||
objectsToDelete = append(objectsToDelete, object.Key)
|
||||
// metadata/<remoteKey>/ on the destination store and returns how many
|
||||
// objects it deleted. The argument is a remote key (double-SHA256
|
||||
// derivation), not a human snapshot ID; callers that have a human ID
|
||||
// must hash via snapshot.RemoteSnapshotKey first.
|
||||
func (v *Vaultik) deleteRemoteSnapshotByKey(remoteKey string) (int, error) {
|
||||
objectsToDelete, err := v.remoteSnapshotObjects(remoteKey)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
for _, key := range objectsToDelete {
|
||||
err := v.Storage.Delete(v.ctx, key)
|
||||
for i, key := range objectsToDelete {
|
||||
err = v.Storage.Delete(v.ctx, key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("removing %s: %w", key, err)
|
||||
return i, fmt.Errorf("removing %s: %w", key, err)
|
||||
}
|
||||
|
||||
log.Debug("Deleted remote object", "key", key)
|
||||
}
|
||||
|
||||
return nil
|
||||
return len(objectsToDelete), nil
|
||||
}
|
||||
|
||||
// remoteSnapshotObjects lists the keys of every object under
|
||||
// metadata/<remoteKey>/ on the destination store.
|
||||
func (v *Vaultik) remoteSnapshotObjects(remoteKey string) ([]string, error) {
|
||||
prefix := fmt.Sprintf("metadata/%s/", remoteKey)
|
||||
|
||||
var keys []string
|
||||
|
||||
for object := range v.Storage.ListStream(v.ctx, prefix) {
|
||||
if object.Err != nil {
|
||||
return nil, fmt.Errorf("listing objects: %w", object.Err)
|
||||
}
|
||||
|
||||
keys = append(keys, object.Key)
|
||||
}
|
||||
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
// outputRemoveJSON outputs the removal result as JSON
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
package vaultik_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"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/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"
|
||||
)
|
||||
|
||||
// openFailFs is the real filesystem, except that opening path fails
|
||||
// with err. Phase 1 of a backup only lstats a file, so it still counts
|
||||
// path; phase 2 is the first to open it.
|
||||
type openFailFs struct {
|
||||
afero.OsFs
|
||||
|
||||
path string
|
||||
err error
|
||||
}
|
||||
|
||||
//nolint:ireturn // afero.Fs.Open is defined to return the interface.
|
||||
func (f *openFailFs) Open(name string) (afero.File, error) {
|
||||
if name == f.path {
|
||||
return nil, &os.PathError{Op: "open", Path: name, Err: f.err}
|
||||
}
|
||||
|
||||
return f.OsFs.Open(name)
|
||||
}
|
||||
|
||||
// A file that phase 2 cannot open is reported as failed, not as
|
||||
// unchanged, and neither the summary's data total nor the snapshots row
|
||||
// counts it. See https://git.eeqj.de/sneak/vaultik/issues/280.
|
||||
func TestSnapshotSummaryCountsFileNotStoredAsFailed(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
openErr error
|
||||
skipErrors bool
|
||||
}{
|
||||
// What a normal user gets opening a file with mode 000.
|
||||
{name: "unopenable under skip-errors",
|
||||
openErr: os.ErrPermission, skipErrors: true},
|
||||
// What opening a file removed after phase 1 gives.
|
||||
{name: "removed between the phases",
|
||||
openErr: os.ErrNotExist, skipErrors: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// The scan walks the source path with symlinks resolved, so
|
||||
// failedPath must be spelled the same way to match.
|
||||
tempDir, err := filepath.EvalSymlinks(t.TempDir())
|
||||
require.NoError(t, err)
|
||||
|
||||
srcDir := filepath.Join(tempDir, "src")
|
||||
failedPath := filepath.Join(srcDir, "failed.txt")
|
||||
storedContent := []byte("this file is backed up")
|
||||
storedSize := int64(len(storedContent))
|
||||
|
||||
fs := &openFailFs{path: failedPath, err: tt.openErr}
|
||||
require.NoError(t, fs.MkdirAll(srcDir, 0o755))
|
||||
require.NoError(t, afero.WriteFile(fs,
|
||||
filepath.Join(srcDir, "stored.txt"), storedContent, 0o644))
|
||||
require.NoError(t, afero.WriteFile(fs,
|
||||
failedPath, []byte("this file cannot be opened"), 0o644))
|
||||
|
||||
cfg := faultTestConfig()
|
||||
cfg.IndexPath = filepath.Join(tempDir, "index.sqlite")
|
||||
cfg.Snapshots = map[string]config.SnapshotConfig{
|
||||
"src": {Paths: []string{srcDir}},
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
require.NoError(t, v.CreateSnapshot(&vaultik.SnapshotCreateOptions{
|
||||
SkipErrors: tt.skipErrors,
|
||||
Snapshots: []string{"src"},
|
||||
}))
|
||||
|
||||
summary := out.String()
|
||||
assert.Contains(t, summary,
|
||||
"Files: 2 examined, 1 backed up, 0 unchanged, 1 failed.")
|
||||
assert.Contains(t, summary,
|
||||
fmt.Sprintf("Data: %s total (%s backed up).",
|
||||
v.UI.Size(storedSize), v.UI.Size(storedSize)))
|
||||
|
||||
snap, err := repos.Snapshots.GetByID(ctx,
|
||||
localSnapshotID(ctx, t, repos, "src"))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, snap)
|
||||
|
||||
assert.Equal(t, int64(1), snap.FileCount)
|
||||
assert.Equal(t, storedSize, snap.TotalSize)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -553,8 +553,8 @@ func (v *Vaultik) printSnapshotTable(snapshots []SnapshotInfo) error {
|
||||
|
||||
if snap.LocallyTracked {
|
||||
id = snap.ID.String()
|
||||
uncompressed = formatBytes(snap.UncompressedSize)
|
||||
newChunks = formatBytes(snap.NewChunkSize)
|
||||
uncompressed = ubytes(snap.UncompressedSize)
|
||||
newChunks = ubytes(snap.NewChunkSize)
|
||||
} else {
|
||||
id = formatRemoteOnlyID(snap.RemoteKey)
|
||||
uncompressed = remoteOnlyCell
|
||||
@@ -564,7 +564,7 @@ func (v *Vaultik) printSnapshotTable(snapshots []SnapshotInfo) error {
|
||||
_, err = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n",
|
||||
id,
|
||||
snap.Timestamp.Format("2006-01-02 15:04:05"),
|
||||
formatBytes(snap.CompressedSize),
|
||||
ubytes(snap.CompressedSize),
|
||||
uncompressed,
|
||||
newChunks)
|
||||
if err != nil {
|
||||
|
||||
@@ -122,6 +122,9 @@ func newListEnv(t *testing.T) *listEnv {
|
||||
store := newObservingStorer()
|
||||
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{
|
||||
Config: &config.Config{
|
||||
AgeSecretKey: "",
|
||||
@@ -212,8 +215,8 @@ const (
|
||||
listConfiguredName = "home"
|
||||
listLocalID = "testhost_home_2026-03-01T10:00:00Z"
|
||||
listRemoteID = "otherhost_media_2026-03-02T11:22:33Z"
|
||||
// fiveMegabytes formats as "5.0 MB" through formatBytes.
|
||||
fiveMegabytes = 5 * 1024 * 1024
|
||||
// fiveMegabytes is printed as "5.0 MB" by every command.
|
||||
fiveMegabytes = 5_000_000
|
||||
)
|
||||
|
||||
// TestListSnapshots_RemoteWithoutSecretKey is the regression guard for
|
||||
@@ -306,6 +309,48 @@ func TestListSnapshots_RemoteOnlyRowRendering(t *testing.T) {
|
||||
"are not in the local index")
|
||||
}
|
||||
|
||||
// TestSnapshotListAndPurgePrintSizesLikeRemoteInfo checks that the
|
||||
// `snapshot list` table and the purge listing print a byte count the way
|
||||
// `remote info` does: in powers of 1000, as the MB label says.
|
||||
func TestSnapshotListAndPurgePrintSizesLikeRemoteInfo(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
const wantSize = "5.0 MB" // fiveMegabytes
|
||||
|
||||
env := newListEnv(t)
|
||||
|
||||
// A remote-only snapshot: `snapshot list` and `remote info` both take
|
||||
// its size from the manifest.
|
||||
remoteKey := env.addRemote(t, listRemoteID,
|
||||
time.Date(2026, 3, 2, 11, 22, 33, 0, time.UTC))
|
||||
|
||||
// A locally tracked snapshot of the same size for the purge to list.
|
||||
// The purge prints the snapshot's blob size, the last argument here.
|
||||
start := time.Date(2026, 3, 1, 10, 0, 0, 0, time.UTC)
|
||||
env.addLocal(t, listLocalID, start)
|
||||
env.addRemote(t, listLocalID, start)
|
||||
require.NoError(t, env.v.Repositories.Snapshots.UpdateCounts(
|
||||
context.Background(), nil, listLocalID, 0, 0, 0, 0, fiveMegabytes))
|
||||
|
||||
require.NoError(t, env.v.RemoteInfo(false))
|
||||
assert.Contains(t, findTableRow(t, env.stdout.String(), remoteKey[:16]),
|
||||
wantSize, "remote info")
|
||||
|
||||
env.stdout.Reset()
|
||||
require.NoError(t, env.v.ListSnapshots(false))
|
||||
assert.Contains(t, findTableRow(t, env.stdout.String(),
|
||||
"<remote only:"+remoteKey[:12]+">"), wantSize, "snapshot list")
|
||||
|
||||
// Without --force and with nothing on stdin the purge lists what it
|
||||
// would delete, reads no confirmation, and deletes nothing.
|
||||
env.stdout.Reset()
|
||||
require.NoError(t, env.v.PurgeSnapshotsWithOptions(
|
||||
&vaultik.SnapshotPurgeOptions{OlderThan: "1d"}))
|
||||
assert.Contains(t, findTableRow(t, env.stdout.String(), listLocalID),
|
||||
wantSize, "snapshot purge")
|
||||
}
|
||||
|
||||
// findTableRow returns the single output line containing needle.
|
||||
func findTableRow(t *testing.T, out, needle string) string {
|
||||
t.Helper()
|
||||
@@ -441,12 +486,14 @@ func TestListSnapshots_UnreadableManifestDoesNotHideOthers(t *testing.T) {
|
||||
//
|
||||
//nolint:tagliatelle // snake_case is the established output format
|
||||
type listJSONRow struct {
|
||||
ID string `json:"id"`
|
||||
RemoteKey string `json:"remote_key"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
CompressedSize int64 `json:"compressed_size"`
|
||||
LocallyTracked bool `json:"locally_tracked"`
|
||||
RemotePresent *bool `json:"remote_present"`
|
||||
ID string `json:"id"`
|
||||
RemoteKey string `json:"remote_key"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
CompressedSize int64 `json:"compressed_size"`
|
||||
UncompressedSize int64 `json:"uncompressed_size"`
|
||||
NewChunkSize int64 `json:"new_chunk_size"`
|
||||
LocallyTracked bool `json:"locally_tracked"`
|
||||
RemotePresent *bool `json:"remote_present"`
|
||||
}
|
||||
|
||||
// decodeListJSON parses the command's stdout, which must contain
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package vaultik_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"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/database"
|
||||
"sneak.berlin/go/vaultik/internal/log"
|
||||
"sneak.berlin/go/vaultik/internal/storage"
|
||||
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||
)
|
||||
|
||||
// --skip-errors skips only a file that cannot be opened or read. A local
|
||||
// index error while a directory is recorded stops the backup, and the
|
||||
// snapshot is not recorded as complete. See
|
||||
// https://git.eeqj.de/sneak/vaultik/issues/284.
|
||||
func TestSkipErrorsBackupStopsOnIndexErrorRecordingDirectory(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// The scan walks the source path with symlinks resolved, so dirPath
|
||||
// must be spelled the same way to match the row the scan inserts.
|
||||
tempDir, err := filepath.EvalSymlinks(t.TempDir())
|
||||
require.NoError(t, err)
|
||||
|
||||
srcDir := filepath.Join(tempDir, "src")
|
||||
dirPath := filepath.Join(srcDir, "dir")
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
require.NoError(t, fs.MkdirAll(dirPath, 0o755))
|
||||
require.NoError(t, afero.WriteFile(fs,
|
||||
filepath.Join(dirPath, "file.txt"), []byte("file content"), 0o644))
|
||||
|
||||
cfg := faultTestConfig()
|
||||
cfg.IndexPath = filepath.Join(tempDir, "index.sqlite")
|
||||
cfg.Snapshots = map[string]config.SnapshotConfig{
|
||||
"tree": {Paths: []string{srcDir}},
|
||||
}
|
||||
|
||||
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() })
|
||||
|
||||
// The local index refuses the directory's files row.
|
||||
_, err = db.Conn().ExecContext(ctx, fmt.Sprintf(`
|
||||
CREATE TRIGGER refuse_directory BEFORE INSERT ON files
|
||||
WHEN NEW.path = '%s'
|
||||
BEGIN SELECT RAISE(ABORT, 'simulated index error'); END`, dirPath))
|
||||
require.NoError(t, err)
|
||||
|
||||
repos := database.NewRepositories(db)
|
||||
v := newBackupVaultik(ctx, cfg, store, repos, db, fs)
|
||||
|
||||
err = v.CreateSnapshot(&vaultik.SnapshotCreateOptions{
|
||||
SkipErrors: true,
|
||||
Snapshots: []string{"tree"},
|
||||
})
|
||||
require.ErrorContains(t, err, "simulated index error")
|
||||
|
||||
snapshots, err := repos.Snapshots.ListRecent(ctx, listRecentTestLimit)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, snapshots, 1)
|
||||
assert.Nil(t, snapshots[0].CompletedAt)
|
||||
}
|
||||
@@ -84,7 +84,7 @@ func newSummaryEnv(t *testing.T) *summaryEnv {
|
||||
"split": {Paths: []string{dirOne, dirTwo}},
|
||||
}
|
||||
|
||||
inner, err := storage.NewFileStorer(filepath.Join(tempDir, "remote"))
|
||||
inner, err := storage.NewFileStorer(t.TempDir())
|
||||
require.NoError(t, err)
|
||||
|
||||
store := faultstore.New(inner)
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
)
|
||||
|
||||
// errStorageBindingMismatch is returned when the local index database is
|
||||
// bound to a different destination than the configured storage_url.
|
||||
// bound to a different destination than the configured one.
|
||||
var errStorageBindingMismatch = errors.New(
|
||||
"local index is bound to a different backup destination")
|
||||
|
||||
@@ -24,8 +24,9 @@ var errStorageBindingMismatch = errors.New(
|
||||
//
|
||||
// Behaviour:
|
||||
// - On first use (empty stored value), record the configured
|
||||
// storage_url and log the binding.
|
||||
// - When the stored value matches the configured storage_url, do
|
||||
// destination (storage_url, or the s3.* endpoint, bucket and
|
||||
// prefix) and log the binding.
|
||||
// - When the stored value matches the configured destination, do
|
||||
// nothing and return nil.
|
||||
// - When the two differ, refuse with an error that tells the user
|
||||
// how to recover (revert the config, or run `vaultik database
|
||||
@@ -46,12 +47,10 @@ func (v *Vaultik) EnsureStorageBinding() error {
|
||||
|
||||
configured := v.Config.StorageURL
|
||||
if configured == "" {
|
||||
// Some legacy configs still use the split s3.* keys instead of
|
||||
// storage_url. Falling back to a synthetic URL for those would
|
||||
// hide the fact that the binding is loose. Instead, treat
|
||||
// unset as "nothing to bind against" — the check is
|
||||
// necessarily best-effort for those configs.
|
||||
return nil
|
||||
// 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)
|
||||
@@ -96,7 +95,8 @@ func buildBindingMismatchMessage(stored, configured string) string {
|
||||
"future snapshots referencing blobs that don't exist at the new destination.\n" +
|
||||
"\n" +
|
||||
"To proceed, either:\n" +
|
||||
" - revert storage_url in your config to the bound destination, or\n" +
|
||||
" - revert storage_url (or the s3.* endpoint, bucket and prefix) in your\n" +
|
||||
" config to the bound destination, or\n" +
|
||||
" - run 'vaultik database delete' to discard the local index and rebuild it\n" +
|
||||
" from a fresh full backup against the new destination"
|
||||
}
|
||||
|
||||
@@ -76,3 +76,51 @@ func TestEnsureStorageBinding_MismatchRefuses(t *testing.T) {
|
||||
assert.Contains(t, err.Error(), "file:///mnt/backups/new")
|
||||
assert.Contains(t, err.Error(), "vaultik database delete")
|
||||
}
|
||||
|
||||
// A config with no storage_url names its destination with s3.endpoint,
|
||||
// s3.bucket and s3.prefix; a change to any of them must be refused just
|
||||
// as a changed storage_url is.
|
||||
func TestEnsureStorageBinding_S3FieldsChangeRefuses(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
bound := config.S3Config{
|
||||
Endpoint: "https://s3.example.com",
|
||||
Bucket: "bucket-a",
|
||||
Prefix: "backups",
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
changed config.S3Config
|
||||
}{
|
||||
{"bucket", config.S3Config{
|
||||
Endpoint: bound.Endpoint, Bucket: "bucket-b", Prefix: bound.Prefix,
|
||||
}},
|
||||
{"prefix", config.S3Config{
|
||||
Endpoint: bound.Endpoint, Bucket: bound.Bucket, Prefix: "other",
|
||||
}},
|
||||
{"endpoint", config.S3Config{
|
||||
Endpoint: "https://s3.other.com", Bucket: bound.Bucket, Prefix: bound.Prefix,
|
||||
}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tv, _ := buildBindTestVaultik(t, "")
|
||||
|
||||
tv.Config.S3 = bound
|
||||
require.NoError(t, tv.EnsureStorageBinding(), "first use binds")
|
||||
require.NoError(t, tv.EnsureStorageBinding(), "same destination passes")
|
||||
|
||||
tv.Config.S3 = tt.changed
|
||||
err := tv.EnsureStorageBinding()
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(),
|
||||
"local index is bound to a different backup destination")
|
||||
assert.Contains(t, err.Error(), "vaultik database delete")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
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()
|
||||
firstDir := filepath.Join(tempDir, "first")
|
||||
secondDir := filepath.Join(tempDir, "second")
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
storeDir := t.TempDir()
|
||||
restoreDir := filepath.Join(tempDir, "restored")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package vaultik_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"path/filepath"
|
||||
"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/storage"
|
||||
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||
)
|
||||
|
||||
// TestUncompressedSizeOfChangedFileStaysFixed covers
|
||||
// https://git.eeqj.de/sneak/vaultik/issues/360. A backup that changes a
|
||||
// file leaves the earlier snapshot's UNCOMPRESSED SIZE as it was when that
|
||||
// snapshot's backup completed. The file's row in the local index is shared
|
||||
// by both snapshots and rewritten by the second backup.
|
||||
func TestUncompressedSizeOfChangedFileStaysFixed(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
tempDir := t.TempDir()
|
||||
dataDir := filepath.Join(tempDir, "src")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
changedPath := filepath.Join(dataDir, "changed.bin")
|
||||
|
||||
ctx := context.Background()
|
||||
files := writeFaultSourceTree(t, fs, dataDir)
|
||||
cfg := changedFileConfig(dataDir, dbPath)
|
||||
|
||||
appendRandomBytes(t, fs, files, changedPath, appendedFileSize)
|
||||
|
||||
store, err := storage.NewFileStorer(t.TempDir())
|
||||
require.NoError(t, err)
|
||||
|
||||
db, err := database.New(ctx, dbPath)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
repos := database.NewRepositories(db)
|
||||
v := newBackupVaultik(ctx, cfg, store, repos, db, fs)
|
||||
|
||||
require.NoError(t, backUp(v, "first"))
|
||||
|
||||
// With nothing to compare against, sizeOfNewChunks totals every
|
||||
// distinct chunk of the files.
|
||||
firstID := localSnapshotID(ctx, t, repos, "first")
|
||||
firstSize := sizeOfNewChunks(t, files, nil)
|
||||
require.Equal(t, firstSize, listedUncompressedSizes(t, v)[firstID],
|
||||
"the first snapshot must count the chunks of every file it holds")
|
||||
|
||||
appendRandomBytes(t, fs, files, changedPath, faultChunkSize)
|
||||
require.NoError(t, backUp(v, "second"))
|
||||
|
||||
secondID := localSnapshotID(ctx, t, repos, "second")
|
||||
sizes := listedUncompressedSizes(t, v)
|
||||
assert.Equal(t, firstSize, sizes[firstID],
|
||||
"a later backup must not change the first snapshot's figure")
|
||||
assert.Equal(t, sizeOfNewChunks(t, files, nil), sizes[secondID],
|
||||
"the second snapshot must count the changed file's chunks")
|
||||
}
|
||||
|
||||
// listedUncompressedSizes runs `snapshot list --json` and returns each
|
||||
// listed snapshot's uncompressed_size by snapshot ID.
|
||||
func listedUncompressedSizes(t *testing.T, v *vaultik.Vaultik) map[string]int64 {
|
||||
t.Helper()
|
||||
|
||||
var out bytes.Buffer
|
||||
|
||||
v.Stdout = &out
|
||||
require.NoError(t, v.ListSnapshots(true))
|
||||
|
||||
sizes := make(map[string]int64)
|
||||
for _, row := range decodeListJSON(t, out.String()) {
|
||||
sizes[row.ID] = row.UncompressedSize
|
||||
}
|
||||
|
||||
return sizes
|
||||
}
|
||||
+57
-19
@@ -17,6 +17,7 @@ import (
|
||||
"sneak.berlin/go/vaultik/internal/database"
|
||||
"sneak.berlin/go/vaultik/internal/log"
|
||||
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||
"sneak.berlin/go/vaultik/internal/storage"
|
||||
)
|
||||
|
||||
// Sentinel errors for snapshot verification failures.
|
||||
@@ -32,7 +33,9 @@ var (
|
||||
errManifestExtraBlob = errors.New("manifest contains blob not in database")
|
||||
errManifestMissingBlob = errors.New(
|
||||
"manifest omits blob present in database")
|
||||
errBlobSizeMismatch = errors.New("blob size mismatch")
|
||||
errBlobSizeMismatch = errors.New("blob size mismatch")
|
||||
errBlobsNotInStorage = errors.New(
|
||||
"blobs missing from storage or stored with the wrong size")
|
||||
)
|
||||
|
||||
// verifyStatusFailed is the JSON status value for a failed verification.
|
||||
@@ -57,11 +60,25 @@ type VerifyResult struct {
|
||||
Missing int `json:"missing"`
|
||||
MissingSize int64 `json:"missing_size,omitempty"`
|
||||
Mismatched int `json:"mismatched,omitempty"`
|
||||
// Unchecked lists the blobs shallow verify could not check, because
|
||||
// storage answered with an error other than not-found, so whether
|
||||
// they are present is unknown.
|
||||
Unchecked []UncheckedBlob `json:"unchecked,omitempty"`
|
||||
// DatabaseMissing is set by shallow verify when the snapshot's
|
||||
// encrypted database (metadata/<key>/db.zst.age) is absent, which
|
||||
// makes the snapshot unrestorable regardless of the blobs.
|
||||
DatabaseMissing bool `json:"database_missing,omitempty"`
|
||||
ErrorMessage string `json:"error,omitempty"`
|
||||
DatabaseMissing bool `json:"database_missing,omitempty"`
|
||||
// DatabaseCheckError is set by shallow verify to the error storage
|
||||
// returned when the encrypted database could not be checked.
|
||||
DatabaseCheckError string `json:"database_check_error,omitempty"`
|
||||
ErrorMessage string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// UncheckedBlob is a blob shallow verify could not check, with the error
|
||||
// storage returned for it.
|
||||
type UncheckedBlob struct {
|
||||
Hash string `json:"hash"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// deepVerifyFailure records a failure in the result and returns it appropriately
|
||||
@@ -135,7 +152,6 @@ func (v *Vaultik) RunDeepVerify(snapshotID string, opts *VerifyOptions) error {
|
||||
}
|
||||
|
||||
result.Status = "ok"
|
||||
result.Verified = len(dbBlobs)
|
||||
|
||||
if opts.JSON {
|
||||
return v.outputVerifyJSON(result)
|
||||
@@ -246,6 +262,8 @@ func (v *Vaultik) downloadVerifiedSnapshotDB(
|
||||
fmt.Errorf("failed to download database: %w", err))
|
||||
}
|
||||
|
||||
dbReader = newCloseOnCancelReader(v.ctx, dbReader)
|
||||
|
||||
defer func() { _ = dbReader.Close() }()
|
||||
|
||||
tdb, err := v.decryptAndLoadDatabase(dbReader, identities)
|
||||
@@ -290,7 +308,7 @@ func (v *Vaultik) runVerificationSteps(
|
||||
v.stdoutf("Checking blob existence in remote storage...\n")
|
||||
}
|
||||
|
||||
err = v.verifyBlobExistenceFromDB(dbBlobs)
|
||||
err = v.verifyBlobExistenceFromDB(dbBlobs, result)
|
||||
if err != nil {
|
||||
return v.deepVerifyFailure(result, opts, err.Error(), err)
|
||||
}
|
||||
@@ -301,7 +319,8 @@ func (v *Vaultik) runVerificationSteps(
|
||||
len(dbBlobs), ubytes(totalSize))
|
||||
}
|
||||
|
||||
err = v.performDeepVerificationFromDB(dbBlobs, tdb.db.Conn(), opts, identities)
|
||||
err = v.performDeepVerificationFromDB(
|
||||
dbBlobs, tdb.db.Conn(), opts, result, identities)
|
||||
if err != nil {
|
||||
return v.deepVerifyFailure(result, opts, err.Error(), err)
|
||||
}
|
||||
@@ -653,8 +672,14 @@ func (v *Vaultik) verifyManifestAgainstDatabase(
|
||||
return nil
|
||||
}
|
||||
|
||||
// verifyBlobExistenceFromDB checks that all blobs from database exist in S3
|
||||
func (v *Vaultik) verifyBlobExistenceFromDB(blobs []snapshot.BlobInfo) error {
|
||||
// verifyBlobExistenceFromDB checks that every blob from the database is
|
||||
// in storage with the size the database records. It checks them all,
|
||||
// counting the missing and wrong-size blobs in result, and fails if it
|
||||
// found any. Any storage error other than not found stops the check and
|
||||
// fails with that error.
|
||||
func (v *Vaultik) verifyBlobExistenceFromDB(
|
||||
blobs []snapshot.BlobInfo, result *VerifyResult,
|
||||
) error {
|
||||
log.Info("Verifying blob existence in S3", "blob_count", len(blobs))
|
||||
|
||||
for i, blob := range blobs {
|
||||
@@ -667,17 +692,23 @@ func (v *Vaultik) verifyBlobExistenceFromDB(blobs []snapshot.BlobInfo) error {
|
||||
// Construct blob path
|
||||
blobPath := fmt.Sprintf("blobs/%s/%s/%s", blob.Hash[:2], blob.Hash[2:4], blob.Hash)
|
||||
|
||||
// Check blob exists
|
||||
stat, err := v.Storage.Stat(v.ctx, blobPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("blob %s missing from storage: %w", blob.Hash, err)
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, storage.ErrNotFound):
|
||||
log.Warn("Blob missing from storage", "hash", blob.Hash)
|
||||
|
||||
// Verify size matches
|
||||
if stat.Size != blob.CompressedSize {
|
||||
return fmt.Errorf(
|
||||
"%w: blob %s: S3 has %d bytes, database has %d bytes",
|
||||
errBlobSizeMismatch, blob.Hash, stat.Size, blob.CompressedSize)
|
||||
result.Missing++
|
||||
result.MissingSize += blob.CompressedSize
|
||||
case err != nil:
|
||||
return fmt.Errorf("checking blob %s in storage: %w",
|
||||
blob.Hash, err)
|
||||
case stat.Size != blob.CompressedSize:
|
||||
log.Warn("Blob has the wrong size in storage",
|
||||
"hash", blob.Hash,
|
||||
"storage_size", stat.Size,
|
||||
"database_size", blob.CompressedSize)
|
||||
|
||||
result.Mismatched++
|
||||
}
|
||||
|
||||
// Progress update every 100 blobs
|
||||
@@ -691,16 +722,22 @@ func (v *Vaultik) verifyBlobExistenceFromDB(blobs []snapshot.BlobInfo) error {
|
||||
}
|
||||
}
|
||||
|
||||
if result.Missing > 0 || result.Mismatched > 0 {
|
||||
return fmt.Errorf("%w: %d missing, %d with the wrong size",
|
||||
errBlobsNotInStorage, result.Missing, result.Mismatched)
|
||||
}
|
||||
|
||||
log.Info("✓ All blobs exist in storage")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// performDeepVerificationFromDB downloads and verifies the content of
|
||||
// each blob using the database as source.
|
||||
// each blob using the database as source, stopping at the first blob that
|
||||
// fails. Each blob that passes is counted in result.Verified.
|
||||
func (v *Vaultik) performDeepVerificationFromDB(
|
||||
blobs []snapshot.BlobInfo, db *sql.DB, opts *VerifyOptions,
|
||||
identities []age.Identity,
|
||||
result *VerifyResult, identities []age.Identity,
|
||||
) error {
|
||||
// Calculate total bytes for ETA
|
||||
var totalBytesExpected int64
|
||||
@@ -723,6 +760,7 @@ func (v *Vaultik) performDeepVerificationFromDB(
|
||||
return fmt.Errorf("blob %s verification failed: %w", blobInfo.Hash, err)
|
||||
}
|
||||
|
||||
result.Verified++
|
||||
bytesProcessed += blobInfo.CompressedSize
|
||||
elapsed := time.Since(startTime)
|
||||
remaining := len(blobs) - (i + 1)
|
||||
|
||||
@@ -28,7 +28,7 @@ func TestVerify_QuietSuppressesReport(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
dataDir := filepath.Join(tempDir, "source")
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
storeDir := t.TempDir()
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
chunkSize := int64(32 * 1024)
|
||||
|
||||
Reference in New Issue
Block a user