Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac27768edf |
+5
-22
@@ -54,12 +54,10 @@ The database tracks five primary entities and their relationships:
|
||||
|
||||
#### File (`database.File`)
|
||||
Represents a file, directory, or symlink in the backup system. Stores metadata needed for restoration:
|
||||
- Path, mtime
|
||||
- Path, source_path (for restore path stripping), mtime
|
||||
- Size, mode, ownership (uid, gid)
|
||||
- Symlink target (if applicable)
|
||||
|
||||
It also stores `source_path`, the source directory the scan found it under, made absolute and with symlinks resolved. Restore does not read it.
|
||||
|
||||
#### Chunk (`database.Chunk`)
|
||||
A content-addressed unit of data. Files are split into variable-size chunks using the FastCDC algorithm:
|
||||
- `ChunkHash`: SHA256 hash of chunk content (primary key)
|
||||
@@ -84,11 +82,9 @@ The final storage unit uploaded to S3. Contains many compressed and encrypted ch
|
||||
Blob creation process:
|
||||
1. Chunks are accumulated (up to MaxBlobSize, typically 10GB)
|
||||
2. As each chunk is added, its uncompressed bytes are fed to a running SHA-256
|
||||
3. Concurrently, the same bytes are compressed with zstd, then encrypted with age (recipients configured in config), and written to a temporary file
|
||||
3. Concurrently, the same bytes are compressed with zstd, then encrypted with age (recipients configured in config), and streamed to storage
|
||||
4. On finalize, the blob's name is the double SHA-256 of the uncompressed contents — `hex(SHA256(SHA256(...)))` — not a hash of the compressed, encrypted bytes
|
||||
5. The finished file is uploaded to `blobs/{hash[0:2]}/{hash[2:4]}/{hash}` and then deleted
|
||||
|
||||
A backup needs free temporary space, because each blob is written whole to a temporary file before it is uploaded (up to about `blob_size_limit`; an rclone destination that cannot stream uploads needs about twice that) and the metadata export writes copies of the local index. Temporary files go to `$TMPDIR` (default `/tmp`); with `TMPDIR` unset, SQLite writes one of those copies to `/var/tmp`.
|
||||
5. Uploaded to `blobs/{hash[0:2]}/{hash[2:4]}/{hash}`
|
||||
|
||||
#### BlobChunk (`database.BlobChunk`)
|
||||
Maps chunks to their position within blobs:
|
||||
@@ -311,8 +307,6 @@ Repository interfaces:
|
||||
|
||||
```
|
||||
CreateSnapshot(opts)
|
||||
│
|
||||
├─► markDestination() // Refuse a destination without the marker, then write it
|
||||
│
|
||||
├─► PruneDatabase() // Critical: avoid dedup errors
|
||||
│
|
||||
@@ -341,10 +335,10 @@ CreateSnapshot(opts)
|
||||
│ │
|
||||
│ └─► Accumulate statistics
|
||||
│
|
||||
├─► SnapshotManager.PopulateSnapshotBlobs() // record referenced blobs
|
||||
│
|
||||
├─► SnapshotManager.UpdateSnapshotStatsExtended()
|
||||
│
|
||||
├─► SnapshotManager.PopulateSnapshotBlobs() // record referenced blobs
|
||||
│
|
||||
├─► SnapshotManager.ExportSnapshotMetadata()
|
||||
│ │
|
||||
│ ├─► Copy database to temp file
|
||||
@@ -369,8 +363,6 @@ CreateSnapshot(opts)
|
||||
|
||||
```
|
||||
bucket/
|
||||
├── vaultik-destination.txt # Marker written by every backup
|
||||
│
|
||||
├── blobs/
|
||||
│ └── {hash[0:2]}/
|
||||
│ └── {hash[2:4]}/
|
||||
@@ -382,15 +374,6 @@ 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,10 +24,6 @@ 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
|
||||
@@ -290,13 +286,7 @@ 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`).
|
||||
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))
|
||||
this duration instead of only the latest (e.g. `4w`, `30d`, `6mo`, `1y`)
|
||||
|
||||
**`snapshot list`**: Show every snapshot known to this host — the union
|
||||
of the local index and the backup destination store — with timestamps
|
||||
@@ -325,10 +315,7 @@ 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. 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.
|
||||
local index alone, and still exits zero.
|
||||
* `--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
|
||||
@@ -362,22 +349,16 @@ 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. 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`.
|
||||
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; rerun `vaultik prune`
|
||||
once the store is reachable to finish remote cleanup. 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
|
||||
* `--json`: Output result as JSON. Also skips the confirmation prompt, as
|
||||
`--force` does.
|
||||
* `--json`: Output result as JSON
|
||||
|
||||
**`snapshot restore`**: Restore files from a backup snapshot.
|
||||
* Requires `VAULTIK_AGE_SECRET_KEY` environment variable
|
||||
@@ -399,43 +380,29 @@ 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. 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.
|
||||
manual entry point for the same work.
|
||||
* `--force`: Skip confirmation prompt
|
||||
* `--json`: Output stats as JSON. Also skips the confirmation prompt, as
|
||||
`--force` does.
|
||||
* `--json`: Output stats as JSON
|
||||
|
||||
**`info`**: Display system configuration, storage settings, encryption
|
||||
recipients, and local database statistics.
|
||||
|
||||
**`remote info`**: Show storage backend type and location plus detailed
|
||||
remote storage inventory: per-snapshot metadata sizes, blob counts, and
|
||||
orphaned blob detection. A name under `metadata/` that is not a remote
|
||||
key is skipped with a warning and is not printed. If a listed
|
||||
`manifest.json.zst` cannot be read, or sits under a skipped name, the
|
||||
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`. A file
|
||||
under `blobs/` whose name is not a blob hash is not counted as a blob,
|
||||
since `prune` never deletes it.
|
||||
orphaned blob detection.
|
||||
* `--json`: Output as JSON
|
||||
|
||||
**`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.
|
||||
**`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.
|
||||
* `--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`, or `s3.endpoint`, `s3.bucket` or
|
||||
`s3.prefix`, to a different destination.
|
||||
Use this after changing `storage_url` to a different destination.
|
||||
* `--force`: Skip confirmation prompt
|
||||
|
||||
---
|
||||
@@ -449,29 +416,11 @@ 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. 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.
|
||||
a local or mounted filesystem. Useful for testing or backing up to a NAS.
|
||||
|
||||
**Rclone** (`rclone://remote/path`): Uses rclone's 70+ supported cloud
|
||||
providers. Requires rclone to be configured separately (`rclone config`).
|
||||
|
||||
An upload cut off part-way leaves nothing under the object's name on S3, which
|
||||
shows an object only once its upload has completed, and on the local filesystem
|
||||
backend, which writes a temporary file and renames it into place. Rclone
|
||||
remotes with a server-side move (local and sftp among them) are written under a
|
||||
temporary name ending in `.partial` and moved into place. Rclone remotes without
|
||||
one are written in place: where such a remote shows a file while it is still
|
||||
being written, a killed upload can leave a truncated object under its name,
|
||||
which a later backup takes for complete. A leftover `.partial` file is ignored
|
||||
and can be deleted.
|
||||
|
||||
Legacy S3 configuration via `s3.*` fields (endpoint, bucket, prefix, etc.) is
|
||||
still supported for backward compatibility. `storage_url` takes precedence if
|
||||
both are set.
|
||||
@@ -488,7 +437,6 @@ 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/
|
||||
@@ -497,15 +445,6 @@ 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
|
||||
@@ -600,7 +539,7 @@ complete annotated example also lives in
|
||||
| `s3.*` | | Legacy S3 configuration (endpoint, bucket, credentials) |
|
||||
| `exclude` | | Global exclude patterns (applied to all snapshots) |
|
||||
| `chunk_size` | `10MB` | Average chunk size for content-defined chunking |
|
||||
| `blob_size_limit` | `10GB` | Maximum blob size before splitting. Must be at least four times `chunk_size` (the largest chunk the chunker can emit), otherwise a single-chunk blob could exceed the limit. A backup needs free temporary space, because each blob is written whole to a temporary file before it is uploaded (up to about `blob_size_limit`; an rclone destination that cannot stream uploads needs about twice that) and the metadata export writes copies of the local index. Temporary files go to `$TMPDIR` (default `/tmp`); with `TMPDIR` unset, SQLite writes one of those copies to `/var/tmp` |
|
||||
| `blob_size_limit` | `10GB` | Maximum blob size before splitting. Must be at least four times `chunk_size` (the largest chunk the chunker can emit), otherwise a single-chunk blob could exceed the limit |
|
||||
| `compression_level` | `3` | zstd compression level (1-19) |
|
||||
| `hostname` | system hostname | Hostname used in snapshot IDs |
|
||||
| `index_path` | platform data dir | Local SQLite index path |
|
||||
@@ -859,8 +798,7 @@ them. We provide:
|
||||
in the `Dockerfile` together.
|
||||
* `script/release` — cross-compile and publish the release artifacts
|
||||
with the pinned `goreleaser`. Refuses a `goreleaser` on `PATH` whose
|
||||
version is not the pinned one, because a different version would build
|
||||
a different release from the same tag.
|
||||
version is not the pinned one, on the same reasoning as `script/lint`.
|
||||
* `script/release-snapshot` — the same build with no publishing and no
|
||||
tagging, into `./dist`
|
||||
* `script/test` — run the test suite by building the `test` phase of
|
||||
@@ -970,14 +908,14 @@ It is passed to `goreleaser` as `GITEA_TOKEN`. The runner's automatic
|
||||
token is deliberately not used: it is not guaranteed to carry release
|
||||
write access.
|
||||
|
||||
The Go toolchain that compiles the released binaries is installed by
|
||||
`script/install-go`, which downloads the version named by `go.mod`
|
||||
(currently `1.26.1`, the same version the `Dockerfile` builder stage
|
||||
pins by digest) and refuses the archive unless its sha256 matches the
|
||||
value committed in the script. `goreleaser` shells out to `go` for every
|
||||
The Go toolchain that compiles the released binaries comes from an
|
||||
`actions/setup-go` step pinned by commit sha, reading its version from
|
||||
`go.mod` (currently `1.26.1`, the same version the `Dockerfile` builder
|
||||
stage pins by digest). `goreleaser` shells out to `go` for every
|
||||
cross-compile, so without that step the release would either fail
|
||||
outright or ship binaries built by whatever unpinned toolchain the
|
||||
runner happened to carry.
|
||||
runner happened to carry — the one unpinned thing in an otherwise
|
||||
hash-pinned release path.
|
||||
|
||||
To rehearse the whole build without publishing or tagging anything:
|
||||
|
||||
|
||||
@@ -22,265 +22,6 @@ 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
|
||||
orphan figures were already unknown in that case, but the snapshot's
|
||||
row still gave 0 blobs and 0 B, in the table and in `--json`. The row
|
||||
now reads `unknown` and `--json` gives `null`. A directory with no
|
||||
manifest still shows 0, since the orphan figures count its blobs as
|
||||
orphaned.
|
||||
|
||||
- 2026-10-08: Kept the progress line of a snapshot with more than one
|
||||
path within 100%
|
||||
([issue #271](https://git.eeqj.de/sneak/vaultik/issues/271)). The
|
||||
bytes and files processed counted every path, but the totals they
|
||||
were divided by held only the current path's, so a second path
|
||||
smaller than the first showed more than 100% and an ETA of `unknown`.
|
||||
The totals now add up over the paths scanned so far. The rate behind
|
||||
the ETA restarts when each path's scan phase ends, so that phase does
|
||||
not lower the rate while the path is processed; until then the rate
|
||||
is the previous path's.
|
||||
|
||||
- 2026-10-08: Made a second `snapshot create` of one name succeed when
|
||||
it starts in the same second as the first
|
||||
([issue #270](https://git.eeqj.de/sneak/vaultik/issues/270)). The
|
||||
timestamp in a snapshot ID is in whole seconds, so the second run got
|
||||
the first run's ID and failed with `UNIQUE constraint failed:
|
||||
snapshots.id`. When the local index already has a snapshot with the
|
||||
ID, the create now waits a second and takes a new timestamp.
|
||||
|
||||
- 2026-10-07: Documented that `--json` skips the confirmation prompt of
|
||||
`snapshot remove` and `prune`
|
||||
([issue #268](https://git.eeqj.de/sneak/vaultik/issues/268)). Both
|
||||
commands delete without asking under `--json`, since a prompt on stdout
|
||||
would break the JSON document, but the help and the README described
|
||||
only `--force` as skipping it. The `--json` help of both commands and
|
||||
their README entries now say so.
|
||||
|
||||
- 2026-10-07: Made a symlink whose target cannot be read stop the backup
|
||||
([issue #269](https://git.eeqj.de/sneak/vaultik/issues/269)). It was
|
||||
left out of the snapshot with only a debug log line, even without
|
||||
`--skip-errors`. It now aborts the run, or with `--skip-errors` is
|
||||
skipped with the `Failed to access` error line that any other entry
|
||||
the scan cannot read gets.
|
||||
|
||||
- 2026-10-07: Stopped a killed rclone upload from leaving a truncated
|
||||
object under its key
|
||||
([issue #266](https://git.eeqj.de/sneak/vaultik/issues/266)). The
|
||||
rclone backend wrote each object straight to its key, so killing an
|
||||
upload to a local or sftp remote left a truncated object there; the
|
||||
next backup found the key with `Stat`, skipped the upload and recorded
|
||||
a snapshot that could not be restored. On a remote with a server-side
|
||||
move, an object is now written under a temporary name ending in
|
||||
`.partial` and moved into place, and listings skip such names. Remotes
|
||||
without one are still written in place.
|
||||
|
||||
- 2026-10-07: Made an interrupted command exit 130 and say so
|
||||
([issue #267](https://git.eeqj.de/sneak/vaultik/issues/267)). Ctrl-C
|
||||
or SIGTERM during `snapshot create`, `snapshot restore` or `snapshot
|
||||
verify` exited 0 with no error line (`snapshot verify --json` exited
|
||||
1, also without one), so a `--cron` run that never finished looked
|
||||
like a success. A command stopped by either signal now exits 130 and
|
||||
prints `interrupted before the command finished` on stderr, under
|
||||
`--cron` and `--json` too.
|
||||
|
||||
- 2026-10-07: Corrected documentation, help text and comments that were
|
||||
false about the code
|
||||
([issue #233](https://git.eeqj.de/sneak/vaultik/issues/233)). A blob
|
||||
is not streamed to storage. The README, `ARCHITECTURE.md` and
|
||||
`config.example.yml` now say a backup needs free temporary space,
|
||||
because each blob is written whole to a temporary file before it is
|
||||
uploaded (up to about `blob_size_limit`; an rclone destination that
|
||||
cannot stream uploads needs about twice that) and the metadata export
|
||||
writes copies of the local index. Temporary files go to `$TMPDIR`
|
||||
(default `/tmp`); with `TMPDIR` unset, SQLite writes one of those
|
||||
copies to `/var/tmp`. Also corrected: the snapshot ID format, what restore
|
||||
reads and how incomplete snapshots are removed in `docs/DATAMODEL.md`,
|
||||
what `source_path` holds, the `index_path` and config file defaults,
|
||||
what `snapshot remove` cleans up, how the release gets its Go
|
||||
toolchain, and the `script/release` and `script/fmt-check` comments.
|
||||
|
||||
- 2026-10-07: Cut the time the `internal/vaultik` and `internal/database`
|
||||
tests take ([issue #235](https://git.eeqj.de/sneak/vaultik/issues/235)).
|
||||
Most of the `internal/vaultik` time went to 24 tests that ran one at a
|
||||
time only because they call `log.Initialize`; they now call it before
|
||||
`t.Parallel()`, as the package's other tests do. `TestLargeDatasets`
|
||||
committed each of its 1,500 inserts on its own and now makes them in
|
||||
one transaction. `TestDedupOnlySnapshotRestores` gives its second
|
||||
backup its own snapshot name instead of sleeping past the one-second
|
||||
timestamp in the snapshot ID.
|
||||
|
||||
- 2026-10-07: Made two messages say only what is true
|
||||
([issue #240](https://git.eeqj.de/sneak/vaultik/issues/240)). A config
|
||||
file that others can read was warned about as containing S3
|
||||
credentials even when it set none, as a `file://` config does. The
|
||||
warning now says the file may contain S3 credentials only when
|
||||
`s3.access_key_id` or `s3.secret_access_key` is set, since either may
|
||||
come from a `${...}` reference rather than the file, and otherwise
|
||||
says the file is readable by others. `snapshot purge` against a
|
||||
destination store it could not list gave an error with
|
||||
`listing remote snapshots:` in it twice; the prefix now appears once.
|
||||
|
||||
- 2026-10-07: Made `s3.part_size` set the multipart upload part size
|
||||
([issue #232](https://git.eeqj.de/sneak/vaultik/issues/232)). It was
|
||||
loaded and defaulted but never passed to the S3 client, whose uploader
|
||||
used a fixed 10MiB part. It now reaches the uploader for `storage_url`
|
||||
and for the `s3.*` fields, and a part size S3 refuses, below 5MiB or
|
||||
above 5GiB, `0` included, fails at config load. A blob too large for
|
||||
S3's limit of 10,000 parts at the configured size is uploaded in larger
|
||||
parts. The docs gave the default as `5MB`, which the config file reads
|
||||
as 5,000,000 bytes, below the minimum; they now say `5MiB`.
|
||||
|
||||
- 2026-10-07: Made per-name retention work when the hostname contains `_`
|
||||
([issue #230](https://git.eeqj.de/sneak/vaultik/issues/230)). A
|
||||
snapshot ID is `hostname_name_timestamp`, and the name was read as
|
||||
everything between the first and the last `_`, so with
|
||||
`hostname: my_host` the name `home` came out as `host_home`.
|
||||
`snapshot purge --keep-latest --snapshot home` then printed "No
|
||||
snapshots to delete", and `snapshot create --prune` purged nothing
|
||||
without a message. The name is now read using the hostname the
|
||||
`snapshots` table stores with each snapshot, cut at its first `.` as it
|
||||
is in the ID.
|
||||
|
||||
- 2026-10-07: Made `remote info` stop reporting a snapshot's blobs as
|
||||
orphaned when its manifest cannot be read, and stop printing raw
|
||||
names from under `metadata/`
|
||||
([issue #228](https://git.eeqj.de/sneak/vaultik/issues/228)). A
|
||||
manifest it failed to read was skipped, so that snapshot's blobs were
|
||||
counted as orphaned and the report advised running `vaultik prune`.
|
||||
The orphan figures are now unknown in that case, with no prune
|
||||
advice, and `--json` gives them as `null` with the unreadable remote
|
||||
keys in `unreadable_manifests`. A name under `metadata/` that is not
|
||||
64 lowercase hex characters is now skipped with a warning instead of
|
||||
being printed, control characters included. A manifest under a
|
||||
skipped name is then not read either, so it also leaves the orphan
|
||||
figures unknown, and `--json` counts such manifests in
|
||||
`skipped_manifest_count`. A directory with no manifest in it, as left
|
||||
by an interrupted backup, leaves the figures known.
|
||||
|
||||
- 2026-10-07: Made `config set` keep a string that looks like a number
|
||||
([issue #229](https://git.eeqj.de/sneak/vaultik/issues/229)). It wrote
|
||||
every value unquoted, and `config.Load` reads the file through untyped
|
||||
YAML, so an access key `00112233` loaded as `38043` and a hostname `007`
|
||||
as `7`. A value for a string setting in `config.Config` is now tagged as
|
||||
a YAML string, which the file quotes wherever YAML would read a number or
|
||||
a boolean; other settings are still written unquoted.
|
||||
|
||||
- 2026-10-07: Made a backup notice a file rewritten with its size
|
||||
unchanged and a new mtime in the same second as the one in the index
|
||||
([issue #226](https://git.eeqj.de/sneak/vaultik/issues/226)). The
|
||||
`files` table held mtime in whole seconds and the scanner compared
|
||||
whole seconds, so every later snapshot kept the old content. A new
|
||||
`mtime_nsec` column holds the nanoseconds within the second that
|
||||
`mtime` holds, and the scanner compares the full mtime. A local index
|
||||
created before the change lacks the column and is rebuilt with
|
||||
`vaultik database delete` and a full backup.
|
||||
|
||||
- 2026-10-07: Made taking the process-wide lock atomic
|
||||
([issue #227](https://git.eeqj.de/sneak/vaultik/issues/227)). The lock
|
||||
read `vaultik.pid`, checked whether that PID was alive and then wrote
|
||||
its own, so two writers started together could both pass the check and
|
||||
both run. It is now an `flock` on `vaultik.pid`, held until the run
|
||||
ends; the kernel drops it when the process exits, so a crash leaves no
|
||||
lock behind. A clean exit now empties the file instead of deleting it,
|
||||
because deleting it would let two later runs each lock a different
|
||||
file.
|
||||
|
||||
- 2026-10-06: Made `snapshot remove --json` write only its document to
|
||||
stdout when the destination store cannot be reached
|
||||
([issue #251](https://git.eeqj.de/sneak/vaultik/issues/251)). Its
|
||||
warning that the snapshot's metadata was left on the destination store
|
||||
went to stdout ahead of the document, breaking `| jq` on a command that
|
||||
exited 0. Under `--json` the warning now reaches stderr only, through
|
||||
the logger. The warning, the README and the command's help said
|
||||
`vaultik prune` would finish the cleanup, but `prune` never removes
|
||||
snapshot metadata; they now say to run `vaultik snapshot remove` for the
|
||||
snapshot again once the destination store is reachable.
|
||||
|
||||
- 2026-10-06: Made the backup summary and the `snapshots` row count each
|
||||
file, byte and upload once
|
||||
([issue #225](https://git.eeqj.de/sneak/vaultik/issues/225)). The
|
||||
scanner added a file's bytes again for each new chunk and counted a
|
||||
file as unchanged for each chunk already stored, so a first backup
|
||||
reported twice its size and "backed up" could go negative. Upload
|
||||
figures came from the progress reporter, which `--cron` turns off, and
|
||||
`blob_count` counted earlier paths' blobs again for each later path.
|
||||
The scanner now counts uploads itself; `blob_size`,
|
||||
`blob_uncompressed_size` and `compression_ratio` describe the blobs
|
||||
the snapshot references, and `docs/DATAMODEL.md` now says
|
||||
`chunk_count` and `blob_count` count what the run added.
|
||||
|
||||
- 2026-10-06: Made command output follow the README's stdout and stderr
|
||||
rules ([issue #224](https://git.eeqj.de/sneak/vaultik/issues/224)). The
|
||||
startup banner went to stdout, so a `completion` script or a
|
||||
@@ -290,14 +31,6 @@ the tag exists and is exercised; what is left is merging `next` to
|
||||
`snapshot verify --quiet` printed its whole report; it now prints
|
||||
none, and a failure still reaches stderr with the same exit status.
|
||||
|
||||
- 2026-10-06: Made a backup without `--cron` of a snapshot with two or
|
||||
more `paths` complete instead of panicking with `close of closed
|
||||
channel` ([issue #253](https://git.eeqj.de/sneak/vaultik/issues/253)).
|
||||
`Scan` runs once per path and started and stopped the progress
|
||||
reporter each time, and a second stop panics. The reporter is now
|
||||
started and stopped once per snapshot, around the scans of all its
|
||||
paths.
|
||||
|
||||
- 2026-10-06: Made a restore path argument select only that path and
|
||||
what is beneath it
|
||||
([issue #223](https://git.eeqj.de/sneak/vaultik/issues/223)). The
|
||||
|
||||
+6
-15
@@ -287,18 +287,15 @@ storage_url: "rclone://myremote/path/to/backups"
|
||||
# #use_ssl: true
|
||||
#
|
||||
# # Part size for multipart uploads
|
||||
# # Minimum 5MiB, maximum 5GiB; affects memory usage during upload
|
||||
# # A blob too large for 10,000 parts of this size gets larger parts
|
||||
# # Supports: 10MB, 16MiB, 100MiB, etc. (5MB is below the minimum)
|
||||
# # Default: 5MiB
|
||||
# #part_size: 5MiB
|
||||
# # Minimum 5MB, affects memory usage during upload
|
||||
# # Supports: 5MB, 10M, 100MiB, etc.
|
||||
# # Default: 5MB
|
||||
# #part_size: 5MB
|
||||
|
||||
# Path to local SQLite index database
|
||||
# This database tracks file state for incremental backups
|
||||
# Default: the platform data directory, e.g.
|
||||
# macOS: ~/Library/Application Support/vaultik/index.sqlite
|
||||
# Linux: ~/.local/share/vaultik/index.sqlite
|
||||
#index_path: /path/to/index.sqlite
|
||||
# Default: /var/lib/vaultik/index.sqlite
|
||||
#index_path: /var/lib/vaultik/index.sqlite
|
||||
|
||||
# Average chunk size for content-defined chunking
|
||||
# Smaller chunks = better deduplication but more metadata
|
||||
@@ -313,12 +310,6 @@ storage_url: "rclone://myremote/path/to/backups"
|
||||
# Chunking uses no secret (the FastCDC parameters are fixed and public). At a
|
||||
# large limit a blob holds hundreds of chunks, so individual chunk lengths are
|
||||
# not visible in its size; lowering the limit toward chunk_size exposes them.
|
||||
# A backup needs free temporary space, because each blob is written whole to
|
||||
# a temporary file before it is uploaded (up to about blob_size_limit; an
|
||||
# rclone destination that cannot stream uploads needs about twice that) and
|
||||
# the metadata export writes copies of the local index. Temporary files go to
|
||||
# $TMPDIR (default /tmp); with TMPDIR unset, SQLite writes one of those copies
|
||||
# to /var/tmp.
|
||||
# Supports: 1GB, 10G, 500MB, 1GiB, etc.
|
||||
# Default: 10GB
|
||||
#blob_size_limit: 10GB
|
||||
|
||||
+10
-10
@@ -36,8 +36,7 @@ Stores metadata about files in the filesystem being backed up.
|
||||
**Columns:**
|
||||
- `id` (TEXT PRIMARY KEY) - UUID for the file record
|
||||
- `path` (TEXT NOT NULL UNIQUE) - Absolute file path
|
||||
- `mtime` (INTEGER NOT NULL) - Modification time, whole seconds since the Unix epoch
|
||||
- `mtime_nsec` (INTEGER NOT NULL) - Nanoseconds within that second, 0 to 999999999
|
||||
- `mtime` (INTEGER NOT NULL) - Modification time as Unix timestamp
|
||||
- `size` (INTEGER NOT NULL) - File size in bytes
|
||||
- `mode` (INTEGER NOT NULL) - Unix file permissions and type
|
||||
- `uid` (INTEGER NOT NULL) - User ID of file owner
|
||||
@@ -111,17 +110,17 @@ Maps chunks to the blobs that contain them.
|
||||
Tracks backup snapshots.
|
||||
|
||||
**Columns:**
|
||||
- `id` (TEXT PRIMARY KEY) - Snapshot ID (format: `hostname_name_timestamp`, e.g. `server1_home_2025-06-01T12:00:00Z`: the hostname up to its first `.`, the snapshot name, and an RFC 3339 UTC timestamp)
|
||||
- `id` (TEXT PRIMARY KEY) - Snapshot ID (format: hostname-YYYYMMDD-HHMMSSZ)
|
||||
- `hostname` (TEXT) - Hostname where backup was created
|
||||
- `vaultik_version` (TEXT) - Version of Vaultik used
|
||||
- `vaultik_git_revision` (TEXT) - Git revision of Vaultik used
|
||||
- `started_at` (INTEGER) - Start timestamp
|
||||
- `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
|
||||
- `blob_count` (INTEGER) - Number of blobs this snapshot created
|
||||
- `chunk_count` (INTEGER) - Number of unique chunks
|
||||
- `blob_count` (INTEGER) - Number of blobs referenced
|
||||
- `total_size` (INTEGER) - Total size of all files
|
||||
- `blob_size` (INTEGER) - Total compressed size of all referenced blobs
|
||||
- `blob_size` (INTEGER) - Total size of all blobs (compressed)
|
||||
- `blob_uncompressed_size` (INTEGER) - Total uncompressed size of all referenced blobs
|
||||
- `compression_ratio` (REAL) - Compression ratio achieved
|
||||
- `compression_level` (INTEGER) - Compression level used for this snapshot
|
||||
@@ -218,8 +217,8 @@ The `{remote-key}` directory name is a one-way hash of the human snapshot ID, so
|
||||
### 4. Restore Process
|
||||
|
||||
The restore process doesn't use the local database. Instead:
|
||||
1. Downloads and decrypts the snapshot's metadata database (`db.zst.age`) from S3
|
||||
2. Downloads the blobs holding the chunks of the files being restored, found through that database's `blob_chunks` table; the manifest is not read
|
||||
1. Downloads snapshot metadata from S3
|
||||
2. Downloads required blobs based on manifest
|
||||
3. Reconstructs files from decrypted and decompressed chunks
|
||||
|
||||
### 5. Pruning
|
||||
@@ -232,8 +231,9 @@ The restore process doesn't use the local database. Instead:
|
||||
|
||||
Before each backup:
|
||||
1. Query incomplete snapshots (where `completed_at IS NULL`)
|
||||
2. Delete each one and all its associations, without checking S3 for its metadata
|
||||
3. Clean up orphaned files, chunks, and blobs
|
||||
2. Check if metadata exists in S3
|
||||
3. If no metadata, delete snapshot and all associations
|
||||
4. Clean up orphaned files, chunks, and blobs
|
||||
|
||||
## Repository Pattern
|
||||
|
||||
|
||||
@@ -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, plus a marker file:
|
||||
Vaultik stores all backup data in an S3-compatible object store. The repository consists of two main components:
|
||||
1. **Blobs** - The actual backup data (content-addressed, encrypted)
|
||||
2. **Metadata** - Snapshot information and manifests (partially encrypted)
|
||||
|
||||
@@ -12,7 +12,6 @@ Vaultik stores all backup data in an S3-compatible object store. The repository
|
||||
|
||||
```
|
||||
<bucket>/<prefix>/
|
||||
├── vaultik-destination.txt
|
||||
├── blobs/
|
||||
│ └── <hash[0:2]>/
|
||||
│ └── <hash[2:4]>/
|
||||
@@ -25,10 +24,6 @@ Vaultik stores all backup data in an S3-compatible object store. The repository
|
||||
|
||||
The metadata subdirectory is named with the **remote key**, a one-way hash of the snapshot ID, not with the human-readable snapshot ID itself. See [Remote Key Derivation](#remote-key-derivation).
|
||||
|
||||
## 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,17 +245,6 @@ 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
|
||||
|
||||
+14
-39
@@ -206,10 +206,6 @@ func RunApp(ctx context.Context, app *fx.App) error {
|
||||
// RunOperation through cobra to Entry.
|
||||
var errReported = errors.New("operation failed")
|
||||
|
||||
// errInterrupted marks an operation that SIGINT or SIGTERM stopped
|
||||
// before it finished. Entry shows it and returns exitCodeInterrupted.
|
||||
var errInterrupted = errors.New("interrupted before the command finished")
|
||||
|
||||
// RunOperation runs op against the Vaultik instance inside the fx app
|
||||
// and turns a failure into a returned error rather than an os.Exit from
|
||||
// within the goroutine. An os.Exit there skipped main's deferred
|
||||
@@ -224,21 +220,17 @@ var errInterrupted = errors.New("interrupted before the command finished")
|
||||
// interrupt OnStop cancels op and waits for the goroutine to return, so
|
||||
// op's cleanup (removing decrypted scratch files) runs before the
|
||||
// process exits; the wait is bounded by shutdownTimeout. report is
|
||||
// called with a failure so the caller can show it to the user before
|
||||
// it becomes errReported.
|
||||
//
|
||||
// The run counts as interrupted unless op returned, without an
|
||||
// interrupt having cancelled it, before RunWithApp returned. An
|
||||
// interrupted op is not reported, whatever it returned; RunOperation
|
||||
// returns errInterrupted instead.
|
||||
// called with a non-canceled failure so the caller can show it to the
|
||||
// user before it becomes errReported. A context cancellation is the
|
||||
// interrupt path, not a failure: it is neither reported nor counted as
|
||||
// one.
|
||||
func RunOperation(
|
||||
ctx context.Context, opts AppOptions,
|
||||
op func(v *vaultik.Vaultik) error, report func(err error),
|
||||
) error {
|
||||
var (
|
||||
mu sync.Mutex
|
||||
finished bool // op returned before any interrupt cancelled it
|
||||
failed bool // op finished with an error
|
||||
mu sync.Mutex
|
||||
failed bool
|
||||
)
|
||||
|
||||
opts.Invokes = append(opts.Invokes,
|
||||
@@ -249,21 +241,11 @@ func RunOperation(
|
||||
OnStart: func(_ context.Context) error {
|
||||
stop = v.StartOperation(func() {
|
||||
err := op(v)
|
||||
|
||||
// Only stop, called from OnStop below, cancels the
|
||||
// Vaultik context, so a live context means no
|
||||
// interrupt cancelled op. Check the context, not
|
||||
// err: an interrupted op need not return
|
||||
// context.Canceled (`snapshot verify --json`
|
||||
// returns a verification failure).
|
||||
if v.Context().Err() == nil {
|
||||
if err != nil {
|
||||
report(err)
|
||||
}
|
||||
if err != nil && !errors.Is(err, context.Canceled) {
|
||||
report(err)
|
||||
|
||||
mu.Lock()
|
||||
finished = true
|
||||
failed = err != nil
|
||||
failed = true
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
@@ -296,23 +278,16 @@ func RunOperation(
|
||||
return err
|
||||
}
|
||||
|
||||
// RunWithApp returns only after the app was asked to stop, either by
|
||||
// an interrupt or by the goroutine's Shutdown call. When op finished
|
||||
// without being cancelled, the goroutine set finished before that
|
||||
// call. So if finished is unset here, an interrupt stopped the app,
|
||||
// and op either returned after it was cancelled or is still running
|
||||
// because the shutdown timed out.
|
||||
// The goroutine sets failed before triggering the shutdown that lets
|
||||
// RunWithApp return, so the write is in place by the time we read it.
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
switch {
|
||||
case !finished:
|
||||
return errInterrupted
|
||||
case failed:
|
||||
if failed {
|
||||
return errReported
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// runVaultikApp runs the standard single-operation command lifecycle
|
||||
|
||||
+1
-56
@@ -7,14 +7,11 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"gopkg.in/yaml.v3"
|
||||
"sneak.berlin/go/vaultik/internal/config"
|
||||
"sneak.berlin/go/vaultik/internal/ui"
|
||||
)
|
||||
|
||||
@@ -34,9 +31,6 @@ const configDirMode = 0o755
|
||||
// yaml.Marshal's 4-space default.
|
||||
const configYAMLIndent = 2
|
||||
|
||||
// yamlStringTag is YAML's tag for a string scalar.
|
||||
const yamlStringTag = "!!str"
|
||||
|
||||
var (
|
||||
errConfigExists = errors.New("config file already exists")
|
||||
errEmptyConfig = errors.New("empty config file")
|
||||
@@ -205,7 +199,7 @@ storage_url: ""
|
||||
# access_key_id: YOUR_ACCESS_KEY
|
||||
# secret_access_key: YOUR_SECRET_KEY
|
||||
# # region: us-east-1 # Default: us-east-1
|
||||
# # part_size: 5MiB # Upload part size, 5MiB to 5GiB. Default: 5MiB
|
||||
# # part_size: 5MB # Multipart upload part size. Default: 5MB
|
||||
# # For the s3:// form, disable TLS with ?ssl=false in the URL, not use_ssl.
|
||||
|
||||
# ─── OPTIONAL ────────────────────────────────────────────────────────────────
|
||||
@@ -589,58 +583,9 @@ func yamlPathSet(root *yaml.Node, keys []string, value string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// config.Load reads the file through untyped YAML, which turns an
|
||||
// unquoted 00112233 into the number 38043 and 1e5 into 100000. Tagging
|
||||
// a string setting as a string makes the encoder quote such a value.
|
||||
// Other settings stay unquoted, so compression_level 9 is a number.
|
||||
// The encoder refuses to write a value that is not valid UTF-8 as a
|
||||
// string. Left untagged, such a value is written as base64 !!binary and
|
||||
// loads back unchanged.
|
||||
if configKeyIsString(keys) && utf8.ValidString(value) {
|
||||
node.Tag = yamlStringTag
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// configKeyIsString reports whether the dotted key names a string in
|
||||
// config.Config, following the fields' yaml tags, as s3.access_key_id and
|
||||
// snapshots.home.exclude.0 do.
|
||||
func configKeyIsString(keys []string) bool {
|
||||
typ := reflect.TypeFor[config.Config]()
|
||||
|
||||
for _, key := range keys {
|
||||
switch {
|
||||
case typ.Kind() == reflect.Map || typ.Kind() == reflect.Slice:
|
||||
// The key is a snapshot name or a list index.
|
||||
typ = typ.Elem()
|
||||
case typ.Kind() == reflect.Struct:
|
||||
field, ok := yamlField(typ, key)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
typ = field.Type
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return typ.Kind() == reflect.String
|
||||
}
|
||||
|
||||
// yamlField returns the field of struct type typ whose yaml tag names key.
|
||||
func yamlField(typ reflect.Type, key string) (reflect.StructField, bool) {
|
||||
for field := range typ.Fields() {
|
||||
name, _, _ := strings.Cut(field.Tag.Get("yaml"), ",")
|
||||
if name == key {
|
||||
return field, true
|
||||
}
|
||||
}
|
||||
|
||||
return reflect.StructField{}, false
|
||||
}
|
||||
|
||||
// yamlSetInMapping resolves (creating if needed) the value node for key
|
||||
// within a mapping node, setting it to value when it is the final path
|
||||
// element, and returns the node to descend into.
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -95,110 +94,6 @@ func TestConfigSetRecipientOnFreshConfig(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigSetStringLooksLikeNumber sets string settings to values that
|
||||
// YAML reads as numbers or booleans when they are unquoted, and checks that
|
||||
// config.Load returns each one unchanged.
|
||||
func TestConfigSetStringLooksLikeNumber(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
key string
|
||||
value string
|
||||
field func(cfg *config.Config) string
|
||||
}{
|
||||
{"s3.access_key_id", "00112233",
|
||||
func(cfg *config.Config) string { return cfg.S3.AccessKeyID }},
|
||||
{"s3.secret_access_key", "12345678901234567890123456789012",
|
||||
func(cfg *config.Config) string { return cfg.S3.SecretAccessKey }},
|
||||
{"hostname", "007",
|
||||
func(cfg *config.Config) string { return cfg.Hostname }},
|
||||
{"s3.prefix", "1e5",
|
||||
func(cfg *config.Config) string { return cfg.S3.Prefix }},
|
||||
{"s3.bucket", "true",
|
||||
func(cfg *config.Config) string { return cfg.S3.Bucket }},
|
||||
{"s3.region", "FALSE",
|
||||
func(cfg *config.Config) string { return cfg.S3.Region }},
|
||||
{"snapshots.home.exclude.0", "1.10",
|
||||
func(cfg *config.Config) string { return cfg.Snapshots["home"].Exclude[0] }},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.key+"="+tt.value, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := loadAfterConfigSet(t, tt.key, tt.value)
|
||||
|
||||
got := tt.field(cfg)
|
||||
if got != tt.value {
|
||||
t.Errorf("%s = %q after config set %q", tt.key, got, tt.value)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigSetNonUTF8Path checks that config set still accepts a value that
|
||||
// is not valid UTF-8, such as a path with a Latin-1 file name, and that
|
||||
// config.Load returns it unchanged.
|
||||
func TestConfigSetNonUTF8Path(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const dir = "/srv/caf\xe9"
|
||||
|
||||
cfg := loadAfterConfigSet(t, "snapshots.home.paths.0", dir)
|
||||
|
||||
got := cfg.Snapshots["home"].Paths[0]
|
||||
if got != dir {
|
||||
t.Errorf("snapshots.home.paths.0 = %q, want %q", got, dir)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigSetNumberStaysNumber checks that a number set for an integer
|
||||
// setting is still read as a number, not as a quoted string.
|
||||
func TestConfigSetNumberStaysNumber(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const level = 9
|
||||
|
||||
cfg := loadAfterConfigSet(t, "compression_level", strconv.Itoa(level))
|
||||
|
||||
if cfg.CompressionLevel != level {
|
||||
t.Errorf("compression_level = %d, want %d", cfg.CompressionLevel, level)
|
||||
}
|
||||
}
|
||||
|
||||
// loadAfterConfigSet writes the file `config init` writes, sets storage_url
|
||||
// to a local directory so that the file passes validation, applies
|
||||
// `config set key value` and returns what config.Load reads back.
|
||||
func loadAfterConfigSet(t *testing.T, key, value string) *config.Config {
|
||||
t.Helper()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "config.yml")
|
||||
|
||||
err := os.WriteFile(path, []byte(defaultConfigTemplate), configFileMode)
|
||||
if err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
out := ui.NewWithColor(&bytes.Buffer{}, false)
|
||||
|
||||
err = writeConfigSet(out, path, "storage_url", "file:///mnt/backups")
|
||||
if err != nil {
|
||||
t.Fatalf("config set storage_url: %v", err)
|
||||
}
|
||||
|
||||
err = writeConfigSet(out, path, key, value)
|
||||
if err != nil {
|
||||
t.Fatalf("config set %s: %v", key, err)
|
||||
}
|
||||
|
||||
cfg, err := config.Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("config.Load: %v", err)
|
||||
}
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
const testYAML = `# top comment
|
||||
compression_level: 3
|
||||
age_recipients:
|
||||
|
||||
+4
-15
@@ -15,11 +15,6 @@ import (
|
||||
// the startup banner.
|
||||
const shortCommitLen = 12
|
||||
|
||||
// exitCodeInterrupted is the exit status of a command that SIGINT or
|
||||
// SIGTERM stopped. It is 128 plus SIGINT's number, 2, which is what a
|
||||
// shell reports for a command stopped by Ctrl-C.
|
||||
const exitCodeInterrupted = 130
|
||||
|
||||
// Entry is the main entry point for the CLI application.
|
||||
// It prints the startup banner to stderr (unless a banner-suppressing
|
||||
// flag is present in os.Args — see bannerSuppressedInArgs), executes the
|
||||
@@ -28,10 +23,9 @@ const exitCodeInterrupted = 130
|
||||
// The banner goes to stderr because stdout carries only the output the
|
||||
// user asked for, such as a completion script or a `config get` value.
|
||||
//
|
||||
// It returns the process exit code (0 on success, 130 when interrupted,
|
||||
// 1 on any other error) rather than calling os.Exit, so that main's
|
||||
// deferred profile writers run before the process ends. See run in
|
||||
// cmd/vaultik/main.go.
|
||||
// It returns the process exit code (0 on success, 1 on error) rather
|
||||
// than calling os.Exit, so that main's deferred profile writers run
|
||||
// before the process ends. See run in cmd/vaultik/main.go.
|
||||
func Entry() int {
|
||||
emitStartupBanner(os.Args[1:], os.Stderr)
|
||||
|
||||
@@ -45,16 +39,11 @@ func Entry() int {
|
||||
// document instead); errReported says so. Printing it again
|
||||
// here would double the error line.
|
||||
// Every other error — bad arguments, a config that would not
|
||||
// load, an interrupt — reaches Entry unreported, so it is shown
|
||||
// here.
|
||||
// load — reaches Entry unreported, so it is shown here.
|
||||
if !errors.Is(err, errReported) {
|
||||
ReportErrorf("%s", err.Error())
|
||||
}
|
||||
|
||||
if errors.Is(err, errInterrupted) {
|
||||
return exitCodeInterrupted
|
||||
}
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
package cli //nolint:testpackage // shares runEntry and the argument constants
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/adrg/xdg"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// stalledStoreConfig is hermeticConfig with an s3:// destination store
|
||||
// in place of the file:// one. The server behind it accepts any
|
||||
// credentials.
|
||||
const stalledStoreConfig = `age_recipients:
|
||||
- age1278m9q7dp3chsh2dcy82qk27v047zywyvtxwnj4cvt0z65jw6a7q5dqhfj
|
||||
snapshots:
|
||||
test:
|
||||
paths:
|
||||
- %s
|
||||
storage_url: s3://bucket?endpoint=%s&ssl=false
|
||||
s3:
|
||||
access_key_id: key
|
||||
secret_access_key: secret
|
||||
index_path: %s
|
||||
hostname: test-host
|
||||
`
|
||||
|
||||
// interruptRepeat is how often interruptOnFirstRequest sends SIGINT.
|
||||
const interruptRepeat = 50 * time.Millisecond
|
||||
|
||||
// TestEntryInterruptedRun sends SIGINT to the test process while a
|
||||
// command waits on the destination store, and checks that Entry returns
|
||||
// 130 and prints one line on stderr saying the run was interrupted. The
|
||||
// store is a local HTTP server that holds every request open, so the
|
||||
// command is always mid-operation when the signal arrives. The two
|
||||
// cases cover --cron and --json, which silence other output.
|
||||
//
|
||||
// Not parallel: it signals the process and replaces os.Args, os.Stdout,
|
||||
// os.Stderr and the xdg globals.
|
||||
//
|
||||
//nolint:paralleltest // signals the process and replaces process globals
|
||||
func TestEntryInterruptedRun(t *testing.T) {
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{
|
||||
name: "snapshot create --cron",
|
||||
args: []string{cmdSnapshot, cmdCreate, "--cron"},
|
||||
},
|
||||
{
|
||||
name: "snapshot verify --json",
|
||||
args: []string{cmdSnapshot, cmdVerify, someSnapshotID, flagJSON},
|
||||
},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
endpoint, requestArrived := startStalledStore(t)
|
||||
configPath := writeStalledStoreConfig(t, endpoint)
|
||||
interruptOnFirstRequest(t, requestArrived)
|
||||
|
||||
code, _, stderr := runEntry(t,
|
||||
append([]string{flagConfig, configPath}, testCase.args...)...)
|
||||
|
||||
assert.Equal(t, 130, code)
|
||||
assert.Equal(t, 1,
|
||||
strings.Count(stderr, errInterrupted.Error()), stderr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// interruptOnFirstRequest sends SIGINT to the test process every
|
||||
// interruptRepeat, from the first request to the destination store until
|
||||
// the test ends. One signal is not enough: the command can reach the
|
||||
// store before fx has started catching signals. The test catches SIGINT
|
||||
// too, so that a signal fx is not catching does not kill the test
|
||||
// binary.
|
||||
func interruptOnFirstRequest(t *testing.T, requestArrived <-chan struct{}) {
|
||||
t.Helper()
|
||||
|
||||
self, err := os.FindProcess(os.Getpid())
|
||||
require.NoError(t, err)
|
||||
|
||||
caught := make(chan os.Signal, 1)
|
||||
signal.Notify(caught, os.Interrupt)
|
||||
|
||||
testEnded := make(chan struct{})
|
||||
senderDone := make(chan struct{})
|
||||
|
||||
// Stop catching SIGINT only after the sender has returned. The sender
|
||||
// waits for each SIGINT it sends to arrive on caught; one still on
|
||||
// its way after signal.Stop would kill the test binary.
|
||||
t.Cleanup(func() {
|
||||
close(testEnded)
|
||||
<-senderDone
|
||||
signal.Stop(caught)
|
||||
})
|
||||
|
||||
go func() {
|
||||
defer close(senderDone)
|
||||
|
||||
select {
|
||||
case <-requestArrived:
|
||||
case <-testEnded:
|
||||
return
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(interruptRepeat)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
// Empty caught, so that the receive below waits for this
|
||||
// SIGINT rather than an earlier one.
|
||||
select {
|
||||
case <-caught:
|
||||
default:
|
||||
}
|
||||
|
||||
sendErr := self.Signal(os.Interrupt)
|
||||
if sendErr != nil {
|
||||
t.Errorf("sending SIGINT: %v", sendErr)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
<-caught
|
||||
|
||||
select {
|
||||
case <-testEnded:
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// startStalledStore starts an HTTP server that never answers: each
|
||||
// request is held until the client gives up on it or the test ends.
|
||||
// It returns the server's host:port and a channel that receives a value
|
||||
// when the first request arrives.
|
||||
func startStalledStore(t *testing.T) (string, <-chan struct{}) {
|
||||
t.Helper()
|
||||
|
||||
requestArrived := make(chan struct{}, 1)
|
||||
release := make(chan struct{})
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(
|
||||
func(_ http.ResponseWriter, r *http.Request) {
|
||||
select {
|
||||
case requestArrived <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
case <-release:
|
||||
}
|
||||
}))
|
||||
|
||||
// Cleanups run last-registered first, so release lets any held
|
||||
// request return before Close waits for it.
|
||||
t.Cleanup(server.Close)
|
||||
t.Cleanup(func() { close(release) })
|
||||
|
||||
return server.Listener.Addr().String(), requestArrived
|
||||
}
|
||||
|
||||
// writeStalledStoreConfig writes a config whose destination store is the
|
||||
// server at endpoint and whose snapshot source holds one small file, so
|
||||
// that `snapshot create` has a blob to upload. Returns the config path.
|
||||
func writeStalledStoreConfig(t *testing.T, endpoint string) string {
|
||||
t.Helper()
|
||||
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "config.yml")
|
||||
sourceDir := filepath.Join(dir, "source")
|
||||
|
||||
require.NoError(t, os.Mkdir(sourceDir, 0o750))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(sourceDir, "file.txt"),
|
||||
[]byte("contents"), 0o600))
|
||||
|
||||
contents := fmt.Sprintf(stalledStoreConfig,
|
||||
sourceDir, endpoint, filepath.Join(dir, "index.sqlite"))
|
||||
|
||||
require.NoError(t,
|
||||
os.WriteFile(configPath, []byte(contents), configFileMode))
|
||||
|
||||
// The PID lock lives under xdg.DataHome, which xdg resolves at
|
||||
// package init; point it at the temp dir so the test neither
|
||||
// touches nor collides with the real one.
|
||||
t.Setenv("XDG_DATA_HOME", filepath.Join(dir, "data"))
|
||||
xdg.Reload()
|
||||
t.Cleanup(xdg.Reload)
|
||||
|
||||
return configPath
|
||||
}
|
||||
@@ -101,8 +101,7 @@ 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, and gives the destination the
|
||||
// marker a backup writes. Returns the config path.
|
||||
// counterpart on the destination store. Returns the config path.
|
||||
func writeHermeticPruneConfig(t *testing.T, seedStale bool) string {
|
||||
t.Helper()
|
||||
|
||||
@@ -132,11 +131,6 @@ 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
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
package cli //nolint:testpackage // shares runEntry and the hermetic config helpers
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// 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")
|
||||
}
|
||||
@@ -2,9 +2,7 @@ package cli //nolint:testpackage // shares hermeticConfig and the capture helper
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -89,45 +87,12 @@ func TestEntryJSONFailureIsReportedOnStderr(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestEntrySnapshotRemoveJSONWarningIsOnStderr runs `snapshot remove
|
||||
// --json` on a snapshot in the local index, against a destination
|
||||
// directory that does not exist. The command removes the snapshot from
|
||||
// the local index and still exits 0. Its stdout must hold the document
|
||||
// alone, with the warning about the destination store on stderr: the
|
||||
// command to run again once it is reachable, and the snapshot's ID in
|
||||
// the record's snapshot_id field.
|
||||
//
|
||||
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
|
||||
func TestEntrySnapshotRemoveJSONWarningIsOnStderr(t *testing.T) {
|
||||
configPath, indexPath := writeMissingDestinationConfig(t)
|
||||
seedStaleSnapshotRecord(t, indexPath)
|
||||
|
||||
code, stdout, stderr := runEntry(t, flagConfig, configPath,
|
||||
cmdSnapshot, cmdRemove, stalePruneSnapshotID, flagJSON)
|
||||
|
||||
require.Equal(t, 0, code)
|
||||
requireExactlyOneJSONDocument(t, stdout)
|
||||
|
||||
// stderr is a pipe here, so the logger writes one JSON record a line.
|
||||
var warning map[string]any
|
||||
|
||||
for line := range strings.Lines(stderr) {
|
||||
if strings.Contains(line,
|
||||
"Could not remove snapshot metadata from remote storage") {
|
||||
require.NoError(t, json.Unmarshal([]byte(line), &warning))
|
||||
}
|
||||
}
|
||||
|
||||
require.NotNil(t, warning, "the warning must reach stderr")
|
||||
assert.Contains(t, warning[slog.MessageKey],
|
||||
"run 'vaultik snapshot remove' with the snapshot's ID again")
|
||||
assert.Equal(t, stalePruneSnapshotID, warning["snapshot_id"])
|
||||
}
|
||||
|
||||
// writeMissingDestinationConfig builds a config whose destination
|
||||
// directory does not exist. Returns the config path and the path of
|
||||
// its local index, which is not created here.
|
||||
func writeMissingDestinationConfig(t *testing.T) (string, string) {
|
||||
// writeUnusableDestinationConfig builds a config whose destination
|
||||
// directory does not exist, which fails `remote info`, and whose local
|
||||
// index is bound to another destination, which fails `prune` and
|
||||
// `snapshot remove` (a missing destination alone only makes `snapshot
|
||||
// remove` warn). Returns the config path.
|
||||
func writeUnusableDestinationConfig(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
dir := t.TempDir()
|
||||
@@ -149,19 +114,6 @@ func writeMissingDestinationConfig(t *testing.T) (string, string) {
|
||||
xdg.Reload()
|
||||
t.Cleanup(xdg.Reload)
|
||||
|
||||
return configPath, indexPath
|
||||
}
|
||||
|
||||
// writeUnusableDestinationConfig builds a config whose destination
|
||||
// directory does not exist, which fails `remote info`, and whose local
|
||||
// index is bound to another destination, which fails `prune` and
|
||||
// `snapshot remove` (a missing destination alone only makes `snapshot
|
||||
// remove` warn). Returns the config path.
|
||||
func writeUnusableDestinationConfig(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
configPath, indexPath := writeMissingDestinationConfig(t)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
db, err := database.New(ctx, indexPath)
|
||||
@@ -170,7 +122,7 @@ func writeUnusableDestinationConfig(t *testing.T) string {
|
||||
defer func() { require.NoError(t, db.Close()) }()
|
||||
|
||||
require.NoError(t, database.NewRepositories(db).LocalMeta.Set(ctx,
|
||||
database.LocalMetaKeyStorageURL, "file://"+t.TempDir()))
|
||||
database.LocalMetaKeyStorageURL, "file://"+filepath.Join(dir, "other")))
|
||||
|
||||
return configPath
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
package cli //nolint:testpackage // exercises the unexported command constructor
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// TestJSONHelpSaysConfirmationPromptIsSkipped checks that the --json help
|
||||
// of `snapshot remove` and `prune` says the flag skips the confirmation
|
||||
// prompt. Both delete without asking under --json, since a prompt on
|
||||
// stdout would break the JSON document.
|
||||
func TestJSONHelpSaysConfirmationPromptIsSkipped(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, cmd := range []*cobra.Command{
|
||||
newSnapshotRemoveCommand(),
|
||||
NewPruneCommand(),
|
||||
} {
|
||||
assert.Contains(t, cmd.Flags().Lookup("json").Usage,
|
||||
"skips the confirmation prompt", cmd.Name())
|
||||
}
|
||||
}
|
||||
@@ -22,11 +22,9 @@ scans every snapshot manifest in the destination store, builds the
|
||||
set of still-referenced blob hashes, and deletes any blob not in that
|
||||
set.
|
||||
|
||||
Snapshot create --prune runs the same cleanup automatically; this
|
||||
command is the manual entry point for the same work (e.g. after a
|
||||
crashed backup or to reclaim storage). Snapshot remove leaves blobs in
|
||||
place; run this command afterwards to delete the ones no longer
|
||||
referenced.`,
|
||||
Snapshot create --prune and snapshot remove run the same cleanup
|
||||
automatically; this command is the manual entry point for the same
|
||||
work (e.g. after a crashed backup or to reclaim storage).`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
// Use unified config resolution
|
||||
@@ -57,8 +55,7 @@ referenced.`,
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVar(&opts.Force, "force", false, "Skip confirmation prompt")
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false,
|
||||
"Output pruning stats as JSON; skips the confirmation prompt, as --force does")
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "Output pruning stats as JSON")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -66,16 +66,10 @@ func newSnapshotCreateCommand() *cobra.Command {
|
||||
If snapshot names are provided, only those snapshots are created.
|
||||
If no names are provided, all configured snapshots are created.
|
||||
|
||||
The config is read from the path given by --config or VAULTIK_CONFIG;
|
||||
otherwise from the platform config directory (~/.config/vaultik/config.yml
|
||||
on Linux), then /etc/vaultik/config.yml.`,
|
||||
Config is located at /etc/vaultik/config.yml by default, but can be overridden by
|
||||
specifying a path using --config or by setting VAULTIK_CONFIG to a path.`,
|
||||
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.
|
||||
@@ -102,13 +96,6 @@ 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)
|
||||
})
|
||||
@@ -267,17 +254,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); the snapshot
|
||||
must then be in the local index.
|
||||
snapshot locally without touching the destination store).
|
||||
|
||||
If the remote is unreachable, the local-database removal still completes
|
||||
and a warning is emitted; run 'vaultik snapshot remove <snapshot-id>' again
|
||||
once the destination store is reachable to remove the snapshot's metadata
|
||||
from it ('vaultik prune' does not).
|
||||
and a warning is emitted; rerun 'vaultik prune' once the destination store
|
||||
is reachable to finish remote cleanup.
|
||||
|
||||
To wipe the entire destination store and start over, use 'vaultik remote
|
||||
nuke --force' — it is the single supported entry point for that.`,
|
||||
@@ -296,8 +278,7 @@ nuke --force' — it is the single supported entry point for that.`,
|
||||
cmd.Flags().BoolVarP(&opts.Force, "force", "f", false, "Skip confirmation prompt")
|
||||
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false,
|
||||
"Show what would be removed without removing")
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false,
|
||||
"Output result as JSON; skips the confirmation prompt, as --force does")
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "Output result as JSON")
|
||||
cmd.Flags().BoolVar(&opts.LocalOnly, "local-only", false,
|
||||
"Skip remote cleanup; only touch the local index")
|
||||
|
||||
|
||||
@@ -33,14 +33,11 @@ const secretKeyPrefix = "AGE-SECRET-KEY-"
|
||||
const (
|
||||
defaultBlobSizeLimit = Size(10 * 1024 * 1024 * 1024) // 10GB
|
||||
defaultChunkSize = Size(10 * 1024 * 1024) // 10MB
|
||||
defaultS3PartSize = Size(5 * 1024 * 1024) // 5MiB
|
||||
defaultS3PartSize = Size(5 * 1024 * 1024) // 5MB
|
||||
defaultCompressionLevel = 3
|
||||
minChunkSize = 1024 * 1024 // 1MB
|
||||
minCompressionLevel = 1
|
||||
maxCompressionLevel = 19
|
||||
// S3 accepts a multipart upload part from 5MiB to 5GiB.
|
||||
minS3PartSize = 5 * 1024 * 1024
|
||||
maxS3PartSize = 5 * 1024 * 1024 * 1024
|
||||
)
|
||||
|
||||
// Sentinel validation errors.
|
||||
@@ -58,12 +55,8 @@ var (
|
||||
"blob_size_limit must be at least the largest chunk the chunker can " +
|
||||
"emit (chunk_size times the FastCDC size spread)")
|
||||
errBadCompression = errors.New("compression_level must be between 1 and 19")
|
||||
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")
|
||||
@@ -251,7 +244,6 @@ func Load(path string) (*Config, error) {
|
||||
ChunkSize: defaultChunkSize,
|
||||
IndexPath: filepath.Join(xdg.DataHome, appName, "index.sqlite"),
|
||||
CompressionLevel: defaultCompressionLevel,
|
||||
S3: S3Config{PartSize: defaultS3PartSize},
|
||||
}
|
||||
|
||||
// Convert smartconfig data to YAML then unmarshal
|
||||
@@ -302,13 +294,17 @@ func Load(path string) (*Config, error) {
|
||||
cfg.S3.Region = "us-east-1"
|
||||
}
|
||||
|
||||
if cfg.S3.PartSize == 0 {
|
||||
cfg.S3.PartSize = defaultS3PartSize
|
||||
}
|
||||
|
||||
// Check config file permissions (warn if world or group readable)
|
||||
//nolint:gosec // G703: config path is operator-supplied by design
|
||||
info, statErr := os.Stat(path)
|
||||
if statErr == nil {
|
||||
mode := info.Mode().Perm()
|
||||
if mode&0044 != 0 { // group or world readable
|
||||
log.Warn(cfg.readableByOthersWarning(),
|
||||
log.Warn("Config file has insecure permissions (contains S3 credentials)",
|
||||
"path", path,
|
||||
"mode", fmt.Sprintf("%04o", mode),
|
||||
"recommendation", "chmod 600 "+path)
|
||||
@@ -336,7 +332,6 @@ func Load(path string) (*Config, error) {
|
||||
// (chunk_size times chunker.ChunkSizeSpread), so a single-chunk blob never
|
||||
// exceeds the configured limit
|
||||
// - Compression level must be between 1 and 19
|
||||
// - S3 part size must be between 5MiB and 5GiB, the part sizes S3 accepts
|
||||
//
|
||||
// Returns an error describing the first validation failure encountered.
|
||||
func (c *Config) Validate() error {
|
||||
@@ -381,11 +376,6 @@ func (c *Config) Validate() error {
|
||||
return errBadCompression
|
||||
}
|
||||
|
||||
if c.S3.PartSize.Int64() < minS3PartSize ||
|
||||
c.S3.PartSize.Int64() > maxS3PartSize {
|
||||
return errBadS3PartSize
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -421,18 +411,6 @@ func (c *Config) setAgeSecretKey() {
|
||||
}
|
||||
}
|
||||
|
||||
// readableByOthersWarning is the warning Load logs when others can read
|
||||
// the config file. It says "may contain" because the S3 credentials are
|
||||
// seen only after smartconfig has replaced any ${...} reference in the
|
||||
// file with its value, so a set credential need not be in the file.
|
||||
func (c *Config) readableByOthersWarning() string {
|
||||
if c.S3.AccessKeyID != "" || c.S3.SecretAccessKey != "" {
|
||||
return "Config file is readable by others and may contain S3 credentials"
|
||||
}
|
||||
|
||||
return "Config file is readable by others"
|
||||
}
|
||||
|
||||
// validateStorage validates storage configuration.
|
||||
// If StorageURL is set, it takes precedence. S3 URLs require credentials.
|
||||
// File URLs don't require any S3 configuration.
|
||||
@@ -464,18 +442,11 @@ 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. A file URL must name an absolute path: a relative one
|
||||
// would resolve against the working directory, so the destination would
|
||||
// move with it.
|
||||
// credential fields.
|
||||
func (c *Config) validateStorageURL() error {
|
||||
switch {
|
||||
case strings.HasPrefix(c.StorageURL, "file://"):
|
||||
// storage.ParseStorageURL applies the same rule, but config
|
||||
// cannot import storage, which imports config.
|
||||
if !strings.HasPrefix(c.StorageURL, "file:///") {
|
||||
return errFileURLNotAbsolute
|
||||
}
|
||||
|
||||
// File storage doesn't need S3 credentials
|
||||
return nil
|
||||
case strings.HasPrefix(c.StorageURL, "rclone://"):
|
||||
// Rclone storage uses rclone's own config
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/vaultik/internal/chunker"
|
||||
"sneak.berlin/go/vaultik/internal/log"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -167,7 +166,6 @@ func TestValidateBlobSizeLimit(t *testing.T) {
|
||||
ChunkSize: chunkSize,
|
||||
BlobSizeLimit: blobLimit,
|
||||
CompressionLevel: 3,
|
||||
S3: S3Config{PartSize: defaultS3PartSize},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,149 +221,6 @@ func TestValidateBlobSizeLimit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateS3PartSize checks that s3.part_size is held to the part sizes
|
||||
// S3 accepts, 5MiB to 5GiB, by changing only the part size of the test
|
||||
// config. "5MB" in the config file is 5,000,000 bytes, below the minimum.
|
||||
func TestValidateS3PartSize(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
base, err := Load(os.Getenv("VAULTIK_CONFIG"))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load config: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
partSize Size
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "5MB is rejected",
|
||||
partSize: 5_000_000,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "one byte below 5MiB is rejected",
|
||||
partSize: minS3PartSize - 1,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "5MiB is accepted",
|
||||
partSize: minS3PartSize,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "5GiB is accepted",
|
||||
partSize: maxS3PartSize,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "one byte above 5GiB is rejected",
|
||||
partSize: maxS3PartSize + 1,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := *base
|
||||
cfg.S3.PartSize = tt.partSize
|
||||
|
||||
err := cfg.Validate()
|
||||
if tt.wantErr {
|
||||
if !errors.Is(err, errBadS3PartSize) {
|
||||
t.Fatalf("Validate() error = %v, want errBadS3PartSize", err)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Validate() unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadS3PartSize checks that a config file without s3.part_size loads
|
||||
// with the 5MiB default, and that an explicit 0 fails at load like any other
|
||||
// part size S3 refuses.
|
||||
func TestLoadS3PartSize(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const withoutPartSize = "snapshots:\n" +
|
||||
" test:\n" +
|
||||
" paths: [/tmp/vaultik-test-source]\n" +
|
||||
"storage_url: file:///tmp/vaultik-test-storage\n"
|
||||
|
||||
writeConfig := func(t *testing.T, text string) string {
|
||||
t.Helper()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "config.yml")
|
||||
|
||||
err := os.WriteFile(path, []byte(text), 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
t.Run("absent loads as 5MiB", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg, err := Load(writeConfig(t, withoutPartSize))
|
||||
if err != nil {
|
||||
t.Fatalf("Load() unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if cfg.S3.PartSize != defaultS3PartSize {
|
||||
t.Errorf("s3.part_size = %d, want %d",
|
||||
cfg.S3.PartSize, defaultS3PartSize)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("0 is rejected", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := Load(writeConfig(t, withoutPartSize+"s3:\n part_size: 0\n"))
|
||||
if !errors.Is(err, errBadS3PartSize) {
|
||||
t.Fatalf("Load() error = %v, want errBadS3PartSize", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -381,7 +236,6 @@ func TestValidateAgeRecipients(t *testing.T) {
|
||||
ChunkSize: Size(10 * 1024 * 1024),
|
||||
BlobSizeLimit: Size(10 * 1024 * 1024 * 1024),
|
||||
CompressionLevel: 3,
|
||||
S3: S3Config{PartSize: defaultS3PartSize},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -489,125 +343,3 @@ func TestAgeSecretKeySourceName(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// loadReadableConfig writes configYAML to a file that others can read,
|
||||
// loads it, and returns what the logger wrote to stderr meanwhile. The
|
||||
// logger writes to the os.Stderr it finds when it is initialized, so
|
||||
// os.Stderr is pointed at a file first. Not parallel-safe: os.Stderr and
|
||||
// the logger are process-global.
|
||||
func loadReadableConfig(t *testing.T, configYAML string) string {
|
||||
t.Helper()
|
||||
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "config.yml")
|
||||
stderrPath := filepath.Join(dir, "stderr")
|
||||
|
||||
err := os.WriteFile(configPath, []byte(configYAML), 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("writing config: %v", err)
|
||||
}
|
||||
|
||||
//nolint:gosec // G302: the test needs a config file others can read
|
||||
err = os.Chmod(configPath, 0o644)
|
||||
if err != nil {
|
||||
t.Fatalf("chmod config: %v", err)
|
||||
}
|
||||
|
||||
stderrFile, err := os.Create(stderrPath) //nolint:gosec // G304: test temp path
|
||||
if err != nil {
|
||||
t.Fatalf("creating stderr file: %v", err)
|
||||
}
|
||||
|
||||
previous := os.Stderr
|
||||
os.Stderr = stderrFile
|
||||
|
||||
log.Initialize(log.Config{})
|
||||
|
||||
_, loadErr := Load(configPath)
|
||||
|
||||
os.Stderr = previous
|
||||
|
||||
log.Initialize(log.Config{})
|
||||
|
||||
_ = stderrFile.Close()
|
||||
|
||||
if loadErr != nil {
|
||||
t.Fatalf("Load() error = %v", loadErr)
|
||||
}
|
||||
|
||||
captured, err := os.ReadFile(stderrPath) //nolint:gosec // G304: test temp path
|
||||
if err != nil {
|
||||
t.Fatalf("reading stderr file: %v", err)
|
||||
}
|
||||
|
||||
return string(captured)
|
||||
}
|
||||
|
||||
// TestLoadWarnsReadableConfigWithoutS3Credentials checks that a config
|
||||
// file others can read, holding no S3 credentials, is warned about
|
||||
// without a claim that it holds them.
|
||||
//
|
||||
//nolint:paralleltest // loadReadableConfig replaces os.Stderr
|
||||
func TestLoadWarnsReadableConfigWithoutS3Credentials(t *testing.T) {
|
||||
stderr := loadReadableConfig(t, `
|
||||
storage_url: file:///var/backups/vaultik
|
||||
snapshots:
|
||||
home:
|
||||
paths:
|
||||
- /home
|
||||
`)
|
||||
|
||||
if !strings.Contains(stderr, "Config file is readable by others") {
|
||||
t.Errorf("expected a warning that the file is readable by others, got %q",
|
||||
stderr)
|
||||
}
|
||||
|
||||
if strings.Contains(stderr, "S3 credentials") {
|
||||
t.Errorf("warning names S3 credentials the file does not set: %q", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadWarnsReadableConfigWithS3Credentials checks that a config file
|
||||
// others can read and that sets S3 credentials, as values or as ${ENV:...}
|
||||
// references, is warned about as one that may contain them.
|
||||
//
|
||||
//nolint:paralleltest // loadReadableConfig replaces os.Stderr
|
||||
func TestLoadWarnsReadableConfigWithS3Credentials(t *testing.T) {
|
||||
t.Setenv("VAULTIK_TEST_ACCESS_KEY_ID", "test-access-key")
|
||||
t.Setenv("VAULTIK_TEST_SECRET_ACCESS_KEY", "test-secret-key")
|
||||
|
||||
configs := map[string]string{
|
||||
"values": `
|
||||
storage_url: s3://bucket/prefix?endpoint=s3.example.com
|
||||
s3:
|
||||
access_key_id: test-access-key
|
||||
secret_access_key: test-secret-key
|
||||
snapshots:
|
||||
home:
|
||||
paths:
|
||||
- /home
|
||||
`,
|
||||
"references": `
|
||||
storage_url: s3://bucket/prefix?endpoint=s3.example.com
|
||||
s3:
|
||||
access_key_id: ${ENV:VAULTIK_TEST_ACCESS_KEY_ID}
|
||||
secret_access_key: ${ENV:VAULTIK_TEST_SECRET_ACCESS_KEY}
|
||||
snapshots:
|
||||
home:
|
||||
paths:
|
||||
- /home
|
||||
`,
|
||||
}
|
||||
|
||||
for name, configYAML := range configs {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
stderr := loadReadableConfig(t, configYAML)
|
||||
|
||||
if !strings.Contains(stderr,
|
||||
"Config file is readable by others and may contain S3 credentials") {
|
||||
t.Errorf("expected a warning naming the S3 credentials, got %q",
|
||||
stderr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+19
-28
@@ -33,13 +33,11 @@ func (r *FileRepository) Create(ctx context.Context, tx *sql.Tx, file *File) err
|
||||
}
|
||||
|
||||
query := `
|
||||
INSERT INTO files
|
||||
(id, path, source_path, mtime, mtime_nsec, size, mode, uid, gid, link_target)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO files (id, path, source_path, mtime, size, mode, uid, gid, link_target)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(path) DO UPDATE SET
|
||||
source_path = excluded.source_path,
|
||||
mtime = excluded.mtime,
|
||||
mtime_nsec = excluded.mtime_nsec,
|
||||
size = excluded.size,
|
||||
mode = excluded.mode,
|
||||
uid = excluded.uid,
|
||||
@@ -56,19 +54,16 @@ func (r *FileRepository) Create(ctx context.Context, tx *sql.Tx, file *File) err
|
||||
if tx != nil {
|
||||
LogSQL("Execute", query,
|
||||
file.ID.String(), file.Path.String(), file.SourcePath.String(),
|
||||
file.MTime.Unix(), file.MTime.Nanosecond(),
|
||||
file.Size, file.Mode, file.UID, file.GID,
|
||||
file.MTime.Unix(), file.Size, file.Mode, file.UID, file.GID,
|
||||
file.LinkTarget.String())
|
||||
err = tx.QueryRowContext(ctx, query,
|
||||
file.ID.String(), file.Path.String(), file.SourcePath.String(),
|
||||
file.MTime.Unix(), file.MTime.Nanosecond(),
|
||||
file.Size, file.Mode, file.UID, file.GID,
|
||||
file.MTime.Unix(), file.Size, file.Mode, file.UID, file.GID,
|
||||
file.LinkTarget.String()).Scan(&idStr)
|
||||
} else {
|
||||
err = r.db.QueryRowWithLog(ctx, query,
|
||||
file.ID.String(), file.Path.String(), file.SourcePath.String(),
|
||||
file.MTime.Unix(), file.MTime.Nanosecond(),
|
||||
file.Size, file.Mode, file.UID, file.GID,
|
||||
file.MTime.Unix(), file.Size, file.Mode, file.UID, file.GID,
|
||||
file.LinkTarget.String()).Scan(&idStr)
|
||||
}
|
||||
|
||||
@@ -89,7 +84,7 @@ func (r *FileRepository) Create(ctx context.Context, tx *sql.Tx, file *File) err
|
||||
// in the index.
|
||||
func (r *FileRepository) GetByPath(ctx context.Context, path string) (*File, error) {
|
||||
query := `
|
||||
SELECT id, path, source_path, mtime, mtime_nsec, size, mode, uid, gid, link_target
|
||||
SELECT id, path, source_path, mtime, size, mode, uid, gid, link_target
|
||||
FROM files
|
||||
WHERE path = ?
|
||||
`
|
||||
@@ -109,7 +104,7 @@ func (r *FileRepository) GetByPath(ctx context.Context, path string) (*File, err
|
||||
// GetByID retrieves a file by its UUID
|
||||
func (r *FileRepository) GetByID(ctx context.Context, id types.FileID) (*File, error) {
|
||||
query := `
|
||||
SELECT id, path, source_path, mtime, mtime_nsec, size, mode, uid, gid, link_target
|
||||
SELECT id, path, source_path, mtime, size, mode, uid, gid, link_target
|
||||
FROM files
|
||||
WHERE id = ?
|
||||
`
|
||||
@@ -132,7 +127,7 @@ func (r *FileRepository) GetByPathTx(
|
||||
ctx context.Context, tx *sql.Tx, path string,
|
||||
) (*File, error) {
|
||||
query := `
|
||||
SELECT id, path, source_path, mtime, mtime_nsec, size, mode, uid, gid, link_target
|
||||
SELECT id, path, source_path, mtime, size, mode, uid, gid, link_target
|
||||
FROM files
|
||||
WHERE path = ?
|
||||
`
|
||||
@@ -163,14 +158,13 @@ func (r *FileRepository) ListModifiedSince(
|
||||
ctx context.Context, since time.Time,
|
||||
) ([]*File, error) {
|
||||
query := `
|
||||
SELECT id, path, source_path, mtime, mtime_nsec, size, mode, uid, gid, link_target
|
||||
SELECT id, path, source_path, mtime, size, mode, uid, gid, link_target
|
||||
FROM files
|
||||
WHERE (mtime, mtime_nsec) >= (?, ?)
|
||||
WHERE mtime >= ?
|
||||
ORDER BY path
|
||||
`
|
||||
|
||||
rows, err := r.db.conn.QueryContext(ctx, query,
|
||||
since.Unix(), since.Nanosecond())
|
||||
rows, err := r.db.conn.QueryContext(ctx, query, since.Unix())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("querying files: %w", err)
|
||||
}
|
||||
@@ -245,7 +239,7 @@ func (r *FileRepository) ListUnderPath(
|
||||
|
||||
// LIKE would ignore ASCII case and treat _ and % in path as wildcards.
|
||||
query := `
|
||||
SELECT id, path, source_path, mtime, mtime_nsec, size, mode, uid, gid, link_target
|
||||
SELECT id, path, source_path, mtime, size, mode, uid, gid, link_target
|
||||
FROM files
|
||||
WHERE path = ? OR substr(path, 1, length(?)) = ?
|
||||
ORDER BY path
|
||||
@@ -330,7 +324,7 @@ func (r *FileRepository) ListIDsWithChunksNotInUploadedBlobs(
|
||||
// ListAll returns all files in the database
|
||||
func (r *FileRepository) ListAll(ctx context.Context) ([]*File, error) {
|
||||
query := `
|
||||
SELECT id, path, source_path, mtime, mtime_nsec, size, mode, uid, gid, link_target
|
||||
SELECT id, path, source_path, mtime, size, mode, uid, gid, link_target
|
||||
FROM files
|
||||
ORDER BY path
|
||||
`
|
||||
@@ -371,7 +365,7 @@ func (r *FileRepository) CreateBatch(
|
||||
}
|
||||
|
||||
// Each files row binds this many SQL variables.
|
||||
const fileCols = 10
|
||||
const fileCols = 9
|
||||
|
||||
// Batch at 100 rows to be safe with SQLite's variable limit.
|
||||
const batchSize = 100
|
||||
@@ -382,7 +376,7 @@ func (r *FileRepository) CreateBatch(
|
||||
batch := files[i:end]
|
||||
|
||||
query := `INSERT INTO files
|
||||
(id, path, source_path, mtime, mtime_nsec, size, mode, uid, gid, link_target)
|
||||
(id, path, source_path, mtime, size, mode, uid, gid, link_target)
|
||||
VALUES `
|
||||
|
||||
args := make([]any, 0, len(batch)*fileCols)
|
||||
@@ -394,12 +388,11 @@ func (r *FileRepository) CreateBatch(
|
||||
querySb325.WriteString(", ")
|
||||
}
|
||||
|
||||
querySb325.WriteString("(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
|
||||
querySb325.WriteString("(?, ?, ?, ?, ?, ?, ?, ?, ?)")
|
||||
|
||||
args = append(args,
|
||||
f.ID.String(), f.Path.String(), f.SourcePath.String(),
|
||||
f.MTime.Unix(), f.MTime.Nanosecond(),
|
||||
f.Size, f.Mode, f.UID, f.GID,
|
||||
f.MTime.Unix(), f.Size, f.Mode, f.UID, f.GID,
|
||||
f.LinkTarget.String())
|
||||
}
|
||||
|
||||
@@ -408,7 +401,6 @@ func (r *FileRepository) CreateBatch(
|
||||
query += ` ON CONFLICT(path) DO UPDATE SET
|
||||
source_path = excluded.source_path,
|
||||
mtime = excluded.mtime,
|
||||
mtime_nsec = excluded.mtime_nsec,
|
||||
size = excluded.size,
|
||||
mode = excluded.mode,
|
||||
uid = excluded.uid,
|
||||
@@ -468,7 +460,7 @@ func (r *FileRepository) scanFileFrom(row fileRowScanner) (*File, error) {
|
||||
var (
|
||||
file File
|
||||
idStr, pathStr, sourcePathStr string
|
||||
mtimeUnix, mtimeNsec int64
|
||||
mtimeUnix int64
|
||||
linkTarget sql.NullString
|
||||
)
|
||||
|
||||
@@ -477,7 +469,6 @@ func (r *FileRepository) scanFileFrom(row fileRowScanner) (*File, error) {
|
||||
&pathStr,
|
||||
&sourcePathStr,
|
||||
&mtimeUnix,
|
||||
&mtimeNsec,
|
||||
&file.Size,
|
||||
&file.Mode,
|
||||
&file.UID,
|
||||
@@ -496,7 +487,7 @@ func (r *FileRepository) scanFileFrom(row fileRowScanner) (*File, error) {
|
||||
file.Path = types.FilePath(pathStr)
|
||||
file.SourcePath = types.SourcePath(sourcePathStr)
|
||||
|
||||
file.MTime = time.Unix(mtimeUnix, mtimeNsec).UTC()
|
||||
file.MTime = time.Unix(mtimeUnix, 0).UTC()
|
||||
if linkTarget.Valid {
|
||||
file.LinkTarget = types.FilePath(linkTarget.String)
|
||||
}
|
||||
|
||||
@@ -252,115 +252,6 @@ func TestFileRepositorySymlink(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// An mtime after 2262 or before 1678 does not fit in int64 nanoseconds
|
||||
// since the epoch, and must still come back from the database unchanged.
|
||||
func TestFileRepositoryMTimeOutsideInt64NanosecondRange(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
repo := database.NewFileRepository(db)
|
||||
|
||||
mtimes := []time.Time{
|
||||
time.Date(2300, time.January, 1, 0, 0, 0, 123456789, time.UTC),
|
||||
time.Date(1601, time.January, 1, 0, 0, 0, 987654321, time.UTC),
|
||||
}
|
||||
|
||||
for _, mtime := range mtimes {
|
||||
created := &database.File{
|
||||
Path: types.FilePath("/created-" + mtime.Format(time.RFC3339Nano)),
|
||||
MTime: mtime,
|
||||
}
|
||||
|
||||
err := repo.Create(ctx, nil, created)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create file: %v", err)
|
||||
}
|
||||
|
||||
batched := &database.File{
|
||||
ID: types.NewFileID(),
|
||||
Path: types.FilePath("/batched-" + mtime.Format(time.RFC3339Nano)),
|
||||
MTime: mtime,
|
||||
}
|
||||
|
||||
err = repo.CreateBatch(ctx, nil, []*database.File{batched})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to batch create file: %v", err)
|
||||
}
|
||||
|
||||
for _, path := range []types.FilePath{created.Path, batched.Path} {
|
||||
retrieved, err := repo.GetByPath(ctx, path.String())
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get file: %v", err)
|
||||
}
|
||||
|
||||
if !retrieved.MTime.Equal(mtime) {
|
||||
t.Errorf("%s: mtime got %v, want %v",
|
||||
path, retrieved.MTime, mtime)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A file already in the index and rewritten within the same second must get
|
||||
// its new nanoseconds stored, through both Create and CreateBatch.
|
||||
func TestFileRepositoryUpsertMTimeInSameSecond(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
repo := database.NewFileRepository(db)
|
||||
|
||||
indexed := time.Date(2026, time.October, 7, 12, 0, 0, 100000000, time.UTC)
|
||||
rewritten := indexed.Add(800 * time.Millisecond)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
upsert func(file *database.File) error
|
||||
}{
|
||||
{"Create", func(file *database.File) error {
|
||||
return repo.Create(ctx, nil, file)
|
||||
}},
|
||||
{"CreateBatch", func(file *database.File) error {
|
||||
return repo.CreateBatch(ctx, nil, []*database.File{file})
|
||||
}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
file := &database.File{
|
||||
ID: types.NewFileID(),
|
||||
Path: types.FilePath("/" + tt.name),
|
||||
MTime: indexed,
|
||||
}
|
||||
|
||||
err := tt.upsert(file)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: failed to create file: %v", tt.name, err)
|
||||
}
|
||||
|
||||
file.MTime = rewritten
|
||||
|
||||
err = tt.upsert(file)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: failed to update file: %v", tt.name, err)
|
||||
}
|
||||
|
||||
retrieved, err := repo.GetByPath(ctx, file.Path.String())
|
||||
if err != nil {
|
||||
t.Fatalf("%s: failed to get file: %v", tt.name, err)
|
||||
}
|
||||
|
||||
if !retrieved.MTime.Equal(rewritten) {
|
||||
t.Errorf("%s: mtime got %v, want %v",
|
||||
tt.name, retrieved.MTime, rewritten)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileRepositoryTransaction(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -14,7 +14,8 @@ type File struct {
|
||||
ID types.FileID // UUID primary key
|
||||
Path types.FilePath // Absolute path of the file
|
||||
|
||||
// SourcePath is the source directory this file came from.
|
||||
// SourcePath is the source directory this file came from (used for
|
||||
// restore path stripping).
|
||||
SourcePath types.SourcePath
|
||||
MTime time.Time
|
||||
Size int64
|
||||
@@ -98,8 +99,8 @@ type Snapshot struct {
|
||||
StartedAt time.Time
|
||||
CompletedAt *time.Time // nil if still in progress
|
||||
FileCount int64
|
||||
ChunkCount int64 // Chunks this snapshot stored that were not stored before
|
||||
BlobCount int64 // Blobs this snapshot created
|
||||
ChunkCount int64
|
||||
BlobCount int64
|
||||
TotalSize int64 // Total size of all referenced files
|
||||
|
||||
// BlobSize is the total size of all referenced blobs (compressed and
|
||||
|
||||
@@ -3,7 +3,6 @@ package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -368,7 +367,7 @@ func verifyBlobNullUploadTS(
|
||||
}
|
||||
|
||||
// createLargeDatasetFiles creates fileCount files and adds every other
|
||||
// one to the snapshot, in one transaction as a backup writes them.
|
||||
// one to the snapshot.
|
||||
func createLargeDatasetFiles(
|
||||
t *testing.T,
|
||||
repos *Repositories,
|
||||
@@ -377,38 +376,31 @@ func createLargeDatasetFiles(
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
ctx := context.Background()
|
||||
start := time.Now()
|
||||
|
||||
err := repos.WithTx(context.Background(),
|
||||
func(ctx context.Context, tx *sql.Tx) error {
|
||||
for i := range fileCount {
|
||||
file := &File{
|
||||
Path: types.FilePath(fmt.Sprintf("/large/file%05d.txt", i)),
|
||||
MTime: time.Now(),
|
||||
Size: int64(i * 1024),
|
||||
Mode: 0644,
|
||||
UID: uint32(1000 + (i % 10)),
|
||||
GID: uint32(1000 + (i % 10)),
|
||||
}
|
||||
for i := range fileCount {
|
||||
file := &File{
|
||||
Path: types.FilePath(fmt.Sprintf("/large/file%05d.txt", i)),
|
||||
MTime: time.Now(),
|
||||
Size: int64(i * 1024),
|
||||
Mode: 0644,
|
||||
UID: uint32(1000 + (i % 10)),
|
||||
GID: uint32(1000 + (i % 10)),
|
||||
}
|
||||
|
||||
err := repos.Files.Create(ctx, tx, file)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating file %d: %w", i, err)
|
||||
}
|
||||
err := repos.Files.Create(ctx, nil, file)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create file %d: %v", i, err)
|
||||
}
|
||||
|
||||
// Add half to snapshot
|
||||
if i%2 == 0 {
|
||||
err = repos.Snapshots.AddFileByID(ctx, tx, snapshotID, file.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// Add half to snapshot
|
||||
if i%2 == 0 {
|
||||
err = repos.Snapshots.AddFileByID(ctx, nil, snapshotID, file.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("Created %d files in %v", fileCount, time.Since(start))
|
||||
@@ -614,7 +606,8 @@ func TestTimezoneHandling(t *testing.T) {
|
||||
t.Skip("timezone not available")
|
||||
}
|
||||
|
||||
nyTime := time.Now().In(loc)
|
||||
// Use Truncate to remove sub-second precision since we store as Unix timestamps
|
||||
nyTime := time.Now().In(loc).Truncate(time.Second)
|
||||
file := &File{
|
||||
Path: "/timezone-test.txt",
|
||||
MTime: nyTime,
|
||||
|
||||
@@ -5,9 +5,8 @@
|
||||
CREATE TABLE IF NOT EXISTS files (
|
||||
id TEXT PRIMARY KEY, -- UUID
|
||||
path TEXT NOT NULL UNIQUE,
|
||||
source_path TEXT NOT NULL DEFAULT '', -- The source directory this file came from
|
||||
mtime INTEGER NOT NULL, -- whole seconds since the Unix epoch
|
||||
mtime_nsec INTEGER NOT NULL, -- nanoseconds within that second, 0 to 999999999
|
||||
source_path TEXT NOT NULL DEFAULT '', -- The source directory this file came from (for restore path stripping)
|
||||
mtime INTEGER NOT NULL,
|
||||
size INTEGER NOT NULL,
|
||||
mode INTEGER NOT NULL,
|
||||
uid INTEGER NOT NULL,
|
||||
|
||||
@@ -127,7 +127,6 @@ func (r *SnapshotRepository) UpdateExtendedStats(
|
||||
snapshotID string,
|
||||
blobUncompressedSize int64,
|
||||
compressionLevel int,
|
||||
uploadBytes int64,
|
||||
uploadDurationMs int64,
|
||||
) error {
|
||||
compressionRatio, err := r.extendedCompressionRatio(
|
||||
@@ -142,7 +141,7 @@ func (r *SnapshotRepository) UpdateExtendedStats(
|
||||
SET blob_uncompressed_size = ?,
|
||||
compression_ratio = ?,
|
||||
compression_level = ?,
|
||||
upload_bytes = ?,
|
||||
upload_bytes = blob_size,
|
||||
upload_duration_ms = ?
|
||||
WHERE id = ?
|
||||
`
|
||||
@@ -150,11 +149,11 @@ func (r *SnapshotRepository) UpdateExtendedStats(
|
||||
if tx != nil {
|
||||
_, err = tx.ExecContext(ctx, query,
|
||||
blobUncompressedSize, compressionRatio, compressionLevel,
|
||||
uploadBytes, uploadDurationMs, snapshotID)
|
||||
uploadDurationMs, snapshotID)
|
||||
} else {
|
||||
_, err = r.db.ExecWithLog(ctx, query,
|
||||
blobUncompressedSize, compressionRatio, compressionLevel,
|
||||
uploadBytes, uploadDurationMs, snapshotID)
|
||||
uploadDurationMs, snapshotID)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
@@ -544,30 +543,6 @@ func (r *SnapshotRepository) GetSnapshotTotalCompressedSize(
|
||||
return totalSize, nil
|
||||
}
|
||||
|
||||
// GetSnapshotBlobSizes returns the total compressed and uncompressed sizes
|
||||
// of all blobs referenced by a snapshot.
|
||||
func (r *SnapshotRepository) GetSnapshotBlobSizes(
|
||||
ctx context.Context, snapshotID string,
|
||||
) (int64, int64, error) {
|
||||
query := `
|
||||
SELECT COALESCE(SUM(b.compressed_size), 0),
|
||||
COALESCE(SUM(b.uncompressed_size), 0)
|
||||
FROM snapshot_blobs sb
|
||||
JOIN blobs b ON sb.blob_hash = b.blob_hash
|
||||
WHERE sb.snapshot_id = ?
|
||||
`
|
||||
|
||||
var compressed, uncompressed int64
|
||||
|
||||
err := r.db.conn.QueryRowContext(ctx, query, snapshotID).Scan(
|
||||
&compressed, &uncompressed)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("querying snapshot blob sizes: %w", err)
|
||||
}
|
||||
|
||||
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).
|
||||
func (r *SnapshotRepository) GetSnapshotUncompressedChunkSize(
|
||||
|
||||
@@ -145,65 +145,6 @@ func TestSnapshotRepositoryUpdateCounts(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// GetSnapshotBlobSizes totals the blobs the snapshot references, and only
|
||||
// those.
|
||||
func TestSnapshotRepositoryGetSnapshotBlobSizes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
repos := database.NewRepositories(db)
|
||||
|
||||
snapshot := &database.Snapshot{
|
||||
ID: "2024-01-03T12:00:00Z",
|
||||
Hostname: testHostname,
|
||||
VaultikVersion: testVersion,
|
||||
StartedAt: time.Now().Truncate(time.Second),
|
||||
}
|
||||
|
||||
err := repos.Snapshots.Create(ctx, nil, snapshot)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create snapshot: %v", err)
|
||||
}
|
||||
|
||||
blobs := []*database.Blob{
|
||||
{Hash: "referenced-1", CompressedSize: 10, UncompressedSize: 100},
|
||||
{Hash: "referenced-2", CompressedSize: 20, UncompressedSize: 200},
|
||||
{Hash: "unreferenced", CompressedSize: 40, UncompressedSize: 400},
|
||||
}
|
||||
|
||||
for _, blob := range blobs {
|
||||
blob.ID = types.NewBlobID()
|
||||
blob.CreatedTS = time.Now().Truncate(time.Second)
|
||||
|
||||
err = repos.Blobs.Create(ctx, nil, blob)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create blob %s: %v", blob.Hash, err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, blob := range blobs[:2] {
|
||||
err = repos.Snapshots.AddBlob(ctx, nil, snapshot.ID.String(),
|
||||
blob.ID, blob.Hash)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to add blob %s to snapshot: %v", blob.Hash, err)
|
||||
}
|
||||
}
|
||||
|
||||
compressed, uncompressed, err := repos.Snapshots.GetSnapshotBlobSizes(
|
||||
ctx, snapshot.ID.String())
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get snapshot blob sizes: %v", err)
|
||||
}
|
||||
|
||||
if compressed != 30 || uncompressed != 300 {
|
||||
t.Errorf("blob sizes: got %d and %d, want 30 and 300",
|
||||
compressed, uncompressed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotRepositoryListRecent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -158,3 +158,19 @@ type UploadStats struct {
|
||||
MinDurationMs int64
|
||||
MaxDurationMs int64
|
||||
}
|
||||
|
||||
// GetCountBySnapshot returns the count of uploads for a specific snapshot
|
||||
func (r *UploadRepository) GetCountBySnapshot(
|
||||
ctx context.Context, snapshotID string,
|
||||
) (int64, error) {
|
||||
query := `SELECT COUNT(*) FROM uploads WHERE snapshot_id = ?`
|
||||
|
||||
var count int64
|
||||
|
||||
err := r.conn.QueryRowContext(ctx, query, snapshotID).Scan(&count)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
+49
-64
@@ -10,18 +10,15 @@ import (
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// ErrAlreadyRunning indicates another vaultik instance is running.
|
||||
var ErrAlreadyRunning = errors.New("another vaultik instance is already running")
|
||||
|
||||
// Lock represents an acquired PID lock: an flock(2) on the PID file,
|
||||
// held while the file stays open. The kernel drops it when the process
|
||||
// exits, however it exits, so a crashed run never leaves the lock held.
|
||||
// Lock represents an acquired PID lock.
|
||||
type Lock struct {
|
||||
file *os.File
|
||||
path string
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -32,9 +29,10 @@ const (
|
||||
)
|
||||
|
||||
// Acquire attempts to acquire a PID lock in the specified directory.
|
||||
// If another process holds the lock, it returns ErrAlreadyRunning with
|
||||
// that process's PID. On success, it writes the current PID to the lock
|
||||
// file and returns a Lock that must be released with Release().
|
||||
// If the lock file exists and the process is still running, it returns
|
||||
// ErrAlreadyRunning with details about the existing process.
|
||||
// On success, it writes the current PID to the lock file and returns
|
||||
// a Lock that must be released with Release().
|
||||
func Acquire(lockDir string) (*Lock, error) {
|
||||
// Ensure lock directory exists
|
||||
err := os.MkdirAll(lockDir, lockDirPerm)
|
||||
@@ -44,82 +42,56 @@ func Acquire(lockDir string) (*Lock, error) {
|
||||
|
||||
lockPath := filepath.Join(lockDir, "vaultik.pid")
|
||||
|
||||
// No O_TRUNC: the file may hold the PID of the process that has the
|
||||
// lock, which the error below reports.
|
||||
file, err := os.OpenFile( //nolint:gosec // G304: path is our own lock file
|
||||
lockPath, os.O_RDWR|os.O_CREATE, pidFilePerm)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("opening PID file: %w", err)
|
||||
}
|
||||
|
||||
err = unix.Flock(int(file.Fd()), unix.LOCK_EX|unix.LOCK_NB)
|
||||
if err != nil {
|
||||
_ = file.Close()
|
||||
|
||||
if errors.Is(err, unix.EWOULDBLOCK) {
|
||||
return nil, alreadyRunningError(lockPath)
|
||||
// Check for existing lock
|
||||
existingPID, err := readPIDFile(lockPath)
|
||||
if err == nil {
|
||||
// Lock file exists, check if process is running
|
||||
if isProcessRunning(existingPID) {
|
||||
return nil, fmt.Errorf("%w (PID %d)", ErrAlreadyRunning, existingPID)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("locking PID file: %w", err)
|
||||
// Process is not running, stale lock file - we can take over
|
||||
}
|
||||
|
||||
err = writePID(file)
|
||||
// Write our PID
|
||||
pid := os.Getpid()
|
||||
|
||||
err = os.WriteFile(lockPath, []byte(strconv.Itoa(pid)), pidFilePerm)
|
||||
if err != nil {
|
||||
_ = file.Close()
|
||||
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("writing PID file: %w", err)
|
||||
}
|
||||
|
||||
return &Lock{file: file}, nil
|
||||
return &Lock{path: lockPath}, nil
|
||||
}
|
||||
|
||||
// Release empties the PID file and closes it, which drops the lock.
|
||||
// Release removes the PID lock file.
|
||||
// It is safe to call Release multiple times.
|
||||
func (l *Lock) Release() error {
|
||||
if l == nil || l.file == nil {
|
||||
if l == nil || l.path == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
file := l.file
|
||||
l.file = nil
|
||||
|
||||
// Do not remove the file here. A process that opened it a moment
|
||||
// earlier could then lock the removed file while another creates and
|
||||
// locks a new one, and both would run.
|
||||
truncateErr := file.Truncate(0)
|
||||
closeErr := file.Close()
|
||||
|
||||
return errors.Join(truncateErr, closeErr)
|
||||
}
|
||||
|
||||
// writePID replaces the contents of the locked PID file with the current
|
||||
// PID.
|
||||
func writePID(file *os.File) error {
|
||||
err := file.Truncate(0)
|
||||
// Verify we still own the lock (our PID is in the file)
|
||||
existingPID, err := readPIDFile(l.path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("truncating PID file: %w", err)
|
||||
// File already gone or unreadable - that's fine
|
||||
return nil //nolint:nilerr // unreadable lock file means nothing to release
|
||||
}
|
||||
|
||||
_, err = file.WriteAt([]byte(strconv.Itoa(os.Getpid())), 0)
|
||||
if err != nil {
|
||||
return fmt.Errorf("writing PID file: %w", err)
|
||||
if existingPID != os.Getpid() {
|
||||
// Someone else wrote to our lock file - don't remove it
|
||||
return nil
|
||||
}
|
||||
|
||||
err = os.Remove(l.path)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("removing PID file: %w", err)
|
||||
}
|
||||
|
||||
l.path = "" // Prevent double-release
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// alreadyRunningError reports that another process holds the lock,
|
||||
// naming its PID when the file holds one. The holder writes its PID just
|
||||
// after it locks, so the file can briefly be empty.
|
||||
func alreadyRunningError(lockPath string) error {
|
||||
pid, err := readPIDFile(lockPath)
|
||||
if err != nil {
|
||||
return ErrAlreadyRunning
|
||||
}
|
||||
|
||||
return fmt.Errorf("%w (PID %d)", ErrAlreadyRunning, pid)
|
||||
}
|
||||
|
||||
// readPIDFile reads and parses the PID from a lock file.
|
||||
func readPIDFile(path string) (int, error) {
|
||||
data, err := os.ReadFile(path) //nolint:gosec // G304: path is our own lock file
|
||||
@@ -134,3 +106,16 @@ func readPIDFile(path string) (int, error) {
|
||||
|
||||
return pid, nil
|
||||
}
|
||||
|
||||
// isProcessRunning checks if a process with the given PID is running.
|
||||
func isProcessRunning(pid int) bool {
|
||||
process, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// On Unix, FindProcess always succeeds. We need to send signal 0 to check.
|
||||
err = process.Signal(syscall.Signal(0))
|
||||
|
||||
return err == nil
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -34,10 +33,9 @@ func TestAcquireAndRelease(t *testing.T) {
|
||||
err = lock.Release()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify PID file is empty
|
||||
data, err = os.ReadFile(pidPath) //nolint:gosec // G304: test's own temp file
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, data)
|
||||
// Verify PID file is gone
|
||||
_, err = os.Stat(pidPath)
|
||||
assert.True(t, os.IsNotExist(err))
|
||||
}
|
||||
|
||||
func TestAcquireBlocksSecondInstance(t *testing.T) {
|
||||
@@ -57,64 +55,6 @@ func TestAcquireBlocksSecondInstance(t *testing.T) {
|
||||
lock2, err := pidlock.Acquire(tmpDir)
|
||||
require.ErrorIs(t, err, pidlock.ErrAlreadyRunning)
|
||||
assert.Nil(t, lock2)
|
||||
|
||||
// Once the first lock is released, the next Acquire succeeds
|
||||
require.NoError(t, lock1.Release())
|
||||
|
||||
lock3, err := pidlock.Acquire(tmpDir)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, lock3.Release())
|
||||
}
|
||||
|
||||
// TestConcurrentAcquireAdmitsOne starts many Acquire calls at the same
|
||||
// moment, as two cron entries firing together would, and checks that
|
||||
// exactly one of them gets the lock.
|
||||
func TestConcurrentAcquireAdmitsOne(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const callers = 50
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
start := make(chan struct{})
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
acquired []*pidlock.Lock
|
||||
failures []error
|
||||
wg sync.WaitGroup
|
||||
)
|
||||
|
||||
for range callers {
|
||||
wg.Go(func() {
|
||||
<-start
|
||||
|
||||
lock, err := pidlock.Acquire(tmpDir)
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
if err != nil {
|
||||
failures = append(failures, err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
acquired = append(acquired, lock)
|
||||
})
|
||||
}
|
||||
|
||||
close(start)
|
||||
wg.Wait()
|
||||
|
||||
for _, lock := range acquired {
|
||||
require.NoError(t, lock.Release())
|
||||
}
|
||||
|
||||
assert.Len(t, acquired, 1, "exactly one caller should hold the lock")
|
||||
|
||||
for _, err := range failures {
|
||||
require.ErrorIs(t, err, pidlock.ErrAlreadyRunning)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcquireWithStaleLock(t *testing.T) {
|
||||
|
||||
+5
-23
@@ -27,12 +27,10 @@ type Client struct {
|
||||
bucket string
|
||||
prefix string
|
||||
endpoint string
|
||||
partSize int64
|
||||
}
|
||||
|
||||
// Config contains S3 client configuration.
|
||||
// All fields are required except Prefix, which defaults to an empty string,
|
||||
// and PartSize, where zero means the SDK default of 5 MiB.
|
||||
// All fields are required except Prefix, which defaults to an empty string.
|
||||
// A non-empty Prefix is joined to every key with one "/", whether or not
|
||||
// it ends with one.
|
||||
// The Endpoint field should include the protocol (http:// or https://).
|
||||
@@ -43,9 +41,6 @@ type Config struct {
|
||||
AccessKeyID string
|
||||
SecretAccessKey string
|
||||
Region string
|
||||
// PartSize is the size in bytes of each part of a multipart upload.
|
||||
// An upload too large for S3's limit of 10,000 parts gets larger parts.
|
||||
PartSize int64
|
||||
}
|
||||
|
||||
// nopLogger is a logger that discards all output.
|
||||
@@ -95,7 +90,6 @@ func NewClient(ctx context.Context, cfg Config) (*Client, error) {
|
||||
bucket: cfg.Bucket,
|
||||
prefix: prefix,
|
||||
endpoint: cfg.Endpoint,
|
||||
partSize: cfg.PartSize,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -129,9 +123,12 @@ func (c *Client) PutObjectWithProgress(
|
||||
) error {
|
||||
fullKey := c.prefix + key
|
||||
|
||||
// uploadPartSize is 10MB for better progress granularity.
|
||||
const uploadPartSize = 10 * 1024 * 1024
|
||||
|
||||
// Create an uploader with the S3 client
|
||||
uploader := manager.NewUploader(c.s3Client, func(u *manager.Uploader) {
|
||||
u.PartSize = uploadPartSize(c.partSize, size)
|
||||
u.PartSize = uploadPartSize
|
||||
})
|
||||
|
||||
// Create a progress reader that tracks upload progress
|
||||
@@ -152,21 +149,6 @@ func (c *Client) PutObjectWithProgress(
|
||||
return err
|
||||
}
|
||||
|
||||
// uploadPartSize returns the part size for an upload of size bytes: the
|
||||
// configured part size (the SDK default when zero), raised where needed so
|
||||
// the upload fits in S3's limit of 10,000 parts. The uploader cannot raise
|
||||
// it itself, because it cannot seek the progress reader to learn its size.
|
||||
func uploadPartSize(configured, size int64) int64 {
|
||||
if configured == 0 {
|
||||
configured = manager.DefaultUploadPartSize
|
||||
}
|
||||
|
||||
maxParts := int64(manager.MaxUploadParts)
|
||||
smallestThatFits := (size + maxParts - 1) / maxParts // rounded up
|
||||
|
||||
return max(configured, smallestThatFits)
|
||||
}
|
||||
|
||||
// GetObject downloads an object from S3 with the specified key.
|
||||
// The key is automatically prefixed with the configured prefix.
|
||||
// Returns a ReadCloser containing the object data. The caller must
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
package s3
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestUploadPartSize checks that an upload too large for 10,000 parts of the
|
||||
// configured size gets parts just large enough to fit in 10,000.
|
||||
func TestUploadPartSize(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const mib = 1024 * 1024
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
configured int64
|
||||
size int64
|
||||
want int64
|
||||
}{
|
||||
{
|
||||
name: "an upload that fits keeps the configured size",
|
||||
configured: 5 * mib,
|
||||
size: 10 * 1024 * mib,
|
||||
want: 5 * mib,
|
||||
},
|
||||
{
|
||||
name: "exactly 10,000 parts keeps the configured size",
|
||||
configured: 6 * mib,
|
||||
size: 10_000 * 6 * mib,
|
||||
want: 6 * mib,
|
||||
},
|
||||
{
|
||||
name: "one byte more than 10,000 parts adds a byte to each",
|
||||
configured: 6 * mib,
|
||||
size: 10_000*6*mib + 1,
|
||||
want: 6*mib + 1,
|
||||
},
|
||||
{
|
||||
name: "zero means the SDK default of 5MiB",
|
||||
configured: 0,
|
||||
size: 1,
|
||||
want: 5 * mib,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := uploadPartSize(tt.configured, tt.size)
|
||||
if got != tt.want {
|
||||
t.Errorf("uploadPartSize(%d, %d) = %d, want %d",
|
||||
tt.configured, tt.size, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,6 @@ func provideClient(lc fx.Lifecycle, cfg *config.Config) (*Client, error) {
|
||||
AccessKeyID: cfg.S3.AccessKeyID,
|
||||
SecretAccessKey: cfg.S3.SecretAccessKey,
|
||||
Region: cfg.S3.Region,
|
||||
PartSize: cfg.S3.PartSize.Int64(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -56,27 +56,24 @@ const (
|
||||
|
||||
// ProgressStats holds atomic counters for progress tracking
|
||||
type ProgressStats struct {
|
||||
FilesScanned atomic.Int64 // Total files seen during scan (includes skipped)
|
||||
FilesProcessed atomic.Int64 // Files actually processed in phase 2
|
||||
FilesSkipped atomic.Int64 // Files skipped due to no changes
|
||||
BytesScanned atomic.Int64 // Bytes from new/changed files only
|
||||
BytesSkipped atomic.Int64 // Bytes from unchanged files
|
||||
BytesProcessed atomic.Int64 // Actual bytes processed (for ETA calculation)
|
||||
ChunksCreated atomic.Int64
|
||||
BlobsCreated atomic.Int64
|
||||
BlobsUploaded atomic.Int64
|
||||
BytesUploaded atomic.Int64
|
||||
CurrentFile atomic.Value // stores string
|
||||
TotalSize atomic.Int64 // Size to process in the paths scanned so far
|
||||
TotalFiles atomic.Int64 // Files to process in the paths scanned so far
|
||||
StartTime time.Time
|
||||
mu sync.RWMutex
|
||||
lastDetailTime time.Time
|
||||
|
||||
// Guarded by mu: when the last scan phase ended, and
|
||||
// BytesProcessed at that moment.
|
||||
processStartTime time.Time
|
||||
processStartBytes int64
|
||||
FilesScanned atomic.Int64 // Total files seen during scan (includes skipped)
|
||||
FilesProcessed atomic.Int64 // Files actually processed in phase 2
|
||||
FilesSkipped atomic.Int64 // Files skipped due to no changes
|
||||
BytesScanned atomic.Int64 // Bytes from new/changed files only
|
||||
BytesSkipped atomic.Int64 // Bytes from unchanged files
|
||||
BytesProcessed atomic.Int64 // Actual bytes processed (for ETA calculation)
|
||||
ChunksCreated atomic.Int64
|
||||
BlobsCreated atomic.Int64
|
||||
BlobsUploaded atomic.Int64
|
||||
BytesUploaded atomic.Int64
|
||||
UploadDurationMs atomic.Int64 // Total milliseconds spent uploading
|
||||
CurrentFile atomic.Value // stores string
|
||||
TotalSize atomic.Int64 // Total size to process (set after scan phase)
|
||||
TotalFiles atomic.Int64 // Total files to process in phase 2
|
||||
ProcessStartTime atomic.Value // stores time.Time when processing starts
|
||||
StartTime time.Time
|
||||
mu sync.RWMutex
|
||||
lastDetailTime time.Time
|
||||
|
||||
// Upload tracking
|
||||
CurrentUpload atomic.Value // stores *UploadInfo
|
||||
@@ -152,36 +149,10 @@ func (pr *ProgressReporter) GetStats() *ProgressStats {
|
||||
return pr.stats
|
||||
}
|
||||
|
||||
// AddTotalSize adds the size one path of the snapshot has to process to
|
||||
// the total, once that path's scan phase is done, and starts measuring
|
||||
// the processing rate again. The processed counts run across every path,
|
||||
// so the total does too. Restarting the rate keeps a path's scan phase,
|
||||
// which processes nothing, out of the rate while that path is processed.
|
||||
// During a later path's scan phase the rate is still the previous
|
||||
// path's, and falls as that scan goes on.
|
||||
func (pr *ProgressReporter) AddTotalSize(size int64) {
|
||||
pr.stats.TotalSize.Add(size)
|
||||
|
||||
pr.stats.mu.Lock()
|
||||
defer pr.stats.mu.Unlock()
|
||||
|
||||
pr.stats.processStartTime = time.Now().UTC()
|
||||
pr.stats.processStartBytes = pr.stats.BytesProcessed.Load()
|
||||
}
|
||||
|
||||
// processRate returns the bytes processed per second since the last scan
|
||||
// phase ended, or 0 before the first path's scan phase is done.
|
||||
func (s *ProgressStats) processRate() float64 {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
if s.processStartTime.IsZero() {
|
||||
return 0
|
||||
}
|
||||
|
||||
processed := s.BytesProcessed.Load() - s.processStartBytes
|
||||
|
||||
return float64(processed) / time.Since(s.processStartTime).Seconds()
|
||||
// SetTotalSize sets the total size to process (after scan phase)
|
||||
func (pr *ProgressReporter) SetTotalSize(size int64) {
|
||||
pr.stats.TotalSize.Store(size)
|
||||
pr.stats.ProcessStartTime.Store(time.Now().UTC())
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
@@ -260,6 +231,9 @@ func (pr *ProgressReporter) ReportUploadComplete(
|
||||
// Clear current upload
|
||||
pr.stats.CurrentUpload.Store((*UploadInfo)(nil))
|
||||
|
||||
// Add to total upload duration
|
||||
pr.stats.UploadDurationMs.Add(duration.Milliseconds())
|
||||
|
||||
// Calculate speed
|
||||
if duration < time.Millisecond {
|
||||
duration = time.Millisecond
|
||||
@@ -387,12 +361,19 @@ func (pr *ProgressReporter) printSummaryStatus() {
|
||||
// Calculate ETA if we have total size and are processing
|
||||
etaStr := ""
|
||||
|
||||
processRate := pr.stats.processRate()
|
||||
if totalSize > 0 && processRate > 0 {
|
||||
remainingBytes := totalSize - bytesProcessed
|
||||
remainingSeconds := float64(remainingBytes) / processRate
|
||||
eta := time.Duration(remainingSeconds * float64(time.Second))
|
||||
etaStr = " | ETA: " + formatDuration(eta)
|
||||
if totalSize > 0 && bytesProcessed > 0 {
|
||||
processStart, ok := pr.stats.ProcessStartTime.Load().(time.Time)
|
||||
if ok && !processStart.IsZero() {
|
||||
processElapsed := time.Since(processStart)
|
||||
|
||||
rate := float64(bytesProcessed) / processElapsed.Seconds()
|
||||
if rate > 0 {
|
||||
remainingBytes := totalSize - bytesProcessed
|
||||
remainingSeconds := float64(remainingBytes) / rate
|
||||
eta := time.Duration(remainingSeconds * float64(time.Second))
|
||||
etaStr = " | ETA: " + formatDuration(eta)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rate := float64(bytesScanned+bytesSkipped) / elapsed.Seconds()
|
||||
@@ -444,18 +425,25 @@ func (pr *ProgressReporter) printDetailedStatus() {
|
||||
log.Info("Elapsed time", "duration", formatDuration(elapsed))
|
||||
|
||||
// Calculate and show ETA if we have data
|
||||
processRate := pr.stats.processRate()
|
||||
if totalSize > 0 && processRate > 0 {
|
||||
remainingBytes := totalSize - bytesProcessed
|
||||
remainingSeconds := float64(remainingBytes) / processRate
|
||||
eta := time.Duration(remainingSeconds * float64(time.Second))
|
||||
percentComplete := float64(bytesProcessed) / float64(totalSize) * percentScale
|
||||
log.Info("Overall progress",
|
||||
"percent", fmt.Sprintf("%.1f%%", percentComplete),
|
||||
"processed", humanize.Bytes(safeUint64(bytesProcessed)),
|
||||
"total", humanize.Bytes(safeUint64(totalSize)),
|
||||
"rate", humanize.Bytes(uint64(processRate))+"/s",
|
||||
"eta", formatDuration(eta))
|
||||
if totalSize > 0 && bytesProcessed > 0 {
|
||||
processStart, ok := pr.stats.ProcessStartTime.Load().(time.Time)
|
||||
if ok && !processStart.IsZero() {
|
||||
processElapsed := time.Since(processStart)
|
||||
|
||||
processRate := float64(bytesProcessed) / processElapsed.Seconds()
|
||||
if processRate > 0 {
|
||||
remainingBytes := totalSize - bytesProcessed
|
||||
remainingSeconds := float64(remainingBytes) / processRate
|
||||
eta := time.Duration(remainingSeconds * float64(time.Second))
|
||||
percentComplete := float64(bytesProcessed) / float64(totalSize) * percentScale
|
||||
log.Info("Overall progress",
|
||||
"percent", fmt.Sprintf("%.1f%%", percentComplete),
|
||||
"processed", humanize.Bytes(safeUint64(bytesProcessed)),
|
||||
"total", humanize.Bytes(safeUint64(totalSize)),
|
||||
"rate", humanize.Bytes(uint64(processRate))+"/s",
|
||||
"eta", formatDuration(eta))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.Info("Files processed",
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
//nolint:testpackage // exercises the unexported processRate helper
|
||||
package snapshot
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestProcessRateNotLoweredByLaterScanPhase feeds the reporter two paths
|
||||
// that each take 10 seconds to process, the second after a 30-second scan
|
||||
// phase. That scan phase processes nothing, so the rate while the second
|
||||
// path is processed must be that path's own.
|
||||
func TestProcessRateNotLoweredByLaterScanPhase(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
pathSize = 1000
|
||||
processingTime = 10 * time.Second
|
||||
scanTime = 30 * time.Second
|
||||
)
|
||||
|
||||
// Never started, but Stop releases its tickers and signal handler.
|
||||
progress := NewProgressReporter()
|
||||
defer progress.Stop()
|
||||
|
||||
stats := progress.GetStats()
|
||||
|
||||
// Moves the processing start time back instead of sleeping.
|
||||
elapse := func(d time.Duration) {
|
||||
stats.mu.Lock()
|
||||
defer stats.mu.Unlock()
|
||||
|
||||
stats.processStartTime = stats.processStartTime.Add(-d)
|
||||
}
|
||||
|
||||
progress.AddTotalSize(pathSize)
|
||||
stats.BytesProcessed.Add(pathSize)
|
||||
elapse(processingTime)
|
||||
|
||||
elapse(scanTime)
|
||||
progress.AddTotalSize(pathSize)
|
||||
stats.BytesProcessed.Add(pathSize)
|
||||
elapse(processingTime)
|
||||
|
||||
want := pathSize / processingTime.Seconds()
|
||||
got := stats.processRate()
|
||||
|
||||
// The test's own run time adds to the elapsed time, so got is a hair
|
||||
// under want.
|
||||
if got > want || got < want*0.99 {
|
||||
t.Errorf("rate while the second path is processed is %.1f bytes/s, "+
|
||||
"want %.1f", got, want)
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
package snapshot_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"sneak.berlin/go/vaultik/internal/database"
|
||||
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||
)
|
||||
|
||||
// TestProgressPercentWithSecondPathSmaller backs up two paths with one
|
||||
// scanner, as a snapshot with two paths does, the second path smaller
|
||||
// than the first. The progress line divides the bytes processed by the
|
||||
// total size, so both must count both paths to stay within 100%.
|
||||
func TestProgressPercentWithSecondPathSmaller(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
files := map[string]string{
|
||||
"/large/one.txt": strings.Repeat("1", 4000),
|
||||
"/large/two.txt": strings.Repeat("2", 4000),
|
||||
"/small/three.txt": strings.Repeat("3", 1000),
|
||||
}
|
||||
|
||||
for path, content := range files {
|
||||
err := fs.MkdirAll(filepath.Dir(path), 0755)
|
||||
if err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
|
||||
err = afero.WriteFile(fs, path, []byte(content), 0644)
|
||||
if err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
db, err := database.NewTestDB()
|
||||
if err != nil {
|
||||
t.Fatalf("create test db: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
cerr := db.Close()
|
||||
if cerr != nil {
|
||||
t.Errorf("close db: %v", cerr)
|
||||
}
|
||||
})
|
||||
|
||||
repos := database.NewRepositories(db)
|
||||
|
||||
scanner := snapshot.NewScanner(snapshot.ScannerConfig{
|
||||
FS: fs,
|
||||
ChunkSize: int64(1024 * 16),
|
||||
Repositories: repos,
|
||||
MaxBlobSize: int64(1024 * 1024),
|
||||
CompressionLevel: 3,
|
||||
AgeRecipients: []string{testAgePublicKey},
|
||||
EnableProgress: true,
|
||||
})
|
||||
|
||||
// Never started, but Stop releases its tickers and signal handler.
|
||||
progress := scanner.GetProgress()
|
||||
defer progress.Stop()
|
||||
|
||||
ctx := context.Background()
|
||||
snapshotID := "test-snapshot-progress"
|
||||
createTestSnapshotRecord(ctx, t, repos, snapshotID)
|
||||
|
||||
for _, path := range []string{"/large", "/small"} {
|
||||
_, err := scanner.Scan(ctx, path, snapshotID)
|
||||
if err != nil {
|
||||
t.Fatalf("scanning %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
stats := progress.GetStats()
|
||||
|
||||
// Directories count toward the total size but produce no chunks, so
|
||||
// the percentage ends just under 100%.
|
||||
percent := float64(stats.BytesProcessed.Load()) /
|
||||
float64(stats.TotalSize.Load()) * 100
|
||||
if percent > 100 {
|
||||
t.Errorf("progress after both paths is %.1f%%, want at most 100%%",
|
||||
percent)
|
||||
}
|
||||
|
||||
if stats.FilesProcessed.Load() != stats.TotalFiles.Load() {
|
||||
t.Errorf("progress after both paths is %d of %d files, want all",
|
||||
stats.FilesProcessed.Load(), stats.TotalFiles.Load())
|
||||
}
|
||||
}
|
||||
+113
-122
@@ -58,8 +58,8 @@ type Scanner struct {
|
||||
compressionLevel int
|
||||
ageRecipient string
|
||||
snapshotID string // Current snapshot being processed
|
||||
// currentSourcePath is the source directory being scanned, stored with
|
||||
// each file record.
|
||||
// currentSourcePath is the source directory being scanned (used for
|
||||
// restore path stripping).
|
||||
currentSourcePath string
|
||||
exclude []string // Glob patterns for files/directories to exclude
|
||||
compiledExclude []compiledPattern // Compiled glob patterns
|
||||
@@ -92,6 +92,9 @@ type Scanner struct {
|
||||
|
||||
// Mutex for coordinating blob creation
|
||||
packerMu sync.Mutex // Blocks chunk production during blob creation
|
||||
|
||||
// Context for cancellation
|
||||
scanCtx context.Context //nolint:containedctx // set per-Scan for packer callbacks
|
||||
}
|
||||
|
||||
// Periodic status output intervals and thresholds for the scan and
|
||||
@@ -131,26 +134,18 @@ type ScannerConfig struct {
|
||||
SkipErrors bool
|
||||
}
|
||||
|
||||
// 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. FilesFailed counts the
|
||||
// new and changed files that phase 2 could not store; FilesScanned
|
||||
// includes them and BytesScanned does not.
|
||||
// ScanResult contains the results of a scan operation
|
||||
type ScanResult struct {
|
||||
FilesScanned int
|
||||
FilesSkipped int
|
||||
FilesFailed int
|
||||
FilesDeleted int
|
||||
BytesScanned int64
|
||||
BytesSkipped int64
|
||||
BytesDeleted int64
|
||||
ChunksCreated int
|
||||
BlobsCreated int
|
||||
BlobsUploaded int
|
||||
BytesUploaded int64
|
||||
UploadDuration time.Duration
|
||||
StartTime time.Time
|
||||
EndTime time.Time
|
||||
FilesScanned int
|
||||
FilesSkipped int
|
||||
FilesDeleted int
|
||||
BytesScanned int64
|
||||
BytesSkipped int64
|
||||
BytesDeleted int64
|
||||
ChunksCreated int
|
||||
BlobsCreated int
|
||||
StartTime time.Time
|
||||
EndTime time.Time
|
||||
}
|
||||
|
||||
// NewScanner creates a new scanner instance
|
||||
@@ -214,7 +209,9 @@ func (s *Scanner) Scan(
|
||||
ctx context.Context, path string, snapshotID string,
|
||||
) (*ScanResult, error) {
|
||||
s.snapshotID = snapshotID
|
||||
// Store source path for file records (used during restore)
|
||||
s.currentSourcePath = path
|
||||
s.scanCtx = ctx
|
||||
result := &ScanResult{
|
||||
StartTime: time.Now().UTC(),
|
||||
}
|
||||
@@ -222,13 +219,17 @@ func (s *Scanner) Scan(
|
||||
// Set blob handler for concurrent upload
|
||||
if s.storage != nil {
|
||||
log.Debug("Setting blob handler for storage uploads")
|
||||
s.packer.SetBlobHandler(func(blobWithReader *blob.WithReader) error {
|
||||
return s.handleBlobReady(ctx, blobWithReader, result)
|
||||
})
|
||||
s.packer.SetBlobHandler(s.handleBlobReady)
|
||||
} else {
|
||||
log.Debug("No storage configured, blobs will not be uploaded")
|
||||
}
|
||||
|
||||
// Start progress reporting if enabled
|
||||
if s.progress != nil {
|
||||
s.progress.Start()
|
||||
defer s.progress.Stop()
|
||||
}
|
||||
|
||||
// Phase 0: Repair any state left by an interrupted previous run, then
|
||||
// load known files and chunks from the database into memory for fast
|
||||
// lookup.
|
||||
@@ -293,14 +294,13 @@ func (s *Scanner) Scan(
|
||||
log.Info("Phase 2/3: Skipping (no files need processing, metadata-only snapshot)")
|
||||
}
|
||||
|
||||
result.EndTime = time.Now().UTC()
|
||||
// Finalize result with blob statistics
|
||||
s.finalizeScanResult(ctx, result)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetProgress returns the progress reporter for this scanner, or nil when
|
||||
// progress is off. Scan neither starts nor stops it: the caller does,
|
||||
// once for all the paths it scans, because a second Stop panics.
|
||||
// GetProgress returns the progress reporter for this scanner
|
||||
func (s *Scanner) GetProgress() *ProgressReporter {
|
||||
return s.progress
|
||||
}
|
||||
@@ -410,8 +410,8 @@ func (s *Scanner) summarizeScanPhase(
|
||||
}
|
||||
|
||||
if s.progress != nil {
|
||||
s.progress.AddTotalSize(totalSizeToProcess)
|
||||
s.progress.GetStats().TotalFiles.Add(int64(len(filesToProcess)))
|
||||
s.progress.SetTotalSize(totalSizeToProcess)
|
||||
s.progress.GetStats().TotalFiles.Store(int64(len(filesToProcess)))
|
||||
}
|
||||
|
||||
log.Info("Phase 1 complete",
|
||||
@@ -435,6 +435,27 @@ func (s *Scanner) summarizeScanPhase(
|
||||
s.ui.Completef("%s.", msg)
|
||||
}
|
||||
|
||||
// finalizeScanResult populates final blob statistics in the scan result
|
||||
// by querying the packer and database for blob/upload counts
|
||||
func (s *Scanner) finalizeScanResult(ctx context.Context, result *ScanResult) {
|
||||
blobs := s.packer.GetFinishedBlobs()
|
||||
result.BlobsCreated += len(blobs)
|
||||
|
||||
// Query database for actual blob count created during this snapshot
|
||||
// The database is authoritative, especially for concurrent blob uploads
|
||||
// We count uploads rather than all snapshot_blobs to get only NEW blobs
|
||||
if s.snapshotID != "" {
|
||||
uploadCount, err := s.repos.Uploads.GetCountBySnapshot(ctx, s.snapshotID)
|
||||
if err != nil {
|
||||
log.Warn("Failed to query upload count from database", "error", err)
|
||||
} else {
|
||||
result.BlobsCreated = int(uploadCount)
|
||||
}
|
||||
}
|
||||
|
||||
result.EndTime = time.Now().UTC()
|
||||
}
|
||||
|
||||
// loadKnownFiles loads the known files at and beneath path from the
|
||||
// database into a map for fast lookup. Every loaded file the scan does
|
||||
// not find is counted as deleted. This avoids per-file database queries
|
||||
@@ -892,10 +913,9 @@ func (s *Scanner) scanPhase(
|
||||
}
|
||||
|
||||
// Handle symlinks and directories
|
||||
handled, err := s.recordSpecialEntry(
|
||||
filePath, info, existingFiles, collector, result)
|
||||
if handled {
|
||||
return err
|
||||
if handled := s.recordSpecialEntry(
|
||||
filePath, info, existingFiles, collector, result); handled {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Skip other non-regular files (devices, sockets, etc.)
|
||||
@@ -937,25 +957,22 @@ func (s *Scanner) scanPhase(
|
||||
}
|
||||
|
||||
// recordSpecialEntry records symlinks and directories (which have no
|
||||
// data to chunk) and reports whether it handled the entry. For a symlink
|
||||
// whose target cannot be read it returns handleWalkError's result.
|
||||
// data to chunk) and reports whether it handled the entry.
|
||||
func (s *Scanner) recordSpecialEntry(
|
||||
filePath string, info os.FileInfo,
|
||||
existingFiles map[string]struct{},
|
||||
collector *scanCollector, result *ScanResult,
|
||||
) (bool, error) {
|
||||
) bool {
|
||||
// Handle symlinks
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
file, err := s.buildSymlinkEntry(filePath, info)
|
||||
if err != nil {
|
||||
return true, s.handleWalkError(filePath, err)
|
||||
file := s.buildSymlinkEntry(filePath, info)
|
||||
if file != nil {
|
||||
existingFiles[filePath] = struct{}{}
|
||||
collector.addToProcess(filePath, info, file)
|
||||
s.updateScanEntryStats(result, true, info)
|
||||
}
|
||||
|
||||
existingFiles[filePath] = struct{}{}
|
||||
collector.addToProcess(filePath, info, file)
|
||||
s.updateScanEntryStats(result, true, info)
|
||||
|
||||
return true, nil
|
||||
return true
|
||||
}
|
||||
|
||||
// Handle directories (record for permission/ownership preservation
|
||||
@@ -965,10 +982,10 @@ func (s *Scanner) recordSpecialEntry(
|
||||
existingFiles[filePath] = struct{}{}
|
||||
collector.addToProcess(filePath, info, file)
|
||||
|
||||
return true, nil
|
||||
return true
|
||||
}
|
||||
|
||||
return false, nil
|
||||
return false
|
||||
}
|
||||
|
||||
// handleWalkError deals with a filesystem error surfaced by the walk:
|
||||
@@ -1118,12 +1135,13 @@ func (s *Scanner) printScanProgressLine(
|
||||
}
|
||||
|
||||
// buildSymlinkEntry creates a File record for a symlink.
|
||||
func (s *Scanner) buildSymlinkEntry(
|
||||
path string, info os.FileInfo,
|
||||
) (*database.File, error) {
|
||||
// Returns nil if the link target cannot be read.
|
||||
func (s *Scanner) buildSymlinkEntry(path string, info os.FileInfo) *database.File {
|
||||
target, err := os.Readlink(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
log.Debug("Cannot read symlink target", "path", path, "error", err)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var uid, gid uint32
|
||||
@@ -1142,7 +1160,7 @@ func (s *Scanner) buildSymlinkEntry(
|
||||
UID: uid,
|
||||
GID: gid,
|
||||
LinkTarget: types.FilePath(target),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// buildDirectoryEntry creates a File record for a directory.
|
||||
@@ -1203,8 +1221,9 @@ func (s *Scanner) checkFileInMemory(
|
||||
}
|
||||
|
||||
file := &database.File{
|
||||
ID: fileID,
|
||||
Path: types.FilePath(path),
|
||||
ID: fileID,
|
||||
Path: types.FilePath(path),
|
||||
// Store source directory for restore path stripping
|
||||
SourcePath: types.SourcePath(s.currentSourcePath),
|
||||
MTime: info.ModTime(),
|
||||
Size: info.Size(),
|
||||
@@ -1225,7 +1244,7 @@ func (s *Scanner) checkFileInMemory(
|
||||
|
||||
// Check if file has changed
|
||||
if existingFile.Size != file.Size ||
|
||||
!existingFile.MTime.Equal(file.MTime) ||
|
||||
existingFile.MTime.Unix() != file.MTime.Unix() ||
|
||||
existingFile.Mode != file.Mode ||
|
||||
existingFile.UID != file.UID ||
|
||||
existingFile.GID != file.GID {
|
||||
@@ -1295,11 +1314,6 @@ 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 {
|
||||
@@ -1317,13 +1331,6 @@ 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)
|
||||
@@ -1360,32 +1367,13 @@ func (s *Scanner) processPhase(
|
||||
return s.finalizeProcessPhase(ctx, result)
|
||||
}
|
||||
|
||||
// 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).
|
||||
// processFileWithErrorHandling wraps processFileStreaming 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
|
||||
@@ -1399,7 +1387,7 @@ func (s *Scanner) processFileWithErrorHandling(
|
||||
log.Warn("File was deleted during backup, skipping",
|
||||
"path", fileToProcess.Path)
|
||||
|
||||
countFailedFile(fileToProcess, result)
|
||||
result.FilesSkipped++
|
||||
|
||||
return true, nil
|
||||
}
|
||||
@@ -1410,7 +1398,7 @@ func (s *Scanner) processFileWithErrorHandling(
|
||||
s.ui.Errorf("Failed to process %s: %v. Skipping (--skip-errors).",
|
||||
s.ui.Path(fileToProcess.Path), err)
|
||||
|
||||
countFailedFile(fileToProcess, result)
|
||||
result.FilesSkipped++
|
||||
|
||||
return true, nil
|
||||
}
|
||||
@@ -1421,13 +1409,6 @@ 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(
|
||||
@@ -1534,24 +1515,24 @@ func (s *Scanner) finalizeProcessPhase(ctx context.Context, result *ScanResult)
|
||||
}
|
||||
|
||||
// handleBlobReady is called by the packer when a blob is finalized
|
||||
func (s *Scanner) handleBlobReady(
|
||||
ctx context.Context, blobWithReader *blob.WithReader, result *ScanResult,
|
||||
) error {
|
||||
func (s *Scanner) handleBlobReady(blobWithReader *blob.WithReader) error {
|
||||
startTime := time.Now().UTC()
|
||||
finishedBlob := blobWithReader.FinishedBlob
|
||||
|
||||
result.BlobsCreated++
|
||||
|
||||
if s.progress != nil {
|
||||
s.progress.ReportUploadStart(finishedBlob.Hash, finishedBlob.Compressed)
|
||||
s.progress.GetStats().BlobsCreated.Add(1)
|
||||
}
|
||||
|
||||
ctx := s.scanCtx
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
blobPath := fmt.Sprintf("blobs/%s/%s/%s",
|
||||
finishedBlob.Hash[:2], finishedBlob.Hash[2:4], finishedBlob.Hash)
|
||||
|
||||
blobExists, err := s.uploadBlobIfNeeded(
|
||||
ctx, blobPath, blobWithReader, startTime, result)
|
||||
blobExists, err := s.uploadBlobIfNeeded(ctx, blobPath, blobWithReader, startTime)
|
||||
if err != nil {
|
||||
s.cleanupBlobTempFile(blobWithReader)
|
||||
|
||||
@@ -1586,7 +1567,6 @@ func (s *Scanner) uploadBlobIfNeeded(
|
||||
blobPath string,
|
||||
blobWithReader *blob.WithReader,
|
||||
startTime time.Time,
|
||||
result *ScanResult,
|
||||
) (bool, error) {
|
||||
finishedBlob := blobWithReader.FinishedBlob
|
||||
|
||||
@@ -1622,10 +1602,6 @@ func (s *Scanner) uploadBlobIfNeeded(
|
||||
uploadDuration := time.Since(startTime)
|
||||
uploadSpeedBps := float64(finishedBlob.Compressed) / uploadDuration.Seconds()
|
||||
|
||||
result.BlobsUploaded++
|
||||
result.BytesUploaded += finishedBlob.Compressed
|
||||
result.UploadDuration += uploadDuration
|
||||
|
||||
s.ui.Completef("Uploaded blob %s (%s) in %s at %s.",
|
||||
s.ui.Hex(finishedBlob.Hash),
|
||||
s.ui.Size(finishedBlob.Compressed),
|
||||
@@ -1796,11 +1772,16 @@ func (e *packerError) Error() string { return e.err.Error() }
|
||||
|
||||
func (e *packerError) Unwrap() error { return e.err }
|
||||
|
||||
// processFileStreaming processes a regular file by streaming chunks directly
|
||||
// to the packer
|
||||
// processFileStreaming processes a 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))
|
||||
@@ -1836,9 +1817,9 @@ func (s *Scanner) processFileStreaming(
|
||||
size: chunk.Size,
|
||||
})
|
||||
|
||||
if !chunkExists {
|
||||
s.updateChunkStats(chunk.Size, result)
|
||||
s.updateChunkStats(chunkExists, chunk.Size, result)
|
||||
|
||||
if !chunkExists {
|
||||
err := s.addChunkToPacker(ctx, chunk)
|
||||
if err != nil {
|
||||
// Mark as a packer error so --skip-errors cannot swallow it:
|
||||
@@ -1866,16 +1847,26 @@ 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.
|
||||
func (s *Scanner) updateChunkStats(chunkSize int64, result *ScanResult) {
|
||||
result.ChunksCreated++
|
||||
// updateChunkStats updates scan result and progress stats for a processed chunk
|
||||
func (s *Scanner) updateChunkStats(
|
||||
chunkExists bool, chunkSize int64, result *ScanResult,
|
||||
) {
|
||||
if chunkExists {
|
||||
result.FilesSkipped++
|
||||
|
||||
if s.progress != nil {
|
||||
s.progress.GetStats().ChunksCreated.Add(1)
|
||||
s.progress.GetStats().BytesProcessed.Add(chunkSize)
|
||||
s.progress.UpdateChunkingActivity()
|
||||
result.BytesSkipped += chunkSize
|
||||
if s.progress != nil {
|
||||
s.progress.GetStats().BytesSkipped.Add(chunkSize)
|
||||
}
|
||||
} else {
|
||||
result.ChunksCreated++
|
||||
result.BytesScanned += chunkSize
|
||||
|
||||
if s.progress != nil {
|
||||
s.progress.GetStats().ChunksCreated.Add(1)
|
||||
s.progress.GetStats().BytesProcessed.Add(chunkSize)
|
||||
s.progress.UpdateChunkingActivity()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ package snapshot_test
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -14,7 +13,6 @@ import (
|
||||
"github.com/spf13/afero"
|
||||
"sneak.berlin/go/vaultik/internal/database"
|
||||
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||
"sneak.berlin/go/vaultik/internal/ui"
|
||||
)
|
||||
|
||||
// errSimTempFail is the one-time temp-file creation failure blobTempFailFs
|
||||
@@ -82,56 +80,6 @@ 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.
|
||||
type linkRemovedAfterLstatFs struct {
|
||||
afero.OsFs
|
||||
|
||||
t *testing.T
|
||||
target string
|
||||
}
|
||||
|
||||
func (f *linkRemovedAfterLstatFs) LstatIfPossible(
|
||||
name string,
|
||||
) (os.FileInfo, bool, error) {
|
||||
info, lstatCalled, err := f.OsFs.LstatIfPossible(name)
|
||||
if err == nil && name == f.target {
|
||||
rmErr := os.Remove(name)
|
||||
if rmErr != nil {
|
||||
f.t.Errorf("removing %s: %v", name, rmErr)
|
||||
}
|
||||
}
|
||||
|
||||
return info, lstatCalled, err
|
||||
}
|
||||
|
||||
// writeSkipErrorTestFile writes one file into fs with a fixed mtime.
|
||||
func writeSkipErrorTestFile(t *testing.T, fs afero.Fs, path, content string) {
|
||||
t.Helper()
|
||||
@@ -154,16 +102,14 @@ func writeSkipErrorTestFile(t *testing.T, fs afero.Fs, path, content string) {
|
||||
}
|
||||
}
|
||||
|
||||
// runSkipErrorScan scans source on fs under ctx with the given skip-errors
|
||||
// setting, printing user-facing messages to uiw (nil discards them), and
|
||||
// runSkipErrorScan scans /source on fs with the given skip-errors setting and
|
||||
// returns the repositories (for inspection) and the scan error.
|
||||
func runSkipErrorScan(
|
||||
ctx context.Context, t *testing.T, fs afero.Fs, source string,
|
||||
skipErrors bool, uiw *ui.Writer,
|
||||
t *testing.T, fs afero.Fs, skipErrors bool,
|
||||
) (*database.Repositories, error) {
|
||||
t.Helper()
|
||||
|
||||
db, err := database.New(ctx, ":memory:")
|
||||
db, err := database.NewTestDB()
|
||||
if err != nil {
|
||||
t.Fatalf("create test db: %v", err)
|
||||
}
|
||||
@@ -184,14 +130,14 @@ func runSkipErrorScan(
|
||||
MaxBlobSize: int64(1024 * 1024),
|
||||
CompressionLevel: 3,
|
||||
AgeRecipients: []string{testAgePublicKey},
|
||||
UI: uiw,
|
||||
SkipErrors: skipErrors,
|
||||
})
|
||||
|
||||
ctx := context.Background()
|
||||
snapshotID := "test-snapshot-skip-errors"
|
||||
createTestSnapshotRecord(ctx, t, repos, snapshotID)
|
||||
|
||||
_, err = scanner.Scan(ctx, source, snapshotID)
|
||||
_, err = scanner.Scan(ctx, "/source", snapshotID)
|
||||
|
||||
return repos, err
|
||||
}
|
||||
@@ -211,7 +157,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(context.Background(), t, fs, "/source", true, nil)
|
||||
repos, err := runSkipErrorScan(t, fs, true)
|
||||
if err == nil {
|
||||
t.Fatal("expected scan to abort on the packer error, got nil")
|
||||
}
|
||||
@@ -238,7 +184,7 @@ func TestScannerReadErrorAbortsWithoutSkipErrors(t *testing.T) {
|
||||
fs := &readFailFs{Fs: afero.NewMemMapFs(), target: target}
|
||||
writeSkipErrorTestFile(t, fs, target, "content that cannot be read")
|
||||
|
||||
_, err := runSkipErrorScan(context.Background(), t, fs, "/source", false, nil)
|
||||
_, err := runSkipErrorScan(t, fs, false)
|
||||
if err == nil {
|
||||
t.Fatal("expected scan to fail on the read error, got nil")
|
||||
}
|
||||
@@ -254,7 +200,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(context.Background(), t, fs, "/source", true, nil)
|
||||
repos, err := runSkipErrorScan(t, fs, true)
|
||||
if err != nil {
|
||||
t.Fatalf("expected scan to complete with --skip-errors, got %v", err)
|
||||
}
|
||||
@@ -268,173 +214,3 @@ func TestScannerReadErrorSkippedWithSkipErrors(t *testing.T) {
|
||||
t.Fatalf("expected unreadable file skipped, got %d chunks", len(chunks))
|
||||
}
|
||||
}
|
||||
|
||||
// writeSymlinkSource creates a source directory on disk holding one symlink
|
||||
// and returns the directory and the symlink's path.
|
||||
func writeSymlinkSource(t *testing.T) (string, string) {
|
||||
t.Helper()
|
||||
|
||||
sourceDir := t.TempDir()
|
||||
linkPath := filepath.Join(sourceDir, "link")
|
||||
|
||||
err := os.Symlink("target.txt", linkPath)
|
||||
if err != nil {
|
||||
t.Fatalf("creating symlink: %v", err)
|
||||
}
|
||||
|
||||
return sourceDir, linkPath
|
||||
}
|
||||
|
||||
// TestScannerUnreadableSymlinkAbortsWithoutSkipErrors checks that a symlink
|
||||
// whose target cannot be read aborts the run when --skip-errors is not set.
|
||||
func TestScannerUnreadableSymlinkAbortsWithoutSkipErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sourceDir, linkPath := writeSymlinkSource(t)
|
||||
fs := &linkRemovedAfterLstatFs{t: t, target: linkPath}
|
||||
|
||||
_, 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)
|
||||
}
|
||||
}
|
||||
|
||||
// TestScannerUnreadableSymlinkSkippedWithSkipErrors checks that a symlink
|
||||
// whose target cannot be read is skipped with an error line, and the run
|
||||
// completes, when --skip-errors is set.
|
||||
func TestScannerUnreadableSymlinkSkippedWithSkipErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sourceDir, linkPath := writeSymlinkSource(t)
|
||||
fs := &linkRemovedAfterLstatFs{t: t, target: linkPath}
|
||||
uiw := ui.NewWithColor(io.Discard, false)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
if uiw.ErrorCount() != 1 {
|
||||
t.Fatalf("expected one error line for the symlink, got %d",
|
||||
uiw.ErrorCount())
|
||||
}
|
||||
|
||||
file, err := repos.Files.GetByPath(context.Background(), linkPath)
|
||||
if err != nil {
|
||||
t.Fatalf("getting %s: %v", linkPath, err)
|
||||
}
|
||||
|
||||
if file != nil {
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,47 +105,26 @@ func (sm *SnapshotManager) CreateSnapshot(
|
||||
return sm.CreateSnapshotWithName(ctx, hostname, "", version, gitRevision)
|
||||
}
|
||||
|
||||
// ShortHostname returns hostname up to its first dot. A snapshot ID starts
|
||||
// with this form, while the snapshots table stores the full hostname.
|
||||
func ShortHostname(hostname string) string {
|
||||
short, _, _ := strings.Cut(hostname, ".")
|
||||
|
||||
return short
|
||||
}
|
||||
|
||||
// CreateSnapshotWithName creates a new snapshot record with an optional
|
||||
// snapshot name. The snapshot ID format is: hostname_name_timestamp or
|
||||
// hostname_timestamp if name is empty. The timestamp is in whole seconds.
|
||||
// If the local index already has a snapshot with that ID, from a run of the
|
||||
// same name that started in the same second, it waits a second and takes a
|
||||
// new timestamp.
|
||||
// hostname_timestamp if name is empty.
|
||||
func (sm *SnapshotManager) CreateSnapshotWithName(
|
||||
ctx context.Context, hostname, name, version, gitRevision string,
|
||||
) (string, error) {
|
||||
shortHostname := ShortHostname(hostname)
|
||||
// Use short hostname (strip domain if present)
|
||||
shortHostname := hostname
|
||||
if before, _, ok := strings.Cut(hostname, "."); ok {
|
||||
shortHostname = before
|
||||
}
|
||||
|
||||
// Build snapshot ID with optional name
|
||||
timestamp := time.Now().UTC().Format("2006-01-02T15:04:05Z")
|
||||
|
||||
var snapshotID string
|
||||
|
||||
for {
|
||||
// Build snapshot ID with optional name
|
||||
timestamp := time.Now().UTC().Format("2006-01-02T15:04:05Z")
|
||||
|
||||
if name != "" {
|
||||
snapshotID = fmt.Sprintf("%s_%s_%s", shortHostname, name, timestamp)
|
||||
} else {
|
||||
snapshotID = fmt.Sprintf("%s_%s", shortHostname, timestamp)
|
||||
}
|
||||
|
||||
existing, err := sm.repos.Snapshots.GetByID(ctx, snapshotID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("looking up snapshot %s: %w", snapshotID, err)
|
||||
}
|
||||
|
||||
if existing == nil {
|
||||
break
|
||||
}
|
||||
|
||||
time.Sleep(time.Second)
|
||||
if name != "" {
|
||||
snapshotID = fmt.Sprintf("%s_%s_%s", shortHostname, name, timestamp)
|
||||
} else {
|
||||
snapshotID = fmt.Sprintf("%s_%s", shortHostname, timestamp)
|
||||
}
|
||||
|
||||
snapshot := &database.Snapshot{
|
||||
@@ -175,6 +154,26 @@ func (sm *SnapshotManager) CreateSnapshotWithName(
|
||||
return snapshotID, nil
|
||||
}
|
||||
|
||||
// UpdateSnapshotStats updates the statistics for a snapshot during backup
|
||||
func (sm *SnapshotManager) UpdateSnapshotStats(
|
||||
ctx context.Context, snapshotID string, stats BackupStats,
|
||||
) error {
|
||||
err := sm.repos.WithTx(ctx, func(ctx context.Context, tx *sql.Tx) error {
|
||||
return sm.repos.Snapshots.UpdateCounts(ctx, tx, snapshotID,
|
||||
int64(stats.FilesScanned),
|
||||
int64(stats.ChunksCreated),
|
||||
int64(stats.BlobsCreated),
|
||||
stats.BytesScanned,
|
||||
stats.BytesUploaded,
|
||||
)
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("updating snapshot stats: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateSnapshotStatsExtended updates snapshot statistics with extended metrics.
|
||||
// This includes compression level, uncompressed blob size, and upload duration.
|
||||
func (sm *SnapshotManager) UpdateSnapshotStatsExtended(
|
||||
@@ -186,8 +185,8 @@ func (sm *SnapshotManager) UpdateSnapshotStatsExtended(
|
||||
int64(stats.FilesScanned),
|
||||
int64(stats.ChunksCreated),
|
||||
int64(stats.BlobsCreated),
|
||||
stats.TotalSize,
|
||||
stats.BlobSize,
|
||||
stats.BytesScanned,
|
||||
stats.BytesUploaded,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -197,7 +196,6 @@ func (sm *SnapshotManager) UpdateSnapshotStatsExtended(
|
||||
return sm.repos.Snapshots.UpdateExtendedStats(ctx, tx, snapshotID,
|
||||
stats.BlobUncompressedSize,
|
||||
stats.CompressionLevel,
|
||||
stats.BytesUploaded,
|
||||
stats.UploadDurationMs,
|
||||
)
|
||||
})
|
||||
@@ -892,7 +890,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 the files in the snapshot
|
||||
BytesScanned int64
|
||||
ChunksCreated int
|
||||
BlobsCreated int
|
||||
BytesUploaded int64
|
||||
@@ -902,7 +900,6 @@ type BackupStats struct {
|
||||
type ExtendedBackupStats struct {
|
||||
BackupStats
|
||||
|
||||
BlobSize int64 // Total compressed size of all referenced blobs
|
||||
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
|
||||
|
||||
@@ -386,36 +386,3 @@ func TestCleanSnapshotDBNonExistentSnapshot(t *testing.T) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Two creates of one snapshot name back to back start within one second,
|
||||
// the resolution of the timestamp in a snapshot ID. See
|
||||
// https://git.eeqj.de/sneak/vaultik/issues/270.
|
||||
func TestCreateSnapshotWithNameTwiceBackToBack(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
db, err := database.New(ctx, filepath.Join(t.TempDir(), "index.sqlite"))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create database: %v", err)
|
||||
}
|
||||
|
||||
defer func() { _ = db.Close() }()
|
||||
|
||||
sm := &SnapshotManager{repos: database.NewRepositories(db)}
|
||||
|
||||
first, err := sm.CreateSnapshotWithName(ctx, "test-host", "data", "v", "g")
|
||||
if err != nil {
|
||||
t.Fatalf("first create failed: %v", err)
|
||||
}
|
||||
|
||||
second, err := sm.CreateSnapshotWithName(ctx, "test-host", "data", "v", "g")
|
||||
if err != nil {
|
||||
t.Fatalf("second create failed: %v", err)
|
||||
}
|
||||
|
||||
if first == second {
|
||||
t.Fatalf("both creates returned snapshot ID %s", first)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,9 +14,8 @@ import (
|
||||
|
||||
// runStorerConformance is the shared Storer contract. Every backend that
|
||||
// can run in-process is expected to pass it: TestFileStorer runs it against
|
||||
// file://, TestS3Storer against s3://, TestRcloneStorer against rclone's
|
||||
// local backend. A new backend inherits this coverage by passing its own
|
||||
// constructor, so the contract is defined once.
|
||||
// file://, TestS3Storer against s3://. A new backend inherits this coverage
|
||||
// by passing its own constructor, so the contract is defined once.
|
||||
//
|
||||
// It exercises the public Storer interface: round-trip, stat, list with
|
||||
// prefix filtering, overwrite, delete, delete-of-missing, and not-found on
|
||||
|
||||
@@ -171,11 +171,6 @@ 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()
|
||||
|
||||
@@ -21,10 +21,9 @@ type FileStorer struct {
|
||||
// NewFileStorer creates a new filesystem storage backend.
|
||||
//
|
||||
// Construction is intentionally cheap and does not touch the filesystem.
|
||||
// The backend never creates basePath: the user does, so that a write to
|
||||
// an unplugged volume fails instead of landing on the disk under its
|
||||
// empty mount point. Put and PutWithProgress fail on a missing basePath
|
||||
// and create the directories below it as needed.
|
||||
// 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.
|
||||
// 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.
|
||||
@@ -51,10 +50,9 @@ const storageDirPerm = 0o755
|
||||
// temp file carrying this suffix and only renames it onto the real key once
|
||||
// the whole object is on disk, so an interrupted write can never leave a
|
||||
// truncated object at the key a later run would Stat and trust as a complete
|
||||
// blob. The rclone backend's upload does the same on remotes with a
|
||||
// server-side move. List and ListStream skip these files, so a leftover from
|
||||
// an interrupted write is never listed or trusted as a blob; it is otherwise
|
||||
// harmless.
|
||||
// blob. List and ListStream skip these files, so a leftover from an
|
||||
// interrupted write is never listed or trusted as a blob; it is otherwise
|
||||
// harmless and is overwritten when the same key is written again.
|
||||
const tempSuffix = ".partial"
|
||||
|
||||
// Put stores data at the specified key.
|
||||
@@ -243,42 +241,6 @@ func (f *FileStorer) ListStream(ctx context.Context, prefix string) <-chan Objec
|
||||
return ch
|
||||
}
|
||||
|
||||
// DeletePartialUploads removes every file under prefix whose name ends in
|
||||
// tempSuffix. A missing prefix has none to remove.
|
||||
func (f *FileStorer) DeletePartialUploads(ctx context.Context, prefix string) error {
|
||||
basePath := f.fullPath(prefix)
|
||||
|
||||
exists, err := afero.Exists(f.fs, basePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("checking path: %w", err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return nil
|
||||
}
|
||||
|
||||
err = afero.Walk(f.fs, 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{
|
||||
@@ -298,19 +260,7 @@ func (f *FileStorer) writeAtomic(
|
||||
path := f.fullPath(key)
|
||||
dir := filepath.Dir(path)
|
||||
|
||||
// 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)
|
||||
err := f.fs.MkdirAll(dir, storageDirPerm)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating directories: %w", err)
|
||||
}
|
||||
|
||||
@@ -14,9 +14,6 @@ import (
|
||||
// errStreamInterrupted stands in for an upload cut off mid-stream.
|
||||
var errStreamInterrupted = errors.New("connection reset mid-upload")
|
||||
|
||||
// testBlobKey is a key laid out as a blob's key is.
|
||||
const testBlobKey = "blobs/aa/bb/aabbccddeeff"
|
||||
|
||||
// failingReader yields its data once, then fails.
|
||||
type failingReader struct {
|
||||
data []byte
|
||||
@@ -46,7 +43,7 @@ func TestFileStorer_InterruptedWriteLeavesNoTrustedObject(t *testing.T) {
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
key := testBlobKey
|
||||
key := "blobs/aa/bb/aabbccddeeff"
|
||||
|
||||
err = f.PutWithProgress(ctx, key, &failingReader{data: []byte("partial")}, 4096, nil)
|
||||
if err == nil {
|
||||
@@ -82,7 +79,7 @@ func TestFileStorer_ListSkipsPartialFiles(t *testing.T) {
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
realKey := testBlobKey
|
||||
realKey := "blobs/aa/bb/aabbccddeeff"
|
||||
|
||||
err = f.Put(ctx, realKey, strings.NewReader("blob-bytes"))
|
||||
if err != nil {
|
||||
@@ -120,45 +117,3 @@ 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,9 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/vaultik/internal/storage"
|
||||
@@ -62,43 +60,3 @@ func TestFileStorerListMissingDestination(t *testing.T) {
|
||||
t.Errorf("ListStream error = %v, want a not-exist error", streamErr)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,7 +99,6 @@ func storerFromParsedS3URL(parsed *URL, cfg *config.Config) (Storer, error) {
|
||||
AccessKeyID: cfg.S3.AccessKeyID,
|
||||
SecretAccessKey: cfg.S3.SecretAccessKey,
|
||||
Region: region,
|
||||
PartSize: cfg.S3.PartSize.Int64(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating S3 client: %w", err)
|
||||
@@ -135,7 +134,6 @@ func storerFromLegacyS3Config(cfg *config.Config) (Storer, error) {
|
||||
AccessKeyID: cfg.S3.AccessKeyID,
|
||||
SecretAccessKey: cfg.S3.SecretAccessKey,
|
||||
Region: region,
|
||||
PartSize: cfg.S3.PartSize.Int64(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating S3 client: %w", err)
|
||||
|
||||
+15
-89
@@ -3,7 +3,6 @@ package storage
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -69,7 +68,14 @@ func (r *RcloneStorer) Put(ctx context.Context, key string, data io.Reader) erro
|
||||
return fmt.Errorf("reading data: %w", err)
|
||||
}
|
||||
|
||||
return r.upload(ctx, key, bytes.NewReader(buf))
|
||||
// Upload the object
|
||||
_, err = operations.Rcat(ctx, r.fsys, key,
|
||||
io.NopCloser(bytes.NewReader(buf)), time.Now(), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("uploading object: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// PutWithProgress stores data with progress reporting.
|
||||
@@ -83,7 +89,13 @@ func (r *RcloneStorer) PutWithProgress(
|
||||
callback: progress,
|
||||
}
|
||||
|
||||
return r.upload(ctx, key, pr)
|
||||
// Upload the object
|
||||
_, err := operations.Rcat(ctx, r.fsys, key, io.NopCloser(pr), time.Now(), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("uploading object: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get retrieves data from the specified key.
|
||||
@@ -161,10 +173,6 @@ func (r *RcloneStorer) List(ctx context.Context, prefix string) ([]string, error
|
||||
|
||||
err := operations.ListFn(ctx, r.fsys, func(obj fs.Object) {
|
||||
key := obj.Remote()
|
||||
if strings.HasSuffix(key, tempSuffix) {
|
||||
return
|
||||
}
|
||||
|
||||
if prefix == "" || strings.HasPrefix(key, prefix) {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
@@ -194,10 +202,6 @@ func (r *RcloneStorer) ListStream(
|
||||
}
|
||||
|
||||
key := obj.Remote()
|
||||
if strings.HasSuffix(key, tempSuffix) {
|
||||
return
|
||||
}
|
||||
|
||||
if prefix == "" || strings.HasPrefix(key, prefix) {
|
||||
ch <- ObjectInfo{
|
||||
Key: key,
|
||||
@@ -213,31 +217,6 @@ 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
|
||||
@@ -251,59 +230,6 @@ func (r *RcloneStorer) Info() Info {
|
||||
}
|
||||
}
|
||||
|
||||
// upload writes data to key. Where the remote has a server-side move, it
|
||||
// writes under a temporary name ending in tempSuffix and moves the object
|
||||
// onto key once it is complete, so a killed upload cannot leave a truncated
|
||||
// object at key; a remote without one is written in place. List and
|
||||
// ListStream skip a temporary object left behind.
|
||||
//
|
||||
// rclone's own copy does this only where the remote also sets
|
||||
// PartialUploads. That flag is not checked here: hdfs, for one, shows a
|
||||
// file while it is written without setting it.
|
||||
func (r *RcloneStorer) upload(ctx context.Context, key string, data io.Reader) error {
|
||||
if r.fsys.Features().Move == nil {
|
||||
_, err := operations.Rcat(ctx, r.fsys, key, io.NopCloser(data), time.Now(), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("uploading object: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
tempKey := key + "-" + rand.Text() + tempSuffix
|
||||
|
||||
obj, err := operations.Rcat(ctx, r.fsys, tempKey, io.NopCloser(data), time.Now(), nil)
|
||||
if err != nil {
|
||||
// Rcat returns the object it wrote when the written data fails its check.
|
||||
if obj != nil {
|
||||
_ = obj.Remove(ctx)
|
||||
}
|
||||
|
||||
return fmt.Errorf("uploading object: %w", err)
|
||||
}
|
||||
|
||||
// On drive, dropbox, onedrive and others the remote's own move does not
|
||||
// replace an object already at key. operations.Move removes the object
|
||||
// it is given first, and copies where the remote refuses the move.
|
||||
existing, err := r.fsys.NewObject(ctx, key)
|
||||
if errors.Is(err, fs.ErrorObjectNotFound) {
|
||||
existing = nil
|
||||
} else if err != nil {
|
||||
_ = obj.Remove(ctx)
|
||||
|
||||
return fmt.Errorf("looking up existing object: %w", err)
|
||||
}
|
||||
|
||||
_, err = operations.Move(ctx, r.fsys, existing, key, obj)
|
||||
if err != nil {
|
||||
_ = obj.Remove(ctx)
|
||||
|
||||
return fmt.Errorf("moving object into place: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// progressReader wraps an io.Reader to track read progress.
|
||||
type progressReader struct {
|
||||
reader io.Reader
|
||||
|
||||
+14
-348
@@ -1,47 +1,28 @@
|
||||
package storage_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/rclone/rclone/fs"
|
||||
"github.com/rclone/rclone/fs/config/configmap"
|
||||
"sneak.berlin/go/vaultik/internal/storage"
|
||||
)
|
||||
|
||||
// The rclone backend is a thin adapter over the rclone library: it turns a
|
||||
// (remote, path) pair into rclone's "remote:path" string, hands it to
|
||||
// rclone, and maps rclone's own results back to the Storer interface. What
|
||||
// can be tested in-process, without a configured remote or network, is that
|
||||
// adapter layer — how the arguments are shaped and how construction errors
|
||||
// are reported. The data-plane operations (Put/Get/List/Delete) are rclone's
|
||||
// own, exercised against a real provider (drive, s3-via-rclone, ...), which
|
||||
// needs a configured remote with credentials and network access and so is
|
||||
// out of reach of a unit test. The shared Storer conformance suite therefore
|
||||
// runs against the in-process file and s3 backends; the rclone backend
|
||||
// inherits that contract once a remote is configured.
|
||||
//
|
||||
// These tests use rclone's ":local:" on-the-fly backend, which addresses the
|
||||
// local filesystem directly without any configured remote, so they run
|
||||
// entirely in-process. A remote that needs credentials and network access
|
||||
// (drive, s3 via rclone, ...) is out of reach of a unit test.
|
||||
|
||||
// newRcloneStorer builds an rclone backend on rclone's local backend,
|
||||
// rooted at a fresh temp directory.
|
||||
//
|
||||
//nolint:ireturn // conformance runs against the Storer interface by design
|
||||
func newRcloneStorer(t *testing.T) storage.Storer {
|
||||
t.Helper()
|
||||
|
||||
s, err := storage.NewRcloneStorer(context.Background(), ":local", t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("NewRcloneStorer: %v", err)
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// TestRcloneStorer runs the shared Storer contract against the rclone
|
||||
// backend.
|
||||
//
|
||||
//nolint:paralleltest // NewRcloneStorer installs the process-global rclone config
|
||||
func TestRcloneStorer(t *testing.T) {
|
||||
runStorerConformance(t, newRcloneStorer)
|
||||
}
|
||||
// local filesystem directly without any configured remote, so construction
|
||||
// runs entirely in-process.
|
||||
|
||||
// TestNewRcloneStorerConstruction checks that a valid remote constructs a
|
||||
// backend and that Info() reports the shaped "remote:path" location.
|
||||
@@ -63,321 +44,6 @@ func TestNewRcloneStorerConstruction(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// unbufferedUploadContext returns a context in which rclone neither reads
|
||||
// ahead of the write nor holds a small upload in memory. Without it, a
|
||||
// progress callback runs before anything is written to the remote.
|
||||
func unbufferedUploadContext() context.Context {
|
||||
ctx, ci := fs.AddConfig(context.Background())
|
||||
ci.BufferSize = 0
|
||||
ci.StreamingUploadCutoff = 0
|
||||
|
||||
return ctx
|
||||
}
|
||||
|
||||
// objectAtKeyDuringUpload uploads data to testBlobKey and reports whether
|
||||
// an object was at the key before the upload finished. It fails the test
|
||||
// unless the key then reads back as the uploaded data.
|
||||
func objectAtKeyDuringUpload(
|
||||
ctx context.Context, t *testing.T, s *storage.RcloneStorer,
|
||||
) bool {
|
||||
t.Helper()
|
||||
|
||||
data := bytes.Repeat([]byte("blob-bytes"), 1000)
|
||||
seen := false
|
||||
|
||||
err := s.PutWithProgress(ctx, testBlobKey, bytes.NewReader(data),
|
||||
int64(len(data)), func(int64) error {
|
||||
_, statErr := s.Stat(ctx, testBlobKey)
|
||||
if statErr == nil {
|
||||
seen = true
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PutWithProgress: %v", err)
|
||||
}
|
||||
|
||||
got := readObject(ctx, t, s, testBlobKey)
|
||||
if !bytes.Equal(got, data) {
|
||||
t.Errorf("object read back as %d bytes, want the %d uploaded",
|
||||
len(got), len(data))
|
||||
}
|
||||
|
||||
return seen
|
||||
}
|
||||
|
||||
// readObject returns the contents of the object at key.
|
||||
func readObject(
|
||||
ctx context.Context, t *testing.T, s *storage.RcloneStorer, key string,
|
||||
) []byte {
|
||||
t.Helper()
|
||||
|
||||
rc, err := s.Get(ctx, key)
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
|
||||
defer func() { _ = rc.Close() }()
|
||||
|
||||
got, err := io.ReadAll(rc)
|
||||
if err != nil {
|
||||
t.Fatalf("reading object: %v", err)
|
||||
}
|
||||
|
||||
return got
|
||||
}
|
||||
|
||||
// TestRcloneStorerObjectAppearsOnlyWhenComplete checks that on a remote
|
||||
// with a server-side move, such as local, nothing is at the key until the
|
||||
// upload has finished, so a killed upload cannot leave a truncated object
|
||||
// there.
|
||||
//
|
||||
//nolint:paralleltest // NewRcloneStorer installs the process-global rclone config
|
||||
func TestRcloneStorerObjectAppearsOnlyWhenComplete(t *testing.T) {
|
||||
ctx := unbufferedUploadContext()
|
||||
|
||||
s, err := storage.NewRcloneStorer(ctx, ":local", t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("NewRcloneStorer: %v", err)
|
||||
}
|
||||
|
||||
if objectAtKeyDuringUpload(ctx, t, s) {
|
||||
t.Error("object was at its key before the upload finished")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRcloneStorerListSkipsPartialFiles checks that a temporary file left
|
||||
// by a killed upload is never listed as a key.
|
||||
//
|
||||
//nolint:paralleltest // NewRcloneStorer installs the process-global rclone config
|
||||
func TestRcloneStorerListSkipsPartialFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ctx := context.Background()
|
||||
|
||||
s, err := storage.NewRcloneStorer(ctx, ":local", dir)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRcloneStorer: %v", err)
|
||||
}
|
||||
|
||||
realKey := testBlobKey
|
||||
|
||||
err = s.Put(ctx, realKey, strings.NewReader("blob-bytes"))
|
||||
if err != nil {
|
||||
t.Fatalf("Put: %v", err)
|
||||
}
|
||||
|
||||
leftover := filepath.Join(dir, realKey+"-123456.partial")
|
||||
|
||||
err = os.WriteFile(leftover, []byte("half"), 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("writing leftover temp file: %v", err)
|
||||
}
|
||||
|
||||
keys, err := s.List(ctx, "blobs/")
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
|
||||
if len(keys) != 1 || keys[0] != realKey {
|
||||
t.Fatalf("List should return only the real key, got %v", keys)
|
||||
}
|
||||
|
||||
var streamed []string
|
||||
|
||||
for obj := range s.ListStream(ctx, "blobs/") {
|
||||
if obj.Err != nil {
|
||||
t.Fatalf("ListStream: %v", obj.Err)
|
||||
}
|
||||
|
||||
streamed = append(streamed, obj.Key)
|
||||
}
|
||||
|
||||
if len(streamed) != 1 || streamed[0] != realKey {
|
||||
t.Fatalf("ListStream should return only the real key, got %v", streamed)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// that it behaves like a remote a unit test cannot reach.
|
||||
func newRcloneStorerOnWrappedLocal(
|
||||
ctx context.Context, t *testing.T, name string, wrap func(fs.Fs) fs.Fs,
|
||||
) *storage.RcloneStorer {
|
||||
t.Helper()
|
||||
|
||||
fs.Register(&fs.RegInfo{
|
||||
Name: name,
|
||||
NewFs: func(
|
||||
ctx context.Context, _, root string, _ configmap.Mapper,
|
||||
) (fs.Fs, error) {
|
||||
local, err := fs.NewFs(ctx, ":local:"+root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return wrap(local), nil
|
||||
},
|
||||
})
|
||||
|
||||
s, err := storage.NewRcloneStorer(ctx, ":"+name, t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("NewRcloneStorer: %v", err)
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// withoutPartialUploads is rclone's local backend with the PartialUploads
|
||||
// flag cleared. Like hdfs, it then has a server-side move and shows a file
|
||||
// while it is written, without setting that flag.
|
||||
type withoutPartialUploads struct {
|
||||
fs.Fs
|
||||
}
|
||||
|
||||
func (f *withoutPartialUploads) Features() *fs.Features {
|
||||
features := *f.Fs.Features()
|
||||
features.PartialUploads = false
|
||||
|
||||
return &features
|
||||
}
|
||||
|
||||
// TestRcloneStorerMovesIntoPlaceWithoutPartialUploads checks that on a
|
||||
// remote with a server-side move nothing is at the key until the upload has
|
||||
// finished, even when rclone does not mark the remote as showing partial
|
||||
// uploads.
|
||||
//
|
||||
//nolint:paralleltest // NewRcloneStorer installs the process-global rclone config
|
||||
func TestRcloneStorerMovesIntoPlaceWithoutPartialUploads(t *testing.T) {
|
||||
ctx := unbufferedUploadContext()
|
||||
s := newRcloneStorerOnWrappedLocal(ctx, t, "withoutpartialuploads",
|
||||
func(local fs.Fs) fs.Fs { return &withoutPartialUploads{Fs: local} })
|
||||
|
||||
if objectAtKeyDuringUpload(ctx, t, s) {
|
||||
t.Error("object was at its key before the upload finished")
|
||||
}
|
||||
}
|
||||
|
||||
// withoutMove is rclone's local backend reporting no server-side move.
|
||||
type withoutMove struct {
|
||||
fs.Fs
|
||||
}
|
||||
|
||||
func (f *withoutMove) Features() *fs.Features {
|
||||
features := *f.Fs.Features()
|
||||
features.Move = nil
|
||||
|
||||
return &features
|
||||
}
|
||||
|
||||
// TestRcloneStorerWritesInPlaceWithoutMove checks that on a remote with no
|
||||
// server-side move an object is written straight to its key and reads back
|
||||
// from there.
|
||||
//
|
||||
//nolint:paralleltest // NewRcloneStorer installs the process-global rclone config
|
||||
func TestRcloneStorerWritesInPlaceWithoutMove(t *testing.T) {
|
||||
ctx := unbufferedUploadContext()
|
||||
s := newRcloneStorerOnWrappedLocal(ctx, t, "withoutmove",
|
||||
func(local fs.Fs) fs.Fs { return &withoutMove{Fs: local} })
|
||||
|
||||
if !objectAtKeyDuringUpload(ctx, t, s) {
|
||||
t.Error("object was not at its key while it was uploaded")
|
||||
}
|
||||
}
|
||||
|
||||
var errNameConflict = errors.New("an object with this name already exists")
|
||||
|
||||
// moveRefusesExisting is rclone's local backend with a server-side move
|
||||
// that, like dropbox's or onedrive's, refuses to move onto an existing
|
||||
// object.
|
||||
type moveRefusesExisting struct {
|
||||
fs.Fs
|
||||
}
|
||||
|
||||
func (f *moveRefusesExisting) Features() *fs.Features {
|
||||
features := *f.Fs.Features()
|
||||
features.Move = f.move
|
||||
|
||||
return &features
|
||||
}
|
||||
|
||||
//nolint:ireturn // the signature is rclone's
|
||||
func (f *moveRefusesExisting) move(
|
||||
ctx context.Context, src fs.Object, remote string,
|
||||
) (fs.Object, error) {
|
||||
_, err := f.NewObject(ctx, remote)
|
||||
if err == nil {
|
||||
return nil, errNameConflict
|
||||
}
|
||||
|
||||
return f.Fs.Features().Move(ctx, src, remote)
|
||||
}
|
||||
|
||||
// TestRcloneStorerOverwritesWhereMoveRefusesExisting checks that writing a
|
||||
// key twice replaces the object on a remote whose server-side move will not
|
||||
// replace an existing object.
|
||||
//
|
||||
//nolint:paralleltest // NewRcloneStorer installs the process-global rclone config
|
||||
func TestRcloneStorerOverwritesWhereMoveRefusesExisting(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newRcloneStorerOnWrappedLocal(ctx, t, "moverefusesexisting",
|
||||
func(local fs.Fs) fs.Fs { return &moveRefusesExisting{Fs: local} })
|
||||
|
||||
for _, content := range []string{"first", "second"} {
|
||||
err := s.Put(ctx, testBlobKey, strings.NewReader(content))
|
||||
if err != nil {
|
||||
t.Fatalf("Put %q: %v", content, err)
|
||||
}
|
||||
}
|
||||
|
||||
got := readObject(ctx, t, s, testBlobKey)
|
||||
if string(got) != "second" {
|
||||
t.Errorf("object = %q, want %q", got, "second")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewRcloneStorerUnknownRemote checks that a remote that is not in the
|
||||
// rclone config fails construction with the ErrRemoteNotFound sentinel,
|
||||
// rather than silently returning a backend pointed nowhere.
|
||||
|
||||
@@ -99,12 +99,6 @@ 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{
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
package storage_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/johannesboyne/gofakes3"
|
||||
@@ -22,13 +19,6 @@ 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.
|
||||
const (
|
||||
s3TestAccessKeyID = "key"
|
||||
s3TestSecretAccessKey = "secret"
|
||||
)
|
||||
|
||||
// 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,
|
||||
@@ -138,8 +128,8 @@ func TestS3URLPrefixKeyLayout(t *testing.T) {
|
||||
storer, err := storage.NewStorer(&config.Config{
|
||||
StorageURL: storageURL + "?endpoint=" + srv.URL,
|
||||
S3: config.S3Config{
|
||||
AccessKeyID: s3TestAccessKeyID,
|
||||
SecretAccessKey: s3TestSecretAccessKey,
|
||||
AccessKeyID: "key",
|
||||
SecretAccessKey: "secret",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
@@ -181,90 +171,6 @@ func TestS3URLPrefixKeyLayout(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestS3UploadUsesConfiguredPartSize checks that s3.part_size reaches the
|
||||
// multipart uploader, through storage_url and through the s3.* fields. An
|
||||
// object three parts long must arrive as three parts; at the SDK's default
|
||||
// of 5 MiB it would arrive as four.
|
||||
func TestS3UploadUsesConfiguredPartSize(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
partSize = 6 * 1024 * 1024
|
||||
wantParts = 3
|
||||
)
|
||||
|
||||
backend := s3mem.New()
|
||||
|
||||
err := backend.CreateBucket(s3TestBucket)
|
||||
if err != nil {
|
||||
t.Fatalf("create bucket: %v", err)
|
||||
}
|
||||
|
||||
// Every part of a multipart upload is one request with a partNumber.
|
||||
var parts atomic.Int32
|
||||
|
||||
fake := gofakes3.New(backend).Server()
|
||||
srv := httptest.NewServer(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Has("partNumber") {
|
||||
parts.Add(1)
|
||||
}
|
||||
|
||||
fake.ServeHTTP(w, r)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
cfg *config.Config
|
||||
}{
|
||||
{
|
||||
name: "storage_url",
|
||||
cfg: &config.Config{
|
||||
StorageURL: "s3://" + s3TestBucket + "?endpoint=" + srv.URL,
|
||||
S3: config.S3Config{
|
||||
AccessKeyID: s3TestAccessKeyID,
|
||||
SecretAccessKey: s3TestSecretAccessKey,
|
||||
PartSize: partSize,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "s3.endpoint",
|
||||
cfg: &config.Config{
|
||||
S3: config.S3Config{
|
||||
Endpoint: srv.URL,
|
||||
Bucket: s3TestBucket,
|
||||
AccessKeyID: s3TestAccessKeyID,
|
||||
SecretAccessKey: s3TestSecretAccessKey,
|
||||
PartSize: partSize,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
parts.Store(0)
|
||||
|
||||
storer, err := storage.NewStorer(tc.cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: NewStorer: %v", tc.name, err)
|
||||
}
|
||||
|
||||
data := bytes.NewReader(make([]byte, wantParts*partSize))
|
||||
|
||||
err = storer.PutWithProgress(
|
||||
context.Background(), "blob", data, data.Size(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: PutWithProgress: %v", tc.name, err)
|
||||
}
|
||||
|
||||
if got := parts.Load(); got != wantParts {
|
||||
t.Errorf("%s: uploaded in %d parts, want %d", tc.name, got, wantParts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// listStreamKeys returns the keys ListStream yields under a prefix, and
|
||||
// fails the test on a listing error.
|
||||
func listStreamKeys(t *testing.T, s storage.Storer, prefix string) []string {
|
||||
|
||||
@@ -41,8 +41,7 @@ type Info struct {
|
||||
// directory for filesystem).
|
||||
type Storer interface {
|
||||
// Put stores data at the specified key.
|
||||
// The filesystem backend fails when its destination directory does
|
||||
// not exist, and creates the directories below it as needed.
|
||||
// Parent directories are created automatically for filesystem backends.
|
||||
Put(ctx context.Context, key string, data io.Reader) error
|
||||
|
||||
// PutWithProgress stores data with progress reporting.
|
||||
@@ -72,12 +71,6 @@ 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
|
||||
}
|
||||
|
||||
+12
-26
@@ -17,10 +17,8 @@ const (
|
||||
|
||||
// Sentinel errors for storage URL parsing.
|
||||
var (
|
||||
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")
|
||||
ErrEmptyStorageURL = errors.New("storage URL is empty")
|
||||
ErrEmptyFilePath = errors.New("file URL path is empty")
|
||||
ErrMissingBucket = errors.New("s3 URL missing bucket name")
|
||||
ErrMissingRemote = errors.New("rclone URL missing remote name")
|
||||
ErrUnsupportedScheme = errors.New(
|
||||
@@ -53,8 +51,17 @@ func ParseStorageURL(rawURL string) (*URL, error) {
|
||||
return nil, ErrEmptyStorageURL
|
||||
}
|
||||
|
||||
// Handle file:// URLs
|
||||
if after, ok := strings.CutPrefix(rawURL, "file://"); ok {
|
||||
return parseFileURL(after)
|
||||
path := after
|
||||
if path == "" {
|
||||
return nil, ErrEmptyFilePath
|
||||
}
|
||||
|
||||
return &URL{
|
||||
Scheme: schemeFile,
|
||||
Prefix: path,
|
||||
}, nil
|
||||
}
|
||||
|
||||
if strings.HasPrefix(rawURL, "s3://") {
|
||||
@@ -68,27 +75,6 @@ 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,10 +14,7 @@ import (
|
||||
func TestParseStorageURLValid(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
bucket = "mybucket"
|
||||
fileScheme = "file"
|
||||
)
|
||||
const bucket = "mybucket"
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -27,17 +24,7 @@ func TestParseStorageURLValid(t *testing.T) {
|
||||
{
|
||||
name: "file absolute path",
|
||||
raw: "file:///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"},
|
||||
want: &storage.URL{Scheme: "file", Prefix: "/var/backups/vaultik"},
|
||||
},
|
||||
{
|
||||
name: "s3 bucket and prefix, ssl defaults on",
|
||||
@@ -103,7 +90,6 @@ 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},
|
||||
|
||||
@@ -155,8 +155,8 @@ type BlobHash string
|
||||
// FilePath represents an absolute path to a file or directory.
|
||||
type FilePath string
|
||||
|
||||
// SourcePath is the source directory a scan found a file under, made
|
||||
// absolute and with symlinks resolved.
|
||||
// SourcePath represents the root directory from which files are backed up.
|
||||
// Used during restore to strip the source prefix from paths.
|
||||
type SourcePath string
|
||||
|
||||
// Hostname identifies a host machine.
|
||||
|
||||
@@ -130,14 +130,15 @@ func assertThirdSnapshotRestores(
|
||||
// up, and that snapshot is removed. The first snapshot keeps the file row,
|
||||
// which now lists the appended content's chunks, while removal drops the
|
||||
// blob that held them.
|
||||
//
|
||||
//nolint:paralleltest // installs the global logger via log.Initialize
|
||||
func TestBackupAfterRemovingNewestSnapshotRestoresChangedFile(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
tempDir := t.TempDir()
|
||||
dataDir := filepath.Join(tempDir, "src")
|
||||
storeDir := t.TempDir()
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
restoreDir := filepath.Join(tempDir, "restored")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
changedPath := filepath.Join(dataDir, "appended.bin")
|
||||
@@ -177,14 +178,15 @@ func TestBackupAfterRemovingNewestSnapshotRestoresChangedFile(t *testing.T) {
|
||||
// The next run's prune drops that incomplete snapshot and its blob, while
|
||||
// the first snapshot keeps the file row, which now lists the appended
|
||||
// content's chunks.
|
||||
//
|
||||
//nolint:paralleltest // installs the global logger via log.Initialize
|
||||
func TestBackupAfterInterruptedRunRestoresChangedFile(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
tempDir := t.TempDir()
|
||||
dataDir := filepath.Join(tempDir, "src")
|
||||
storeDir := t.TempDir()
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
restoreDir := filepath.Join(tempDir, "restored")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
changedPath := filepath.Join(dataDir, "appended.bin")
|
||||
|
||||
@@ -30,7 +30,7 @@ func TestDeepVerifyAcceptsHealthyAndRejectsCorruptBlob(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
dataDir := filepath.Join(tempDir, "source")
|
||||
storeDir := t.TempDir()
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
chunkSize := int64(64 * 1024)
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
package vaultik
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"sneak.berlin/go/vaultik/internal/storage"
|
||||
)
|
||||
|
||||
// destinationMarkerKey is the object every backup writes at the root of
|
||||
// the backup destination store. While the local index holds a snapshot,
|
||||
// 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
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
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))
|
||||
}
|
||||
@@ -257,10 +257,6 @@ 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{}
|
||||
}
|
||||
|
||||
@@ -38,9 +38,11 @@ import (
|
||||
// (https://git.eeqj.de/sneak/vaultik/issues/130) and is not re-tested
|
||||
// here; these tests target the layers above the backend.
|
||||
//
|
||||
// log.Initialize replaces the package-global logger that a running
|
||||
// backup or restore reads, so each test calls it before t.Parallel,
|
||||
// while no parallel test is running yet.
|
||||
// The tests run serially, not with t.Parallel: each calls
|
||||
// log.Initialize, which replaces the package-global logger, and a
|
||||
// backup or restore running concurrently reads that same logger. Under
|
||||
// -race the two collide. Running one at a time is the same choice
|
||||
// prune_count_test.go already makes for the same reason.
|
||||
|
||||
const (
|
||||
faultChunkSize = int64(64 * 1024)
|
||||
@@ -163,19 +165,17 @@ func newReaderVaultik(
|
||||
// Scenario 3: a stored blob's bytes are flipped before restore reads
|
||||
// them. Restore must fail loudly, and no file must be left on the
|
||||
// restore target holding corrupt content.
|
||||
//
|
||||
//nolint:paralleltest // installs the global logger via log.Initialize
|
||||
func TestRestoreRejectsCorruptBlob(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
assertRestoreRejectsDamagedBlob(t, faultstore.GetCorrupt, "corrupt")
|
||||
}
|
||||
|
||||
// Scenario 4: a stored blob is truncated before restore reads it. Same
|
||||
// contract as the corrupt case.
|
||||
//
|
||||
//nolint:paralleltest // installs the global logger via log.Initialize
|
||||
func TestRestoreRejectsTruncatedBlob(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
assertRestoreRejectsDamagedBlob(t, faultstore.GetTruncate, "truncated")
|
||||
}
|
||||
|
||||
@@ -188,11 +188,12 @@ func assertRestoreRejectsDamagedBlob(
|
||||
t *testing.T, fault faultstore.GetFault, name string,
|
||||
) {
|
||||
t.Helper()
|
||||
log.Initialize(log.Config{})
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
tempDir := t.TempDir()
|
||||
dataDir := filepath.Join(tempDir, "src")
|
||||
storeDir := t.TempDir()
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
restoreDir := filepath.Join(tempDir, "restored")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
@@ -231,14 +232,15 @@ func assertRestoreRejectsDamagedBlob(
|
||||
|
||||
// Scenario 6: the backend accepts blob uploads and reports success but
|
||||
// stores nothing. verify --deep must catch it.
|
||||
//
|
||||
//nolint:paralleltest // installs the global logger via log.Initialize
|
||||
func TestDeepVerifyCatchesLyingBackend(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
tempDir := t.TempDir()
|
||||
dataDir := filepath.Join(tempDir, "src")
|
||||
storeDir := t.TempDir()
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
ctx := context.Background()
|
||||
@@ -283,14 +285,15 @@ func TestDeepVerifyCatchesLyingBackend(t *testing.T) {
|
||||
// Scenario 1a: a blob upload fails partway through. The interrupted run
|
||||
// must not record the blob as uploaded, must not reference it from the
|
||||
// snapshot, and must leave no blob object at the destination.
|
||||
//
|
||||
//nolint:paralleltest // installs the global logger via log.Initialize
|
||||
func TestInterruptedBlobUploadRecordsNoUploadedBlob(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
tempDir := t.TempDir()
|
||||
dataDir := filepath.Join(tempDir, "src")
|
||||
storeDir := t.TempDir()
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
ctx := context.Background()
|
||||
@@ -298,6 +301,10 @@ 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)
|
||||
|
||||
@@ -354,14 +361,15 @@ func TestInterruptedBlobUploadRecordsNoUploadedBlob(t *testing.T) {
|
||||
// chunks in a blob that was actually uploaded, so the retry re-chunks and
|
||||
// re-uploads the affected data instead of silently referencing data that
|
||||
// never reached storage.
|
||||
//
|
||||
//nolint:paralleltest // installs the global logger via log.Initialize
|
||||
func TestBackupRetryAfterInterruptedUploadIsRestorable(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
tempDir := t.TempDir()
|
||||
dataDir := filepath.Join(tempDir, "src")
|
||||
storeDir := t.TempDir()
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
restoreDir := filepath.Join(tempDir, "restored")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
@@ -418,14 +426,15 @@ func TestBackupRetryAfterInterruptedUploadIsRestorable(t *testing.T) {
|
||||
// covered by TestBackupCompletesOnlyAfterMetadataExport
|
||||
// (https://git.eeqj.de/sneak/vaultik/issues/177); this test exercises the
|
||||
// lower-level export path in isolation.
|
||||
//
|
||||
//nolint:paralleltest // installs the global logger via log.Initialize
|
||||
func TestBackupSurvivesMetadataExportInterruption(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
tempDir := t.TempDir()
|
||||
dataDir := filepath.Join(tempDir, "src")
|
||||
storeDir := t.TempDir()
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
ctx := context.Background()
|
||||
@@ -500,14 +509,15 @@ func TestBackupSurvivesMetadataExportInterruption(t *testing.T) {
|
||||
// destination. Rerunning the backup must then prune the incomplete
|
||||
// snapshot, produce a snapshot whose destination metadata and local index
|
||||
// agree, and restore. See https://git.eeqj.de/sneak/vaultik/issues/177.
|
||||
//
|
||||
//nolint:paralleltest // installs the global logger via log.Initialize
|
||||
func TestBackupCompletesOnlyAfterMetadataExport(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
tempDir := t.TempDir()
|
||||
dataDir := filepath.Join(tempDir, "src")
|
||||
storeDir := t.TempDir()
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
restoreDir := filepath.Join(tempDir, "restored")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
@@ -675,14 +685,15 @@ func faultScannerFactory(
|
||||
// Scenario 5: the restore target runs out of space mid-file. Restore
|
||||
// must fail with an out-of-space error, and must not leave a truncated
|
||||
// file at the target path presenting as a complete restore.
|
||||
//
|
||||
//nolint:paralleltest // installs the global logger via log.Initialize
|
||||
func TestRestoreReportsDiskFull(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
osFS := afero.NewOsFs()
|
||||
tempDir := t.TempDir()
|
||||
dataDir := filepath.Join(tempDir, "src")
|
||||
storeDir := t.TempDir()
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
restoreDir := filepath.Join(tempDir, "restored")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
|
||||
+26
-47
@@ -3,14 +3,12 @@ package vaultik
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dustin/go-humanize"
|
||||
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||
"sneak.berlin/go/vaultik/internal/types"
|
||||
)
|
||||
|
||||
@@ -48,13 +46,12 @@ 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
|
||||
// Snapshot IDs split on "_" into hostname, optional name parts, and a
|
||||
// trailing timestamp.
|
||||
const (
|
||||
minSnapshotIDParts = 2
|
||||
minSnapshotIDNameParts = 3
|
||||
)
|
||||
|
||||
// SnapshotInfo contains information about a snapshot.
|
||||
//
|
||||
@@ -124,36 +121,29 @@ func parseSnapshotTimestamp(snapshotID string) (time.Time, error) {
|
||||
return timestamp.UTC(), nil
|
||||
}
|
||||
|
||||
// parseSnapshotName extracts the snapshot name from a snapshot ID of the
|
||||
// form hostname_name_timestamp, given the hostname stored with that
|
||||
// snapshot. The hostname and the name may both contain underscores, so the
|
||||
// name is what is left after removing the short hostname and its "_" from
|
||||
// the front and the last "_" and the timestamp from the end. Returns "" for
|
||||
// an ID with no name (hostname_timestamp), and for an ID that does not start
|
||||
// with that hostname, which CreateSnapshotWithName never writes.
|
||||
func parseSnapshotName(snapshotID, hostname string) string {
|
||||
prefix := snapshot.ShortHostname(hostname) + "_"
|
||||
|
||||
rest, ok := strings.CutPrefix(snapshotID, prefix)
|
||||
if !ok {
|
||||
// parseSnapshotName extracts the snapshot name from a snapshot ID.
|
||||
// Format: hostname_snapshotname_timestamp — the middle part(s) between hostname
|
||||
// and the RFC3339 timestamp are the snapshot name (may contain underscores).
|
||||
// Returns the snapshot name, or empty string if the ID is malformed.
|
||||
func parseSnapshotName(snapshotID string) string {
|
||||
parts := strings.Split(snapshotID, "_")
|
||||
if len(parts) < minSnapshotIDNameParts {
|
||||
// Format: hostname_timestamp — no snapshot name
|
||||
return ""
|
||||
}
|
||||
|
||||
end := strings.LastIndex(rest, "_")
|
||||
if end < 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
return rest[:end]
|
||||
// Format: hostname_name_timestamp — middle parts are the name.
|
||||
// The last part is the RFC3339 timestamp, the first part is the hostname,
|
||||
// everything in between is the snapshot name (which may itself contain underscores).
|
||||
return strings.Join(parts[1:len(parts)-1], "_")
|
||||
}
|
||||
|
||||
// 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, 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).
|
||||
// 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).
|
||||
func parseDuration(s string) (time.Duration, error) {
|
||||
if strings.HasPrefix(strings.TrimSpace(s), "-") {
|
||||
return 0, errNegativeDuration
|
||||
@@ -186,30 +176,19 @@ 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":
|
||||
unitLength = day
|
||||
total += time.Duration(n) * day
|
||||
case "w", "week", "weeks":
|
||||
unitLength = week
|
||||
total += time.Duration(n) * week
|
||||
case "mo", "month", "months":
|
||||
unitLength = month
|
||||
total += time.Duration(n) * month
|
||||
case "y", "year", "years":
|
||||
unitLength = year
|
||||
total += time.Duration(n) * 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
|
||||
|
||||
@@ -11,55 +11,33 @@ func TestParseSnapshotName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
snapshotID string
|
||||
hostname string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "standard format with name",
|
||||
snapshotID: "myhost_home_2026-01-12T14:41:15Z",
|
||||
hostname: "myhost",
|
||||
want: "home",
|
||||
},
|
||||
{
|
||||
name: "standard format with different name",
|
||||
snapshotID: "server1_system_2026-02-15T09:30:00Z",
|
||||
hostname: "server1",
|
||||
want: "system",
|
||||
},
|
||||
{
|
||||
name: "name with underscores",
|
||||
snapshotID: "myhost_my_special_backup_2026-03-01T00:00:00Z",
|
||||
hostname: "myhost",
|
||||
want: "my_special_backup",
|
||||
},
|
||||
{
|
||||
name: "hostname with underscores",
|
||||
snapshotID: "my_host_docs_2026-03-01T00:00:00Z",
|
||||
hostname: "my_host",
|
||||
want: "docs",
|
||||
},
|
||||
{
|
||||
name: "stored hostname with domain",
|
||||
snapshotID: "my_host_mail_2026-03-01T00:00:00Z",
|
||||
hostname: "my_host.example.com",
|
||||
want: "mail",
|
||||
},
|
||||
{
|
||||
name: "no name",
|
||||
snapshotID: "my_host_2026-03-01T00:00:00Z",
|
||||
hostname: "my_host",
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := parseSnapshotName(tt.snapshotID, tt.hostname)
|
||||
got := parseSnapshotName(tt.snapshotID)
|
||||
if got != tt.want {
|
||||
t.Errorf("parseSnapshotName(%q, %q) = %q, want %q",
|
||||
tt.snapshotID, tt.hostname, got, tt.want)
|
||||
t.Errorf("parseSnapshotName(%q) = %q, want %q",
|
||||
tt.snapshotID, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -111,17 +89,6 @@ 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 {
|
||||
|
||||
+49
-140
@@ -167,6 +167,10 @@ 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
|
||||
@@ -177,16 +181,8 @@ type SnapshotMetadataInfo struct {
|
||||
ManifestSize int64 `json:"manifest_size"`
|
||||
DatabaseSize int64 `json:"database_size"`
|
||||
TotalSize int64 `json:"total_size"`
|
||||
|
||||
// Both stay nil (null in the JSON) when the snapshot's manifest was
|
||||
// listed but could not be read.
|
||||
BlobCount *int `json:"blob_count"`
|
||||
BlobsSize *int64 `json:"blobs_size"`
|
||||
|
||||
// Set when the listing holds this snapshot's manifest.json.zst. A
|
||||
// backup interrupted before its manifest upload leaves a directory
|
||||
// without one, which prune does not treat as a snapshot.
|
||||
hasManifest bool
|
||||
BlobCount int `json:"blob_count"`
|
||||
BlobsSize int64 `json:"blobs_size"`
|
||||
}
|
||||
|
||||
// RemoteInfoResult contains all remote storage information
|
||||
@@ -210,20 +206,9 @@ type RemoteInfoResult struct {
|
||||
ReferencedBlobCount int `json:"referenced_blob_count"`
|
||||
ReferencedBlobSize int64 `json:"referenced_blob_size"`
|
||||
|
||||
// Orphaned blobs. Both stay nil (null in the JSON) when a manifest
|
||||
// was listed but not read, since that snapshot's blobs would be
|
||||
// counted as orphaned.
|
||||
OrphanedBlobCount *int `json:"orphaned_blob_count"`
|
||||
OrphanedBlobSize *int64 `json:"orphaned_blob_size"`
|
||||
|
||||
// Remote key of each snapshot whose manifest could not be read
|
||||
UnreadableManifests []string `json:"unreadable_manifests,omitempty"`
|
||||
|
||||
// Number of manifests not read because the name above them under
|
||||
// metadata/ is not a remote key. The names themselves are not
|
||||
// reported: they come from the destination store and may hold
|
||||
// control characters.
|
||||
SkippedManifestCount int `json:"skipped_manifest_count,omitempty"`
|
||||
// Orphaned blobs
|
||||
OrphanedBlobCount int `json:"orphaned_blob_count"`
|
||||
OrphanedBlobSize int64 `json:"orphaned_blob_size"`
|
||||
}
|
||||
|
||||
// RemoteInfo displays information about remote storage
|
||||
@@ -249,28 +234,16 @@ func (v *Vaultik) RemoteInfo(jsonOutput bool) error {
|
||||
v.stdoutf("Scanning snapshot metadata...\n")
|
||||
}
|
||||
|
||||
snapshotMetadata, snapshotIDs, skippedManifestCount, err := v.collectSnapshotMetadata()
|
||||
snapshotMetadata, snapshotIDs, err := v.collectSnapshotMetadata()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result.SkippedManifestCount = skippedManifestCount
|
||||
|
||||
if showText {
|
||||
manifestCount := 0
|
||||
|
||||
for _, info := range snapshotMetadata {
|
||||
if info.hasManifest {
|
||||
manifestCount++
|
||||
}
|
||||
}
|
||||
|
||||
v.stdoutf("Downloading %d manifest(s)...\n", manifestCount)
|
||||
v.stdoutf("Downloading %d manifest(s)...\n", len(snapshotIDs))
|
||||
}
|
||||
|
||||
referencedBlobs, unreadableManifests := v.collectReferencedBlobsFromManifests(
|
||||
snapshotIDs, snapshotMetadata)
|
||||
result.UnreadableManifests = unreadableManifests
|
||||
referencedBlobs := v.collectReferencedBlobsFromManifests(snapshotIDs, snapshotMetadata)
|
||||
|
||||
v.populateRemoteInfoResult(result, snapshotMetadata, snapshotIDs, referencedBlobs)
|
||||
|
||||
@@ -283,7 +256,7 @@ func (v *Vaultik) RemoteInfo(jsonOutput bool) error {
|
||||
"snapshots", result.TotalMetadataCount,
|
||||
"total_blobs", result.TotalBlobCount,
|
||||
"referenced_blobs", result.ReferencedBlobCount,
|
||||
"unreadable_manifests", len(result.UnreadableManifests))
|
||||
"orphaned_blobs", result.OrphanedBlobCount)
|
||||
|
||||
if jsonOutput {
|
||||
enc := json.NewEncoder(v.Stdout)
|
||||
@@ -300,18 +273,16 @@ func (v *Vaultik) RemoteInfo(jsonOutput bool) error {
|
||||
}
|
||||
|
||||
// collectSnapshotMetadata scans remote metadata and returns
|
||||
// per-snapshot info, sorted IDs and the number of manifests it skipped
|
||||
// because the name above them is not a remote key.
|
||||
// per-snapshot info and sorted IDs.
|
||||
func (v *Vaultik) collectSnapshotMetadata() (
|
||||
map[string]*SnapshotMetadataInfo, []string, int, error,
|
||||
map[string]*SnapshotMetadataInfo, []string, error,
|
||||
) {
|
||||
snapshotMetadata := make(map[string]*SnapshotMetadataInfo)
|
||||
skippedManifestCount := 0
|
||||
|
||||
metadataCh := v.Storage.ListStream(v.ctx, "metadata/")
|
||||
for obj := range metadataCh {
|
||||
if obj.Err != nil {
|
||||
return nil, nil, 0, fmt.Errorf("listing metadata: %w", obj.Err)
|
||||
return nil, nil, fmt.Errorf("listing metadata: %w", obj.Err)
|
||||
}
|
||||
|
||||
parts := strings.Split(obj.Key, "/")
|
||||
@@ -320,22 +291,6 @@ func (v *Vaultik) collectSnapshotMetadata() (
|
||||
}
|
||||
|
||||
snapshotID := parts[1]
|
||||
filename := parts[2]
|
||||
isManifest := filename == "manifest.json.zst"
|
||||
|
||||
// The name comes from the destination store, which is not
|
||||
// trusted, and is printed in the report. Accept it only in the
|
||||
// form of a remote key.
|
||||
if !isBlobHash(snapshotID) {
|
||||
log.Warn("Skipping non-conforming key under metadata/",
|
||||
"key", obj.Key)
|
||||
|
||||
if isManifest {
|
||||
skippedManifestCount++
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if _, exists := snapshotMetadata[snapshotID]; !exists {
|
||||
snapshotMetadata[snapshotID] = &SnapshotMetadataInfo{SnapshotID: snapshotID}
|
||||
@@ -343,10 +298,7 @@ func (v *Vaultik) collectSnapshotMetadata() (
|
||||
|
||||
info := snapshotMetadata[snapshotID]
|
||||
|
||||
if isManifest {
|
||||
info.hasManifest = true
|
||||
}
|
||||
|
||||
filename := parts[2]
|
||||
if strings.HasPrefix(filename, "manifest") {
|
||||
info.ManifestSize = obj.Size
|
||||
} else if strings.HasPrefix(filename, "db") {
|
||||
@@ -363,29 +315,17 @@ func (v *Vaultik) collectSnapshotMetadata() (
|
||||
|
||||
sort.Strings(snapshotIDs)
|
||||
|
||||
return snapshotMetadata, snapshotIDs, skippedManifestCount, nil
|
||||
return snapshotMetadata, snapshotIDs, nil
|
||||
}
|
||||
|
||||
// collectReferencedBlobsFromManifests downloads the listed manifests
|
||||
// and returns referenced blob hashes with sizes, and the remote keys
|
||||
// of the manifests it could not read.
|
||||
// collectReferencedBlobsFromManifests downloads manifests and returns
|
||||
// referenced blob hashes with sizes.
|
||||
func (v *Vaultik) collectReferencedBlobsFromManifests(
|
||||
snapshotIDs []string, snapshotMetadata map[string]*SnapshotMetadataInfo,
|
||||
) (map[string]int64, []string) {
|
||||
) map[string]int64 {
|
||||
referencedBlobs := make(map[string]int64)
|
||||
|
||||
var unreadable []string
|
||||
|
||||
for _, snapshotID := range snapshotIDs {
|
||||
info := snapshotMetadata[snapshotID]
|
||||
if !info.hasManifest {
|
||||
// The orphan figures count this directory's blobs as
|
||||
// orphaned, so it references none.
|
||||
info.BlobCount, info.BlobsSize = new(int), new(int64)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
// snapshotIDs here are remote keys, taken straight from the
|
||||
// metadata/ listing. downloadManifestByKey is the single reader
|
||||
// for remote manifests; see its doc comment.
|
||||
@@ -393,12 +333,11 @@ func (v *Vaultik) collectReferencedBlobsFromManifests(
|
||||
if err != nil {
|
||||
log.Warn("Failed to read manifest", "snapshot", snapshotID, "error", err)
|
||||
|
||||
unreadable = append(unreadable, snapshotID)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
blobCount := manifest.BlobCount
|
||||
info := snapshotMetadata[snapshotID]
|
||||
info.BlobCount = manifest.BlobCount
|
||||
|
||||
var blobsSize int64
|
||||
|
||||
@@ -407,11 +346,10 @@ func (v *Vaultik) collectReferencedBlobsFromManifests(
|
||||
blobsSize += blob.CompressedSize
|
||||
}
|
||||
|
||||
info.BlobCount = &blobCount
|
||||
info.BlobsSize = &blobsSize
|
||||
info.BlobsSize = blobsSize
|
||||
}
|
||||
|
||||
return referencedBlobs, unreadable
|
||||
return referencedBlobs
|
||||
}
|
||||
|
||||
// populateRemoteInfoResult fills in the result's snapshot and
|
||||
@@ -440,9 +378,8 @@ func (v *Vaultik) populateRemoteInfoResult(
|
||||
}
|
||||
|
||||
// scanRemoteBlobStorage lists all blobs on remote and computes orphan
|
||||
// stats when every listed manifest was read. showText is true only
|
||||
// when the human report is being printed (not --json, not --quiet),
|
||||
// gating the progress line.
|
||||
// stats. showText is true only when the human report is being printed
|
||||
// (not --json, not --quiet), gating the progress line.
|
||||
func (v *Vaultik) scanRemoteBlobStorage(
|
||||
result *RemoteInfoResult, referencedBlobs map[string]int64, showText bool,
|
||||
) error {
|
||||
@@ -450,40 +387,32 @@ func (v *Vaultik) scanRemoteBlobStorage(
|
||||
v.stdoutf("Scanning blobs...\n")
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
blobCh := v.Storage.ListStream(v.ctx, "blobs/")
|
||||
allBlobs := make(map[string]int64)
|
||||
|
||||
for _, size := range allBlobs {
|
||||
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
|
||||
result.TotalBlobCount++
|
||||
result.TotalBlobSize += size
|
||||
result.TotalBlobSize += obj.Size
|
||||
}
|
||||
|
||||
// A blob named only by a manifest that could not be read, or by one
|
||||
// under a skipped name, would be counted as orphaned, so the orphan
|
||||
// figures stay unknown.
|
||||
if len(result.UnreadableManifests) > 0 || result.SkippedManifestCount > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
orphanedCount int
|
||||
orphanedSize int64
|
||||
)
|
||||
|
||||
for hash, size := range allBlobs {
|
||||
if _, referenced := referencedBlobs[hash]; !referenced {
|
||||
orphanedCount++
|
||||
orphanedSize += size
|
||||
result.OrphanedBlobCount++
|
||||
result.OrphanedBlobSize += size
|
||||
}
|
||||
}
|
||||
|
||||
result.OrphanedBlobCount = &orphanedCount
|
||||
result.OrphanedBlobSize = &orphanedSize
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -513,23 +442,13 @@ func (v *Vaultik) printRemoteInfoTable(result *RemoteInfoResult) {
|
||||
v.stdoutf("%s", separator)
|
||||
|
||||
for _, info := range result.Snapshots {
|
||||
blobCount := unknownText
|
||||
if info.BlobCount != nil {
|
||||
blobCount = humanize.Comma(int64(*info.BlobCount))
|
||||
}
|
||||
|
||||
blobsSize := unknownText
|
||||
if info.BlobsSize != nil {
|
||||
blobsSize = ubytes(*info.BlobsSize)
|
||||
}
|
||||
|
||||
v.stdoutf(rowFormat,
|
||||
truncateString(info.SnapshotID, snapshotIDColWidth),
|
||||
ubytes(info.ManifestSize),
|
||||
ubytes(info.DatabaseSize),
|
||||
ubytes(info.TotalSize),
|
||||
blobCount,
|
||||
blobsSize,
|
||||
humanize.Comma(int64(info.BlobCount)),
|
||||
ubytes(info.BlobsSize),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -546,21 +465,11 @@ func (v *Vaultik) printRemoteInfoTable(result *RemoteInfoResult) {
|
||||
v.stdoutf("Referenced by snapshots: %s (%s)\n",
|
||||
humanize.Comma(int64(result.ReferencedBlobCount)),
|
||||
ubytes(result.ReferencedBlobSize))
|
||||
|
||||
if result.OrphanedBlobCount == nil {
|
||||
v.stdoutf("Orphaned (unreferenced): unknown "+
|
||||
"(%d manifest(s) could not be read, "+
|
||||
"%d manifest(s) under a non-conforming name skipped)\n",
|
||||
len(result.UnreadableManifests), result.SkippedManifestCount)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
v.stdoutf("Orphaned (unreferenced): %s (%s)\n",
|
||||
humanize.Comma(int64(*result.OrphanedBlobCount)),
|
||||
ubytes(*result.OrphanedBlobSize))
|
||||
humanize.Comma(int64(result.OrphanedBlobCount)),
|
||||
ubytes(result.OrphanedBlobSize))
|
||||
|
||||
if *result.OrphanedBlobCount > 0 {
|
||||
if result.OrphanedBlobCount > 0 {
|
||||
v.stdoutf("\nRun 'vaultik prune' to remove orphaned blobs.\n")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,12 +155,6 @@ 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",
|
||||
@@ -771,7 +765,7 @@ func TestEndToEndFileStorage(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
dataDir := filepath.Join(tempDir, "source")
|
||||
storeDir := t.TempDir()
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
restoreDir := filepath.Join(tempDir, "restored")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
@@ -934,17 +928,17 @@ func setupDedupBackupEnv(
|
||||
}
|
||||
}
|
||||
|
||||
// runDedupSnapshot creates a snapshot with the given name, scans dataDir
|
||||
// into it, completes it, and exports its metadata, returning the snapshot
|
||||
// ID and scan result.
|
||||
// runDedupSnapshot creates a "dedup" snapshot, scans dataDir into it,
|
||||
// completes it, and exports its metadata, returning the snapshot ID and
|
||||
// scan result.
|
||||
func runDedupSnapshot(
|
||||
ctx context.Context, t *testing.T,
|
||||
sm *snapshot.SnapshotManager, scanner *snapshot.Scanner,
|
||||
hostname, name, dataDir, dbPath string,
|
||||
hostname, dataDir, dbPath string,
|
||||
) (string, *snapshot.ScanResult) {
|
||||
t.Helper()
|
||||
|
||||
id, err := sm.CreateSnapshotWithName(ctx, hostname, name, "v", "g")
|
||||
id, err := sm.CreateSnapshotWithName(ctx, hostname, "dedup", "v", "g")
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := scanner.Scan(ctx, dataDir, id)
|
||||
@@ -964,7 +958,7 @@ func TestDedupOnlySnapshotRestores(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
dataDir := filepath.Join(tempDir, "source")
|
||||
storeDir := t.TempDir()
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
restoreDir := filepath.Join(tempDir, "restored")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
@@ -986,15 +980,16 @@ func TestDedupOnlySnapshotRestores(t *testing.T) {
|
||||
|
||||
// First snapshot — uploads all blobs.
|
||||
_, r1 := runDedupSnapshot(ctx, t, sm, makeScanner(),
|
||||
cfg.Hostname, "first", dataDir, dbPath)
|
||||
cfg.Hostname, dataDir, dbPath)
|
||||
require.Positive(t, r1.BlobsCreated,
|
||||
"first snapshot should upload at least one blob")
|
||||
|
||||
// Second snapshot — same data, every chunk dedups. Its own name gives
|
||||
// it a different snapshot ID without waiting for the one-second
|
||||
// timestamp in the ID to tick over.
|
||||
// Second snapshot — same data, every chunk dedups. Sleep past the
|
||||
// second-precision timestamp so the snapshot IDs differ.
|
||||
time.Sleep(1100 * time.Millisecond)
|
||||
|
||||
id2, r2 := runDedupSnapshot(ctx, t, sm, makeScanner(),
|
||||
cfg.Hostname, "second", dataDir, dbPath)
|
||||
cfg.Hostname, dataDir, dbPath)
|
||||
require.Equal(t, 0, r2.BlobsCreated,
|
||||
"second snapshot should upload zero new blobs (fully dedup'd)")
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
@@ -24,11 +23,13 @@ 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.
|
||||
|
||||
// newFileDestinationVaultik returns a Vaultik that backs up the
|
||||
// snapshot named "first" to a file:// destination at storeDir.
|
||||
func newFileDestinationVaultik(
|
||||
// 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(
|
||||
ctx context.Context, t *testing.T, storeDir string,
|
||||
) (*vaultik.Vaultik, *database.Repositories) {
|
||||
) (*vaultik.Vaultik, *database.Repositories, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
|
||||
osFs := afero.NewOsFs()
|
||||
@@ -47,22 +48,8 @@ func newFileDestinationVaultik(
|
||||
|
||||
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{}
|
||||
@@ -87,36 +74,31 @@ func backUpThenUnplug(
|
||||
return v, repos, out
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// 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.
|
||||
//
|
||||
//nolint:paralleltest // installs the global logger via log.Initialize
|
||||
func TestFirstBackupCreatesDestinationDirectory(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
volume := filepath.Join(t.TempDir(), "volume")
|
||||
v, repos := newFileDestinationVaultik(ctx, t, filepath.Join(volume, "backup"))
|
||||
storeDir := filepath.Join(t.TempDir(), "volume", "backup")
|
||||
v, _, out := backUpToFileDestination(ctx, t, storeDir)
|
||||
|
||||
err := backUp(v, "first")
|
||||
require.ErrorIs(t, err, fs.ErrNotExist)
|
||||
require.ErrorContains(t, err, "create it, or mount the volume it is on")
|
||||
require.NoError(t, v.ListSnapshots(false))
|
||||
|
||||
_, 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)
|
||||
assert.NotContains(t, out.String(), "Could not list backup destination store")
|
||||
assert.NotContains(t, out.String(), "not found in backup destination store")
|
||||
}
|
||||
|
||||
// TestListSnapshotsWarnsWhenDestinationMissing checks that snapshot list
|
||||
// warns and shows the local index alone, without reporting the local
|
||||
// snapshot as missing from the destination.
|
||||
//
|
||||
//nolint:paralleltest // installs the global logger via log.Initialize
|
||||
func TestListSnapshotsWarnsWhenDestinationMissing(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
v, repos, out := backUpThenUnplug(ctx, t)
|
||||
@@ -133,9 +115,10 @@ func TestListSnapshotsWarnsWhenDestinationMissing(t *testing.T) {
|
||||
// TestRemoveSnapshotWarnsWhenDestinationMissing checks that snapshot
|
||||
// remove warns that the metadata could not be removed from the
|
||||
// destination, instead of reporting that it was.
|
||||
//
|
||||
//nolint:paralleltest // installs the global logger via log.Initialize
|
||||
func TestRemoveSnapshotWarnsWhenDestinationMissing(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
v, repos, out := backUpThenUnplug(ctx, t)
|
||||
@@ -154,9 +137,10 @@ func TestRemoveSnapshotWarnsWhenDestinationMissing(t *testing.T) {
|
||||
// TestPruneKeepsLocalRecordsWhenDestinationMissing checks that prune
|
||||
// fails on a destination it cannot list and deletes no local snapshot
|
||||
// record.
|
||||
//
|
||||
//nolint:paralleltest // installs the global logger via log.Initialize
|
||||
func TestPruneKeepsLocalRecordsWhenDestinationMissing(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
v, repos, _ := backUpThenUnplug(ctx, t)
|
||||
@@ -169,22 +153,3 @@ func TestPruneKeepsLocalRecordsWhenDestinationMissing(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, snapshots, 1, "prune must delete no local snapshot record")
|
||||
}
|
||||
|
||||
// TestPurgeSaysListingFailedOnceWhenDestinationMissing checks that
|
||||
// snapshot purge fails on a destination it cannot list, with an error
|
||||
// that says "listing remote snapshots" once.
|
||||
func TestPurgeSaysListingFailedOnceWhenDestinationMissing(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
v, _, _ := backUpThenUnplug(ctx, t)
|
||||
|
||||
err := v.PurgeSnapshotsWithOptions(&vaultik.SnapshotPurgeOptions{
|
||||
KeepLatest: true,
|
||||
Force: true,
|
||||
})
|
||||
require.ErrorIs(t, err, fs.ErrNotExist)
|
||||
assert.Equal(t, 1, strings.Count(err.Error(), "listing remote snapshots"),
|
||||
err.Error())
|
||||
}
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
package vaultik_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/log"
|
||||
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||
)
|
||||
|
||||
// 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, _ := 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)
|
||||
}
|
||||
@@ -23,15 +23,9 @@ var errNukeRequiresForce = errors.New(
|
||||
// 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, 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.
|
||||
// storage. 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.
|
||||
@@ -54,22 +48,6 @@ func (v *Vaultik) NukeRemote(force bool) error {
|
||||
return fmt.Errorf("pruning blobs: %w", err)
|
||||
}
|
||||
|
||||
// The file and rclone listings skip `.partial` objects, so the two
|
||||
// steps above never delete them.
|
||||
for _, prefix := range []string{"metadata/", "blobs/"} {
|
||||
err = v.Storage.DeletePartialUploads(v.ctx, prefix)
|
||||
if err != nil {
|
||||
return fmt.Errorf("deleting partial uploads: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
v.UI.Completef("Backup destination store is now empty.")
|
||||
|
||||
return nil
|
||||
|
||||
@@ -14,9 +14,10 @@ import (
|
||||
// the discarded-error bug: getTableCount for a table its query cannot
|
||||
// resolve must not silently become 0. A count that could not be read is
|
||||
// reported as unknown, which a reader can tell apart from an empty table.
|
||||
//
|
||||
//nolint:paralleltest // installs the global logger via log.Initialize
|
||||
func TestTableCountForReportSurfacesReadFailure(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -42,7 +43,7 @@ func TestTableCountForReportSurfacesReadFailure(t *testing.T) {
|
||||
assert.Nil(t, missing, "a failed read is unknown, not a count")
|
||||
|
||||
// The rendered count for a failed read must say unknown, never 0.
|
||||
assert.Equal(t, unknownText, countText(missing))
|
||||
assert.Equal(t, countUnknown, countText(missing))
|
||||
assert.NotEqual(t, "0", countText(missing))
|
||||
}
|
||||
|
||||
@@ -57,7 +58,7 @@ func TestCountTextDistinguishesEmptyFromUnknown(t *testing.T) {
|
||||
|
||||
assert.Equal(t, "0", countText(&zero))
|
||||
assert.Equal(t, "7", countText(&seven))
|
||||
assert.Equal(t, unknownText, countText(nil))
|
||||
assert.Equal(t, countUnknown, countText(nil))
|
||||
}
|
||||
|
||||
// TestCountDiffUnknownWhenEitherSideUnknown checks that a delta computed
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
package vaultik_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/log"
|
||||
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||
)
|
||||
|
||||
// TestPruneBlobs_JSONDeletesWithoutAsking checks that prune with --json
|
||||
// and without --force deletes an unreferenced blob without the
|
||||
// confirmation prompt. Stdin is empty, so a prompt would read no answer
|
||||
// and cancel, and its text would come before the JSON document.
|
||||
func TestPruneBlobs_JSONDeletesWithoutAsking(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)
|
||||
|
||||
err := env.v.PruneBlobs(&vaultik.PruneOptions{JSON: true})
|
||||
require.NoError(t, err)
|
||||
|
||||
blobKey := "blobs/" + testBlobHashA[:2] + "/" + testBlobHashA[2:4] +
|
||||
"/" + testBlobHashA
|
||||
assert.False(t, env.store.hasKey(blobKey),
|
||||
"the unreferenced blob must be deleted")
|
||||
|
||||
var result vaultik.PruneBlobsResult
|
||||
|
||||
require.NoError(t, json.Unmarshal(env.stdout.Bytes(), &result),
|
||||
"stdout must hold only the JSON document, got:\n%s",
|
||||
env.stdout.String())
|
||||
assert.Equal(t, 1, result.BlobsDeleted)
|
||||
}
|
||||
@@ -34,9 +34,6 @@ 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])
|
||||
@@ -45,7 +42,7 @@ func setupConsistencyTest(
|
||||
completedAt := startedAt.Add(5 * time.Minute)
|
||||
snap := &database.Snapshot{
|
||||
ID: types.SnapshotID(id),
|
||||
Hostname: snapHostname,
|
||||
Hostname: testHostname,
|
||||
VaultikVersion: testLabel,
|
||||
StartedAt: startedAt,
|
||||
CompletedAt: &completedAt,
|
||||
|
||||
@@ -17,10 +17,8 @@ import (
|
||||
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||
)
|
||||
|
||||
// Snapshot IDs reused across the purge tests, and the hostname they were
|
||||
// taken on.
|
||||
// Snapshot IDs reused across the purge tests.
|
||||
const (
|
||||
snapHostname = "testhost"
|
||||
snapSystemT0 = "testhost_system_2026-01-01T00:00:00Z"
|
||||
snapHomeT0 = "testhost_home_2026-01-01T00:00:00Z"
|
||||
snapHomeT1 = "testhost_home_2026-01-01T01:00:00Z"
|
||||
@@ -28,12 +26,9 @@ const (
|
||||
)
|
||||
|
||||
// setupPurgeTest creates a Vaultik instance with an in-memory database and mock
|
||||
// storage pre-populated with the given snapshot IDs, all taken on hostname.
|
||||
// Each snapshot is marked as completed. Remote metadata stubs are created so
|
||||
// syncWithRemote keeps them.
|
||||
func setupPurgeTest(
|
||||
t *testing.T, hostname string, snapshotIDs []string,
|
||||
) *vaultik.Vaultik {
|
||||
// storage pre-populated with the given snapshot IDs. Each snapshot is marked as
|
||||
// completed. Remote metadata stubs are created so syncWithRemote keeps them.
|
||||
func setupPurgeTest(t *testing.T, snapshotIDs []string) *vaultik.Vaultik {
|
||||
t.Helper()
|
||||
|
||||
ctx := context.Background()
|
||||
@@ -44,9 +39,6 @@ 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 {
|
||||
@@ -59,7 +51,7 @@ func setupPurgeTest(
|
||||
completedAt := startedAt.Add(5 * time.Minute)
|
||||
snap := &database.Snapshot{
|
||||
ID: types.SnapshotID(id),
|
||||
Hostname: types.Hostname(hostname),
|
||||
Hostname: "testhost",
|
||||
VaultikVersion: testLabel,
|
||||
StartedAt: startedAt,
|
||||
CompletedAt: &completedAt,
|
||||
@@ -128,7 +120,7 @@ func TestPurgeKeepLatest_PerName(t *testing.T) {
|
||||
"testhost_system_2026-01-01T04:00:00Z",
|
||||
}
|
||||
|
||||
v := setupPurgeTest(t, snapHostname, snapshotIDs)
|
||||
v := setupPurgeTest(t, snapshotIDs)
|
||||
|
||||
err := v.PurgeSnapshotsWithOptions(&vaultik.SnapshotPurgeOptions{
|
||||
KeepLatest: true,
|
||||
@@ -156,7 +148,7 @@ func TestPurgeKeepLatest_SingleName(t *testing.T) {
|
||||
"testhost_home_2026-01-01T02:00:00Z",
|
||||
}
|
||||
|
||||
v := setupPurgeTest(t, snapHostname, snapshotIDs)
|
||||
v := setupPurgeTest(t, snapshotIDs)
|
||||
|
||||
err := v.PurgeSnapshotsWithOptions(&vaultik.SnapshotPurgeOptions{
|
||||
KeepLatest: true,
|
||||
@@ -184,7 +176,7 @@ func TestPurgeKeepLatest_WithNameFilter(t *testing.T) {
|
||||
"testhost_home_2026-01-01T04:00:00Z",
|
||||
}
|
||||
|
||||
v := setupPurgeTest(t, snapHostname, snapshotIDs)
|
||||
v := setupPurgeTest(t, snapshotIDs)
|
||||
|
||||
err := v.PurgeSnapshotsWithOptions(&vaultik.SnapshotPurgeOptions{
|
||||
KeepLatest: true,
|
||||
@@ -206,7 +198,7 @@ func TestPurgeKeepLatest_NoSnapshots(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
v := setupPurgeTest(t, snapHostname, nil)
|
||||
v := setupPurgeTest(t, nil)
|
||||
|
||||
err := v.PurgeSnapshotsWithOptions(&vaultik.SnapshotPurgeOptions{
|
||||
KeepLatest: true,
|
||||
@@ -224,7 +216,7 @@ func TestPurgeKeepLatest_NameFilterNoMatch(t *testing.T) {
|
||||
"testhost_system_2026-01-01T01:00:00Z",
|
||||
}
|
||||
|
||||
v := setupPurgeTest(t, snapHostname, snapshotIDs)
|
||||
v := setupPurgeTest(t, snapshotIDs)
|
||||
|
||||
err := v.PurgeSnapshotsWithOptions(&vaultik.SnapshotPurgeOptions{
|
||||
KeepLatest: true,
|
||||
@@ -251,7 +243,7 @@ func TestPurgeOlderThan_WithNameFilter(t *testing.T) {
|
||||
snapHomeT0,
|
||||
}
|
||||
|
||||
v := setupPurgeTest(t, snapHostname, snapshotIDs)
|
||||
v := setupPurgeTest(t, snapshotIDs)
|
||||
|
||||
// Purge only "home" snapshots older than 365 days
|
||||
err := v.PurgeSnapshotsWithOptions(&vaultik.SnapshotPurgeOptions{
|
||||
@@ -270,29 +262,6 @@ 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()
|
||||
@@ -308,7 +277,7 @@ func TestPurgeKeepLatest_ThreeNames(t *testing.T) {
|
||||
"testhost_home_2026-01-01T06:00:00Z",
|
||||
}
|
||||
|
||||
v := setupPurgeTest(t, snapHostname, snapshotIDs)
|
||||
v := setupPurgeTest(t, snapshotIDs)
|
||||
|
||||
err := v.PurgeSnapshotsWithOptions(&vaultik.SnapshotPurgeOptions{
|
||||
KeepLatest: true,
|
||||
@@ -322,28 +291,3 @@ func TestPurgeKeepLatest_ThreeNames(t *testing.T) {
|
||||
assert.Contains(t, remaining, "testhost_system_2026-01-01T04:00:00Z")
|
||||
assert.Contains(t, remaining, "testhost_media_2026-01-01T05:00:00Z")
|
||||
}
|
||||
|
||||
// A hostname may contain underscores, so the snapshot name cannot be found
|
||||
// by splitting the ID at them. A purge by name must still select "docs".
|
||||
func TestPurgeKeepLatest_HostnameWithUnderscore(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
system = "my_host_system_2026-01-01T00:00:00Z"
|
||||
docsT1 = "my_host_docs_2026-01-01T01:00:00Z"
|
||||
docsT2 = "my_host_docs_2026-01-01T02:00:00Z"
|
||||
)
|
||||
|
||||
v := setupPurgeTest(t, "my_host", []string{system, docsT1, docsT2})
|
||||
|
||||
err := v.PurgeSnapshotsWithOptions(&vaultik.SnapshotPurgeOptions{
|
||||
KeepLatest: true,
|
||||
Force: true,
|
||||
Names: []string{"docs"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.ElementsMatch(t, []string{system, docsT2},
|
||||
listRemainingSnapshots(t, v))
|
||||
}
|
||||
|
||||
@@ -1,261 +0,0 @@
|
||||
package vaultik_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/log"
|
||||
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||
)
|
||||
|
||||
// testBlobHashB is a blob that the manifest written by addRemote does
|
||||
// not reference.
|
||||
const testBlobHashB = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" +
|
||||
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||||
|
||||
// TestRemoteInfo_UnreadableManifestLeavesOrphansUnknown checks that a
|
||||
// manifest remote info cannot read makes the orphan figures unknown. A
|
||||
// blob referenced only by that snapshot would otherwise be counted as
|
||||
// orphaned, and the report would advise running prune.
|
||||
func TestRemoteInfo_UnreadableManifestLeavesOrphansUnknown(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
env := newListEnv(t)
|
||||
|
||||
// The readable manifest references blob A only.
|
||||
env.addRemote(t, listRemoteID, time.Date(2026, 3, 2, 0, 0, 0, 0, time.UTC))
|
||||
addBlob(t, env.store.testStorer, testBlobHashA)
|
||||
addBlob(t, env.store.testStorer, testBlobHashB)
|
||||
|
||||
// With every manifest readable, blob B is orphaned.
|
||||
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["orphaned_blob_count"], 0)
|
||||
|
||||
// A second snapshot whose manifest cannot be decoded. Blob B may be
|
||||
// one of its blobs.
|
||||
unreadableKey := snapshot.RemoteSnapshotKey(listLocalID)
|
||||
require.NoError(t, env.store.Put(context.Background(),
|
||||
"metadata/"+unreadableKey+"/manifest.json.zst",
|
||||
bytes.NewReader([]byte("not a valid manifest"))))
|
||||
|
||||
env.stdout.Reset()
|
||||
require.NoError(t, env.v.RemoteInfo(false))
|
||||
|
||||
text := env.stdout.String()
|
||||
assert.Contains(t, text, "Orphaned (unreferenced): unknown "+
|
||||
"(1 manifest(s) could not be read, "+
|
||||
"0 manifest(s) under a non-conforming name skipped)")
|
||||
assert.NotContains(t, text, "vaultik prune")
|
||||
|
||||
env.stdout.Reset()
|
||||
require.NoError(t, env.v.RemoteInfo(true))
|
||||
|
||||
doc = nil
|
||||
require.NoError(t, json.Unmarshal(env.stdout.Bytes(), &doc))
|
||||
assert.Contains(t, doc, "orphaned_blob_count")
|
||||
assert.Nil(t, doc["orphaned_blob_count"])
|
||||
assert.Contains(t, doc, "orphaned_blob_size")
|
||||
assert.Nil(t, doc["orphaned_blob_size"])
|
||||
assert.Equal(t, []any{unreadableKey}, doc["unreadable_manifests"])
|
||||
}
|
||||
|
||||
// TestRemoteInfo_UnreadableManifestLeavesSnapshotBlobsUnknown checks
|
||||
// that the row of a snapshot whose manifest cannot be read gives its
|
||||
// blob count and blob size as unknown in the table and as null in
|
||||
// --json, not as 0. A directory without a manifest still shows 0: the
|
||||
// orphan figures count its blobs as orphaned, so it references none.
|
||||
func TestRemoteInfo_UnreadableManifestLeavesSnapshotBlobsUnknown(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
env := newListEnv(t)
|
||||
readableKey := env.addRemote(t, listRemoteID,
|
||||
time.Date(2026, 3, 2, 0, 0, 0, 0, time.UTC))
|
||||
|
||||
unreadableKey := snapshot.RemoteSnapshotKey(listLocalID)
|
||||
require.NoError(t, env.store.Put(context.Background(),
|
||||
"metadata/"+unreadableKey+"/manifest.json.zst",
|
||||
bytes.NewReader([]byte("not a valid manifest"))))
|
||||
|
||||
noManifestKey := snapshot.RemoteSnapshotKey("testhost_home_2026-03-03T10:00:00Z")
|
||||
require.NoError(t, env.store.Put(context.Background(),
|
||||
"metadata/"+noManifestKey+"/db.zst.age",
|
||||
bytes.NewReader([]byte("not a valid database"))))
|
||||
|
||||
require.NoError(t, env.v.RemoteInfo(false))
|
||||
|
||||
// The table truncates the remote key, so a row is found by a prefix.
|
||||
wantUnknown := map[string]int{readableKey: 0, unreadableKey: 2, noManifestKey: 0}
|
||||
for key, want := range wantUnknown {
|
||||
var row string
|
||||
|
||||
for line := range strings.SplitSeq(env.stdout.String(), "\n") {
|
||||
if strings.HasPrefix(line, key[:16]) {
|
||||
row = line
|
||||
}
|
||||
}
|
||||
|
||||
require.NotEmpty(t, row, "no table row for %s", key)
|
||||
assert.Equal(t, want, strings.Count(row, "unknown"), "row: %q", row)
|
||||
}
|
||||
|
||||
env.stdout.Reset()
|
||||
require.NoError(t, env.v.RemoteInfo(true))
|
||||
|
||||
var doc struct {
|
||||
Snapshots []map[string]any `json:"snapshots"`
|
||||
}
|
||||
|
||||
require.NoError(t, json.Unmarshal(env.stdout.Bytes(), &doc))
|
||||
require.Len(t, doc.Snapshots, len(wantUnknown))
|
||||
|
||||
for _, entry := range doc.Snapshots {
|
||||
switch entry["snapshot_id"] {
|
||||
case readableKey:
|
||||
assert.InDelta(t, 1, entry["blob_count"], 0)
|
||||
assert.InDelta(t, fiveMegabytes, entry["blobs_size"], 0)
|
||||
case noManifestKey:
|
||||
assert.InDelta(t, 0, entry["blob_count"], 0)
|
||||
assert.InDelta(t, 0, entry["blobs_size"], 0)
|
||||
default:
|
||||
assert.Equal(t, unreadableKey, entry["snapshot_id"])
|
||||
assert.Contains(t, entry, "blob_count")
|
||||
assert.Nil(t, entry["blob_count"])
|
||||
assert.Contains(t, entry, "blobs_size")
|
||||
assert.Nil(t, entry["blobs_size"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRemoteInfo_SkipsNonConformingMetadataName checks that a directory
|
||||
// under metadata/ whose name is not a remote key is left out of the
|
||||
// report, and that the orphan figures are unknown when it holds a
|
||||
// manifest. The name comes from the destination store; printed raw, its
|
||||
// control characters would reach the terminal. Its manifest is not
|
||||
// read, so a blob only it references would otherwise be counted as
|
||||
// orphaned.
|
||||
func TestRemoteInfo_SkipsNonConformingMetadataName(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)
|
||||
addBlob(t, env.store.testStorer, testBlobHashB)
|
||||
require.NoError(t, env.store.Put(context.Background(),
|
||||
"metadata/\x1b[31mred/manifest.json.zst",
|
||||
bytes.NewReader([]byte("not a valid manifest"))))
|
||||
|
||||
require.NoError(t, env.v.RemoteInfo(false))
|
||||
|
||||
text := env.stdout.String()
|
||||
assert.NotContains(t, text, "\x1b")
|
||||
assert.NotContains(t, text, "31mred")
|
||||
assert.Contains(t, text, "Total (1 snapshots)")
|
||||
assert.Contains(t, text, "Orphaned (unreferenced): unknown "+
|
||||
"(0 manifest(s) could not be read, "+
|
||||
"1 manifest(s) under a non-conforming name skipped)")
|
||||
assert.NotContains(t, text, "vaultik prune")
|
||||
|
||||
env.stdout.Reset()
|
||||
require.NoError(t, env.v.RemoteInfo(true))
|
||||
|
||||
out := env.stdout.String()
|
||||
assert.NotContains(t, out, "31mred")
|
||||
|
||||
var doc map[string]any
|
||||
|
||||
require.NoError(t, json.Unmarshal([]byte(out), &doc))
|
||||
assert.Contains(t, doc, "orphaned_blob_count")
|
||||
assert.Nil(t, doc["orphaned_blob_count"])
|
||||
assert.Contains(t, doc, "orphaned_blob_size")
|
||||
assert.Nil(t, doc["orphaned_blob_size"])
|
||||
assert.InDelta(t, 1, doc["skipped_manifest_count"], 0)
|
||||
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
|
||||
// orphan figures known. prune does not treat such a directory as a
|
||||
// snapshot and deletes the blobs the report lists as orphaned.
|
||||
func TestRemoteInfo_DirectoryWithoutManifestLeavesOrphansKnown(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)
|
||||
addBlob(t, env.store.testStorer, testBlobHashB)
|
||||
|
||||
// One directory under a remote key and one under a non-conforming
|
||||
// name, each holding only a database.
|
||||
names := []string{snapshot.RemoteSnapshotKey(listLocalID), "\x1b[31mred"}
|
||||
for _, name := range names {
|
||||
require.NoError(t, env.store.Put(context.Background(),
|
||||
"metadata/"+name+"/db.zst.age",
|
||||
bytes.NewReader([]byte("not a valid database"))))
|
||||
}
|
||||
|
||||
require.NoError(t, env.v.RemoteInfo(false))
|
||||
|
||||
text := env.stdout.String()
|
||||
assert.NotContains(t, text, "\x1b")
|
||||
assert.Contains(t, text, "Downloading 1 manifest(s)...")
|
||||
assert.Contains(t, text, "Orphaned (unreferenced): 1 (")
|
||||
assert.Contains(t, text, "Run 'vaultik prune' to remove orphaned blobs.")
|
||||
|
||||
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["orphaned_blob_count"], 0)
|
||||
assert.NotContains(t, doc, "unreadable_manifests")
|
||||
assert.NotContains(t, doc, "skipped_manifest_count")
|
||||
}
|
||||
@@ -3,21 +3,17 @@ 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"
|
||||
)
|
||||
|
||||
@@ -129,12 +125,6 @@ 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,
|
||||
@@ -218,37 +208,6 @@ 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
|
||||
// ============================================================================
|
||||
@@ -268,7 +227,6 @@ 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)
|
||||
@@ -303,12 +261,7 @@ 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)
|
||||
@@ -357,89 +310,6 @@ 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()
|
||||
|
||||
@@ -42,7 +42,7 @@ func TestRestoreOnAnotherMachine(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
dataDir := filepath.Join(tempDir, "source")
|
||||
storeDir := t.TempDir()
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
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 := t.TempDir()
|
||||
storeDir := filepath.Join(root, "remote")
|
||||
restoreDir := filepath.Join(root, "restored")
|
||||
dbPath := filepath.Join(root, "index.sqlite")
|
||||
|
||||
@@ -164,9 +164,10 @@ func scratchEntries(t *testing.T, dir string) []string {
|
||||
// restore while a blob download is in progress. The download fails only
|
||||
// because of the cancel, so Restore must return context.Canceled without
|
||||
// reporting the file that needs the blob as failed.
|
||||
//
|
||||
//nolint:paralleltest // installs the global logger via log.Initialize
|
||||
func TestRestoreSkipErrorsCancelDuringBlobDownload(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
tempDir := t.TempDir()
|
||||
|
||||
@@ -197,7 +197,7 @@ func TestRestoreLocalityAndReadAt(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
dataDir := filepath.Join(tempDir, "source")
|
||||
storeDir := t.TempDir()
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
restoreDir := filepath.Join(tempDir, "restored")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ func backupOneFile(
|
||||
require.NoError(t, afero.WriteFile(fs, srcPath, content, mode))
|
||||
require.NoError(t, fs.Chmod(srcPath, mode))
|
||||
|
||||
storeDir := t.TempDir()
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
storer, err := storage.NewFileStorer(storeDir)
|
||||
|
||||
@@ -45,10 +45,9 @@ type missingBlobBackup struct {
|
||||
// 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.
|
||||
//
|
||||
//nolint:paralleltest // installs the global logger via log.Initialize
|
||||
func TestRestoreSkipErrorsSkipsFilesOfMissingBlob(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
backup := backupThenDeleteOneBlob(ctx, t)
|
||||
|
||||
@@ -86,10 +85,9 @@ func TestRestoreSkipErrorsSkipsFilesOfMissingBlob(t *testing.T) {
|
||||
|
||||
// TestRestoreMissingBlobAbortsWithoutSkipErrors checks that a deleted blob
|
||||
// still ends the restore with an error when SkipErrors is not set.
|
||||
//
|
||||
//nolint:paralleltest // installs the global logger via log.Initialize
|
||||
func TestRestoreMissingBlobAbortsWithoutSkipErrors(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
backup := backupThenDeleteOneBlob(ctx, t)
|
||||
|
||||
@@ -109,6 +107,7 @@ func backupThenDeleteOneBlob(
|
||||
ctx context.Context, t *testing.T,
|
||||
) *missingBlobBackup {
|
||||
t.Helper()
|
||||
log.Initialize(log.Config{})
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
tempDir := t.TempDir()
|
||||
@@ -127,7 +126,7 @@ func backupThenDeleteOneBlob(
|
||||
require.NoError(t, afero.WriteFile(fs, path, files[path], 0o644))
|
||||
}
|
||||
|
||||
storer, err := storage.NewFileStorer(t.TempDir())
|
||||
storer, err := storage.NewFileStorer(filepath.Join(tempDir, "remote"))
|
||||
require.NoError(t, err)
|
||||
|
||||
db, err := database.New(ctx, dbPath)
|
||||
|
||||
@@ -29,7 +29,7 @@ func TestRestoreAndDeepVerifyRejectSwappedDatabase(t *testing.T) {
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
tempDir := t.TempDir()
|
||||
storeDir := t.TempDir()
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
|
||||
chunkSize := int64(64 * 1024)
|
||||
|
||||
@@ -100,7 +100,7 @@ func TestDeepVerifyRejectsSwappedDatabaseWithEmptyManifest(t *testing.T) {
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
tempDir := t.TempDir()
|
||||
storeDir := t.TempDir()
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
|
||||
chunkSize := int64(64 * 1024)
|
||||
|
||||
|
||||
@@ -186,7 +186,7 @@ func TestRestoreSweeperEvictsBlobs(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
dataDir := filepath.Join(tempDir, "source")
|
||||
storeDir := t.TempDir()
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
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 := t.TempDir()
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
restoreDir := filepath.Join(tempDir, "restored")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
package vaultik_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"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"
|
||||
)
|
||||
|
||||
// A file rewritten with its size unchanged and a new mtime in the same
|
||||
// second as the mtime the index holds must still be backed up. See
|
||||
// https://git.eeqj.de/sneak/vaultik/issues/226.
|
||||
func TestBackupOfSameSecondRewriteRestoresNewContent(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
tempDir := t.TempDir()
|
||||
dataDir := filepath.Join(tempDir, "src")
|
||||
storeDir := t.TempDir()
|
||||
restoreDir := filepath.Join(tempDir, "restored")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
rewrittenPath := filepath.Join(dataDir, "small.txt")
|
||||
|
||||
ctx := context.Background()
|
||||
files := writeFaultSourceTree(t, fs, dataDir)
|
||||
cfg := changedFileConfig(dataDir, dbPath)
|
||||
|
||||
firstMTime := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC).
|
||||
Add(100 * time.Millisecond)
|
||||
secondMTime := firstMTime.Add(800 * time.Millisecond)
|
||||
|
||||
require.NoError(t, fs.Chtimes(rewrittenPath, firstMTime, firstMTime))
|
||||
|
||||
store, err := storage.NewFileStorer(storeDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
db, err := database.New(ctx, dbPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
repos := database.NewRepositories(db)
|
||||
v := newBackupVaultik(ctx, cfg, store, repos, db, fs)
|
||||
|
||||
require.NoError(t, backUp(v, "first"))
|
||||
|
||||
// Upper-casing ASCII text keeps its size.
|
||||
files[rewrittenPath] = bytes.ToUpper(files[rewrittenPath])
|
||||
require.NoError(t, afero.WriteFile(fs, rewrittenPath, files[rewrittenPath], 0o644))
|
||||
require.NoError(t, fs.Chtimes(rewrittenPath, secondMTime, secondMTime))
|
||||
|
||||
require.NoError(t, backUp(v, "second"))
|
||||
|
||||
id := localSnapshotID(ctx, t, repos, "second")
|
||||
require.NoError(t, db.Close())
|
||||
|
||||
reader := newReaderVaultik(ctx, cfg, store, nil, fs)
|
||||
require.NoError(t, reader.Restore(&vaultik.RestoreOptions{
|
||||
SnapshotID: id,
|
||||
TargetDir: restoreDir,
|
||||
Verify: true,
|
||||
}))
|
||||
|
||||
assertRestoredTree(t, fs, restoreDir, files)
|
||||
}
|
||||
@@ -31,7 +31,7 @@ func TestShallowVerifyDetectsWrongBlobSize(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
dataDir := filepath.Join(tempDir, "source")
|
||||
storeDir := t.TempDir()
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
chunkSize := int64(32 * 1024)
|
||||
@@ -105,7 +105,7 @@ func TestShallowVerifyDetectsMissingDatabase(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
dataDir := filepath.Join(tempDir, "source")
|
||||
storeDir := t.TempDir()
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
chunkSize := int64(32 * 1024)
|
||||
|
||||
+127
-268
@@ -14,7 +14,6 @@ import (
|
||||
|
||||
"sneak.berlin/go/vaultik/internal/log"
|
||||
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||
"sneak.berlin/go/vaultik/internal/types"
|
||||
)
|
||||
|
||||
// Sentinel errors for snapshot management.
|
||||
@@ -23,19 +22,12 @@ var (
|
||||
errNoSnapshotsInConfig = errors.New("no snapshots configured")
|
||||
errSnapshotVerifyFailed = errors.New("verification failed")
|
||||
errRemoveAllNeedsForce = errors.New("--all requires --force")
|
||||
errSnapshotNotFound = errors.New("snapshot not found")
|
||||
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
|
||||
@@ -51,26 +43,6 @@ 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
|
||||
@@ -92,13 +64,6 @@ 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
|
||||
@@ -121,9 +86,21 @@ func (v *Vaultik) CreateSnapshot(opts *SnapshotCreateOptions) error {
|
||||
return fmt.Errorf("prune database: %w", err)
|
||||
}
|
||||
|
||||
snapshotNames, err := v.snapshotNamesToCreate(opts.Snapshots)
|
||||
if err != nil {
|
||||
return 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
|
||||
}
|
||||
|
||||
// Process each named snapshot
|
||||
@@ -144,7 +121,7 @@ func (v *Vaultik) CreateSnapshot(opts *SnapshotCreateOptions) error {
|
||||
if opts.Prune {
|
||||
err = v.runPostBackupPrune(snapshotNames, opts.KeepNewerThan)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %w", ErrPostBackupPruneFailed, err)
|
||||
return fmt.Errorf("post-backup prune: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,28 +140,6 @@ 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
|
||||
@@ -229,17 +184,11 @@ type snapshotStats struct {
|
||||
totalBlobs int
|
||||
totalBytesSkipped int64
|
||||
totalFilesSkipped int
|
||||
totalFilesFailed int
|
||||
totalFilesDeleted int
|
||||
totalBytesDeleted int64
|
||||
totalBytesUploaded int64
|
||||
totalBlobsUploaded int
|
||||
uploadDuration time.Duration
|
||||
|
||||
// The sizes of all blobs the snapshot references, set by
|
||||
// finalizeSnapshotMetadata once snapshot_blobs is populated.
|
||||
blobSize int64
|
||||
blobUncompressedSize int64
|
||||
}
|
||||
|
||||
// createNamedSnapshot creates a single named snapshot
|
||||
@@ -279,6 +228,8 @@ func (v *Vaultik) createNamedSnapshot(
|
||||
return err
|
||||
}
|
||||
|
||||
v.collectUploadStats(scanner, stats)
|
||||
|
||||
err = v.finalizeSnapshotMetadata(snapshotID, stats)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -330,11 +281,6 @@ func (v *Vaultik) resolveSnapshotPaths(snapName string) ([]string, error) {
|
||||
func (v *Vaultik) scanAllDirectories(
|
||||
scanner *snapshot.Scanner, resolvedDirs []string, snapshotID string,
|
||||
) (*snapshotStats, error) {
|
||||
if progress := scanner.GetProgress(); progress != nil {
|
||||
progress.Start()
|
||||
defer progress.Stop()
|
||||
}
|
||||
|
||||
stats := &snapshotStats{}
|
||||
|
||||
for i, dir := range resolvedDirs {
|
||||
@@ -360,19 +306,14 @@ func (v *Vaultik) scanAllDirectories(
|
||||
stats.totalChunks += result.ChunksCreated
|
||||
stats.totalBlobs += result.BlobsCreated
|
||||
stats.totalFilesSkipped += result.FilesSkipped
|
||||
stats.totalFilesFailed += result.FilesFailed
|
||||
stats.totalBytesSkipped += result.BytesSkipped
|
||||
stats.totalFilesDeleted += result.FilesDeleted
|
||||
stats.totalBytesDeleted += result.BytesDeleted
|
||||
stats.totalBlobsUploaded += result.BlobsUploaded
|
||||
stats.totalBytesUploaded += result.BytesUploaded
|
||||
stats.uploadDuration += result.UploadDuration
|
||||
|
||||
log.Info("Directory scan complete",
|
||||
"path", dir,
|
||||
"files", result.FilesScanned,
|
||||
"files_skipped", result.FilesSkipped,
|
||||
"files_failed", result.FilesFailed,
|
||||
"bytes", result.BytesScanned,
|
||||
"bytes_skipped", result.BytesSkipped,
|
||||
"chunks", result.ChunksCreated,
|
||||
@@ -383,6 +324,18 @@ func (v *Vaultik) scanAllDirectories(
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// collectUploadStats gathers upload statistics from the scanner's
|
||||
// progress reporter.
|
||||
func (v *Vaultik) collectUploadStats(scanner *snapshot.Scanner, stats *snapshotStats) {
|
||||
if s := scanner.GetProgress(); s != nil {
|
||||
progressStats := s.GetStats()
|
||||
stats.totalBytesUploaded = progressStats.BytesUploaded.Load()
|
||||
stats.totalBlobsUploaded = int(progressStats.BlobsUploaded.Load())
|
||||
stats.uploadDuration = time.Duration(
|
||||
progressStats.UploadDurationMs.Load()) * time.Millisecond
|
||||
}
|
||||
}
|
||||
|
||||
// finalizeSnapshotMetadata updates stats, exports metadata, and only then
|
||||
// marks the snapshot complete. Recording completion last is deliberate: an
|
||||
// export interrupted by a crash leaves the snapshot incomplete rather than
|
||||
@@ -392,41 +345,31 @@ func (v *Vaultik) scanAllDirectories(
|
||||
func (v *Vaultik) finalizeSnapshotMetadata(
|
||||
snapshotID string, stats *snapshotStats,
|
||||
) error {
|
||||
// snapshot_blobs must be populated before the blob sizes below, which
|
||||
// total the snapshot's blobs, and before the export, which builds the
|
||||
// manifest and the trimmed metadata database from it.
|
||||
err := v.SnapshotManager.PopulateSnapshotBlobs(v.ctx, snapshotID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("populating snapshot blobs: %w", err)
|
||||
}
|
||||
|
||||
stats.blobSize, stats.blobUncompressedSize, err =
|
||||
v.Repositories.Snapshots.GetSnapshotBlobSizes(v.ctx, snapshotID)
|
||||
if err != nil {
|
||||
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 - stats.totalFilesFailed,
|
||||
TotalSize: stats.totalBytes + stats.totalBytesSkipped,
|
||||
FilesScanned: stats.totalFiles,
|
||||
BytesScanned: stats.totalBytes,
|
||||
ChunksCreated: stats.totalChunks,
|
||||
BlobsCreated: stats.totalBlobs,
|
||||
BytesUploaded: stats.totalBytesUploaded,
|
||||
},
|
||||
BlobSize: stats.blobSize,
|
||||
BlobUncompressedSize: stats.blobUncompressedSize,
|
||||
BlobUncompressedSize: 0,
|
||||
CompressionLevel: v.Config.CompressionLevel,
|
||||
UploadDurationMs: stats.uploadDuration.Milliseconds(),
|
||||
}
|
||||
|
||||
err = v.SnapshotManager.UpdateSnapshotStatsExtended(v.ctx, snapshotID, extStats)
|
||||
err := v.SnapshotManager.UpdateSnapshotStatsExtended(v.ctx, snapshotID, extStats)
|
||||
if err != nil {
|
||||
return fmt.Errorf("updating snapshot stats: %w", err)
|
||||
}
|
||||
|
||||
// snapshot_blobs must be populated before the export, which builds the
|
||||
// manifest and the trimmed metadata database from it.
|
||||
err = v.SnapshotManager.PopulateSnapshotBlobs(v.ctx, snapshotID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("populating snapshot blobs: %w", err)
|
||||
}
|
||||
|
||||
err = v.SnapshotManager.ExportSnapshotMetadata(
|
||||
v.ctx, v.Config.IndexPath, snapshotID)
|
||||
if err != nil {
|
||||
@@ -458,14 +401,15 @@ func (v *Vaultik) printSnapshotSummary(
|
||||
snapshotID string, startTime time.Time, stats *snapshotStats,
|
||||
) {
|
||||
snapshotDuration := time.Since(startTime)
|
||||
totalFilesChanged := stats.totalFiles - stats.totalFilesSkipped -
|
||||
stats.totalFilesFailed
|
||||
totalFilesChanged := stats.totalFiles - stats.totalFilesSkipped
|
||||
totalBytesAll := stats.totalBytes + stats.totalBytesSkipped
|
||||
|
||||
// Get total blob sizes from database
|
||||
compressedSize, uncompressedSize := v.getSnapshotBlobSizes(snapshotID)
|
||||
|
||||
var compressionRatio float64
|
||||
if stats.blobUncompressedSize > 0 {
|
||||
compressionRatio = float64(stats.blobSize) /
|
||||
float64(stats.blobUncompressedSize)
|
||||
if uncompressedSize > 0 {
|
||||
compressionRatio = float64(compressedSize) / float64(uncompressedSize)
|
||||
} else {
|
||||
compressionRatio = 1.0
|
||||
}
|
||||
@@ -476,10 +420,6 @@ 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))
|
||||
}
|
||||
@@ -497,8 +437,8 @@ func (v *Vaultik) printSnapshotSummary(
|
||||
|
||||
if stats.totalBlobsUploaded > 0 {
|
||||
v.UI.Detailf("Storage: %s compressed from %s (%.2fx ratio).",
|
||||
v.UI.Size(stats.blobSize),
|
||||
v.UI.Size(stats.blobUncompressedSize),
|
||||
v.UI.Size(compressedSize),
|
||||
v.UI.Size(uncompressedSize),
|
||||
compressionRatio)
|
||||
v.UI.Detailf("Upload: %d blobs, %s in %s (%s).",
|
||||
stats.totalBlobsUploaded,
|
||||
@@ -510,6 +450,27 @@ func (v *Vaultik) printSnapshotSummary(
|
||||
v.UI.Detailf("Snapshot create duration: %s.", v.UI.Duration(snapshotDuration))
|
||||
}
|
||||
|
||||
// getSnapshotBlobSizes returns total compressed and uncompressed blob
|
||||
// sizes for a snapshot.
|
||||
func (v *Vaultik) getSnapshotBlobSizes(snapshotID string) (int64, int64) {
|
||||
var compressed, uncompressed int64
|
||||
|
||||
blobHashes, err := v.Repositories.Snapshots.GetBlobHashes(v.ctx, snapshotID)
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
for _, hash := range blobHashes {
|
||||
blob, err := v.Repositories.Blobs.GetByHash(v.ctx, hash)
|
||||
if err == nil && blob != nil {
|
||||
compressed += blob.CompressedSize
|
||||
uncompressed += blob.UncompressedSize
|
||||
}
|
||||
}
|
||||
|
||||
return compressed, uncompressed
|
||||
}
|
||||
|
||||
// SnapshotPurgeOptions contains options for the snapshot purge command.
|
||||
type SnapshotPurgeOptions struct {
|
||||
KeepLatest bool // Keep only the most recent snapshot per name
|
||||
@@ -550,23 +511,19 @@ func (v *Vaultik) PurgeSnapshotsWithOptions(opts *SnapshotPurgeOptions) error {
|
||||
nameFilter[n] = struct{}{}
|
||||
}
|
||||
|
||||
// Collect completed snapshots and their names, applying the name filter.
|
||||
// Collect completed snapshots, applying the name filter.
|
||||
snapshots := make([]SnapshotInfo, 0, len(dbSnapshots))
|
||||
names := make(map[types.SnapshotID]string, len(dbSnapshots))
|
||||
|
||||
for _, s := range dbSnapshots {
|
||||
if s.CompletedAt == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
name := parseSnapshotName(s.ID.String(), s.Hostname.String())
|
||||
if len(nameFilter) > 0 {
|
||||
if _, ok := nameFilter[name]; !ok {
|
||||
if _, ok := nameFilter[parseSnapshotName(s.ID.String())]; !ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
names[s.ID] = name
|
||||
snapshots = append(snapshots, SnapshotInfo{
|
||||
ID: s.ID,
|
||||
Timestamp: s.StartedAt,
|
||||
@@ -579,7 +536,7 @@ func (v *Vaultik) PurgeSnapshotsWithOptions(opts *SnapshotPurgeOptions) error {
|
||||
return snapshots[i].Timestamp.After(snapshots[j].Timestamp)
|
||||
})
|
||||
|
||||
toDelete, err := selectSnapshotsToPurge(snapshots, names, opts)
|
||||
toDelete, err := selectSnapshotsToPurge(snapshots, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -597,11 +554,9 @@ func (v *Vaultik) PurgeSnapshotsWithOptions(opts *SnapshotPurgeOptions) error {
|
||||
|
||||
// selectSnapshotsToPurge applies the purge retention criteria to the
|
||||
// newest-first sorted snapshot list and returns the deletion
|
||||
// candidates. names maps each snapshot's ID to its snapshot name.
|
||||
// candidates.
|
||||
func selectSnapshotsToPurge(
|
||||
snapshots []SnapshotInfo,
|
||||
names map[types.SnapshotID]string,
|
||||
opts *SnapshotPurgeOptions,
|
||||
snapshots []SnapshotInfo, opts *SnapshotPurgeOptions,
|
||||
) ([]SnapshotInfo, error) {
|
||||
var toDelete []SnapshotInfo
|
||||
|
||||
@@ -612,7 +567,7 @@ func selectSnapshotsToPurge(
|
||||
seen := make(map[string]bool)
|
||||
|
||||
for _, snap := range snapshots {
|
||||
name := names[snap.ID]
|
||||
name := parseSnapshotName(snap.ID.String())
|
||||
if seen[name] {
|
||||
toDelete = append(toDelete, snap)
|
||||
|
||||
@@ -687,7 +642,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)
|
||||
}
|
||||
@@ -1106,7 +1061,7 @@ func (v *Vaultik) syncWithRemote() error {
|
||||
// every local snapshot record (issue #160).
|
||||
remoteKeys, err := v.listAllRemoteSnapshotKeys()
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("listing remote snapshots: %w", err)
|
||||
}
|
||||
|
||||
remoteKeySet := make(map[string]bool, len(remoteKeys))
|
||||
@@ -1171,12 +1126,6 @@ type RemoveResult struct {
|
||||
// just-removed snapshot left behind on the destination store.
|
||||
const pruneCommandHint = "vaultik prune"
|
||||
|
||||
// snapshotRemoveCommandHint is the command suggested, with the
|
||||
// snapshot's ID, when a remove could not reach the destination store:
|
||||
// running it again removes the snapshot's metadata there, which
|
||||
// `vaultik prune` never does.
|
||||
const snapshotRemoveCommandHint = "vaultik snapshot remove"
|
||||
|
||||
// RemoveSnapshot removes a snapshot from the local index database and,
|
||||
// unless LocalOnly is set, also strips the snapshot's metadata from the
|
||||
// destination store. Blobs are NOT touched: removing a snapshot's
|
||||
@@ -1197,38 +1146,23 @@ 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 !v.confirmRemoveSnapshot(snapshotID, opts) {
|
||||
if !opts.Force && !opts.JSON && !v.confirmRemoveSnapshot(snapshotID, opts) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Checked before the local record is deleted: it may be the last
|
||||
// snapshot in the local index, and the check applies only while the
|
||||
// local index holds one.
|
||||
var markerErr error
|
||||
if !opts.LocalOnly {
|
||||
markerErr = v.checkDestinationMarker()
|
||||
}
|
||||
log.Info("Removing snapshot from local database", "snapshot_id", snapshotID)
|
||||
|
||||
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)
|
||||
}
|
||||
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, markerErr, opts)
|
||||
result.RemoteRemoved = v.removeSnapshotRemote(snapshotID)
|
||||
}
|
||||
|
||||
if v.SnapshotManager != nil {
|
||||
@@ -1243,11 +1177,9 @@ func (v *Vaultik) RemoveSnapshot(
|
||||
return result, v.outputRemoveJSON(result)
|
||||
}
|
||||
|
||||
if inLocalIndex {
|
||||
v.stdoutf("Removed snapshot '%s' from local database\n", snapshotID)
|
||||
}
|
||||
v.stdoutf("Removed snapshot '%s' from local database\n", snapshotID)
|
||||
|
||||
if result.RemoteRemoved {
|
||||
if !opts.LocalOnly && 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 "+
|
||||
@@ -1257,45 +1189,6 @@ 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(
|
||||
@@ -1320,13 +1213,9 @@ func (v *Vaultik) removeSnapshotDryRun(
|
||||
}
|
||||
|
||||
// confirmRemoveSnapshot prompts for confirmation before removing a
|
||||
// snapshot, unless --force or --json is set. Returns false (treating
|
||||
// EOF/read errors as "no") unless the user answers "y".
|
||||
// snapshot. 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)
|
||||
@@ -1356,44 +1245,29 @@ func (v *Vaultik) confirmRemoveSnapshot(snapshotID string, opts *RemoveOptions)
|
||||
// removeSnapshotRemote strips the snapshot's metadata from the
|
||||
// destination store, warning and proceeding on failure: the local-DB
|
||||
// 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). 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 {
|
||||
// didn't finish and can retry with `vaultik prune` once the destination
|
||||
// store is reachable. Returns true when the remote removal succeeded.
|
||||
func (v *Vaultik) removeSnapshotRemote(snapshotID string) bool {
|
||||
log.Info("Removing snapshot metadata from remote storage",
|
||||
"snapshot_id", snapshotID)
|
||||
|
||||
var deleted int
|
||||
|
||||
err := markerErr
|
||||
if err == nil {
|
||||
deleted, err = v.deleteRemoteSnapshotByKey(
|
||||
snapshot.RemoteSnapshotKey(snapshotID))
|
||||
}
|
||||
remoteKey := 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 "+
|
||||
"again once the remote is reachable",
|
||||
"snapshot_id", snapshotID, "error", err)
|
||||
log.Warn("Could not remove snapshot metadata from remote storage",
|
||||
"error", err)
|
||||
|
||||
// The UI writes to stdout, which under --json holds only the
|
||||
// document; the log record above is the warning on stderr.
|
||||
if v.UI != nil && !opts.JSON {
|
||||
if v.UI != nil {
|
||||
v.UI.Warningf("Could not remove snapshot metadata from remote: "+
|
||||
"%v. Run '%s %s' again once the remote is reachable.",
|
||||
err, snapshotRemoveCommandHint, snapshotID)
|
||||
"%v. Run '%s' once the remote is reachable to finish cleanup.",
|
||||
err, pruneCommandHint)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
return deleted > 0
|
||||
return true
|
||||
}
|
||||
|
||||
// RemoveAllSnapshots removes every snapshot known to the local
|
||||
@@ -1479,8 +1353,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, or is not present by checkDestinationMarker; callers must
|
||||
// treat that as "no remote info available," not fatal.
|
||||
// listed; callers must treat that as "no remote info available," not
|
||||
// fatal.
|
||||
func (v *Vaultik) listAllRemoteSnapshotKeys() ([]string, error) {
|
||||
log.Info("Listing all remote snapshots")
|
||||
|
||||
@@ -1529,11 +1403,6 @@ func (v *Vaultik) listAllRemoteSnapshotKeys() ([]string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
err := v.checkDestinationMarker()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
@@ -1690,7 +1559,7 @@ func (v *Vaultik) removeAllLocalSnapshots(
|
||||
}
|
||||
|
||||
if !opts.LocalOnly {
|
||||
_, err = v.deleteRemoteSnapshotByKey(
|
||||
err = v.deleteRemoteSnapshotByKey(
|
||||
snapshot.RemoteSnapshotKey(snapshotID))
|
||||
if err != nil {
|
||||
log.Warn("Failed to remove snapshot metadata from remote",
|
||||
@@ -1716,7 +1585,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)
|
||||
@@ -1763,44 +1632,34 @@ func (v *Vaultik) deleteSnapshotFromLocalDB(snapshotID string) error {
|
||||
}
|
||||
|
||||
// deleteRemoteSnapshotByKey removes everything under
|
||||
// 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
|
||||
// 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)
|
||||
}
|
||||
|
||||
for i, key := range objectsToDelete {
|
||||
err = v.Storage.Delete(v.ctx, key)
|
||||
for _, key := range objectsToDelete {
|
||||
err := v.Storage.Delete(v.ctx, key)
|
||||
if err != nil {
|
||||
return i, fmt.Errorf("removing %s: %w", key, err)
|
||||
return fmt.Errorf("removing %s: %w", key, err)
|
||||
}
|
||||
|
||||
log.Debug("Deleted remote object", "key", key)
|
||||
}
|
||||
|
||||
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
|
||||
return nil
|
||||
}
|
||||
|
||||
// outputRemoveJSON outputs the removal result as JSON
|
||||
@@ -1894,9 +1753,9 @@ func (v *Vaultik) PruneDatabase() (*PruneResult, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// unknownText is what a count or size reads as when it could not be
|
||||
// determined, distinct from "0", which is a real zero.
|
||||
const unknownText = "unknown"
|
||||
// countUnknown is what a count reads as when its query could not be run,
|
||||
// distinct from "0", which means the table really was empty.
|
||||
const countUnknown = "unknown"
|
||||
|
||||
// tableCountForReport returns the row count of a table for the prune
|
||||
// summary, or nil if the count could not be read. A read failure is
|
||||
@@ -1933,7 +1792,7 @@ func countDiff(before, after *int64) *int64 {
|
||||
// one that could not be queried.
|
||||
func countText(count *int64) string {
|
||||
if count == nil {
|
||||
return unknownText
|
||||
return countUnknown
|
||||
}
|
||||
|
||||
return strconv.FormatInt(*count, 10)
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -122,9 +122,6 @@ 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: "",
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,286 +0,0 @@
|
||||
package vaultik_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/config"
|
||||
"sneak.berlin/go/vaultik/internal/database"
|
||||
"sneak.berlin/go/vaultik/internal/log"
|
||||
"sneak.berlin/go/vaultik/internal/storage"
|
||||
"sneak.berlin/go/vaultik/internal/storage/faultstore"
|
||||
"sneak.berlin/go/vaultik/internal/ui"
|
||||
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||
)
|
||||
|
||||
// These tests cover https://git.eeqj.de/sneak/vaultik/issues/225: the
|
||||
// summary printed after a backup, and the statistics stored in the
|
||||
// snapshots table, count each file, byte and upload once, and a --cron
|
||||
// run records its uploads.
|
||||
|
||||
// summaryUploadDelay slows every blob upload, so a run's upload time is
|
||||
// at least this long per blob even on a local store.
|
||||
const summaryUploadDelay = 20 * time.Millisecond
|
||||
|
||||
// summaryEnv is a backup setup whose user-facing output is kept in out.
|
||||
type summaryEnv struct {
|
||||
v *vaultik.Vaultik
|
||||
db *database.DB
|
||||
repos *database.Repositories
|
||||
out *bytes.Buffer
|
||||
|
||||
// aPath is a.bin, whose content copy.bin repeats; aSize is its size
|
||||
// and totalSize the size of all three source files.
|
||||
aPath string
|
||||
aSize int64
|
||||
totalSize int64
|
||||
}
|
||||
|
||||
// newSummaryEnv writes src/one/a.bin, src/one/small.txt and
|
||||
// src/two/copy.bin, a copy of a.bin. Every chunk of copy.bin is therefore
|
||||
// already stored by the time the backup reaches it.
|
||||
//
|
||||
// The snapshot names "first" and "second" back up src; "split" backs up
|
||||
// src/one and src/two as two paths.
|
||||
func newSummaryEnv(t *testing.T) *summaryEnv {
|
||||
t.Helper()
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
tempDir := t.TempDir()
|
||||
srcDir := filepath.Join(tempDir, "src")
|
||||
dirOne := filepath.Join(srcDir, "one")
|
||||
dirTwo := filepath.Join(srcDir, "two")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
ctx := context.Background()
|
||||
|
||||
aContent := bytesPattern("a-", int(3*faultChunkSize))
|
||||
smallContent := []byte("hello vaultik")
|
||||
files := map[string][]byte{
|
||||
filepath.Join(dirOne, "a.bin"): aContent,
|
||||
filepath.Join(dirOne, "small.txt"): smallContent,
|
||||
filepath.Join(dirTwo, "copy.bin"): aContent,
|
||||
}
|
||||
|
||||
for path, content := range files {
|
||||
require.NoError(t, fs.MkdirAll(filepath.Dir(path), 0o755))
|
||||
require.NoError(t, afero.WriteFile(fs, path, content, 0o644))
|
||||
}
|
||||
|
||||
cfg := faultTestConfig()
|
||||
cfg.IndexPath = dbPath
|
||||
cfg.ChunkSize = config.Size(faultChunkSize)
|
||||
cfg.Snapshots = map[string]config.SnapshotConfig{
|
||||
"first": {Paths: []string{srcDir}},
|
||||
"second": {Paths: []string{srcDir}},
|
||||
"split": {Paths: []string{dirOne, dirTwo}},
|
||||
}
|
||||
|
||||
inner, err := storage.NewFileStorer(t.TempDir())
|
||||
require.NoError(t, err)
|
||||
|
||||
store := faultstore.New(inner)
|
||||
store.OnPut = func(key string) faultstore.PutAction {
|
||||
if strings.HasPrefix(key, "blobs/") {
|
||||
time.Sleep(summaryUploadDelay)
|
||||
}
|
||||
|
||||
return faultstore.PutNormal
|
||||
}
|
||||
|
||||
db, err := database.New(ctx, dbPath)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
repos := database.NewRepositories(db)
|
||||
out := &bytes.Buffer{}
|
||||
v := newBackupVaultik(ctx, cfg, store, repos, db, fs)
|
||||
v.UI = ui.NewWithColor(out, false)
|
||||
|
||||
return &summaryEnv{
|
||||
v: v,
|
||||
db: db,
|
||||
repos: repos,
|
||||
out: out,
|
||||
aPath: filepath.Join(dirOne, "a.bin"),
|
||||
aSize: int64(len(aContent)),
|
||||
totalSize: int64(2*len(aContent) + len(smallContent)),
|
||||
}
|
||||
}
|
||||
|
||||
// backUp runs a backup of the named snapshot and returns its output.
|
||||
func (e *summaryEnv) backUp(t *testing.T, name string, cron bool) string {
|
||||
t.Helper()
|
||||
|
||||
e.out.Reset()
|
||||
require.NoError(t, e.v.CreateSnapshot(&vaultik.SnapshotCreateOptions{
|
||||
Cron: cron,
|
||||
Snapshots: []string{name},
|
||||
}))
|
||||
|
||||
return e.out.String()
|
||||
}
|
||||
|
||||
// snapshot returns the local snapshots row of the snapshot named name.
|
||||
func (e *summaryEnv) snapshot(t *testing.T, name string) *database.Snapshot {
|
||||
t.Helper()
|
||||
|
||||
ctx := context.Background()
|
||||
snap, err := e.repos.Snapshots.GetByID(ctx,
|
||||
localSnapshotID(ctx, t, e.repos, name))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, snap)
|
||||
|
||||
return snap
|
||||
}
|
||||
|
||||
// uploads returns how many blobs the snapshot uploaded and their
|
||||
// total size, as recorded in the uploads table.
|
||||
func (e *summaryEnv) uploads(t *testing.T, snapshotID string) (int64, int64) {
|
||||
t.Helper()
|
||||
|
||||
var count, size int64
|
||||
|
||||
err := e.db.Conn().QueryRowContext(context.Background(), `
|
||||
SELECT COUNT(*), COALESCE(SUM(size), 0)
|
||||
FROM uploads WHERE snapshot_id = ?`, snapshotID).Scan(&count, &size)
|
||||
require.NoError(t, err)
|
||||
|
||||
return count, size
|
||||
}
|
||||
|
||||
// referencedBlobSizes returns the compressed and uncompressed sizes of
|
||||
// all blobs the snapshot references.
|
||||
func (e *summaryEnv) referencedBlobSizes(
|
||||
t *testing.T, snapshotID string,
|
||||
) (int64, int64) {
|
||||
t.Helper()
|
||||
|
||||
var compressed, uncompressed int64
|
||||
|
||||
err := e.db.Conn().QueryRowContext(context.Background(), `
|
||||
SELECT COALESCE(SUM(b.compressed_size), 0),
|
||||
COALESCE(SUM(b.uncompressed_size), 0)
|
||||
FROM snapshot_blobs sb JOIN blobs b ON b.blob_hash = sb.blob_hash
|
||||
WHERE sb.snapshot_id = ?`, snapshotID).Scan(&compressed, &uncompressed)
|
||||
require.NoError(t, err)
|
||||
|
||||
return compressed, uncompressed
|
||||
}
|
||||
|
||||
// filesLine returns the summary's line of file counts.
|
||||
func filesLine(examined, backedUp, unchanged int) string {
|
||||
return fmt.Sprintf("Files: %d examined, %d backed up, %d unchanged.",
|
||||
examined, backedUp, unchanged)
|
||||
}
|
||||
|
||||
// dataLine returns the summary's line of byte counts.
|
||||
func (e *summaryEnv) dataLine(total, backedUp int64) string {
|
||||
return fmt.Sprintf("Data: %s total (%s backed up).",
|
||||
e.v.UI.Size(total), e.v.UI.Size(backedUp))
|
||||
}
|
||||
|
||||
// A first backup stores copy.bin's chunks while backing up a.bin, so
|
||||
// copy.bin's chunks are deduplicated within the run. Each file and byte
|
||||
// is still counted once.
|
||||
func TestSnapshotSummaryFirstRun(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
env := newSummaryEnv(t)
|
||||
|
||||
summary := env.backUp(t, "first", false)
|
||||
|
||||
assert.Contains(t, summary, filesLine(3, 3, 0))
|
||||
assert.Contains(t, summary, env.dataLine(env.totalSize, env.totalSize))
|
||||
|
||||
snap := env.snapshot(t, "first")
|
||||
uploadCount, uploadBytes := env.uploads(t, snap.ID.String())
|
||||
require.Positive(t, uploadCount)
|
||||
|
||||
assert.Contains(t, summary, fmt.Sprintf("Upload: %d blobs, %s in ",
|
||||
uploadCount, env.v.UI.Size(uploadBytes)))
|
||||
|
||||
assert.Equal(t, int64(3), snap.FileCount)
|
||||
assert.Equal(t, env.totalSize, snap.TotalSize)
|
||||
assert.Equal(t, uploadCount, snap.BlobCount)
|
||||
assert.Equal(t, uploadBytes, snap.UploadBytes)
|
||||
}
|
||||
|
||||
// An incremental backup where a.bin's mtime changed but its content did
|
||||
// not: a.bin is backed up again and every one of its chunks is already
|
||||
// stored.
|
||||
func TestSnapshotSummaryIncrementalRunWithDeduplicatedChunks(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
env := newSummaryEnv(t)
|
||||
|
||||
env.backUp(t, "first", false)
|
||||
|
||||
later := time.Now().Add(time.Hour)
|
||||
require.NoError(t, os.Chtimes(env.aPath, later, later))
|
||||
|
||||
summary := env.backUp(t, "second", false)
|
||||
|
||||
assert.Contains(t, summary, filesLine(3, 1, 2))
|
||||
assert.Contains(t, summary, env.dataLine(env.totalSize, env.aSize))
|
||||
assert.NotContains(t, summary, "Upload:")
|
||||
|
||||
snap := env.snapshot(t, "second")
|
||||
compressed, uncompressed := env.referencedBlobSizes(t, snap.ID.String())
|
||||
require.Positive(t, compressed)
|
||||
|
||||
assert.Equal(t, env.totalSize, snap.TotalSize)
|
||||
assert.Zero(t, snap.ChunkCount)
|
||||
assert.Zero(t, snap.BlobCount)
|
||||
assert.Zero(t, snap.UploadBytes)
|
||||
assert.Equal(t, compressed, snap.BlobSize,
|
||||
"blob_size must total the blobs the snapshot references")
|
||||
assert.Equal(t, uncompressed, snap.BlobUncompressedSize)
|
||||
}
|
||||
|
||||
// Under --cron the progress reporter is off; the upload figures must
|
||||
// still reach the summary and the snapshots row. The snapshot has two
|
||||
// paths, each backed up by its own scan.
|
||||
func TestSnapshotSummaryCronRunRecordsUploads(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
env := newSummaryEnv(t)
|
||||
|
||||
summary := env.backUp(t, "split", true)
|
||||
|
||||
snap := env.snapshot(t, "split")
|
||||
uploadCount, uploadBytes := env.uploads(t, snap.ID.String())
|
||||
require.Positive(t, uploadCount)
|
||||
|
||||
assert.Contains(t, summary, filesLine(3, 3, 0))
|
||||
assert.Contains(t, summary, env.dataLine(env.totalSize, env.totalSize))
|
||||
assert.Contains(t, summary, fmt.Sprintf("Upload: %d blobs, %s in ",
|
||||
uploadCount, env.v.UI.Size(uploadBytes)))
|
||||
|
||||
assert.Equal(t, env.totalSize, snap.TotalSize)
|
||||
assert.Equal(t, uploadCount, snap.BlobCount,
|
||||
"blob_count must count each blob once, however many paths the "+
|
||||
"snapshot has")
|
||||
assert.Equal(t, uploadBytes, snap.UploadBytes)
|
||||
assert.GreaterOrEqual(t, snap.UploadDurationMs,
|
||||
uploadCount*summaryUploadDelay.Milliseconds())
|
||||
|
||||
compressed, uncompressed := env.referencedBlobSizes(t, snap.ID.String())
|
||||
require.Positive(t, uncompressed)
|
||||
|
||||
assert.Equal(t, compressed, snap.BlobSize)
|
||||
assert.Equal(t, uncompressed, snap.BlobUncompressedSize)
|
||||
assert.InDelta(t, float64(compressed)/float64(uncompressed),
|
||||
snap.CompressionRatio, 1e-9)
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
)
|
||||
|
||||
// errStorageBindingMismatch is returned when the local index database is
|
||||
// bound to a different destination than the configured one.
|
||||
// bound to a different destination than the configured storage_url.
|
||||
var errStorageBindingMismatch = errors.New(
|
||||
"local index is bound to a different backup destination")
|
||||
|
||||
@@ -24,9 +24,8 @@ var errStorageBindingMismatch = errors.New(
|
||||
//
|
||||
// Behaviour:
|
||||
// - On first use (empty stored value), record the configured
|
||||
// destination (storage_url, or the s3.* endpoint, bucket and
|
||||
// prefix) and log the binding.
|
||||
// - When the stored value matches the configured destination, do
|
||||
// storage_url and log the binding.
|
||||
// - When the stored value matches the configured storage_url, 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
|
||||
@@ -47,10 +46,12 @@ func (v *Vaultik) EnsureStorageBinding() error {
|
||||
|
||||
configured := v.Config.StorageURL
|
||||
if configured == "" {
|
||||
// The s3.* form: bind on its endpoint, bucket and prefix,
|
||||
// written the way storage_url would name the same destination.
|
||||
s3 := v.Config.S3
|
||||
configured = "s3://" + s3.Bucket + "/" + s3.Prefix + "?endpoint=" + s3.Endpoint
|
||||
// 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
|
||||
}
|
||||
|
||||
stored, err := v.Repositories.LocalMeta.Get(v.ctx, database.LocalMetaKeyStorageURL)
|
||||
@@ -95,8 +96,7 @@ 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 (or the s3.* endpoint, bucket and prefix) in your\n" +
|
||||
" config to the bound destination, or\n" +
|
||||
" - revert storage_url in your 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,51 +76,3 @@ 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")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
package vaultik_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"maps"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"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"
|
||||
)
|
||||
|
||||
// A backup without --cron runs the progress reporter while one scanner
|
||||
// scans each path of the snapshot in turn. See
|
||||
// https://git.eeqj.de/sneak/vaultik/issues/253.
|
||||
func TestBackupWithoutCronOfTwoPathSnapshotRestoresBothPaths(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
const snapshotName = "data"
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
tempDir := t.TempDir()
|
||||
firstDir := filepath.Join(tempDir, "first")
|
||||
secondDir := filepath.Join(tempDir, "second")
|
||||
storeDir := t.TempDir()
|
||||
restoreDir := filepath.Join(tempDir, "restored")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
ctx := context.Background()
|
||||
files := writeFaultSourceTree(t, fs, firstDir)
|
||||
maps.Copy(files, writeFaultSourceTree(t, fs, secondDir))
|
||||
|
||||
cfg := faultTestConfig()
|
||||
cfg.IndexPath = dbPath
|
||||
cfg.ChunkSize = config.Size(faultChunkSize)
|
||||
cfg.Snapshots = map[string]config.SnapshotConfig{
|
||||
snapshotName: {Paths: []string{firstDir, secondDir}},
|
||||
}
|
||||
|
||||
store, err := storage.NewFileStorer(storeDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
db, err := database.New(ctx, dbPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
repos := database.NewRepositories(db)
|
||||
v := newBackupVaultik(ctx, cfg, store, repos, db, fs)
|
||||
|
||||
require.NoError(t, v.CreateSnapshot(&vaultik.SnapshotCreateOptions{
|
||||
Snapshots: []string{snapshotName},
|
||||
}))
|
||||
|
||||
id := localSnapshotID(ctx, t, repos, snapshotName)
|
||||
require.NoError(t, db.Close())
|
||||
|
||||
reader := newReaderVaultik(ctx, cfg, store, nil, fs)
|
||||
require.NoError(t, reader.Restore(&vaultik.RestoreOptions{
|
||||
SnapshotID: id,
|
||||
TargetDir: restoreDir,
|
||||
Verify: true,
|
||||
}))
|
||||
|
||||
assertRestoredTree(t, fs, restoreDir, files)
|
||||
}
|
||||
@@ -28,7 +28,7 @@ func TestVerify_QuietSuppressesReport(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
dataDir := filepath.Join(tempDir, "source")
|
||||
storeDir := t.TempDir()
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
chunkSize := int64(32 * 1024)
|
||||
|
||||
+2
-5
@@ -1,9 +1,6 @@
|
||||
#!/bin/sh
|
||||
# script/fmt-check: check formatting (read-only). Fails instead of
|
||||
# writing. It checks every Go file outside .tool, which is more than
|
||||
# script/fmt formats: `go fmt ./...` skips `testdata` directories and
|
||||
# files and directories whose names start with `.` or `_`. Fix a file
|
||||
# only this reports with `gofmt -w`.
|
||||
# script/fmt-check: check formatting (read-only). Same scope as
|
||||
# script/fmt, but fails instead of writing.
|
||||
set -eu
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
|
||||
+4
-3
@@ -21,9 +21,10 @@ goreleaser_version() {
|
||||
head -n 1
|
||||
}
|
||||
|
||||
# Resolve the goreleaser to run. A binary on PATH is accepted only when
|
||||
# it is exactly the pinned version, because a differently versioned tool
|
||||
# would produce a differently built release from the same tag. Anything else
|
||||
# Resolve the goreleaser to run, on the same rule script/lint uses for
|
||||
# golangci-lint: a binary on PATH is accepted only when it is exactly
|
||||
# the pinned version, because a differently versioned tool would
|
||||
# produce a differently built release from the same tag. Anything else
|
||||
# comes from .tool/bin, and a missing one is a loud failure naming the
|
||||
# script that installs it rather than a silent fallback.
|
||||
resolve_goreleaser() {
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ s3:
|
||||
secret_access_key: test-secret-key
|
||||
region: us-east-1
|
||||
use_ssl: true
|
||||
part_size: 5242880 # 5MiB
|
||||
part_size: 5242880 # 5MB
|
||||
index_path: /tmp/vaultik-test.sqlite
|
||||
chunk_size: 10MB
|
||||
blob_size_limit: 10GB
|
||||
|
||||
Reference in New Issue
Block a user