Compare commits
25
Commits
v1.0.0-rc2
...
59d7376c04
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59d7376c04 | ||
|
|
6a8bb2f389 | ||
|
|
5f1f8c4f66 | ||
|
|
e8ab13b526 | ||
|
|
f6df500ba2 | ||
|
|
7aae6e4ff3 | ||
|
|
d6eed26482 | ||
|
|
cdcbe8adfd | ||
|
|
1a478ef275 | ||
|
|
d8d13d1c8b | ||
|
|
028a166256 | ||
|
|
c07db21b45 | ||
|
|
1df1d23094 | ||
|
|
0d0c76fa24 | ||
|
|
88a2aa7953 | ||
|
|
098ed80990 | ||
|
|
aae7fa17ae | ||
|
|
9236c0727c | ||
|
|
4418dc3e97 | ||
|
|
5a0da6da12 | ||
|
|
13a35a986a | ||
|
|
5abacbc5c1 | ||
|
|
ff50a137dd | ||
|
|
e9b17cc5fd | ||
|
|
850e2de25a |
No files matched your search
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
yarn.lock
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"tabWidth": 4,
|
||||
"proseWrap": "always"
|
||||
}
|
||||
@@ -4,127 +4,122 @@ Version: 2025-06-08
|
||||
|
||||
# Instructions and Contextual Information
|
||||
|
||||
* Be direct, robotic, expert, accurate, and professional.
|
||||
- Be direct, robotic, expert, accurate, and professional.
|
||||
|
||||
* Do not butter me up or kiss my ass.
|
||||
- Do not butter me up or kiss my ass.
|
||||
|
||||
* Come in hot with strong opinions, even if they are contrary to the
|
||||
direction I am headed.
|
||||
- Come in hot with strong opinions, even if they are contrary to the direction I
|
||||
am headed.
|
||||
|
||||
* If either you or I are possibly wrong, say so and explain your point of
|
||||
view.
|
||||
- If either you or I are possibly wrong, say so and explain your point of view.
|
||||
|
||||
* Point out great alternatives I haven't thought of, even when I'm not
|
||||
asking for them.
|
||||
- Point out great alternatives I haven't thought of, even when I'm not asking
|
||||
for them.
|
||||
|
||||
* Treat me like the world's leading expert in every situation and every
|
||||
- Treat me like the world's leading expert in every situation and every
|
||||
conversation, and deliver the absolute best recommendations.
|
||||
|
||||
* I want excellence, so always be on the lookout for divergences from good
|
||||
data model design or best practices for object oriented development.
|
||||
- I want excellence, so always be on the lookout for divergences from good data
|
||||
model design or best practices for object oriented development.
|
||||
|
||||
* IMPORTANT: This is production code, not a research or teaching exercise.
|
||||
- IMPORTANT: This is production code, not a research or teaching exercise.
|
||||
Deliver professional-level results, not prototypes.
|
||||
|
||||
* Please read and understand the `README.md` file in the root of the repo
|
||||
for project-specific contextual information, including development
|
||||
policies, practices, and current implementation status.
|
||||
- Please read and understand the `README.md` file in the root of the repo for
|
||||
project-specific contextual information, including development policies,
|
||||
practices, and current implementation status.
|
||||
|
||||
* Be proactive in suggesting improvements or refactorings in places where we
|
||||
- Be proactive in suggesting improvements or refactorings in places where we
|
||||
diverge from best practices for clean, modular, maintainable code.
|
||||
|
||||
# Policies
|
||||
|
||||
1. Before committing, tests must pass (`make test`), linting must pass
|
||||
(`make lint`), and code must be formatted (`make fmt`). For go, those
|
||||
(`make lint`), and code must be formatted (`make fmt`). For go, those
|
||||
makefile targets should use `go fmt` and `go test -v ./...` and
|
||||
`golangci-lint run`. Each Makefile target does exactly one thing — to
|
||||
run lint + fmt-check + test together (the standard pre-commit gate),
|
||||
use `make check`.
|
||||
`golangci-lint run`. Each Makefile target does exactly one thing — to run
|
||||
lint + fmt-check + test together (the standard pre-commit gate), use
|
||||
`make check`.
|
||||
|
||||
2. Always write a `Makefile` with the default target being `test`, and with
|
||||
a `fmt` target that formats the code. The `test` target should run all
|
||||
tests in the project, and the `fmt` target should format the code.
|
||||
`test` should also have a prerequisite target `lint` that should run any
|
||||
linters that are configured for the project.
|
||||
2. Always write a `Makefile` with the default target being `test`, and with a
|
||||
`fmt` target that formats the code. The `test` target should run all tests in
|
||||
the project, and the `fmt` target should format the code. `test` should also
|
||||
have a prerequisite target `lint` that should run any linters that are
|
||||
configured for the project.
|
||||
|
||||
3. After each completed bugfix or feature, the code must be committed. Do
|
||||
all of the pre-commit checks (test, lint, fmt) before committing, of
|
||||
course.
|
||||
3. After each completed bugfix or feature, the code must be committed. Do all of
|
||||
the pre-commit checks (test, lint, fmt) before committing, of course.
|
||||
|
||||
4. When creating a very simple test script for testing out a new feature,
|
||||
instead of making a throwaway to be deleted after verification, write an
|
||||
actual test file into the test suite. It doesn't need to be very big or
|
||||
actual test file into the test suite. It doesn't need to be very big or
|
||||
complex, but it should be a real test that can be run.
|
||||
|
||||
5. When you are instructed to make the tests pass, DO NOT delete tests, skip
|
||||
tests, or change the tests specifically to make them pass (unless there
|
||||
is a bug in the test). This is cheating, and it is bad. You should only
|
||||
be modifying the test if it is incorrect or if the test is no longer
|
||||
relevant. In almost all cases, you should be fixing the code that is
|
||||
being tested, or updating the tests to match a refactored implementation.
|
||||
tests, or change the tests specifically to make them pass (unless there is a
|
||||
bug in the test). This is cheating, and it is bad. You should only be
|
||||
modifying the test if it is incorrect or if the test is no longer relevant.
|
||||
In almost all cases, you should be fixing the code that is being tested, or
|
||||
updating the tests to match a refactored implementation.
|
||||
|
||||
6. When dealing with dates and times or timestamps, always use, display, and
|
||||
store UTC. Set the local timezone to UTC on startup. If the user needs
|
||||
to see the time in a different timezone, store the user's timezone in a
|
||||
separate field and convert the UTC time to the user's timezone when
|
||||
displaying it. For internal use and internal applications and
|
||||
administrative purposes, always display UTC.
|
||||
store UTC. Set the local timezone to UTC on startup. If the user needs to see
|
||||
the time in a different timezone, store the user's timezone in a separate
|
||||
field and convert the UTC time to the user's timezone when displaying it. For
|
||||
internal use and internal applications and administrative purposes, always
|
||||
display UTC.
|
||||
|
||||
7. Always write tests, even if they are extremely simple and just check for
|
||||
correct syntax (ability to compile/import). If you are writing a new
|
||||
feature, write a test for it. You don't need to target complete
|
||||
coverage, but you should at least test any new functionality you add. If
|
||||
you are fixing a bug, write a test first that reproduces the bug, and
|
||||
then fix the bug in the code.
|
||||
correct syntax (ability to compile/import). If you are writing a new feature,
|
||||
write a test for it. You don't need to target complete coverage, but you
|
||||
should at least test any new functionality you add. If you are fixing a bug,
|
||||
write a test first that reproduces the bug, and then fix the bug in the code.
|
||||
|
||||
8. When implementing new features, be aware of potential side-effects (such
|
||||
as state files on disk, data in the database, etc.) and ensure that it is
|
||||
8. When implementing new features, be aware of potential side-effects (such as
|
||||
state files on disk, data in the database, etc.) and ensure that it is
|
||||
possible to mock or stub these side-effects in tests.
|
||||
|
||||
9. Always use structured logging. Log any relevant state/context with the
|
||||
messages (but do not log secrets). If the log stream is not a terminal,
|
||||
9. Always use structured logging. Log any relevant state/context with the
|
||||
messages (but do not log secrets). If the log stream is not a terminal,
|
||||
output the structured logs in jsonl format.
|
||||
|
||||
10. Avoid using bare strings or numbers in code, especially if they appear
|
||||
anywhere more than once. Always define a constant (usually at the top
|
||||
of the file) and give it a descriptive name, then use that constant in
|
||||
the code instead of the bare string or number.
|
||||
anywhere more than once. Always define a constant (usually at the top of the
|
||||
file) and give it a descriptive name, then use that constant in the code
|
||||
instead of the bare string or number.
|
||||
|
||||
11. You do not need to summarize your changes in the chat after making them.
|
||||
Making the changes and committing them is sufficient. If anything out
|
||||
of the ordinary happened, please explain it, but in the normal case
|
||||
where you found and fixed the bug, or implemented the feature, there is
|
||||
no need for the end-of-change summary.
|
||||
Making the changes and committing them is sufficient. If anything out of the
|
||||
ordinary happened, please explain it, but in the normal case where you found
|
||||
and fixed the bug, or implemented the feature, there is no need for the
|
||||
end-of-change summary.
|
||||
|
||||
12. Do not create additional files in the root directory of the project
|
||||
without asking permission first. Configuration files, documentation, and
|
||||
build files are acceptable in the root, but source code and other files
|
||||
should be organized in appropriate subdirectories.
|
||||
12. Do not create additional files in the root directory of the project without
|
||||
asking permission first. Configuration files, documentation, and build files
|
||||
are acceptable in the root, but source code and other files should be
|
||||
organized in appropriate subdirectories.
|
||||
|
||||
13. Pre-1.0: NEVER add a database migration. Migrations are supported, but
|
||||
nothing is installed anywhere yet, so there is nothing to migrate. To
|
||||
change the schema, edit `internal/database/schema/001.sql` (and any
|
||||
code that touches the affected tables) directly. After 1.0, each schema
|
||||
change is a new numbered file in that directory and a released file is
|
||||
never edited; an existing local database is then migrated when vaultik
|
||||
is updated. Before 1.0, a local database left on an older schema is
|
||||
deleted and re-created by a full backup. See
|
||||
[`docs/DATAMODEL.md`](docs/DATAMODEL.md#schema-migrations).
|
||||
nothing is installed anywhere yet, so there is nothing to migrate. To change
|
||||
the schema, edit `internal/database/schema/001.sql` (and any code that
|
||||
touches the affected tables) directly. After 1.0, each schema change is a
|
||||
new numbered file in that directory and a released file is never edited; an
|
||||
existing local database is then migrated when vaultik is updated. Before
|
||||
1.0, a local database left on an older schema is deleted and re-created by a
|
||||
full backup. See [`docs/DATAMODEL.md`](docs/DATAMODEL.md#schema-migrations).
|
||||
|
||||
14. Never use `git add -A`. Stage only the files you intentionally
|
||||
changed.
|
||||
14. Never use `git add -A`. Stage only the files you intentionally changed.
|
||||
|
||||
15. Commit messages carry no attribution or advertising trailers for the
|
||||
tool that helped write the code, or for its vendor: the owner is the
|
||||
sole author of code written with a tool.
|
||||
15. Commit messages carry no attribution or advertising trailers for the tool
|
||||
that helped write the code, or for its vendor: the owner is the sole author
|
||||
of code written with a tool.
|
||||
|
||||
16. Run the whole test suite with `make test` every time, and read its full
|
||||
output. Never run `go test`, a single test or a single package, and
|
||||
never grep the output.
|
||||
output. Never run `go test`, a single test or a single package, and never
|
||||
grep the output.
|
||||
|
||||
17. Do not stop working on a task until the definition of done given in the
|
||||
initial instruction is met: all of the work, not part or most of it.
|
||||
|
||||
18. For estimates: backing up over 2.5Gbit/s ethernet to an S3 server
|
||||
backed by 2000MB/sec SSD takes about 4 seconds per gigabyte.
|
||||
18. For estimates: backing up over 2.5Gbit/s ethernet to an S3 server backed by
|
||||
2000MB/sec SSD takes about 4 seconds per gigabyte.
|
||||
@@ -295,6 +295,7 @@ storage_url: "rclone://myremote/path/to/backups"
|
||||
|
||||
# Path to local SQLite index database
|
||||
# This database tracks file state for incremental backups
|
||||
# It must be an absolute path; ~ is expanded
|
||||
# Default: the platform data directory, e.g.
|
||||
# macOS: ~/Library/Application Support/vaultik/index.sqlite
|
||||
# Linux: ~/.local/share/vaultik/index.sqlite
|
||||
@@ -329,14 +330,16 @@ storage_url: "rclone://myremote/path/to/backups"
|
||||
# Default: 3
|
||||
compression_level: 5
|
||||
|
||||
# How old a blob that no snapshot lists must be before prune deletes it
|
||||
# A backup uploads its blobs before the manifest that lists them, so on a
|
||||
# How old a blob that no snapshot lists must be before prune deletes it, and
|
||||
# how old every object in a directory under metadata/ with no manifest must
|
||||
# be before prune deletes that directory
|
||||
# A backup uploads its blobs and its db.zst.age before its manifest, so on a
|
||||
# destination shared between hosts or users this must be longer than the
|
||||
# longest backup run
|
||||
# It does not protect a blob a backup reuses from an earlier run that stopped
|
||||
# before its manifest: that blob keeps the time of its first upload, so a
|
||||
# prune can delete it
|
||||
# Supports: 36h, 90m, etc.; 0s deletes them at once
|
||||
# Supports: 36h, 90m, etc.; 0s deletes such blobs and directories at once
|
||||
# Default: 24h
|
||||
#prune_grace_period: 24h
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
# Vaultik Architecture
|
||||
|
||||
This document describes the internal architecture of Vaultik, focusing on the data model, type instantiation, and the relationships between core modules.
|
||||
This document describes the internal architecture of Vaultik, focusing on the
|
||||
data model, type instantiation, and the relationships between core modules.
|
||||
|
||||
## Overview
|
||||
|
||||
Vaultik is a backup system that uses content-defined chunking for deduplication and packs chunks into large, compressed, encrypted blobs for efficient cloud storage. The system is built around dependency injection using [uber-go/fx](https://github.com/uber-go/fx).
|
||||
Vaultik is a backup system that uses content-defined chunking for deduplication
|
||||
and packs chunks into large, compressed, encrypted blobs for efficient cloud
|
||||
storage. The system is built around dependency injection using
|
||||
[uber-go/fx](https://github.com/uber-go/fx).
|
||||
|
||||
## Data Flow
|
||||
|
||||
@@ -53,57 +57,88 @@ The database tracks five primary entities and their relationships:
|
||||
### Entity Descriptions
|
||||
|
||||
#### File (`database.File`)
|
||||
Represents a file, directory, or symlink in the backup system. Stores metadata needed for restoration:
|
||||
|
||||
Represents a file, directory, or symlink in the backup system. Stores metadata
|
||||
needed for restoration:
|
||||
|
||||
- Path, 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.
|
||||
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:
|
||||
|
||||
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)
|
||||
- `Size`: Chunk size in bytes
|
||||
|
||||
Chunk sizes vary between `avgChunkSize/4` and `avgChunkSize*4` (2.5MB-40MB for the 10MB default average).
|
||||
Chunk sizes vary between `avgChunkSize/4` and `avgChunkSize*4` (2.5MB-40MB for
|
||||
the 10MB default average).
|
||||
|
||||
#### FileChunk (`database.FileChunk`)
|
||||
|
||||
Maps files to their constituent chunks:
|
||||
|
||||
- `FileID`: Reference to the file
|
||||
- `Idx`: Position of this chunk within the file (0-indexed)
|
||||
- `ChunkHash`: Reference to the chunk
|
||||
|
||||
#### Blob (`database.Blob`)
|
||||
The final storage unit uploaded to S3. Contains many compressed and encrypted chunks:
|
||||
|
||||
The final storage unit uploaded to S3. Contains many compressed and encrypted
|
||||
chunks:
|
||||
|
||||
- `ID`: UUID assigned at creation
|
||||
- `Hash`: `hex(SHA256(SHA256(uncompressed blob contents)))`, computed before compression and encryption (see [docs/REPOSTRUCTURE.md](docs/REPOSTRUCTURE.md#blobs-directory-blobs))
|
||||
- `Hash`: `hex(SHA256(SHA256(uncompressed blob contents)))`, computed before
|
||||
compression and encryption (see
|
||||
[docs/REPOSTRUCTURE.md](REPOSTRUCTURE.md#blobs-directory-blobs))
|
||||
- `UncompressedSize`: Total raw chunk data before compression
|
||||
- `CompressedSize`: Size after zstd compression and age encryption
|
||||
- `CreatedTS`, `FinishedTS`, `UploadedTS`: Lifecycle timestamps
|
||||
|
||||
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
|
||||
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
|
||||
3. Concurrently, the same bytes are compressed with zstd, then encrypted with
|
||||
age (recipients configured in config), and written to a temporary file
|
||||
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`.
|
||||
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`.
|
||||
|
||||
#### BlobChunk (`database.BlobChunk`)
|
||||
|
||||
Maps chunks to their position within blobs:
|
||||
|
||||
- `BlobID`: Reference to the blob
|
||||
- `ChunkHash`: Reference to the chunk
|
||||
- `Offset`: Byte offset within the uncompressed blob
|
||||
- `Length`: Chunk size
|
||||
|
||||
#### Snapshot (`database.Snapshot`)
|
||||
|
||||
Represents a point-in-time backup:
|
||||
- `ID`: Format is `{hostname}_{snapshot-name}_{RFC3339}` (e.g. `server1_home_2025-06-01T12:00:00Z`)
|
||||
|
||||
- `ID`: Format is `{hostname}_{snapshot-name}_{RFC3339}` (e.g.
|
||||
`server1_home_2025-06-01T12:00:00Z`)
|
||||
- Tracks file count, chunk count, blob count, sizes, compression ratio
|
||||
- `CompletedAt`: Null until snapshot finishes successfully
|
||||
|
||||
#### SnapshotFile / SnapshotBlob
|
||||
|
||||
Join tables linking snapshots to their files and blobs.
|
||||
|
||||
### Relationship Summary
|
||||
@@ -140,21 +175,27 @@ fx.New(
|
||||
### Key Type Instantiation Points
|
||||
|
||||
#### 1. Config (`config.Config`)
|
||||
|
||||
- **Created by**: `config.Module` via `config.LoadConfig()`
|
||||
- **When**: Application startup (fx DI)
|
||||
- **Contains**: All configuration from YAML file (S3 credentials, encryption keys, paths, etc.)
|
||||
- **Contains**: All configuration from YAML file (S3 credentials, encryption
|
||||
keys, paths, etc.)
|
||||
|
||||
#### 2. Database (`database.DB`)
|
||||
|
||||
- **Created by**: `database.Module` via `database.New()`
|
||||
- **When**: Application startup (fx DI)
|
||||
- **Contains**: SQLite connection, path reference
|
||||
|
||||
#### 3. Repositories (`database.Repositories`)
|
||||
|
||||
- **Created by**: `database.Module` via `database.NewRepositories()`
|
||||
- **When**: Application startup (fx DI)
|
||||
- **Contains**: All repository interfaces (Files, Chunks, Blobs, Snapshots, etc.)
|
||||
- **Contains**: All repository interfaces (Files, Chunks, Blobs, Snapshots,
|
||||
etc.)
|
||||
|
||||
#### 4. Vaultik (`vaultik.Vaultik`)
|
||||
|
||||
- **Created by**: `vaultik.New(VaultikParams)`
|
||||
- **When**: Application startup (fx DI)
|
||||
- **Contains**: All dependencies for backup operations
|
||||
@@ -176,11 +217,13 @@ type Vaultik struct {
|
||||
```
|
||||
|
||||
#### 5. SnapshotManager (`snapshot.SnapshotManager`)
|
||||
|
||||
- **Created by**: `snapshot.Module` via `snapshot.NewSnapshotManager()`
|
||||
- **When**: Application startup (fx DI)
|
||||
- **Responsibility**: Creates/completes snapshots, exports metadata to S3
|
||||
|
||||
#### 6. Scanner (`snapshot.Scanner`)
|
||||
|
||||
- **Created by**: `ScannerFactory(ScannerParams)`
|
||||
- **When**: Each `CreateSnapshot()` call
|
||||
- **Contains**: Chunker, Packer, progress reporter
|
||||
@@ -194,20 +237,22 @@ scanner := v.ScannerFactory(snapshot.ScannerParams{
|
||||
```
|
||||
|
||||
#### 7. Chunker (`chunker.Chunker`)
|
||||
|
||||
- **Created by**: `chunker.NewChunker(avgChunkSize)`
|
||||
- **When**: Inside `snapshot.NewScanner()`
|
||||
- **Configuration**:
|
||||
- `avgChunkSize`: From config (default 10MB)
|
||||
- `minChunkSize`: avgChunkSize / 4
|
||||
- `maxChunkSize`: avgChunkSize * 4
|
||||
- `avgChunkSize`: From config (default 10MB)
|
||||
- `minChunkSize`: avgChunkSize / 4
|
||||
- `maxChunkSize`: avgChunkSize \* 4
|
||||
|
||||
#### 8. Packer (`blob.Packer`)
|
||||
|
||||
- **Created by**: `blob.NewPacker(PackerConfig)`
|
||||
- **When**: Inside `snapshot.NewScanner()`
|
||||
- **Configuration**:
|
||||
- `MaxBlobSize`: Maximum blob size before finalization (typically 10GB)
|
||||
- `CompressionLevel`: zstd level (1-19)
|
||||
- `Recipients`: age public keys for encryption
|
||||
- `MaxBlobSize`: Maximum blob size before finalization (typically 10GB)
|
||||
- `CompressionLevel`: zstd level (1-19)
|
||||
- `Recipients`: age public keys for encryption
|
||||
|
||||
```go
|
||||
// snapshot/scanner.go: NewScanner()
|
||||
@@ -224,17 +269,22 @@ packer, err := blob.NewPacker(packerCfg)
|
||||
## Module Responsibilities
|
||||
|
||||
### `internal/cli`
|
||||
Entry point for fx application. Combines all modules and handles signal interrupts.
|
||||
|
||||
Entry point for fx application. Combines all modules and handles signal
|
||||
interrupts.
|
||||
|
||||
Key functions:
|
||||
|
||||
- `NewApp(AppOptions)` → Creates fx.App with all modules
|
||||
- `RunApp(ctx, app)` → Starts app, handles graceful shutdown
|
||||
- `RunWithApp(ctx, opts)` → Convenience wrapper
|
||||
|
||||
### `internal/vaultik`
|
||||
|
||||
Main orchestrator containing all dependencies and command implementations.
|
||||
|
||||
Key methods:
|
||||
|
||||
- `New(VaultikParams)` → Constructor (fx DI)
|
||||
- `CreateSnapshot(opts)` → Main backup operation
|
||||
- `ListSnapshots(jsonOutput)` → List available snapshots
|
||||
@@ -242,27 +292,34 @@ Key methods:
|
||||
- `PurgeSnapshots(...)` → Remove old snapshots
|
||||
|
||||
### `internal/chunker`
|
||||
|
||||
Content-defined chunking using FastCDC algorithm.
|
||||
|
||||
Key types:
|
||||
|
||||
- `Chunk` → Hash, Data, Offset, Size
|
||||
- `Chunker` → avgChunkSize, minChunkSize, maxChunkSize
|
||||
|
||||
Key methods:
|
||||
|
||||
- `NewChunker(avgChunkSize)` → Constructor
|
||||
- `ChunkReaderStreaming(reader, callback)` → Stream chunks with callback (preferred)
|
||||
- `ChunkReaderStreaming(reader, callback)` → Stream chunks with callback
|
||||
(preferred)
|
||||
- `ChunkReader(reader)` → Return all chunks at once (memory-intensive)
|
||||
|
||||
### `internal/blob`
|
||||
|
||||
Blob packing: accumulates chunks, compresses, encrypts, tracks metadata.
|
||||
|
||||
Key types:
|
||||
|
||||
- `Packer` → Thread-safe blob accumulator
|
||||
- `ChunkRef` → Hash + Data for adding to packer
|
||||
- `FinishedBlob` → Completed blob ready for upload
|
||||
- `BlobWithReader` → FinishedBlob + io.Reader for streaming upload
|
||||
|
||||
Key methods:
|
||||
|
||||
- `NewPacker(PackerConfig)` → Constructor
|
||||
- `AddChunk(ChunkRef)` → Add chunk to current blob
|
||||
- `FinalizeBlob()` → Compress, encrypt, hash current blob
|
||||
@@ -272,35 +329,48 @@ Key methods:
|
||||
### `internal/snapshot`
|
||||
|
||||
#### Scanner
|
||||
|
||||
Orchestrates the backup process for a directory.
|
||||
|
||||
Key methods:
|
||||
|
||||
- `NewScanner(ScannerConfig)` → Constructor (creates Chunker + Packer)
|
||||
- `Scan(ctx, path, snapshotID)` → Main scan operation
|
||||
|
||||
Scan phases:
|
||||
|
||||
1. **Phase 1**: Walk directory, identify files needing processing
|
||||
2. **Phase 1b**: Count as deleted the files that the previous snapshot of the same name lists, the walk did not find, and are gone from disk; a file that is excluded, or inside an excluded directory, is not counted
|
||||
2. **Phase 1b**: Count as deleted the files that the previous snapshot of the
|
||||
same name lists, the walk did not find, and are gone from disk; a file that
|
||||
is excluded, or inside an excluded directory, is not counted
|
||||
3. **Phase 2**: Process files (chunk → pack → upload)
|
||||
|
||||
#### SnapshotManager
|
||||
|
||||
Manages snapshot lifecycle and metadata export.
|
||||
|
||||
Key methods:
|
||||
|
||||
- `CreateSnapshot(ctx, hostname, version, commit)` → Create snapshot record
|
||||
- `PopulateSnapshotBlobs(ctx, snapshotID)` → Record every blob the snapshot references
|
||||
- `PopulateSnapshotBlobs(ctx, snapshotID)` → Record every blob the snapshot
|
||||
references
|
||||
- `ExportSnapshotMetadata(ctx, dbPath, snapshotID)` → Export to S3
|
||||
- `MarkSnapshotComplete(ctx, snapshotID)` → Record completion, only after a successful export
|
||||
- `CompleteSnapshot(ctx, snapshotID)` → Convenience: populate blobs, then mark complete (no export between)
|
||||
- `MarkSnapshotComplete(ctx, snapshotID)` → Record completion, only after a
|
||||
successful export
|
||||
- `CompleteSnapshot(ctx, snapshotID)` → Convenience: populate blobs, then mark
|
||||
complete (no export between)
|
||||
|
||||
### `internal/database`
|
||||
|
||||
SQLite database for local index. Single-writer mode for thread safety.
|
||||
|
||||
Key types:
|
||||
|
||||
- `DB` → Database connection wrapper
|
||||
- `Repositories` → Collection of all repository interfaces
|
||||
|
||||
Repository interfaces:
|
||||
|
||||
- `FilesRepository` → CRUD for File records
|
||||
- `ChunksRepository` → CRUD for Chunk records
|
||||
- `BlobsRepository` → CRUD for Blob records
|
||||
@@ -312,7 +382,7 @@ Repository interfaces:
|
||||
```
|
||||
CreateSnapshot(opts)
|
||||
│
|
||||
├─► markDestination() // Refuse a destination without the marker, then write it
|
||||
├─► markDestination() // Check the marker (see Storage Layout in S3), then write it
|
||||
│
|
||||
├─► PruneDatabase() // Critical: avoid dedup errors
|
||||
│
|
||||
@@ -359,11 +429,16 @@ CreateSnapshot(opts)
|
||||
|
||||
## Deduplication Strategy
|
||||
|
||||
1. **File-level**: Files and symlinks unchanged since last backup are skipped (metadata comparison: size, mtime, mode, uid, gid, and a symlink's target), unless the file lists a chunk that no uploaded blob holds; such a file is re-chunked
|
||||
1. **File-level**: Files and symlinks unchanged since last backup are skipped
|
||||
(metadata comparison: size, mtime, mode, uid, gid, and a symlink's target),
|
||||
unless the file lists a chunk that no uploaded blob holds; such a file is
|
||||
re-chunked
|
||||
|
||||
2. **Chunk-level**: Chunks are content-addressed by SHA256 hash. If a chunk hash already exists in the database, the chunk data is not re-uploaded.
|
||||
2. **Chunk-level**: Chunks are content-addressed by SHA256 hash. If a chunk hash
|
||||
already exists in the database, the chunk data is not re-uploaded.
|
||||
|
||||
3. **Blob-level**: Blobs contain only unique chunks. Duplicate chunks within a blob are skipped.
|
||||
3. **Blob-level**: Blobs contain only unique chunks. Duplicate chunks within a
|
||||
blob are skipped.
|
||||
|
||||
## Storage Layout in S3
|
||||
|
||||
@@ -382,25 +457,30 @@ bucket/
|
||||
└── manifest.json.zst # Blob list (for pruning/verification)
|
||||
```
|
||||
|
||||
A destination without `vaultik-destination.txt` is treated as not present
|
||||
while the local index holds snapshots stored there (`checkDestinationMarker`
|
||||
in `internal/vaultik/destination_marker.go`): `snapshot create` refuses to
|
||||
write to it, `prune`, `snapshot purge` and `remote nuke` fail, and
|
||||
`snapshot list` and `snapshot remove` behave as for a destination that cannot
|
||||
be listed. This keeps those commands from taking an unplugged volume's empty
|
||||
mount point for a destination whose snapshots are all gone. The `file://`
|
||||
backend never creates its destination directory, so a write to a missing one
|
||||
fails.
|
||||
A destination that answers a look-up of `vaultik-destination.txt` with not found
|
||||
is treated as not present while the local index holds snapshots stored there
|
||||
(`checkDestinationMarker` in `internal/vaultik/destination_marker.go`):
|
||||
`snapshot create` refuses to write to it, `prune`, `snapshot purge` and
|
||||
`remote nuke` fail, and `snapshot list` and `snapshot remove` behave as for a
|
||||
destination that cannot be listed. This keeps those commands from taking an
|
||||
unplugged volume's empty mount point for a destination whose snapshots are all
|
||||
gone. When the look-up fails for any reason other than not found, for example
|
||||
when S3 refuses it to credentials that allow only PUT, the check is skipped, so
|
||||
a host with such credentials does not notice an emptied destination; see
|
||||
[docs/REPOSTRUCTURE.md](REPOSTRUCTURE.md#marker-file-vaultik-destinationtxt).
|
||||
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
|
||||
[docs/REPOSTRUCTURE.md](docs/REPOSTRUCTURE.md#remote-key-derivation) for the
|
||||
[docs/REPOSTRUCTURE.md](REPOSTRUCTURE.md#remote-key-derivation) for the
|
||||
derivation and a worked example.
|
||||
|
||||
## Thread Safety
|
||||
|
||||
- `Packer`: Thread-safe via mutex. Multiple goroutines can call `AddChunk()`.
|
||||
- `Scanner`: Uses `packerMu` mutex to coordinate blob finalization.
|
||||
- `Database`: Single-writer mode (`MaxOpenConns=1`) ensures SQLite thread safety.
|
||||
- `Database`: Single-writer mode (`MaxOpenConns=1`) ensures SQLite thread
|
||||
safety.
|
||||
- `Repositories.WithTx()`: Handles transaction lifecycle automatically.
|
||||
+155
-59
@@ -2,7 +2,9 @@
|
||||
|
||||
## Overview
|
||||
|
||||
Vaultik uses a local SQLite database to track file metadata, chunk mappings, and blob associations during the backup process. This database serves as an index for incremental backups and enables efficient deduplication.
|
||||
Vaultik uses a local SQLite database to track file metadata, chunk mappings, and
|
||||
blob associations during the backup process. This database serves as an index
|
||||
for incremental backups and enables efficient deduplication.
|
||||
|
||||
**Important Notes:**
|
||||
|
||||
@@ -25,19 +27,23 @@ recorded `001.sql` as run, so it keeps the old schema and can become unusable;
|
||||
`vaultik database delete` followed by a full backup rebuilds it.
|
||||
|
||||
**After 1.0** each schema change is a new numbered file, so an existing local
|
||||
database is migrated the first time an updated vaultik opens it. A file that
|
||||
has shipped in a release is never edited.
|
||||
database is migrated the first time an updated vaultik opens it. A file that has
|
||||
shipped in a release is never edited.
|
||||
|
||||
## Database Tables
|
||||
|
||||
### 1. `files`
|
||||
|
||||
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, whole seconds since the Unix
|
||||
epoch
|
||||
- `mtime_nsec` (INTEGER NOT NULL) - Nanoseconds within that second, 0 to
|
||||
999999999
|
||||
- `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
|
||||
@@ -45,23 +51,30 @@ Stores metadata about files in the filesystem being backed up.
|
||||
- `link_target` (TEXT) - Symlink target path (NULL for regular files)
|
||||
|
||||
**Indexes:**
|
||||
|
||||
- `idx_files_path` on `path` for efficient lookups
|
||||
|
||||
**Purpose:** Tracks file metadata to detect changes between backup runs. Used for incremental backup decisions. The UUID primary key provides stable references that don't change if files are moved.
|
||||
**Purpose:** Tracks file metadata to detect changes between backup runs. Used
|
||||
for incremental backup decisions. The UUID primary key provides stable
|
||||
references that don't change if files are moved.
|
||||
|
||||
### 2. `chunks`
|
||||
|
||||
Stores information about content-defined chunks created from files.
|
||||
|
||||
**Columns:**
|
||||
|
||||
- `chunk_hash` (TEXT PRIMARY KEY) - SHA256 hash of chunk content
|
||||
- `size` (INTEGER NOT NULL) - Chunk size in bytes
|
||||
|
||||
**Purpose:** Enables deduplication by tracking unique chunks across all files.
|
||||
|
||||
### 3. `file_chunks`
|
||||
|
||||
Maps files to their constituent chunks in order.
|
||||
|
||||
**Columns:**
|
||||
|
||||
- `file_id` (TEXT) - File ID (FK to files.id)
|
||||
- `idx` (INTEGER) - Chunk index within file (0-based)
|
||||
- `chunk_hash` (TEXT) - Chunk hash (FK to chunks.chunk_hash)
|
||||
@@ -70,35 +83,49 @@ Maps files to their constituent chunks in order.
|
||||
**Purpose:** Allows reconstruction of files from chunks during restore.
|
||||
|
||||
### 4. `chunk_files`
|
||||
|
||||
Reverse mapping showing which files contain each chunk.
|
||||
|
||||
**Columns:**
|
||||
|
||||
- `chunk_hash` (TEXT) - Chunk hash (FK to chunks.chunk_hash)
|
||||
- `file_id` (TEXT) - File ID (FK to files.id)
|
||||
- `file_offset` (INTEGER) - Byte offset of chunk within file
|
||||
- `length` (INTEGER) - Length of chunk in bytes
|
||||
- PRIMARY KEY (`chunk_hash`, `file_id`)
|
||||
|
||||
**Purpose:** Supports efficient queries for chunk usage and deduplication statistics.
|
||||
**Purpose:** Supports efficient queries for chunk usage and deduplication
|
||||
statistics.
|
||||
|
||||
### 5. `blobs`
|
||||
|
||||
Stores information about packed, compressed, and encrypted blob files.
|
||||
|
||||
**Columns:**
|
||||
|
||||
- `id` (TEXT PRIMARY KEY) - UUID assigned when blob creation starts
|
||||
- `blob_hash` (TEXT UNIQUE) - `hex(SHA256(SHA256(uncompressed blob contents)))`, computed before compression and encryption (NULL until finalized); see [REPOSTRUCTURE.md](REPOSTRUCTURE.md#blobs-directory-blobs)
|
||||
- `blob_hash` (TEXT UNIQUE) - `hex(SHA256(SHA256(uncompressed blob contents)))`,
|
||||
computed before compression and encryption (NULL until finalized); see
|
||||
[REPOSTRUCTURE.md](REPOSTRUCTURE.md#blobs-directory-blobs)
|
||||
- `created_ts` (INTEGER NOT NULL) - Creation timestamp
|
||||
- `finished_ts` (INTEGER) - Finalization timestamp (NULL if in progress)
|
||||
- `uncompressed_size` (INTEGER NOT NULL DEFAULT 0) - Total size of chunks before compression
|
||||
- `compressed_size` (INTEGER NOT NULL DEFAULT 0) - Size after compression and encryption. Once uploaded, the size of the object in storage: when the blob was already stored, under another compression level or other recipients, this can differ from the size of the local copy
|
||||
- `uncompressed_size` (INTEGER NOT NULL DEFAULT 0) - Total size of chunks before
|
||||
compression
|
||||
- `compressed_size` (INTEGER NOT NULL DEFAULT 0) - Size after compression and
|
||||
encryption. Once uploaded, the size of the object in storage: when the blob
|
||||
was already stored, under another compression level or other recipients, this
|
||||
can differ from the size of the local copy
|
||||
- `uploaded_ts` (INTEGER) - Upload completion timestamp (NULL if not uploaded)
|
||||
|
||||
**Purpose:** Tracks blob lifecycle from creation through upload. The UUID primary key allows immediate association of chunks with blobs.
|
||||
**Purpose:** Tracks blob lifecycle from creation through upload. The UUID
|
||||
primary key allows immediate association of chunks with blobs.
|
||||
|
||||
### 6. `blob_chunks`
|
||||
|
||||
Maps chunks to the blobs that contain them.
|
||||
|
||||
**Columns:**
|
||||
|
||||
- `blob_id` (TEXT) - Blob ID (FK to blobs.id)
|
||||
- `chunk_hash` (TEXT) - Chunk hash (FK to chunks.chunk_hash)
|
||||
- `offset` (INTEGER) - Byte offset of chunk within blob (before compression)
|
||||
@@ -108,34 +135,46 @@ Maps chunks to the blobs that contain them.
|
||||
**Purpose:** Enables chunk retrieval from blobs during restore operations.
|
||||
|
||||
### 7. `snapshots`
|
||||
|
||||
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_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)
|
||||
- `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
|
||||
- `new_chunk_size` (INTEGER) - Total size of those chunks before compression; `snapshot list` shows it as NEW CHUNK SIZE
|
||||
- `chunk_count` (INTEGER) - Number of chunks this snapshot stored that were not
|
||||
stored before
|
||||
- `new_chunk_size` (INTEGER) - Total size of those chunks before compression;
|
||||
`snapshot list` shows it as NEW CHUNK SIZE
|
||||
- `blob_count` (INTEGER) - Number of blobs this snapshot created
|
||||
- `total_size` (INTEGER) - Total size of all files
|
||||
- `uncompressed_chunk_size` (INTEGER) - Total size before compression of the distinct chunks the snapshot's files reference, recorded when its backup ran; `snapshot list` shows it as UNCOMPRESSED SIZE
|
||||
- `uncompressed_chunk_size` (INTEGER) - Total size before compression of the
|
||||
distinct chunks the snapshot's files reference, recorded when its backup ran;
|
||||
`snapshot list` shows it as UNCOMPRESSED SIZE
|
||||
- `blob_size` (INTEGER) - Total compressed size of all referenced blobs
|
||||
- `blob_uncompressed_size` (INTEGER) - Total uncompressed size of all referenced blobs
|
||||
- `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
|
||||
- `upload_bytes` (INTEGER) - Total bytes uploaded during this snapshot
|
||||
- `upload_duration_ms` (INTEGER) - Total milliseconds spent uploading to S3
|
||||
|
||||
**Purpose:** Provides snapshot metadata and statistics including version tracking for compatibility.
|
||||
**Purpose:** Provides snapshot metadata and statistics including version
|
||||
tracking for compatibility.
|
||||
|
||||
### 8. `snapshot_files`
|
||||
|
||||
Maps snapshots to the files they contain.
|
||||
|
||||
**Columns:**
|
||||
|
||||
- `snapshot_id` (TEXT) - Snapshot ID (FK to snapshots.id)
|
||||
- `file_id` (TEXT) - File ID (FK to files.id)
|
||||
- PRIMARY KEY (`snapshot_id`, `file_id`)
|
||||
@@ -143,99 +182,146 @@ Maps snapshots to the files they contain.
|
||||
**Purpose:** Records which files are included in each snapshot.
|
||||
|
||||
### 9. `snapshot_blobs`
|
||||
|
||||
Maps snapshots to the blobs they reference.
|
||||
|
||||
**Columns:**
|
||||
|
||||
- `snapshot_id` (TEXT) - Snapshot ID (FK to snapshots.id)
|
||||
- `blob_id` (TEXT) - Blob ID (FK to blobs.id)
|
||||
- `blob_hash` (TEXT) - Denormalized blob hash for manifest generation
|
||||
- PRIMARY KEY (`snapshot_id`, `blob_id`)
|
||||
|
||||
**Purpose:** Tracks blob dependencies for snapshots and enables manifest generation.
|
||||
**Purpose:** Tracks blob dependencies for snapshots and enables manifest
|
||||
generation.
|
||||
|
||||
### 10. `snapshot_mount_points`
|
||||
|
||||
The directories at or below a configured path, the configured path itself
|
||||
included, that a snapshot's backup found an entry in and, once it had listed
|
||||
them, found on a different filesystem from their parent directory (a different
|
||||
device number), as a mount point is. An automount point whose volume cannot be
|
||||
mounted is not recorded, because the backup finds no entry in it.
|
||||
|
||||
**Columns:**
|
||||
|
||||
- `snapshot_id` (TEXT) - Snapshot ID (FK to snapshots.id)
|
||||
- `path` (TEXT) - Absolute path of the directory
|
||||
- PRIMARY KEY (`snapshot_id`, `path`)
|
||||
|
||||
**Purpose:** Lets `snapshot create --prune` skip the prune when a backup does
|
||||
not find a mount point that an earlier snapshot of the same name found, as when
|
||||
the volume is not mounted.
|
||||
|
||||
### 11. `uploads`
|
||||
|
||||
### 10. `uploads`
|
||||
Tracks blob upload metrics.
|
||||
|
||||
**Columns:**
|
||||
|
||||
- `blob_hash` (TEXT PRIMARY KEY) - Hash of uploaded blob
|
||||
- `snapshot_id` (TEXT NOT NULL) - The snapshot that triggered this upload (FK to snapshots.id)
|
||||
- `snapshot_id` (TEXT NOT NULL) - The snapshot that triggered this upload (FK to
|
||||
snapshots.id)
|
||||
- `uploaded_at` (INTEGER) - Upload timestamp
|
||||
- `size` (INTEGER) - Size of uploaded blob
|
||||
- `duration_ms` (INTEGER) - Upload duration in milliseconds
|
||||
|
||||
**Purpose:** Performance monitoring and tracking which blobs were newly created (uploaded) during each snapshot.
|
||||
**Purpose:** Performance monitoring and tracking which blobs were newly created
|
||||
(uploaded) during each snapshot.
|
||||
|
||||
## Data Flow and Operations
|
||||
|
||||
### 1. Backup Process
|
||||
|
||||
1. **File Scanning**
|
||||
- `INSERT OR REPLACE INTO files` - Update file metadata
|
||||
- `SELECT * FROM files WHERE path = ?` - Check if file has changed
|
||||
- `INSERT INTO snapshot_files` - Add file to current snapshot
|
||||
- `INSERT OR REPLACE INTO files` - Update file metadata
|
||||
- `SELECT * FROM files WHERE path = ?` - Check if file has changed
|
||||
- `INSERT INTO snapshot_files` - Add file to current snapshot
|
||||
- `INSERT OR IGNORE INTO snapshot_mount_points` - Record each directory on a
|
||||
different filesystem from its parent that the walk found an entry in
|
||||
|
||||
2. **Chunking** (for changed files)
|
||||
- `INSERT OR IGNORE INTO chunks` - Store new chunks
|
||||
- `INSERT INTO file_chunks` - Map chunks to file
|
||||
- `INSERT INTO chunk_files` - Create reverse mapping
|
||||
- `INSERT OR IGNORE INTO chunks` - Store new chunks
|
||||
- `INSERT INTO file_chunks` - Map chunks to file
|
||||
- `INSERT INTO chunk_files` - Create reverse mapping
|
||||
|
||||
3. **Blob Packing**
|
||||
- `INSERT INTO blobs` - Create blob record with UUID (blob_hash NULL)
|
||||
- `INSERT INTO blob_chunks` - Associate chunks with blob immediately
|
||||
- `UPDATE blobs SET blob_hash = ?, finished_ts = ?` - Finalize blob after packing
|
||||
- `INSERT INTO blobs` - Create blob record with UUID (blob_hash NULL)
|
||||
- `INSERT INTO blob_chunks` - Associate chunks with blob immediately
|
||||
- `UPDATE blobs SET blob_hash = ?, finished_ts = ?` - Finalize blob after
|
||||
packing
|
||||
|
||||
4. **Upload**
|
||||
- `UPDATE blobs SET uploaded_ts = ?, compressed_size = ?` - Mark blob as uploaded, with the size of the object in storage (the upload is skipped when the blob is already stored)
|
||||
- `INSERT INTO uploads` - Record upload metrics with snapshot_id
|
||||
- `INSERT INTO snapshot_blobs` - Associate blob with snapshot
|
||||
- `UPDATE blobs SET uploaded_ts = ?, compressed_size = ?` - Mark blob as
|
||||
uploaded, with the size of the object in storage (the upload is skipped
|
||||
when the blob is already stored)
|
||||
- `INSERT INTO uploads` - Record upload metrics with snapshot_id
|
||||
- `INSERT INTO snapshot_blobs` - Associate blob with snapshot
|
||||
|
||||
5. **Snapshot Completion**
|
||||
- `UPDATE snapshots SET completed_at = ?, stats...` - Finalize snapshot
|
||||
- Generate and upload blob manifest from `snapshot_blobs`
|
||||
- `UPDATE snapshots SET completed_at = ?, stats...` - Finalize snapshot
|
||||
- Generate and upload blob manifest from `snapshot_blobs`
|
||||
|
||||
### 2. Incremental Backup
|
||||
|
||||
1. **Change Detection**
|
||||
- `SELECT * FROM files WHERE path = ?` - Get previous file metadata
|
||||
- Compare mtime, size, mode to detect changes
|
||||
- Re-chunk a file that lists a chunk no uploaded blob holds, even when its metadata is unchanged
|
||||
- Skip unchanged files but still add to `snapshot_files`
|
||||
- `SELECT * FROM files WHERE path = ?` - Get previous file metadata
|
||||
- Compare mtime, size, mode to detect changes
|
||||
- Re-chunk a file that lists a chunk no uploaded blob holds, even when its
|
||||
metadata is unchanged
|
||||
- Skip unchanged files but still add to `snapshot_files`
|
||||
|
||||
2. **Chunk Reuse**
|
||||
- `SELECT * FROM blob_chunks WHERE chunk_hash = ?` - Find existing chunks
|
||||
- `INSERT INTO snapshot_blobs` - Reference existing blobs for unchanged files
|
||||
- `SELECT * FROM blob_chunks WHERE chunk_hash = ?` - Find existing chunks
|
||||
- `INSERT INTO snapshot_blobs` - Reference existing blobs for unchanged
|
||||
files
|
||||
|
||||
### 3. Snapshot Metadata Export
|
||||
|
||||
After a snapshot is completed:
|
||||
|
||||
1. Copy database to temporary file
|
||||
2. Clean temporary database to contain only current snapshot data
|
||||
3. VACUUM the trimmed database so deleted rows leave no pages behind
|
||||
4. Compress with zstd and encrypt with age
|
||||
5. Upload to S3 as `metadata/{remote-key}/db.zst.age`
|
||||
6. Generate blob manifest and upload as `metadata/{remote-key}/manifest.json.zst`
|
||||
6. Generate blob manifest and upload as
|
||||
`metadata/{remote-key}/manifest.json.zst`
|
||||
|
||||
The `{remote-key}` directory name is a one-way hash of the human snapshot ID, so the ID is never written to the store in plaintext. The hash uses no secret, so a guessed hostname and snapshot name can still be confirmed against a listing; see [REPOSTRUCTURE.md](REPOSTRUCTURE.md#remote-key-derivation) and its [Accepted Risks](REPOSTRUCTURE.md#accepted-risks).
|
||||
The `{remote-key}` directory name is a one-way hash of the human snapshot ID, so
|
||||
the ID is never written to the store in plaintext. The hash uses no secret, so a
|
||||
guessed hostname and snapshot name can still be confirmed against a listing; see
|
||||
[REPOSTRUCTURE.md](REPOSTRUCTURE.md#remote-key-derivation) and its
|
||||
[Accepted Risks](REPOSTRUCTURE.md#accepted-risks).
|
||||
|
||||
### 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 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
|
||||
3. Reconstructs files from decrypted and decompressed chunks
|
||||
|
||||
### 5. Pruning
|
||||
|
||||
1. **Identify Unreferenced Blobs**
|
||||
- Query blobs not referenced by any remaining snapshot
|
||||
- Delete from S3, once older than `prune_grace_period`, and from the local
|
||||
database
|
||||
- Query blobs not referenced by any remaining snapshot
|
||||
- Delete from S3, once older than `prune_grace_period`, and from the local
|
||||
database
|
||||
2. **Delete Metadata Directories With No Manifest**
|
||||
- Delete each directory under `metadata/` that has no `manifest.json.zst`
|
||||
and no local snapshot record, once every object in it is older than
|
||||
`prune_grace_period`
|
||||
|
||||
### 6. Incomplete Snapshot Cleanup
|
||||
|
||||
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
|
||||
2. Delete each one and all its associations, without checking S3 for its
|
||||
metadata
|
||||
3. Clean up orphaned files, chunks, and blobs
|
||||
|
||||
## Repository Pattern
|
||||
@@ -245,18 +331,20 @@ Vaultik uses a repository pattern for database access:
|
||||
- `FileRepository` - CRUD operations for files and file metadata
|
||||
- `ChunkRepository` - CRUD operations for content chunks
|
||||
- `FileChunkRepository` - Manage file-to-chunk mappings
|
||||
- `ChunkFileRepository` - Manage chunk-to-file reverse mappings
|
||||
- `ChunkFileRepository` - Manage chunk-to-file reverse mappings
|
||||
- `BlobRepository` - Manage blob lifecycle (creation, finalization, upload)
|
||||
- `BlobChunkRepository` - Manage blob-to-chunk associations
|
||||
- `SnapshotRepository` - Manage snapshots and their relationships
|
||||
- `UploadRepository` - Track blob upload metrics
|
||||
|
||||
Each repository provides methods like:
|
||||
|
||||
- `Create()` - Insert new record
|
||||
- `GetByID()` / `GetByPath()` / `GetByHash()` - Retrieve records
|
||||
- `Update()` - Update existing records
|
||||
- `Delete()` - Remove records
|
||||
- Specialized queries for each entity type (e.g., `DeleteOrphaned()`, `GetIncompleteByHostname()`)
|
||||
- Specialized queries for each entity type (e.g., `DeleteOrphaned()`,
|
||||
`GetIncompleteByHostname()`)
|
||||
|
||||
## Transaction Management
|
||||
|
||||
@@ -269,25 +357,33 @@ err := repos.WithTx(ctx, func(ctx context.Context, tx *sql.Tx) error {
|
||||
```
|
||||
|
||||
This ensures consistency, especially important for operations like:
|
||||
|
||||
- Creating file-chunk mappings
|
||||
- Associating chunks with blobs
|
||||
- Updating snapshot statistics
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
1. **Indexes**:
|
||||
- Primary keys are automatically indexed
|
||||
- `idx_files_path` on `files(path)` for efficient file lookups
|
||||
1. **Indexes**:
|
||||
- Primary keys are automatically indexed
|
||||
- `idx_files_path` on `files(path)` for efficient file lookups
|
||||
|
||||
2. **Prepared Statements**: All queries use prepared statements for performance and security
|
||||
2. **Prepared Statements**: All queries use prepared statements for performance
|
||||
and security
|
||||
|
||||
3. **Batch Operations**: Where possible, operations are batched within transactions
|
||||
3. **Batch Operations**: Where possible, operations are batched within
|
||||
transactions
|
||||
|
||||
4. **Write-Ahead Logging**: The local index runs in SQLite WAL mode with a 10-second busy timeout, so a read-only command such as `snapshot list` can read it while a backup writes to it. Committed rows can sit in the `-wal` file beside the index until a checkpoint, so the metadata export copies the index through SQLite (`VACUUM INTO`), not as a file
|
||||
4. **Write-Ahead Logging**: The local index runs in SQLite WAL mode with a
|
||||
10-second busy timeout, so a read-only command such as `snapshot list` can
|
||||
read it while a backup writes to it. Committed rows can sit in the `-wal`
|
||||
file beside the index until a checkpoint, so the metadata export copies the
|
||||
index through SQLite (`VACUUM INTO`), not as a file
|
||||
|
||||
## Data Integrity
|
||||
|
||||
1. **Foreign Keys**: Enforced through CASCADE DELETE and application-level repository methods
|
||||
1. **Foreign Keys**: Enforced through CASCADE DELETE and application-level
|
||||
repository methods
|
||||
2. **Unique Constraints**: Chunk hashes, file paths, and blob hashes are unique
|
||||
3. **Null Handling**: Nullable fields clearly indicate in-progress operations
|
||||
4. **Timestamp Tracking**: All major operations record timestamps for auditing
|
||||
4. **Timestamp Tracking**: All major operations record timestamps for auditing
|
||||
+228
-56
@@ -1,10 +1,13 @@
|
||||
# Vaultik S3 Repository Structure
|
||||
|
||||
This document describes the structure and organization of data stored in the S3 bucket by Vaultik.
|
||||
This document describes the structure and organization of data stored in the S3
|
||||
bucket by Vaultik.
|
||||
|
||||
## 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, plus a marker file:
|
||||
|
||||
1. **Blobs** - The actual backup data (content-addressed, encrypted)
|
||||
2. **Metadata** - Snapshot information and manifests (partially encrypted)
|
||||
|
||||
@@ -23,118 +26,208 @@ Vaultik stores all backup data in an S3-compatible object store. The repository
|
||||
└── manifest.json.zst
|
||||
```
|
||||
|
||||
The metadata subdirectory is named with the **remote key**, a one-way hash of the snapshot ID, not with the human-readable snapshot ID itself. See [Remote Key Derivation](#remote-key-derivation).
|
||||
The metadata subdirectory is named with the **remote key**, a one-way hash of
|
||||
the snapshot ID, not with the human-readable snapshot ID itself. See
|
||||
[Remote Key Derivation](#remote-key-derivation).
|
||||
|
||||
## Marker File (`vaultik-destination.txt`)
|
||||
|
||||
Every backup writes this file at the root before it uploads anything. It holds one fixed line of plain text and names no host, path or time. A destination without it is treated as not present while the local index holds snapshots stored there: `snapshot create` refuses to write to it, and `prune`, `snapshot purge`, `snapshot remove`, `snapshot list` and `remote nuke` 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.
|
||||
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 stored there, a destination that answers a look-up of the
|
||||
marker with not found is treated as not present: `snapshot create` refuses to
|
||||
write to it, and `prune`, `snapshot purge`, `snapshot remove`, `snapshot list`
|
||||
and `remote nuke` 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.
|
||||
|
||||
When the look-up of the marker fails for any reason other than not found, the
|
||||
check is skipped and the command goes on. S3 refuses it on every run to
|
||||
credentials that allow only PUT, so a host with such credentials does not notice
|
||||
a destination emptied since its last backup, and its next backup would leave out
|
||||
the data its local index records as already stored. After the destination is
|
||||
emptied, run `vaultik database delete` on such a host before it backs up again.
|
||||
|
||||
## Blobs Directory (`blobs/`)
|
||||
|
||||
### Structure
|
||||
|
||||
- **Path format**: `blobs/<first-2-chars>/<next-2-chars>/<full-hash>`
|
||||
- **Example**: `blobs/ca/fe/cafebabe1234567890abcdef1234567890abcdef1234567890abcdef12345678`
|
||||
- **Sharding**: The two-level directory structure (using the first 4 characters of the hash) prevents any single directory from containing too many objects
|
||||
- **Example**:
|
||||
`blobs/ca/fe/cafebabe1234567890abcdef1234567890abcdef1234567890abcdef12345678`
|
||||
- **Sharding**: The two-level directory structure (using the first 4 characters
|
||||
of the hash) prevents any single directory from containing too many objects
|
||||
|
||||
### Content
|
||||
|
||||
- **What it contains**: Packed collections of content-defined chunks from files
|
||||
- **Format**: Zstandard compressed, then Age encrypted
|
||||
- **Encryption**: Always encrypted with Age using the configured recipients
|
||||
- **Naming**: Content-addressed. The blob's name is `hex(SHA256(SHA256(uncompressed blob contents)))` — the double SHA-256 of the concatenated chunk data, computed before compression and encryption, not a hash of the stored (compressed, encrypted) bytes. One consequence: only a holder of the age private key can check a stored blob's integrity, because matching a blob to its name means decrypting and decompressing it first — which is what `restore` and `verify --deep` do. Implemented in `internal/blobgen` (`DoubleSHA256`). This is the canonical description of blob naming; other documents and comments point here.
|
||||
- **Naming**: Content-addressed. The blob's name is
|
||||
`hex(SHA256(SHA256(uncompressed blob contents)))` — the double SHA-256 of the
|
||||
concatenated chunk data, computed before compression and encryption, not a
|
||||
hash of the stored (compressed, encrypted) bytes. One consequence: only a
|
||||
holder of the age private key can check a stored blob's integrity, because
|
||||
matching a blob to its name means decrypting and decompressing it first —
|
||||
which is what `restore` and `verify --deep` do. Implemented in
|
||||
`internal/blobgen` (`DoubleSHA256`). This is the canonical description of blob
|
||||
naming; other documents and comments point here.
|
||||
|
||||
### Why Encrypted
|
||||
Blobs contain the actual file data from backups and must be encrypted for security. The content-addressing ensures deduplication while the encryption ensures privacy.
|
||||
|
||||
Blobs contain the actual file data from backups and must be encrypted for
|
||||
security. The content-addressing ensures deduplication while the encryption
|
||||
ensures privacy.
|
||||
|
||||
## Metadata Directory (`metadata/`)
|
||||
|
||||
Each snapshot has its own subdirectory. The directory is **not** named with the human-readable snapshot ID; it is named with the remote key — a one-way hash of that ID. The human ID is never written to the destination store as a directory name (see [Remote Key Derivation](#remote-key-derivation)).
|
||||
Each snapshot has its own subdirectory. The directory is **not** named with the
|
||||
human-readable snapshot ID; it is named with the remote key — a one-way hash of
|
||||
that ID. The human ID is never written to the destination store as a directory
|
||||
name (see [Remote Key Derivation](#remote-key-derivation)).
|
||||
|
||||
### Snapshot ID Format
|
||||
|
||||
The human-readable snapshot ID is used in CLI arguments, log lines, and the local database. It is not written to the destination store.
|
||||
- **Format**: `<hostname>_<snapshot-name>_<RFC3339>` (or `<hostname>_<RFC3339>` if no
|
||||
name was specified)
|
||||
The human-readable snapshot ID is used in CLI arguments, log lines, and the
|
||||
local database. It is not written to the destination store.
|
||||
|
||||
- **Format**: `<hostname>_<snapshot-name>_<RFC3339>` (or `<hostname>_<RFC3339>`
|
||||
if no name was specified)
|
||||
- **Example**: `laptop_home_2024-01-15T14:30:52Z`
|
||||
- **Components**:
|
||||
- Short hostname (everything before the first dot is stripped from the FQDN)
|
||||
- Snapshot name from the configured `snapshots:` map (optional)
|
||||
- RFC3339 UTC timestamp
|
||||
- Short hostname (everything before the first dot is stripped from the FQDN)
|
||||
- Snapshot name from the configured `snapshots:` map (optional)
|
||||
- RFC3339 UTC timestamp
|
||||
|
||||
This ID reveals the hostname, the configured snapshot name, and the backup time, so it is never used as the on-disk directory name — the remote key is used instead.
|
||||
This ID reveals the hostname, the configured snapshot name, and the backup time,
|
||||
so it is never used as the on-disk directory name — the remote key is used
|
||||
instead.
|
||||
|
||||
### Remote Key Derivation
|
||||
|
||||
The remote key is `hex(SHA256(SHA256("vaultik|" + snapshot-id)))`: a double SHA-256 over the snapshot ID, with a `vaultik|` domain-separation prefix. The result is a 64-character hex string. The hash is not reversible, but it uses no secret: an observer who guesses a candidate hostname and snapshot name can hash it the same way and confirm whether that snapshot is present. The remote key keeps names out of a plain listing; it does not hide them from a guess. Implemented in `internal/snapshot/remotekey.go`.
|
||||
The remote key is `hex(SHA256(SHA256("vaultik|" + snapshot-id)))`: a double
|
||||
SHA-256 over the snapshot ID, with a `vaultik|` domain-separation prefix. The
|
||||
result is a 64-character hex string. The hash is not reversible, but it uses no
|
||||
secret: an observer who guesses a candidate hostname and snapshot name can hash
|
||||
it the same way and confirm whether that snapshot is present. The remote key
|
||||
keeps names out of a plain listing; it does not hide them from a guess.
|
||||
Implemented in `internal/snapshot/remotekey.go`.
|
||||
|
||||
Worked example:
|
||||
|
||||
- Snapshot ID: `server1_home_2025-06-01T12:00:00Z`
|
||||
- Remote key: `17f97bcde958748af076b926af59823943db59e80ce7170b40f124dfa28f64aa`
|
||||
- Directory: `metadata/17f97bcde958748af076b926af59823943db59e80ce7170b40f124dfa28f64aa/`
|
||||
- Directory:
|
||||
`metadata/17f97bcde958748af076b926af59823943db59e80ce7170b40f124dfa28f64aa/`
|
||||
|
||||
A plain listing of the destination store therefore shows only these hashes, not the hostname or snapshot name of any backup — but because the hash uses no secret, a guessed hostname and snapshot name can be hashed and confirmed against the listing. The same remote key is stored in the manifest's `snapshot_id` field.
|
||||
A plain listing of the destination store therefore shows only these hashes, not
|
||||
the hostname or snapshot name of any backup — but because the hash uses no
|
||||
secret, a guessed hostname and snapshot name can be hashed and confirmed against
|
||||
the listing. The same remote key is stored in the manifest's `snapshot_id`
|
||||
field.
|
||||
|
||||
### Files in Each Snapshot Directory
|
||||
|
||||
#### `db.zst.age` - Encrypted Database
|
||||
|
||||
- **What it contains**: Pruned binary SQLite database for this snapshot
|
||||
- **Format**: Binary SQLite → Zstandard compressed → Age encrypted
|
||||
- **Encryption**: Encrypted with Age
|
||||
- **Purpose**: Contains full file metadata, chunk mappings, and all relationships
|
||||
- **Why encrypted**: Contains sensitive metadata like file paths, permissions, and ownership
|
||||
- **Purpose**: Contains full file metadata, chunk mappings, and all
|
||||
relationships
|
||||
- **Why encrypted**: Contains sensitive metadata like file paths, permissions,
|
||||
and ownership
|
||||
|
||||
#### `manifest.json.zst` - Unencrypted Blob Manifest
|
||||
|
||||
- **What it contains**: JSON list of all blob hashes referenced by this snapshot
|
||||
- **Format**: JSON → Zstandard compressed (NOT encrypted)
|
||||
- **Encryption**: NOT encrypted
|
||||
- **Purpose**: Enables pruning operations without requiring decryption keys
|
||||
- **Structure**:
|
||||
|
||||
```json
|
||||
{
|
||||
"snapshot_id": "17f97bcde958748af076b926af59823943db59e80ce7170b40f124dfa28f64aa",
|
||||
"timestamp": "2025-06-01T12:00:00Z",
|
||||
"blob_count": 42,
|
||||
"total_compressed_size": 1048576,
|
||||
"blobs": [
|
||||
{ "hash": "cafebabe1234567890abcdef1234567890abcdef1234567890abcdef12345678", "compressed_size": 24576 },
|
||||
{ "hash": "deadbeef1234567890abcdef1234567890abcdef1234567890abcdef12345678", "compressed_size": 32768 }
|
||||
]
|
||||
"snapshot_id": "17f97bcde958748af076b926af59823943db59e80ce7170b40f124dfa28f64aa",
|
||||
"timestamp": "2025-06-01T12:00:00Z",
|
||||
"blob_count": 42,
|
||||
"total_compressed_size": 1048576,
|
||||
"blobs": [
|
||||
{
|
||||
"hash": "cafebabe1234567890abcdef1234567890abcdef1234567890abcdef12345678",
|
||||
"compressed_size": 24576
|
||||
},
|
||||
{
|
||||
"hash": "deadbeef1234567890abcdef1234567890abcdef1234567890abcdef12345678",
|
||||
"compressed_size": 32768
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
`snapshot_id` is the remote key (a hash), not the human ID; `timestamp` is written in the clear.
|
||||
|
||||
`snapshot_id` is the remote key (a hash), not the human ID; `timestamp` is
|
||||
written in the clear.
|
||||
|
||||
### Why Manifest is Unencrypted
|
||||
|
||||
The manifest must be readable without the private key to enable:
|
||||
|
||||
1. **Pruning operations** - Identifying unreferenced blobs for deletion
|
||||
2. **Storage analysis** - Understanding space usage without decryption
|
||||
3. **Verification** - Checking blob existence without decryption
|
||||
4. **Cross-snapshot deduplication analysis** - Finding shared blobs between snapshots
|
||||
4. **Cross-snapshot deduplication analysis** - Finding shared blobs between
|
||||
snapshots
|
||||
|
||||
The manifest contains the remote key, the backup timestamp, the blob count and total compressed size, and each blob's hash and compressed size. It contains no file names, paths, or other decrypted metadata.
|
||||
The manifest contains the remote key, the backup timestamp, the blob count and
|
||||
total compressed size, and each blob's hash and compressed size. It contains no
|
||||
file names, paths, or other decrypted metadata.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### What's Encrypted
|
||||
|
||||
- **All file content** (in blobs)
|
||||
- **All file metadata** (paths, permissions, timestamps, ownership in db.zst.age)
|
||||
- **All file metadata** (paths, permissions, timestamps, ownership in
|
||||
db.zst.age)
|
||||
- **File-to-chunk mappings** (in db.zst.age)
|
||||
|
||||
### What's Not Encrypted
|
||||
- **The remote key** — directory names and the manifest `snapshot_id`, a one-way hash of the snapshot ID (see [Remote Key Derivation](#remote-key-derivation))
|
||||
|
||||
- **The remote key** — directory names and the manifest `snapshot_id`, a one-way
|
||||
hash of the snapshot ID (see [Remote Key Derivation](#remote-key-derivation))
|
||||
- **The backup timestamp** (in manifest.json.zst)
|
||||
- **Blob hashes and their compressed sizes** (in manifest.json.zst)
|
||||
- **Blob count and total compressed size per snapshot** (in manifest.json.zst)
|
||||
|
||||
### Privacy Implications
|
||||
From the unencrypted data, an observer of the destination store can determine:
|
||||
- **When each backup was taken** — not from the directory name, which is a one-way hash, but from the plaintext `timestamp` field in manifest.json.zst, which is published in the clear
|
||||
- How many blobs each snapshot references, and the total compressed size
|
||||
- The compressed size of each blob, and which blobs are shared between snapshots (deduplication patterns)
|
||||
- **Whether a guessed hostname and snapshot name are present** — the remote key is an unkeyed hash, so an observer holding candidate names can hash each one and match it against the directory listing. The human ID is never published, so it cannot be read off directly, but it can be confirmed by guessing.
|
||||
|
||||
Together these give an observer a timing-and-size profile of every snapshot. This is an accepted, documented property of the format, not a defect: the manifest is unencrypted so that pruning can run without the private key, and the timing channel could not be closed by encrypting it anyway — object creation times and per-object sizes stay visible at the storage layer on both `s3://` and `file://` destinations regardless.
|
||||
From the unencrypted data, an observer of the destination store can determine:
|
||||
|
||||
- **When each backup was taken** — not from the directory name, which is a
|
||||
one-way hash, but from the plaintext `timestamp` field in manifest.json.zst,
|
||||
which is published in the clear
|
||||
- How many blobs each snapshot references, and the total compressed size
|
||||
- The compressed size of each blob, and which blobs are shared between snapshots
|
||||
(deduplication patterns)
|
||||
- **Whether a guessed hostname and snapshot name are present** — the remote key
|
||||
is an unkeyed hash, so an observer holding candidate names can hash each one
|
||||
and match it against the directory listing. The human ID is never published,
|
||||
so it cannot be read off directly, but it can be confirmed by guessing.
|
||||
|
||||
Together these give an observer a timing-and-size profile of every snapshot.
|
||||
This is an accepted, documented property of the format, not a defect: the
|
||||
manifest is unencrypted so that pruning can run without the private key, and the
|
||||
timing channel could not be closed by encrypting it anyway — object creation
|
||||
times and per-object sizes stay visible at the storage layer on both `s3://` and
|
||||
`file://` destinations regardless.
|
||||
|
||||
An observer cannot determine:
|
||||
- The hostname or snapshot name of any backup by reading it off the store — the directory name and the manifest `snapshot_id` are unkeyed hashes of the human ID, so the text is never published (though a guessed name can be confirmed, as above)
|
||||
|
||||
- The hostname or snapshot name of any backup by reading it off the store — the
|
||||
directory name and the manifest `snapshot_id` are unkeyed hashes of the human
|
||||
ID, so the text is never published (though a guessed name can be confirmed, as
|
||||
above)
|
||||
- File names or paths
|
||||
- File contents
|
||||
- File permissions or ownership
|
||||
@@ -143,40 +236,119 @@ An observer cannot determine:
|
||||
|
||||
### Accepted Risks
|
||||
|
||||
These are known, deliberate properties of the format and the tooling, recorded so an operator can weigh them rather than discover them.
|
||||
These are known, deliberate properties of the format and the tooling, recorded
|
||||
so an operator can weigh them rather than discover them.
|
||||
|
||||
1. **No proof of authorship.** Restore and `verify --deep` prove that data decrypts with the age private key and matches its unkeyed content hashes. They do not prove who wrote it: anyone who knows a recipient public key and can replace objects on the destination can substitute a snapshot they built. The recipient string is not stored at the destination, but a compromised backed-up host has it. Defences live on the destination side — bucket versioning or object lock, credentials for the source host that cannot delete or overwrite existing versions, and pruning from a trusted host. Note that S3 `PutObject` overwrites an existing key, so PUT permission alone is not append-only.
|
||||
2. **Compression reveals sizes.** Blobs and `db.zst.age` are zstd-compressed then age-encrypted; the manifest is compressed only. age does not pad, so an object's size is the exact compressed length of its contents. All new chunks packed into one blob share a single zstd stream (8 MiB window, 4 MiB at compression levels 1-2), and a blob is closed at `blob_size_limit` and at the end of each configured path. Because a stored chunk is never packed again, someone who can write into a backed-up file and watch blob sizes learns something only when their controlled data and a secret land in the same chunk of a file that keeps changing. Advice: back up any outsider-writable directory as its own snapshot.
|
||||
3. **Chunking uses no secret.** The FastCDC parameters are fixed and public. The default 10 MB average yields chunks between 2.5 MB and 40 MB, and any file of 2.5 MB or less is a single chunk. At the default 10 GB `blob_size_limit` a blob holds hundreds of chunks, so individual chunk lengths are not visible in the blob's size; lowering the limit toward the chunk size begins to expose them.
|
||||
4. **Decrypted data on local disk.** Several commands stage plaintext under `$TMPDIR`: `snapshot restore` writes decrypted blobs under `vaultik-blobcache-*/` (no size cap) and the decrypted metadata database at `vaultik-restore-*/snapshot.db`; `verify --deep` writes that database at `vaultik-verify-*/snapshot.db`; `snapshot create` keeps a plaintext copy of the index at `vaultik-snapshot-*/snapshot.db`. These files are created `0600` and removed on success, but a `kill -9` or a power loss leaves them behind — delete any leftover `vaultik-*` directory under `$TMPDIR` by hand. `$TMPDIR` should be trusted to the same degree as the restore target.
|
||||
5. **Store permissions differ per command.** The backed-up host needs only PUT to run `snapshot create`: it writes blobs and metadata and neither reads nor deletes them. Other commands need more — `snapshot verify`, `snapshot restore`, and `prune` list and read; `prune`, `snapshot purge`, `snapshot remove`, and `remote nuke` also delete. The recommended cron line uses `--prune`, which runs `prune` on the backed-up host, so granting that host `--prune` gives it credentials that can delete its own backups. To keep the source host to PUT only, prune from a separate trusted host instead.
|
||||
6. **Changing recipients does not re-encrypt existing data.** Deduplicated chunks and same-named blobs already on the destination stay encrypted to the recipients in force when they were written. A new snapshot that reuses them cannot be restored with a newly added recipient's key alone, because those reused objects were never encrypted to it. To make everything readable by a new key, run `vaultik database delete` and take a full backup to a fresh destination or prefix.
|
||||
7. **X25519 recipients only.** vaultik rejects age ssh and plugin recipients. Long-lived ciphertext held by a third party (the destination operator) has no fallback if X25519 is ever broken: there is no second recipient type and no post-quantum option.
|
||||
1. **No proof of authorship.** Restore and `verify --deep` prove that data
|
||||
decrypts with the age private key and matches its unkeyed content hashes.
|
||||
They do not prove who wrote it: anyone who knows a recipient public key and
|
||||
can replace objects on the destination can substitute a snapshot they built.
|
||||
The recipient string is not stored at the destination, but a compromised
|
||||
backed-up host has it. Defences live on the destination side — bucket
|
||||
versioning or object lock, credentials for the source host that cannot delete
|
||||
or overwrite existing versions, and pruning from a trusted host. Note that S3
|
||||
`PutObject` overwrites an existing key, so PUT permission alone is not
|
||||
append-only.
|
||||
2. **Compression reveals sizes.** Blobs and `db.zst.age` are zstd-compressed
|
||||
then age-encrypted; the manifest is compressed only. age does not pad, so an
|
||||
object's size is the exact compressed length of its contents. All new chunks
|
||||
packed into one blob share a single zstd stream (8 MiB window, 4 MiB at
|
||||
compression levels 1-2), and a blob is closed at `blob_size_limit` and at the
|
||||
end of each configured path. Because a stored chunk is never packed again,
|
||||
someone who can write into a backed-up file and watch blob sizes learns
|
||||
something only when their controlled data and a secret land in the same chunk
|
||||
of a file that keeps changing. Advice: back up any outsider-writable
|
||||
directory as its own snapshot.
|
||||
3. **Chunking uses no secret.** The FastCDC parameters are fixed and public. The
|
||||
default 10 MB average yields chunks between 2.5 MB and 40 MB, and any file of
|
||||
2.5 MB or less is a single chunk. At the default 10 GB `blob_size_limit` a
|
||||
blob holds hundreds of chunks, so individual chunk lengths are not visible in
|
||||
the blob's size; lowering the limit toward the chunk size begins to expose
|
||||
them.
|
||||
4. **Decrypted data on local disk.** Several commands stage plaintext under
|
||||
`$TMPDIR`: `snapshot restore` writes decrypted blobs under
|
||||
`vaultik-blobcache-*/` (no size cap) and the decrypted metadata database at
|
||||
`vaultik-restore-*/snapshot.db`; `verify --deep` writes that database at
|
||||
`vaultik-verify-*/snapshot.db`; `snapshot create` keeps a plaintext copy of
|
||||
the index at `vaultik-snapshot-*/snapshot.db`. These files are created `0600`
|
||||
and removed on success, but a `kill -9` or a power loss leaves them behind —
|
||||
delete any leftover `vaultik-*` directory under `$TMPDIR` by hand. `$TMPDIR`
|
||||
should be trusted to the same degree as the restore target.
|
||||
5. **Store permissions differ per command.** The backed-up host needs only PUT
|
||||
to run `snapshot create`: it writes blobs and metadata and neither reads nor
|
||||
deletes them. Other commands need more — `snapshot verify`,
|
||||
`snapshot restore`, and `prune` list and read; `prune`, `snapshot purge`,
|
||||
`snapshot remove`, and `remote nuke` also delete. The recommended cron line
|
||||
uses `--prune`, which runs `prune` on the backed-up host, so granting that
|
||||
host `--prune` gives it credentials that can delete its own backups. To keep
|
||||
the source host to PUT only, prune from a separate trusted host instead.
|
||||
6. **Changing recipients does not re-encrypt existing data.** Deduplicated
|
||||
chunks and same-named blobs already on the destination stay encrypted to the
|
||||
recipients in force when they were written. A new snapshot that reuses them
|
||||
cannot be restored with a newly added recipient's key alone, because those
|
||||
reused objects were never encrypted to it. To make everything readable by a
|
||||
new key, run `vaultik database delete` and take a full backup to a fresh
|
||||
destination or prefix.
|
||||
7. **X25519 recipients only.** vaultik rejects age ssh and plugin recipients.
|
||||
Long-lived ciphertext held by a third party (the destination operator) has no
|
||||
fallback if X25519 is ever broken: there is no second recipient type and no
|
||||
post-quantum option.
|
||||
|
||||
## Consistency Guarantees
|
||||
|
||||
1. **Blobs are immutable** - Once written, a blob is never modified
|
||||
2. **Blobs are written before metadata** - A snapshot's metadata is only written after all its blobs are successfully uploaded
|
||||
3. **Metadata is written atomically** - Both db.zst.age and manifest.json.zst are written as complete files
|
||||
4. **A snapshot is marked complete in the local DB only after its metadata is uploaded** - `finalizeSnapshotMetadata` runs `ExportSnapshotMetadata` first and records completion (`MarkSnapshotComplete`) only once the export succeeds (see the backup data flow in [ARCHITECTURE.md](../ARCHITECTURE.md)). A crash during the export therefore leaves the snapshot incomplete, so the next backup's `PruneDatabase` drops it and re-backs-up its data, rather than leaving a completed-looking row in the local index with no matching metadata on the destination store. (A crash in the brief moment after the export succeeds but before completion is recorded leaves a fully-restorable snapshot on the destination that the local index drops as incomplete on the next run; `snapshot list` then reports it honestly as remote-only, which is the safe direction: the destination copy stays restorable.)
|
||||
2. **Blobs are written before metadata** - A snapshot's metadata is only written
|
||||
after all its blobs are successfully uploaded
|
||||
3. **Metadata is written atomically** - Both db.zst.age and manifest.json.zst
|
||||
are written as complete files
|
||||
4. **A snapshot is marked complete in the local DB only after its metadata is
|
||||
uploaded** - `finalizeSnapshotMetadata` runs `ExportSnapshotMetadata` first
|
||||
and records completion (`MarkSnapshotComplete`) only once the export succeeds
|
||||
(see the backup data flow in [ARCHITECTURE.md](ARCHITECTURE.md)). A crash
|
||||
during the export therefore leaves the snapshot incomplete, so the next
|
||||
backup's `PruneDatabase` drops it and re-backs-up its data, rather than
|
||||
leaving a completed-looking row in the local index with no matching metadata
|
||||
on the destination store. (A crash in the brief moment after the export
|
||||
succeeds but before completion is recorded leaves a fully-restorable snapshot
|
||||
on the destination that the local index drops as incomplete on the next run;
|
||||
`snapshot list` then reports it honestly as remote-only, which is the safe
|
||||
direction: the destination copy stays restorable.)
|
||||
|
||||
## Pruning Safety
|
||||
|
||||
The prune operation is safe because:
|
||||
1. It keeps every blob listed in any snapshot's manifest and deletes only blobs that no manifest references
|
||||
|
||||
1. It keeps every blob listed in any snapshot's manifest and deletes only blobs
|
||||
that no manifest references. Under `metadata/` it deletes only a directory
|
||||
that has no `manifest.json.zst`, whose name is a remote key, and that no
|
||||
local snapshot record claims
|
||||
2. Manifests are unencrypted and can be read without keys
|
||||
3. If any manifest cannot be downloaded or decoded, prune deletes nothing and exits with an error, rather than treating that snapshot's blobs as unreferenced
|
||||
4. It deletes an unreferenced blob only once the blob is older than `prune_grace_period` (default `24h`). A backup uploads its blobs before the manifest that lists them, so a prune running meanwhile, on this host or another sharing the destination, leaves them as long as no backup run takes longer than the grace period. The grace period does not protect a blob a backup reuses from an earlier run that stopped before its manifest: the backup skips the upload of a blob already on the destination, so that blob keeps the time of its first upload, and a prune before the backup's manifest is written can delete it; the new snapshot then cannot be restored. `remote nuke` has no such check
|
||||
3. If any manifest cannot be downloaded or decoded, prune deletes nothing and
|
||||
exits with an error, rather than treating that snapshot's blobs as
|
||||
unreferenced
|
||||
4. It deletes an unreferenced blob only once the blob is older than
|
||||
`prune_grace_period` (default `24h`), and a directory under `metadata/` with
|
||||
no manifest only once every object in it is. A backup uploads its blobs and
|
||||
its `db.zst.age` before its manifest, so a prune running meanwhile, on this
|
||||
host or another sharing the destination, leaves them as long as no backup run
|
||||
takes longer than the grace period. The grace period does not protect a blob
|
||||
a backup reuses from an earlier run that stopped before its manifest: the
|
||||
backup skips the upload of a blob already on the destination, so that blob
|
||||
keeps the time of its first upload, and a prune before the backup's manifest
|
||||
is written can delete it; the new snapshot then cannot be restored.
|
||||
`remote nuke` has no such check
|
||||
|
||||
## Restoration Requirements
|
||||
|
||||
To restore from a backup, you need:
|
||||
|
||||
1. **The Age private key** - To decrypt blobs and database
|
||||
2. **The snapshot metadata** - Both files from the snapshot's metadata directory
|
||||
3. **All referenced blobs** - As listed in the manifest
|
||||
|
||||
The restoration process:
|
||||
|
||||
1. Download and decrypt the database dump to understand file structure
|
||||
2. Download and decrypt the required blobs
|
||||
3. Reconstruct files from their chunks
|
||||
4. Restore file metadata (permissions, timestamps, etc.)
|
||||
4. Restore file metadata (permissions, timestamps, etc.)
|
||||
@@ -0,0 +1,22 @@
|
||||
# Workflow
|
||||
|
||||
- branch (from `main`)
|
||||
- do the work in Next Step
|
||||
- delete Next Step from `TODO.md`
|
||||
- move the top item of Future Steps into Next Step
|
||||
- commit (`TODO.md` changes in the same commit as the work)
|
||||
- merge to `main` if the branch is not protected, otherwise open a PR
|
||||
- push
|
||||
|
||||
# Status
|
||||
|
||||
pre-1.0
|
||||
|
||||
# Next Step
|
||||
|
||||
Fix the open bugs on the issue tracker that block the release. Merging `next`
|
||||
into `main` is the owner's.
|
||||
|
||||
# Future Steps
|
||||
|
||||
Work planned after 1.0 is listed in the README [roadmap](../README.md#roadmap).
|
||||
@@ -24,6 +24,7 @@ require (
|
||||
github.com/stretchr/testify v1.11.1
|
||||
go.uber.org/dig v1.19.0
|
||||
go.uber.org/fx v1.24.0
|
||||
golang.org/x/mod v0.29.0
|
||||
golang.org/x/sync v0.18.0
|
||||
golang.org/x/sys v0.38.0
|
||||
golang.org/x/term v0.37.0
|
||||
|
||||
@@ -2,8 +2,10 @@ package blobgen_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"testing/iotest"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -120,6 +122,38 @@ func TestNewReaderTrailingAndGarbage(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestNewReaderGarbageErrorQuotesNoBytes checks that lines of text in
|
||||
// place of a blob fail with ErrNotAgeStream, and that the error carries
|
||||
// none of their bytes. The lines end in a newline because age's own error
|
||||
// quotes the data's first line only when it reads a whole line.
|
||||
func TestNewReaderGarbageErrorQuotesNoBytes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
id, _ := makeIdentity(t)
|
||||
garbage := bytes.Repeat([]byte("garbage-bytes-\n"), 100)
|
||||
|
||||
_, err := blobgen.NewReader(bytes.NewReader(garbage), id)
|
||||
|
||||
require.ErrorIs(t, err, blobgen.ErrNotAgeStream)
|
||||
assert.NotContains(t, err.Error(), "garbage-bytes-")
|
||||
}
|
||||
|
||||
// errConnectionReset stands for a storage read that fails.
|
||||
var errConnectionReset = errors.New("connection reset")
|
||||
|
||||
// TestNewReaderReadFailure checks that a failed read of the age header is
|
||||
// returned as that read error, not as ErrNotAgeStream.
|
||||
func TestNewReaderReadFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
id, _ := makeIdentity(t)
|
||||
|
||||
_, err := blobgen.NewReader(iotest.ErrReader(errConnectionReset), id)
|
||||
|
||||
require.ErrorIs(t, err, errConnectionReset)
|
||||
assert.NotErrorIs(t, err, blobgen.ErrNotAgeStream)
|
||||
}
|
||||
|
||||
// TestNewWriterInvalidLevel covers the rejected end of issue case 9: an
|
||||
// out-of-range compression level errors and writes nothing to the destination.
|
||||
func TestNewWriterInvalidLevel(t *testing.T) {
|
||||
|
||||
@@ -11,6 +11,11 @@ import (
|
||||
"github.com/klauspost/compress/zstd"
|
||||
)
|
||||
|
||||
// ErrNotAgeStream is returned by NewReader when the data cannot be opened
|
||||
// as an age stream, for example because its header is malformed. It
|
||||
// replaces age's own error, which can quote the data's first bytes.
|
||||
var ErrNotAgeStream = errors.New("not a valid age stream")
|
||||
|
||||
// Reader wraps decompression and decryption with SHA256 verification
|
||||
type Reader struct {
|
||||
reader io.Reader
|
||||
@@ -24,11 +29,25 @@ type Reader struct {
|
||||
// NewReader creates a new Reader that decrypts, decompresses, and verifies
|
||||
// data. Every supplied identity is offered to age.Decrypt, so a blob
|
||||
// encrypted to any one of them can be read.
|
||||
//
|
||||
// A failure to read r is returned as that error, and a blob encrypted to
|
||||
// none of the identities as age.NoIdentityMatchError. Any other failure
|
||||
// to open the age stream is ErrNotAgeStream.
|
||||
func NewReader(r io.Reader, identities ...age.Identity) (*Reader, error) {
|
||||
// Create decryption reader
|
||||
decReader, err := age.Decrypt(r, identities...)
|
||||
src := &readErrorRecorder{reader: r}
|
||||
|
||||
decReader, err := age.Decrypt(src, identities...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating decryption reader: %w", err)
|
||||
var noMatch *age.NoIdentityMatchError
|
||||
|
||||
switch {
|
||||
case src.err != nil:
|
||||
return nil, fmt.Errorf("reading age header: %w", src.err)
|
||||
case errors.As(err, &noMatch):
|
||||
return nil, fmt.Errorf("creating decryption reader: %w", err)
|
||||
default:
|
||||
return nil, ErrNotAgeStream
|
||||
}
|
||||
}
|
||||
|
||||
// Create decompression reader
|
||||
@@ -52,6 +71,23 @@ func NewReader(r io.Reader, identities ...age.Identity) (*Reader, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// readErrorRecorder passes reads through to reader and keeps the first
|
||||
// error other than io.EOF, so NewReader can tell a failed read from bad
|
||||
// data.
|
||||
type readErrorRecorder struct {
|
||||
reader io.Reader
|
||||
err error
|
||||
}
|
||||
|
||||
func (r *readErrorRecorder) Read(p []byte) (int, error) {
|
||||
n, err := r.reader.Read(p)
|
||||
if err != nil && !errors.Is(err, io.EOF) && r.err == nil {
|
||||
r.err = err
|
||||
}
|
||||
|
||||
return n, err
|
||||
}
|
||||
|
||||
// Read implements io.Reader
|
||||
func (r *Reader) Read(p []byte) (int, error) {
|
||||
n, err := r.teeReader.Read(p)
|
||||
|
||||
+3
-6
@@ -7,11 +7,9 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/adrg/xdg"
|
||||
"github.com/spf13/cobra"
|
||||
"go.uber.org/dig"
|
||||
"go.uber.org/fx"
|
||||
@@ -90,7 +88,7 @@ func setupGlobals(
|
||||
// blank line. Used both from the fx hook (for subcommand invocations) and
|
||||
// from the root cobra Run handler (for `vaultik` with no subcommand).
|
||||
func writeStartupBanner(w *ui.Writer, startTime time.Time, shortCommit string) {
|
||||
w.Bannerf("%s %s by %s (commit %s, built on %s) starting up at %s.",
|
||||
w.Bannerf("%s %s by %s (commit %s, commit date %s) starting up at %s.",
|
||||
globals.Appname, globals.Version, globals.Author,
|
||||
shortCommit, globals.CommitDate,
|
||||
startTime.Format(time.RFC3339))
|
||||
@@ -339,7 +337,7 @@ func runVaultikApp(
|
||||
},
|
||||
Mode: mode,
|
||||
}, op, func(err error) {
|
||||
log.Error(failMsg, "error", err)
|
||||
log.Info(failMsg, "error", err)
|
||||
ReportErrorf("%s: %v", failMsg, err)
|
||||
})
|
||||
}
|
||||
@@ -351,8 +349,7 @@ func runVaultikApp(
|
||||
// only one runs at a time; a read-only command runs without it and is not
|
||||
// blocked while a mutator holds the lock (opts.Mode).
|
||||
func RunWithApp(ctx context.Context, opts AppOptions) error {
|
||||
release, err := acquireLockIfMutating(opts.Mode,
|
||||
filepath.Join(xdg.DataHome, "vaultik"))
|
||||
release, err := acquireLockIfMutating(opts.Mode, pidlock.Dir())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
+74
-51
@@ -8,6 +8,7 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -30,7 +31,7 @@ const configSetArgs = 2
|
||||
// parent config dirs (e.g. ~/.config) are conventionally traversable.
|
||||
const configDirMode = 0o755
|
||||
|
||||
// configYAMLIndent matches the 2-space indentation of defaultConfigTemplate,
|
||||
// configYAMLIndent matches the 2-space indentation of the default config,
|
||||
// so `config set` writes the file back with the same indentation rather than
|
||||
// yaml.Marshal's 4-space default.
|
||||
const configYAMLIndent = 2
|
||||
@@ -48,9 +49,30 @@ var (
|
||||
errWouldNotLoad = errors.New(
|
||||
"vaultik would not load the config after this change, " +
|
||||
"so the file was not changed")
|
||||
errSetS3KeysFirst = errors.New(
|
||||
"set s3.access_key_id and s3.secret_access_key first, " +
|
||||
"then storage_url")
|
||||
)
|
||||
|
||||
const defaultConfigTemplate = `# vaultik configuration
|
||||
// storageURLKey is the config key that selects the storage backend.
|
||||
const storageURLKey = "storage_url"
|
||||
|
||||
// macOS is the value of runtime.GOOS on macOS.
|
||||
const macOS = "darwin"
|
||||
|
||||
// defaultConfigText returns the file `config init` writes on the operating
|
||||
// system goos, named as runtime.GOOS names it. Only macOS gets the apps
|
||||
// snapshot, since /Applications exists nowhere else.
|
||||
func defaultConfigText(goos string) string {
|
||||
if goos == macOS {
|
||||
return defaultConfigHead + appsSnapshotConfig + defaultConfigTail
|
||||
}
|
||||
|
||||
return defaultConfigHead + defaultConfigTail
|
||||
}
|
||||
|
||||
// defaultConfigHead is the default config up to the end of the home snapshot.
|
||||
const defaultConfigHead = `# vaultik configuration
|
||||
# Documentation: https://sneak.berlin/go/vaultik
|
||||
|
||||
# ─── REQUIRED ────────────────────────────────────────────────────────────────
|
||||
@@ -168,7 +190,10 @@ snapshots:
|
||||
- "/Movies/ProxyMedia"
|
||||
- "/Music/iTunes/Album Artwork"
|
||||
- "/Pictures/iPod Photo Cache"
|
||||
`
|
||||
|
||||
// appsSnapshotConfig is the apps snapshot of the default config.
|
||||
const appsSnapshotConfig = `
|
||||
# Third-party applications. OS-provided apps live in /System/Applications
|
||||
# on modern macOS and are never in /Applications, but Apple-installed
|
||||
# App Store apps (Safari, GarageBand, iWork, iMovie) are excluded since
|
||||
@@ -189,7 +214,10 @@ snapshots:
|
||||
- "/Steam.app"
|
||||
- "/VirtualBox.app"
|
||||
- "/Utilities/Adobe Installers"
|
||||
`
|
||||
|
||||
// defaultConfigTail is the default config after the snapshots.
|
||||
const defaultConfigTail = `
|
||||
# Storage backend (pick ONE of the three forms below).
|
||||
#
|
||||
# S3-compatible:
|
||||
@@ -239,20 +267,23 @@ storage_url: ""
|
||||
# Default: 3
|
||||
# compression_level: 3
|
||||
|
||||
# How old a blob that no snapshot lists must be before prune deletes it.
|
||||
# A backup uploads its blobs before the manifest that lists them, so on a
|
||||
# destination shared between hosts or users this must be longer than the
|
||||
# longest backup run. It does not protect a blob a backup reuses from an
|
||||
# earlier run that stopped before its manifest: that blob keeps the time of
|
||||
# its first upload, so a prune can delete it.
|
||||
# Accepts: 36h, 90m, etc. 0s deletes them at once.
|
||||
# How old a blob that no snapshot lists must be before prune deletes it,
|
||||
# and how old every object in a directory under metadata/ with no manifest
|
||||
# must be before prune deletes that directory. A backup uploads its blobs
|
||||
# and its db.zst.age before its manifest, so on a destination shared
|
||||
# between hosts or users this must be longer than the longest backup run.
|
||||
# It does not protect a blob a backup reuses from an earlier run that
|
||||
# stopped before its manifest: that blob keeps the time of its first
|
||||
# upload, so a prune can delete it.
|
||||
# Accepts: 36h, 90m, etc. 0s deletes such blobs and directories at once.
|
||||
# Default: 24h
|
||||
# prune_grace_period: 24h
|
||||
|
||||
# Hostname used in snapshot IDs. Default: system hostname.
|
||||
# hostname: myserver
|
||||
|
||||
# Path to the local SQLite index database.
|
||||
# Path to the local SQLite index database. It must be an absolute path;
|
||||
# ~ is expanded.
|
||||
# Default: the platform data directory, e.g.
|
||||
# macOS: ~/Library/Application Support/vaultik/index.sqlite
|
||||
# Linux: ~/.local/share/vaultik/index.sqlite
|
||||
@@ -309,7 +340,8 @@ on macOS, ~/.config/ on Linux, /etc/vaultik/ as root).`,
|
||||
return fmt.Errorf("creating config directory %s: %w", dir, err)
|
||||
}
|
||||
|
||||
err = os.WriteFile(path, []byte(defaultConfigTemplate), configFileMode)
|
||||
err = os.WriteFile(path, []byte(defaultConfigText(runtime.GOOS)),
|
||||
configFileMode)
|
||||
if err != nil {
|
||||
return fmt.Errorf("writing config file: %w", err)
|
||||
}
|
||||
@@ -418,11 +450,10 @@ number, otherwise as a map.
|
||||
|
||||
If the config loads, a change after which it would not, such as
|
||||
compression_level 99, is refused and the file is left unchanged. A
|
||||
config that does not load, such as the one config init writes before
|
||||
storage_url is set or one with a ${ENV:...} reference to a variable
|
||||
not set here, is checked one setting at a time: only a change after
|
||||
which the changed setting cannot be read, such as one giving it the
|
||||
wrong type, is refused.
|
||||
config that does not load is checked one setting at a time. Examples
|
||||
are the file config init writes before storage_url is set, and one
|
||||
with a ${ENV:...} reference to a variable not set in this shell. In
|
||||
such a config, an unknown key or a value of the wrong type is refused.
|
||||
|
||||
Examples:
|
||||
vaultik config set storage_url "file:///mnt/backups"
|
||||
@@ -459,6 +490,13 @@ func writeConfigSet(out *ui.Writer, path, key, value string) error {
|
||||
|
||||
keys := strings.Split(key, ".")
|
||||
|
||||
if keys[0] != config.EnvSection {
|
||||
_, err = config.SettingType(keys)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
err = yamlPathSet(root, keys, value)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -481,7 +519,7 @@ func writeConfigSet(out *ui.Writer, path, key, value string) error {
|
||||
|
||||
err = checkConfigChange(oldData, data, settingData)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %w", errWouldNotLoad, err)
|
||||
return wouldNotLoadError(key, err)
|
||||
}
|
||||
|
||||
err = os.WriteFile(path, data, configFileMode)
|
||||
@@ -505,6 +543,21 @@ func writeConfigSet(out *ui.Writer, path, key, value string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// wouldNotLoadError returns the refusal of a change to key that vaultik
|
||||
// would not load for reason. When storage_url moves to s3:// before the S3
|
||||
// credentials are set, it says to set them first: they can be set while
|
||||
// the old storage_url is still in place.
|
||||
func wouldNotLoadError(key string, reason error) error {
|
||||
if key == storageURLKey &&
|
||||
(errors.Is(reason, config.ErrS3KeyIDRequired) ||
|
||||
errors.Is(reason, config.ErrS3SecretRequired)) {
|
||||
return fmt.Errorf("%w: %w; %w", errWouldNotLoad, reason,
|
||||
errSetS3KeysFirst)
|
||||
}
|
||||
|
||||
return fmt.Errorf("%w: %w", errWouldNotLoad, reason)
|
||||
}
|
||||
|
||||
// checkConfigChange returns the reason vaultik would not load newData, the
|
||||
// config after a change, if the change brings that failure in. If oldData,
|
||||
// the config before it, loads, the new one must load too. A config that
|
||||
@@ -558,7 +611,7 @@ func yamlPathOnly(root *yaml.Node, keys []string) (*yaml.Node, error) {
|
||||
}
|
||||
|
||||
// marshalConfigYAML renders a config document tree with 2-space indentation,
|
||||
// matching defaultConfigTemplate. yaml.Marshal defaults to 4 spaces, which
|
||||
// matching the default config. yaml.Marshal defaults to 4 spaces, which
|
||||
// would reindent the whole file on the first `config set` despite the promise
|
||||
// to preserve formatting.
|
||||
func marshalConfigYAML(root *yaml.Node) ([]byte, error) {
|
||||
@@ -714,41 +767,11 @@ func yamlPathSet(root *yaml.Node, keys []string, value string) error {
|
||||
}
|
||||
|
||||
// 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.
|
||||
// config.Config, as s3.access_key_id and snapshots.home.exclude.0 do.
|
||||
func configKeyIsString(keys []string) bool {
|
||||
typ := reflect.TypeFor[config.Config]()
|
||||
typ, err := config.SettingType(keys)
|
||||
|
||||
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
|
||||
return err == nil && typ.Kind() == reflect.String
|
||||
}
|
||||
|
||||
// yamlSetInMapping resolves (creating if needed) the value node for the
|
||||
|
||||
+178
-9
@@ -2,6 +2,7 @@ package cli //nolint:testpackage // exercises unexported yamlPathGet/yamlPathSet
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
@@ -13,14 +14,15 @@ import (
|
||||
"sneak.berlin/go/vaultik/internal/ui"
|
||||
)
|
||||
|
||||
// TestDefaultConfigTemplateParses ensures the init template is valid YAML
|
||||
// that unmarshals into the Config struct with the expected snapshots.
|
||||
func TestDefaultConfigTemplateParses(t *testing.T) {
|
||||
// TestDefaultConfigParsesOnMacOS ensures the config `config init` writes on
|
||||
// macOS is valid YAML that unmarshals into the Config struct with the
|
||||
// expected snapshots.
|
||||
func TestDefaultConfigParsesOnMacOS(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var cfg config.Config
|
||||
|
||||
err := yaml.Unmarshal([]byte(defaultConfigTemplate), &cfg)
|
||||
err := yaml.Unmarshal([]byte(defaultConfigText("darwin")), &cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("default config template is not valid YAML: %v", err)
|
||||
}
|
||||
@@ -58,6 +60,39 @@ func TestDefaultConfigTemplateParses(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultConfigOnLinux checks that the config `config init` writes on
|
||||
// Linux loads with its home snapshot and without the apps snapshot, whose
|
||||
// /Applications exists only on macOS.
|
||||
func TestDefaultConfigOnLinux(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "config.yml")
|
||||
|
||||
err := os.WriteFile(path, []byte(defaultConfigText("linux")), configFileMode)
|
||||
if err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
err = writeConfigSet(ui.NewWithColor(&bytes.Buffer{}, false),
|
||||
path, "storage_url", "file:///mnt/backups")
|
||||
if err != nil {
|
||||
t.Fatalf("config set storage_url: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := config.Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("config.Load: %v", err)
|
||||
}
|
||||
|
||||
if _, ok := cfg.Snapshots["home"]; !ok {
|
||||
t.Error("expected 'home' snapshot in the Linux default config")
|
||||
}
|
||||
|
||||
if _, ok := cfg.Snapshots["apps"]; ok {
|
||||
t.Error("the Linux default config should have no 'apps' snapshot")
|
||||
}
|
||||
}
|
||||
|
||||
// testRecipient is a valid age public key.
|
||||
const testRecipient = "age1278m9q7dp3chsh2dcy82qk27v047zywyvtxwnj4cvt0z65jw6a7q5dqhfj"
|
||||
|
||||
@@ -71,7 +106,7 @@ func TestConfigSetRecipientOnFreshConfig(t *testing.T) {
|
||||
|
||||
path := filepath.Join(t.TempDir(), "config.yml")
|
||||
|
||||
err := os.WriteFile(path, []byte(defaultConfigTemplate), configFileMode)
|
||||
err := os.WriteFile(path, []byte(defaultConfigText(macOS)), configFileMode)
|
||||
if err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
@@ -118,6 +153,21 @@ snapshots:
|
||||
- "~"
|
||||
`
|
||||
|
||||
// testS3URL is an S3 storage_url, which needs credentials to load.
|
||||
const testS3URL = "s3://bucket/prefix?endpoint=https://s3.example.com"
|
||||
|
||||
// configWithS3Keys loads: its storage_url is testS3URL and both S3 keys are
|
||||
// set.
|
||||
const configWithS3Keys = storageURLKey + ": " + testS3URL + `
|
||||
s3:
|
||||
access_key_id: AKIAEXAMPLE
|
||||
secret_access_key: example-secret
|
||||
snapshots:
|
||||
home:
|
||||
paths:
|
||||
- "~"
|
||||
`
|
||||
|
||||
// configWithUnsetReference loads only where VAULTIK_TEST_UNSET_SECRET is
|
||||
// set, as a backup job's environment might set it. No test sets it.
|
||||
const configWithUnsetReference = configWithoutRecipients + `s3:
|
||||
@@ -191,7 +241,7 @@ func TestConfigSetIndexUnderMissingKey(t *testing.T) {
|
||||
// change after which the config would not load, with an error giving the
|
||||
// reason, and leaves the file as it was. On the file `config init` writes,
|
||||
// which does not load until storage_url is set, and on one that cannot be
|
||||
// read here, only a value of the wrong type is refused.
|
||||
// read here, only an unknown key or a value of the wrong type is refused.
|
||||
func TestConfigSetKeepsFileThatWouldNotLoad(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -203,6 +253,14 @@ func TestConfigSetKeepsFileThatWouldNotLoad(t *testing.T) {
|
||||
value string
|
||||
reason string
|
||||
}{
|
||||
// Keys that no setting has, on a config that loads and on one
|
||||
// that does not.
|
||||
{configWithoutRecipients, "nosuchkey", "1",
|
||||
`unknown config key "nosuchkey"`},
|
||||
{configWithoutRecipients, "snapshots.home.exlude", "/tmp",
|
||||
`unknown config key "snapshots.home.exlude"`},
|
||||
{defaultConfigText(macOS), "compression_lvl", "9",
|
||||
`unknown config key "compression_lvl"`},
|
||||
// Index 1 of the empty list the missing key gives.
|
||||
{configWithoutRecipients, "age_recipients.1", testRecipient,
|
||||
"index out of range"},
|
||||
@@ -210,17 +268,25 @@ func TestConfigSetKeepsFileThatWouldNotLoad(t *testing.T) {
|
||||
{configWithoutRecipients, "snapshots.home.paths", "/srv",
|
||||
"into []string"},
|
||||
{configWithoutRecipients, "chunk_size", "fast",
|
||||
"invalid size format"},
|
||||
`chunk_size: "fast" is not a size`},
|
||||
// Values of the right type that fail validation.
|
||||
{configWithoutRecipients, "compression_level", "99",
|
||||
"compression_level must be between 1 and 19"},
|
||||
{configWithoutRecipients, "chunk_size", "1KB",
|
||||
"chunk_size must be at least 1MB"},
|
||||
// An s3:// URL before its credentials names them.
|
||||
{configWithoutRecipients, storageURLKey, testS3URL,
|
||||
"s3.access_key_id is required for s3:// URLs; " +
|
||||
"set s3.access_key_id and s3.secret_access_key first"},
|
||||
{configWithoutRecipients + "s3:\n access_key_id: AKIAEXAMPLE\n",
|
||||
storageURLKey, testS3URL,
|
||||
"s3.secret_access_key is required for s3:// URLs; " +
|
||||
"set s3.access_key_id and s3.secret_access_key first"},
|
||||
// With its reference filled in, the config loads.
|
||||
{levelReference, "blob_size_limit", "1MB",
|
||||
"blob_size_limit must be at least"},
|
||||
// The type check on a config that does not load yet.
|
||||
{defaultConfigTemplate, "snapshots.apps.paths", "/srv",
|
||||
{defaultConfigText(macOS), "snapshots.apps.paths", "/srv",
|
||||
"into []string"},
|
||||
// The type check on a config that cannot be read here.
|
||||
{configWithUnsetReference, "snapshots.home.paths", "/x",
|
||||
@@ -301,6 +367,109 @@ func TestConfigSetRefusesOnlyItsOwnFailure(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigSetS3KeysBeforeStorageURL checks that the order the refusal
|
||||
// gives works on a config that loads with a file:// storage_url.
|
||||
func TestConfigSetS3KeysBeforeStorageURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "config.yml")
|
||||
|
||||
err := os.WriteFile(path, []byte(configWithoutRecipients), configFileMode)
|
||||
if err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
for _, set := range [][2]string{
|
||||
{"s3.access_key_id", "AKIAEXAMPLE"},
|
||||
{"s3.secret_access_key", "example-secret"},
|
||||
{storageURLKey, testS3URL},
|
||||
} {
|
||||
err = writeConfigSet(ui.NewWithColor(&bytes.Buffer{}, false),
|
||||
path, set[0], set[1])
|
||||
if err != nil {
|
||||
t.Fatalf("config set %s: %v", set[0], err)
|
||||
}
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path) //nolint:gosec // G304: test-controlled path
|
||||
if err != nil {
|
||||
t.Fatalf("read config: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(string(data), storageURLKey+": "+testS3URL) {
|
||||
t.Errorf("config set storage_url wrote:\n%s", data)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigSetRefusesClearingS3Key checks that clearing s3.access_key_id on
|
||||
// a config with an s3:// storage_url is refused without saying to set the
|
||||
// S3 keys first. That advice is only for a change to storage_url.
|
||||
func TestConfigSetRefusesClearingS3Key(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const key = "s3.access_key_id"
|
||||
|
||||
path := filepath.Join(t.TempDir(), "config.yml")
|
||||
|
||||
err := os.WriteFile(path, []byte(configWithS3Keys), configFileMode)
|
||||
if err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
err = writeConfigSet(ui.NewWithColor(&bytes.Buffer{}, false), path, key, "")
|
||||
if !errors.Is(err, errWouldNotLoad) {
|
||||
t.Errorf("clearing %s gives error %v, want one saying %q",
|
||||
key, err, errWouldNotLoad)
|
||||
}
|
||||
|
||||
if errors.Is(err, errSetS3KeysFirst) {
|
||||
t.Errorf("clearing %s gives error %v, want one not saying %q",
|
||||
key, err, errSetS3KeysFirst)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path) //nolint:gosec // G304: test-controlled path
|
||||
if err != nil {
|
||||
t.Fatalf("read config: %v", err)
|
||||
}
|
||||
|
||||
if string(data) != configWithS3Keys {
|
||||
t.Errorf("clearing %s changed the file to:\n%s", key, data)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigSetWritesEnvSection checks that config set writes a key under
|
||||
// the top-level env section, whose keys are not settings, on a config that
|
||||
// loads.
|
||||
func TestConfigSetWritesEnvSection(t *testing.T) {
|
||||
const name = "VAULTIK_TEST_CONFIG_SET_ENV"
|
||||
|
||||
// Loading the changed config sets the variable; t.Setenv restores it
|
||||
// after the test.
|
||||
t.Setenv(name, "")
|
||||
|
||||
path := filepath.Join(t.TempDir(), "config.yml")
|
||||
|
||||
err := os.WriteFile(path, []byte(configWithoutRecipients), configFileMode)
|
||||
if err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
err = writeConfigSet(ui.NewWithColor(&bytes.Buffer{}, false),
|
||||
path, "env."+name, "value")
|
||||
if err != nil {
|
||||
t.Fatalf("config set env.%s: %v", name, err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path) //nolint:gosec // G304: test-controlled path
|
||||
if err != nil {
|
||||
t.Fatalf("read config: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(string(data), "env:\n "+name+": value\n") {
|
||||
t.Errorf("config set env.%s wrote:\n%s", name, data)
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -380,7 +549,7 @@ func loadAfterConfigSet(t *testing.T, key, value string) *config.Config {
|
||||
|
||||
path := filepath.Join(t.TempDir(), "config.yml")
|
||||
|
||||
err := os.WriteFile(path, []byte(defaultConfigTemplate), configFileMode)
|
||||
err := os.WriteFile(path, []byte(defaultConfigText(macOS)), configFileMode)
|
||||
if err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
+13
-13
@@ -6,7 +6,9 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"sneak.berlin/go/vaultik/internal/config"
|
||||
"sneak.berlin/go/vaultik/internal/database"
|
||||
"sneak.berlin/go/vaultik/internal/log"
|
||||
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||
)
|
||||
|
||||
// NewDatabaseCommand creates the database command group
|
||||
@@ -90,27 +92,25 @@ Use --force to skip the confirmation prompt.`,
|
||||
|
||||
var confirm string
|
||||
|
||||
_, err = fmt.Scanln(&confirm)
|
||||
// A failure to read the answer means no confirmation.
|
||||
_, err = fmt.Fscanln(cmd.InOrStdin(), &confirm)
|
||||
if err != nil || confirm != "yes" {
|
||||
_, _ = fmt.Fprintln(w, "Aborted.")
|
||||
|
||||
//nolint:nilerr // a failed/aborted confirmation is a clean abort
|
||||
return nil
|
||||
return vaultik.ErrNotConfirmed
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the database file
|
||||
err = os.Remove(dbPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete database: %w", err)
|
||||
// Delete the database file and its side files. A side file
|
||||
// may not exist, so only failing to delete the database file
|
||||
// is an error.
|
||||
for _, indexFile := range database.IndexFiles(dbPath) {
|
||||
err = os.Remove(indexFile)
|
||||
if err != nil && indexFile == dbPath {
|
||||
return fmt.Errorf("failed to delete database: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Also delete WAL and SHM files if they exist
|
||||
walPath := dbPath + "-wal"
|
||||
shmPath := dbPath + "-shm"
|
||||
_ = os.Remove(walPath) // Ignore errors - files may not exist
|
||||
_ = os.Remove(shmPath)
|
||||
|
||||
out.Infof("Local state database deleted: %s.", dbPath)
|
||||
log.Info("Local state database deleted", "path", dbPath)
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package cli //nolint:testpackage // sets the unexported rootFlags directly
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/vaultik/internal/database"
|
||||
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||
)
|
||||
|
||||
// TestDatabaseDeleteDeclinedIsError checks that answering anything but
|
||||
// "yes" keeps the database and returns ErrNotConfirmed, so the command
|
||||
// exits non-zero.
|
||||
//
|
||||
//nolint:paralleltest // mutates the process-global rootFlags
|
||||
func TestDatabaseDeleteDeclinedIsError(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath, cfgPath := seedDatabaseDeleteConfig(t, dir)
|
||||
|
||||
setRootFlags(t, RootFlags{ConfigPath: cfgPath})
|
||||
|
||||
var out bytes.Buffer
|
||||
|
||||
cmd := newDatabaseDeleteCommand()
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetIn(strings.NewReader("no\n"))
|
||||
cmd.SetArgs(nil)
|
||||
|
||||
err := cmd.Execute()
|
||||
if !errors.Is(err, vaultik.ErrNotConfirmed) {
|
||||
t.Errorf("declined database delete returned %v, want %v",
|
||||
err, vaultik.ErrNotConfirmed)
|
||||
}
|
||||
|
||||
_, err = os.Stat(dbPath)
|
||||
if err != nil {
|
||||
t.Errorf("declined database delete removed the file: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(out.String(), "Aborted.") {
|
||||
t.Errorf("declined database delete printed %q, want Aborted.",
|
||||
out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestDatabaseDeleteRemovesSideFiles checks that the side files SQLite
|
||||
// keeps beside the database file are deleted with it.
|
||||
//
|
||||
//nolint:paralleltest // mutates the process-global rootFlags
|
||||
func TestDatabaseDeleteRemovesSideFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath, cfgPath := seedDatabaseDeleteConfig(t, dir)
|
||||
|
||||
indexFiles := database.IndexFiles(dbPath)
|
||||
for _, indexFile := range indexFiles {
|
||||
seedFile(t, dir, filepath.Base(indexFile), "data")
|
||||
}
|
||||
|
||||
setRootFlags(t, RootFlags{ConfigPath: cfgPath})
|
||||
|
||||
mustExecute(t, newDatabaseDeleteCommand(), "--force")
|
||||
|
||||
for _, indexFile := range indexFiles {
|
||||
_, err := os.Stat(indexFile)
|
||||
if !os.IsNotExist(err) {
|
||||
t.Errorf("database delete left %s: stat err = %v", indexFile, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,8 @@ const shortCommitLen = 12
|
||||
const exitCodeInterrupted = 130
|
||||
|
||||
// Entry is the main entry point for the CLI application.
|
||||
// It first calls globals.FillUnsetFromBuildInfo, which fills in the
|
||||
// version, commit and commit date that the linker flags did not set.
|
||||
// It prints the startup banner to stderr (unless a banner-suppressing
|
||||
// flag is present in os.Args — see bannerSuppressedInArgs), executes the
|
||||
// root cobra command, and routes any returned error through the
|
||||
@@ -33,6 +35,7 @@ const exitCodeInterrupted = 130
|
||||
// deferred profile writers run before the process ends. See run in
|
||||
// cmd/vaultik/main.go.
|
||||
func Entry() int {
|
||||
globals.FillUnsetFromBuildInfo()
|
||||
emitStartupBanner(os.Args[1:], os.Stderr)
|
||||
|
||||
rootCmd := NewRootCommand()
|
||||
@@ -82,6 +85,10 @@ func emitStartupBanner(args []string, w io.Writer) {
|
||||
// 🛑 ERROR: format. Use it from goroutine error paths (where returning
|
||||
// an error to cobra isn't an option) and anywhere else a CLI command
|
||||
// must surface a failure outside the normal RunE return path.
|
||||
//
|
||||
// This line is the one report of the failure. A caller that also logs
|
||||
// the failure logs it at info level, so the record appears only under
|
||||
// --verbose or --debug and the error is not printed twice.
|
||||
func ReportErrorf(format string, args ...any) {
|
||||
ui.New(os.Stderr).Errorf(format, args...)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/adrg/xdg"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/globals"
|
||||
)
|
||||
|
||||
// Command words and flags used to build argument vectors below. They are
|
||||
@@ -112,6 +113,35 @@ func TestBannerStillPrintedWithoutSuppressingFlag(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBannerLabelsCommitDate checks that the banner prints the commit
|
||||
// and the commit date, each after its own label. Commit and CommitDate
|
||||
// get different values, so printing the wrong one fails.
|
||||
//
|
||||
//nolint:paralleltest // sets globals.Commit and globals.CommitDate
|
||||
func TestBannerLabelsCommitDate(t *testing.T) {
|
||||
const (
|
||||
commit = "abc1234"
|
||||
commitDate = "2026-11-09"
|
||||
)
|
||||
|
||||
previousCommit, previousCommitDate := globals.Commit, globals.CommitDate
|
||||
|
||||
t.Cleanup(func() {
|
||||
globals.Commit = previousCommit
|
||||
globals.CommitDate = previousCommitDate
|
||||
})
|
||||
|
||||
globals.Commit = commit
|
||||
globals.CommitDate = commitDate
|
||||
|
||||
var banner bytes.Buffer
|
||||
|
||||
emitStartupBanner(nil, &banner)
|
||||
|
||||
assert.Contains(t, banner.String(),
|
||||
"(commit "+commit+", commit date "+commitDate+")")
|
||||
}
|
||||
|
||||
// TestBannerSuppressedInArgs covers the suppression scan directly,
|
||||
// including the flags that suppressed the banner before --json joined
|
||||
// them, so that adding --json cannot regress them.
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
package cli //nolint:testpackage // shares the fixtures and capture helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/database"
|
||||
"sneak.berlin/go/vaultik/internal/ui"
|
||||
)
|
||||
|
||||
const (
|
||||
// errorLinePrefix starts the final line that reports a failed command.
|
||||
errorLinePrefix = "ERROR: "
|
||||
|
||||
flagVerbose = "--verbose"
|
||||
cmdRestore = "restore"
|
||||
|
||||
// blobsDir is the directory of a destination store that holds the
|
||||
// blobs.
|
||||
blobsDir = "blobs"
|
||||
)
|
||||
|
||||
// TestEntryFailureIsPrintedOnce runs commands that fail after startup
|
||||
// against a destination they cannot use. `info` is not among them: it
|
||||
// cannot fail after startup.
|
||||
//
|
||||
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
|
||||
func TestEntryFailureIsPrintedOnce(t *testing.T) {
|
||||
for _, command := range [][]string{
|
||||
{cmdSnapshot, cmdCreate},
|
||||
{cmdSnapshot, cmdRemove, someSnapshotID},
|
||||
{cmdSnapshot, cmdVerify, someSnapshotID},
|
||||
{cmdRemote, cmdInfo},
|
||||
{cmdPrune, flagJSON},
|
||||
} {
|
||||
for _, verbose := range []bool{false, true} {
|
||||
name := fmt.Sprintf("%s verbose=%t", strings.Join(command, " "), verbose)
|
||||
|
||||
t.Run(name, func(t *testing.T) {
|
||||
requireErrorPrintedOnce(t, verbose, append([]string{flagConfig,
|
||||
writeUnusableDestinationConfig(t)}, command...)...)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestEntryBlobUploadFailureIsPrintedOnce backs up to a destination
|
||||
// whose blobs directory is a file, so the blob upload fails.
|
||||
//
|
||||
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
|
||||
func TestEntryBlobUploadFailureIsPrintedOnce(t *testing.T) {
|
||||
for _, verbose := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("verbose=%t", verbose), func(t *testing.T) {
|
||||
configPath, storeDir := writeBackupConfig(t)
|
||||
|
||||
require.NoError(t, os.WriteFile(
|
||||
filepath.Join(storeDir, blobsDir), nil, 0o600))
|
||||
|
||||
requireErrorPrintedOnce(t, verbose,
|
||||
flagConfig, configPath, cmdSnapshot, cmdCreate)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEntryBlobDownloadFailureIsPrintedOnce backs up, deletes the blobs
|
||||
// from the destination, and restores, so the blob download fails.
|
||||
//
|
||||
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
|
||||
func TestEntryBlobDownloadFailureIsPrintedOnce(t *testing.T) {
|
||||
for _, verbose := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("verbose=%t", verbose), func(t *testing.T) {
|
||||
configPath, storeDir := writeBackupConfigWithPrivateKey(t)
|
||||
|
||||
code, _, _ := runEntry(t,
|
||||
flagConfig, configPath, cmdSnapshot, cmdCreate)
|
||||
require.Equal(t, 0, code)
|
||||
|
||||
require.NoError(t, os.RemoveAll(filepath.Join(storeDir, blobsDir)))
|
||||
|
||||
requireErrorPrintedOnce(t, verbose, flagConfig, configPath,
|
||||
cmdSnapshot, cmdRestore, onlySnapshotID(t, configPath), t.TempDir())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// requireErrorPrintedOnce runs Entry with args, adding --verbose when
|
||||
// verbose is set. The command must fail, and stderr must carry its error
|
||||
// once, as the final ERROR: line. A log record carrying the same error
|
||||
// appears only under --verbose.
|
||||
func requireErrorPrintedOnce(t *testing.T, verbose bool, args ...string) {
|
||||
t.Helper()
|
||||
|
||||
if verbose {
|
||||
args = append(args, flagVerbose)
|
||||
}
|
||||
|
||||
code, _, stderr := runEntry(t, args...)
|
||||
require.Equal(t, 1, code)
|
||||
|
||||
errorLine, records := failureOutput(t, stderr)
|
||||
|
||||
if !verbose {
|
||||
assert.Zero(t, records,
|
||||
"without --verbose no log record repeats the error:\n%s", stderr)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
assert.NotZero(t, records,
|
||||
"--verbose must keep the log record of %q", errorLine)
|
||||
}
|
||||
|
||||
// failureOutput requires stderr to hold exactly one ERROR: line and
|
||||
// returns it, with the number of log records whose error attribute is
|
||||
// the error that line reports. stderr is a pipe here, so every log
|
||||
// record is a JSON object on its own line.
|
||||
func failureOutput(t *testing.T, stderr string) (string, int) {
|
||||
t.Helper()
|
||||
|
||||
var (
|
||||
errorLines []string
|
||||
records []map[string]any
|
||||
)
|
||||
|
||||
for line := range strings.Lines(stderr) {
|
||||
var record map[string]any
|
||||
if json.Unmarshal([]byte(line), &record) == nil {
|
||||
records = append(records, record)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.Contains(line, errorLinePrefix) {
|
||||
errorLines = append(errorLines, strings.TrimSpace(line))
|
||||
}
|
||||
}
|
||||
|
||||
require.Len(t, errorLines, 1,
|
||||
"the error must be printed once as an ERROR: line:\n%s", stderr)
|
||||
|
||||
// An error can run over several lines; the ERROR: line holds the
|
||||
// first of them.
|
||||
repeats := 0
|
||||
|
||||
for _, record := range records {
|
||||
failure, ok := record["error"].(string)
|
||||
firstLine, _, _ := strings.Cut(failure, "\n")
|
||||
|
||||
if ok && strings.Contains(errorLines[0], firstLine) {
|
||||
repeats++
|
||||
}
|
||||
}
|
||||
|
||||
return errorLines[0], repeats
|
||||
}
|
||||
|
||||
// writeBackupConfigWithPrivateKey builds what writeBackupConfig builds,
|
||||
// with a generated key pair in place of its recipient. The config also
|
||||
// holds the private key, so that restore can decrypt the backup. Returns
|
||||
// the config path and the store directory.
|
||||
func writeBackupConfigWithPrivateKey(t *testing.T) (string, string) {
|
||||
t.Helper()
|
||||
|
||||
configPath, storeDir := writeBackupConfig(t)
|
||||
|
||||
identity, err := age.GenerateX25519Identity()
|
||||
require.NoError(t, err)
|
||||
|
||||
out := ui.NewWithColor(io.Discard, false)
|
||||
|
||||
require.NoError(t, writeConfigSet(out, configPath,
|
||||
"age_recipients.0", identity.Recipient().String()))
|
||||
require.NoError(t, writeConfigSet(out, configPath,
|
||||
"age_secret_key", identity.String()))
|
||||
|
||||
return configPath, storeDir
|
||||
}
|
||||
|
||||
// onlySnapshotID returns the ID of the one snapshot in the local index
|
||||
// that writeBackupConfig puts beside the config file.
|
||||
func onlySnapshotID(t *testing.T, configPath string) string {
|
||||
t.Helper()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
db, err := database.New(ctx,
|
||||
filepath.Join(filepath.Dir(configPath), "index.sqlite"))
|
||||
require.NoError(t, err)
|
||||
|
||||
defer func() { require.NoError(t, db.Close()) }()
|
||||
|
||||
backedUp, err := database.NewRepositories(db).Snapshots.GetOnlySnapshot(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
return backedUp.ID.String()
|
||||
}
|
||||
@@ -134,7 +134,7 @@ func writeHermeticPruneConfig(t *testing.T, seedStale bool) string {
|
||||
seedStaleSnapshotRecord(t, indexPath)
|
||||
|
||||
// While the local index holds a snapshot, prune fails on a
|
||||
// destination without the marker a backup writes.
|
||||
// destination that answers that it has no marker.
|
||||
require.NoError(t, os.WriteFile(
|
||||
filepath.Join(storeDir, "vaultik-destination.txt"), nil, 0o600))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package cli //nolint:testpackage // shares runEntry and the hermetic config helpers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/adrg/xdg"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestEntrySnapshotCreateSkipsIndexAndLockFiles backs up a directory
|
||||
// that holds vaultik's index and PID file beside one other file, with no
|
||||
// exclude pattern set. The summary counts only the other file. See
|
||||
// https://git.eeqj.de/sneak/vaultik/issues/421.
|
||||
//
|
||||
// Not parallel: it replaces os.Args, os.Stdout, os.Stderr and the xdg
|
||||
// globals.
|
||||
func TestEntrySnapshotCreateSkipsIndexAndLockFiles(t *testing.T) {
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
// linked sets index_path to a symlink outside the backed-up
|
||||
// directory that points to the index inside it.
|
||||
linked bool
|
||||
}{
|
||||
{name: "index in the backed-up directory"},
|
||||
{name: "index_path links into the backed-up directory", linked: true},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "config.yml")
|
||||
sourceDir := filepath.Join(dir, "source")
|
||||
storeDir := filepath.Join(dir, "store")
|
||||
|
||||
indexPath := filepath.Join(sourceDir, "index.sqlite")
|
||||
|
||||
if testCase.linked {
|
||||
link := filepath.Join(dir, "index.sqlite")
|
||||
require.NoError(t, os.Symlink(indexPath, link))
|
||||
|
||||
indexPath = link
|
||||
}
|
||||
|
||||
contents := fmt.Sprintf(hermeticConfig,
|
||||
sourceDir, storeDir, indexPath)
|
||||
require.NoError(t,
|
||||
os.WriteFile(configPath, []byte(contents), configFileMode))
|
||||
require.NoError(t, os.Mkdir(storeDir, 0o750))
|
||||
require.NoError(t, os.Mkdir(sourceDir, 0o750))
|
||||
require.NoError(t, os.WriteFile(
|
||||
filepath.Join(sourceDir, "file.txt"), []byte("hello"), 0o600))
|
||||
|
||||
// The PID file goes in a directory under XDG_DATA_HOME, which
|
||||
// xdg reads at package init.
|
||||
t.Setenv("XDG_DATA_HOME", filepath.Join(sourceDir, "data"))
|
||||
xdg.Reload()
|
||||
t.Cleanup(xdg.Reload)
|
||||
|
||||
code, stdout, _ := runEntry(t,
|
||||
flagConfig, configPath, cmdSnapshot, cmdCreate)
|
||||
|
||||
require.Equal(t, 0, code)
|
||||
assert.Contains(t, stdout,
|
||||
"Files: 1 examined, 1 backed up, 0 unchanged.")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package cli //nolint:testpackage // shares runEntry and the hermetic config helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -8,6 +9,7 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/database"
|
||||
)
|
||||
|
||||
// Flags of `snapshot create` used to build argument vectors below.
|
||||
@@ -59,8 +61,8 @@ func TestEntrySnapshotCreateRejectsKeepNewerThanBeforeBackup(t *testing.T) {
|
||||
|
||||
// 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.
|
||||
// that cannot be decoded, and checks that the failure is reported once,
|
||||
// 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) {
|
||||
@@ -81,12 +83,98 @@ func TestEntrySnapshotCreateReportsPostBackupPruneFailure(t *testing.T) {
|
||||
assert.Contains(t, stderr, "Backup completed, but post-backup prune failed")
|
||||
assert.NotContains(t, stderr, "Snapshot creation failed")
|
||||
|
||||
_, repeats := failureOutput(t, stderr)
|
||||
assert.Zero(t, repeats, "no log record may repeat the error:\n%s", stderr)
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
// TestEntrySnapshotCreatePruneExitsNonZeroWhenSourceIsEmpty backs up,
|
||||
// empties the source directory, as a volume that is not mounted leaves
|
||||
// its mount point, and runs `snapshot create --prune`. The command exits
|
||||
// non-zero and reports the backup as completed, not as a failed snapshot
|
||||
// creation. The error is printed once. See
|
||||
// https://git.eeqj.de/sneak/vaultik/issues/388.
|
||||
//
|
||||
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
|
||||
func TestEntrySnapshotCreatePruneExitsNonZeroWhenSourceIsEmpty(t *testing.T) {
|
||||
configPath, storeDir := writeBackupConfig(t)
|
||||
|
||||
code, _, _ := runEntry(t, flagConfig, configPath, cmdSnapshot, cmdCreate)
|
||||
require.Equal(t, 0, code)
|
||||
|
||||
// writeBackupConfig puts the source directory beside the config file.
|
||||
require.NoError(t, os.Remove(
|
||||
filepath.Join(filepath.Dir(configPath), "source", "file.txt")))
|
||||
|
||||
code, _, stderr := runEntry(t,
|
||||
flagConfig, configPath, cmdSnapshot, cmdCreate, flagPrune)
|
||||
|
||||
assert.Equal(t, 1, code)
|
||||
assert.Contains(t, stderr,
|
||||
"Backup completed, but 1 source path(s) were empty")
|
||||
assert.NotContains(t, stderr, "Snapshot creation failed")
|
||||
|
||||
_, repeats := failureOutput(t, stderr)
|
||||
assert.Zero(t, repeats, "no log record may repeat the error:\n%s", stderr)
|
||||
|
||||
snapshots, err := os.ReadDir(filepath.Join(storeDir, "metadata"))
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, snapshots, 2, "the older snapshot is kept")
|
||||
}
|
||||
|
||||
// TestEntrySnapshotCreatePruneExitsNonZeroWhenMountPointIsMissing backs
|
||||
// up, records in the local index that this backup found the source
|
||||
// directory as a mount point, and runs `snapshot create --prune`, which
|
||||
// does not find it as one. The command exits non-zero, reports the
|
||||
// backup as completed, not as a failed snapshot creation, and keeps the
|
||||
// older snapshot. The error is printed once. See
|
||||
// https://git.eeqj.de/sneak/vaultik/issues/395.
|
||||
//
|
||||
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
|
||||
func TestEntrySnapshotCreatePruneExitsNonZeroWhenMountPointIsMissing(
|
||||
t *testing.T,
|
||||
) {
|
||||
configPath, storeDir := writeBackupConfig(t)
|
||||
|
||||
code, _, _ := runEntry(t, flagConfig, configPath, cmdSnapshot, cmdCreate)
|
||||
require.Equal(t, 0, code)
|
||||
|
||||
// writeBackupConfig puts the source directory and the local index
|
||||
// beside the config file.
|
||||
dir := filepath.Dir(configPath)
|
||||
ctx := context.Background()
|
||||
|
||||
db, err := database.New(ctx, filepath.Join(dir, "index.sqlite"))
|
||||
require.NoError(t, err)
|
||||
|
||||
repos := database.NewRepositories(db)
|
||||
|
||||
backedUp, err := repos.Snapshots.GetOnlySnapshot(ctx)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, repos.Snapshots.AddMountPoint(ctx, nil,
|
||||
backedUp.ID.String(), filepath.Join(dir, "source")))
|
||||
require.NoError(t, db.Close())
|
||||
|
||||
code, _, stderr := runEntry(t,
|
||||
flagConfig, configPath, cmdSnapshot, cmdCreate, flagPrune)
|
||||
|
||||
assert.Equal(t, 1, code)
|
||||
assert.Contains(t, stderr, "Backup completed, but 1 mount point(s) "+
|
||||
"an earlier snapshot recorded were not found")
|
||||
assert.NotContains(t, stderr, "Snapshot creation failed")
|
||||
|
||||
_, repeats := failureOutput(t, stderr)
|
||||
assert.Zero(t, repeats, "no log record may repeat the error:\n%s", stderr)
|
||||
|
||||
snapshots, err := os.ReadDir(filepath.Join(storeDir, "metadata"))
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, snapshots, 2, "the older snapshot is kept")
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -42,7 +42,7 @@ func TestEntrySnapshotPurgeKeepsMetadataWhenLocalIndexDeleteFails(t *testing.T)
|
||||
seedStaleSnapshotRecord(t, indexPath)
|
||||
|
||||
// While the local index holds a snapshot, purge fails on a
|
||||
// destination without the marker a backup writes.
|
||||
// destination that answers that it has no marker.
|
||||
require.NoError(t, os.WriteFile(
|
||||
filepath.Join(dir, "store", "vaultik-destination.txt"), nil, 0o600))
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ func NewInfoCommand() *cobra.Command {
|
||||
}, func(v *vaultik.Vaultik) error {
|
||||
return v.ShowInfo()
|
||||
}, func(err error) {
|
||||
log.Error("Failed to show info", "error", err)
|
||||
log.Info("Failed to show info", "error", err)
|
||||
ReportErrorf("Failed to show info: %v", err)
|
||||
})
|
||||
},
|
||||
|
||||
@@ -13,8 +13,9 @@ func NewPruneCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "prune",
|
||||
Short: "Tidy local database and remote storage",
|
||||
Long: `Removes orphaned data from both the local index database and
|
||||
unreferenced blobs from the backup destination store.
|
||||
Long: `Removes orphaned data from the local index database, and
|
||||
unreferenced blobs and directories under metadata/ with no manifest
|
||||
from the backup destination store.
|
||||
|
||||
Local cleanup drops incomplete snapshots and any files, chunks, or
|
||||
blobs no longer referenced by a completed snapshot. Remote cleanup
|
||||
@@ -26,6 +27,10 @@ another sharing the destination store, that has not yet uploaded its
|
||||
manifest. The grace period does not protect a blob such a backup
|
||||
reuses from an earlier run that stopped before its manifest: that
|
||||
blob keeps the time of its first upload, so it can be deleted.
|
||||
Remote cleanup also deletes each directory under metadata/ with no
|
||||
manifest, as a backup that stopped before its manifest leaves, once
|
||||
all its objects are older than prune_grace_period and its snapshot is
|
||||
not in the local index.
|
||||
|
||||
Snapshot create --prune runs the same cleanup automatically; this
|
||||
command is the manual entry point for the same work (e.g. after a
|
||||
@@ -55,7 +60,7 @@ referenced.`,
|
||||
}, func(v *vaultik.Vaultik) error {
|
||||
return v.Prune(opts)
|
||||
}, func(err error) {
|
||||
log.Error("Prune operation failed", "error", err)
|
||||
log.Info("Prune operation failed", "error", err)
|
||||
ReportErrorf("Prune failed: %v", err)
|
||||
})
|
||||
},
|
||||
|
||||
@@ -98,7 +98,7 @@ func newRemoteInfoCommand() *cobra.Command {
|
||||
}, func(v *vaultik.Vaultik) error {
|
||||
return v.RemoteInfo(jsonOutput)
|
||||
}, func(err error) {
|
||||
log.Error("Failed to get remote info", "error", err)
|
||||
log.Info("Failed to get remote info", "error", err)
|
||||
ReportErrorf("Failed to get remote info: %v", err)
|
||||
})
|
||||
},
|
||||
|
||||
@@ -109,20 +109,22 @@ on Linux), then /etc/vaultik/config.yml.`,
|
||||
return v.CreateSnapshot(opts)
|
||||
}, func(err error) {
|
||||
if errors.Is(err, vaultik.ErrPostBackupPruneFailed) {
|
||||
log.Error("Post-backup prune failed", "error", err)
|
||||
log.Info("Post-backup prune failed", "error", err)
|
||||
ReportErrorf("Backup completed, but %v", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if errors.Is(err, vaultik.ErrFilesUnreadable) {
|
||||
log.Error("Backup completed with unreadable files", "error", err)
|
||||
if errors.Is(err, vaultik.ErrFilesUnreadable) ||
|
||||
errors.Is(err, vaultik.ErrSourcePathEmpty) ||
|
||||
errors.Is(err, vaultik.ErrVolumeNotMounted) {
|
||||
log.Info("Backup completed with an error", "error", err)
|
||||
ReportErrorf("Backup completed, but %v", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
log.Error("Snapshot creation failed", "error", err)
|
||||
log.Info("Snapshot creation failed", "error", err)
|
||||
ReportErrorf("Snapshot creation failed: %v", err)
|
||||
})
|
||||
},
|
||||
@@ -131,9 +133,13 @@ on Linux), then /etc/vaultik/config.yml.`,
|
||||
cmd.Flags().BoolVar(&opts.Cron, "cron", false,
|
||||
"Run in cron mode (silent unless warning or error)")
|
||||
cmd.Flags().BoolVar(&opts.Prune, "prune", false,
|
||||
"After backup, drop older snapshots of the same name and remove "+
|
||||
"orphaned blobs older than prune_grace_period (not after a "+
|
||||
"backup that skipped unreadable files)")
|
||||
"After backup, drop older snapshots of the same name, remove "+
|
||||
"orphaned blobs older than prune_grace_period, and delete each "+
|
||||
"directory under metadata/ with no manifest once all its "+
|
||||
"objects are older than prune_grace_period and its snapshot is "+
|
||||
"not in the local index (not after a backup that skipped "+
|
||||
"unreadable files, found a configured path empty, or did not "+
|
||||
"find a mount point an earlier snapshot found)")
|
||||
cmd.Flags().StringVar(&opts.KeepNewerThan, "keep-newer-than", "",
|
||||
"With --prune: keep snapshots newer than this duration "+
|
||||
"(e.g. 30d, 4w, 6mo, 1y; m is minutes, mo is months) "+
|
||||
@@ -252,7 +258,7 @@ func newSnapshotVerifyCommand() *cobra.Command {
|
||||
return
|
||||
}
|
||||
|
||||
log.Error("Verification failed", "error", err)
|
||||
log.Info("Verification failed", "error", err)
|
||||
ReportErrorf("Verification failed: %v", err)
|
||||
})
|
||||
},
|
||||
|
||||
@@ -104,7 +104,7 @@ func runRestore(cmd *cobra.Command, args []string, opts *RestoreOptions) error {
|
||||
SkipErrors: rootFlags.SkipErrors,
|
||||
})
|
||||
}, func(err error) {
|
||||
log.Error("Restore operation failed", "error", err)
|
||||
log.Info("Restore operation failed", "error", err)
|
||||
ReportErrorf("Restore failed: %v", err)
|
||||
})
|
||||
}
|
||||
@@ -37,13 +37,13 @@ func writeVersion(out *ui.Writer) {
|
||||
w := out.Out()
|
||||
|
||||
_, _ = fmt.Fprintf(w, "vaultik %s\n", globals.Version)
|
||||
_, _ = fmt.Fprintf(w, " commit: %s\n", globals.Commit)
|
||||
_, _ = fmt.Fprintf(w, " build date: %s\n", globals.CommitDate)
|
||||
_, _ = fmt.Fprintf(w, " go: %s\n", runtime.Version())
|
||||
_, _ = fmt.Fprintf(w, " os/arch: %s/%s\n", runtime.GOOS, runtime.GOARCH)
|
||||
_, _ = fmt.Fprintf(w, " author: %s\n", globals.Author)
|
||||
_, _ = fmt.Fprintf(w, " homepage: %s\n", globals.Homepage)
|
||||
_, _ = fmt.Fprintf(w, " license: %s\n", globals.License)
|
||||
_, _ = fmt.Fprintf(w, " commit: %s\n", globals.Commit)
|
||||
_, _ = fmt.Fprintf(w, " commit date: %s\n", globals.CommitDate)
|
||||
_, _ = fmt.Fprintf(w, " go: %s\n", runtime.Version())
|
||||
_, _ = fmt.Fprintf(w, " os/arch: %s/%s\n", runtime.GOOS, runtime.GOARCH)
|
||||
_, _ = fmt.Fprintf(w, " author: %s\n", globals.Author)
|
||||
_, _ = fmt.Fprintf(w, " homepage: %s\n", globals.Homepage)
|
||||
_, _ = fmt.Fprintf(w, " license: %s\n", globals.License)
|
||||
|
||||
if globals.IsDevVersion(globals.Version) {
|
||||
_, _ = fmt.Fprintln(w)
|
||||
|
||||
@@ -2,6 +2,7 @@ package cli_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -49,6 +50,42 @@ func TestVersionCommandReportsBuildVersion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestVersionCommandReportLines checks the label, padding and value of
|
||||
// every line of the report. Commit and CommitDate get different values,
|
||||
// so a line that prints the wrong one fails.
|
||||
//
|
||||
//nolint:paralleltest // sets globals.Commit and globals.CommitDate
|
||||
func TestVersionCommandReportLines(t *testing.T) {
|
||||
const (
|
||||
commit = "abc1234"
|
||||
commitDate = "2026-11-09"
|
||||
)
|
||||
|
||||
previousCommit, previousCommitDate := globals.Commit, globals.CommitDate
|
||||
|
||||
t.Cleanup(func() {
|
||||
globals.Commit = previousCommit
|
||||
globals.CommitDate = previousCommitDate
|
||||
})
|
||||
|
||||
globals.Commit = commit
|
||||
globals.CommitDate = commitDate
|
||||
|
||||
want := "vaultik " + globals.Version + "\n" +
|
||||
" commit: " + commit + "\n" +
|
||||
" commit date: " + commitDate + "\n" +
|
||||
" go: " + runtime.Version() + "\n" +
|
||||
" os/arch: " + runtime.GOOS + "/" + runtime.GOARCH + "\n" +
|
||||
" author: " + globals.Author + "\n" +
|
||||
" homepage: " + globals.Homepage + "\n" +
|
||||
" license: " + globals.License + "\n"
|
||||
|
||||
out := runVersionCommand(t)
|
||||
if !strings.HasPrefix(out, want) {
|
||||
t.Errorf("report:\n%s\nwant it to start with:\n%s", out, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVersionCommandFlagsDevelopmentBuild is the regression test for
|
||||
// the thing this command exists to prevent: a build that is not a
|
||||
// release must say so. The notice used to be gated on the version
|
||||
|
||||
+79
-20
@@ -9,6 +9,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -56,7 +57,7 @@ var (
|
||||
errRecipientNotX25519 = errors.New(
|
||||
"not a valid recipient; only X25519 age1... public keys are supported")
|
||||
errNoSnapshots = errors.New(
|
||||
"at least one snapshot must be configured (see config.example.yml)")
|
||||
"at least one snapshot must be configured (see configs/config.example.yml)")
|
||||
errSnapshotNoPaths = errors.New("snapshot must have at least one path")
|
||||
errChunkSizeTooSmall = errors.New("chunk_size must be at least 1MB")
|
||||
errBlobSizeTooSmall = errors.New(
|
||||
@@ -69,14 +70,22 @@ var (
|
||||
errFileURLNotAbsolute = errors.New(
|
||||
"a file:// storage_url needs an absolute path, " +
|
||||
"as in file:///path/to/backup")
|
||||
errIndexPathNotAbsolute = errors.New(
|
||||
"index_path and VAULTIK_INDEX_PATH need an absolute path, " +
|
||||
"as in ~/.local/share/vaultik/index.sqlite")
|
||||
errNegativePruneGracePeriod = errors.New(
|
||||
"prune_grace_period must not be negative")
|
||||
errStorageNotConfigured = errors.New(
|
||||
"storage not configured; set storage_url or provide s3.endpoint + " +
|
||||
"s3.bucket + credentials")
|
||||
errS3BucketRequired = errors.New("s3.bucket is required (or set storage_url)")
|
||||
errS3KeyIDRequired = errors.New("s3.access_key_id is required")
|
||||
errS3SecretRequired = errors.New("s3.secret_access_key is required")
|
||||
)
|
||||
|
||||
// Validate returns these when S3 storage is configured without its
|
||||
// credentials.
|
||||
var (
|
||||
ErrS3KeyIDRequired = errors.New("s3.access_key_id is required")
|
||||
ErrS3SecretRequired = errors.New("s3.secret_access_key is required")
|
||||
)
|
||||
|
||||
// expandTilde expands ~ at the start of a path to the user's home directory.
|
||||
@@ -192,10 +201,12 @@ type Config struct {
|
||||
CompressionLevel int `yaml:"compression_level"`
|
||||
|
||||
// PruneGracePeriod is how old a blob no manifest lists must be before
|
||||
// a prune deletes it. A backup lists its blobs in a manifest only
|
||||
// once it has uploaded them all, so a younger one may belong to a
|
||||
// backup still running, on this host or another sharing the
|
||||
// destination store.
|
||||
// a prune deletes it, and how old every object in a directory under
|
||||
// metadata/ with no manifest must be before a prune deletes that
|
||||
// directory. A backup writes its manifest only once it has uploaded
|
||||
// all its blobs and its db.zst.age, so a younger blob or directory may
|
||||
// belong to a backup still running, on this host or another sharing
|
||||
// the destination store.
|
||||
PruneGracePeriod time.Duration `yaml:"prune_grace_period"`
|
||||
|
||||
// StorageURL specifies the storage backend using a URL format.
|
||||
@@ -284,15 +295,39 @@ func Load(path string) (*Config, error) {
|
||||
}
|
||||
|
||||
// Parse reads YAML config data as Load does, without validating it. It
|
||||
// applies default values for optional fields, fills in ${...} references
|
||||
// using smartconfig, and expands ~ in paths.
|
||||
// refuses a key that no setting has and a size setting whose value is not a
|
||||
// size, naming the setting. It applies default values for optional
|
||||
// fields, fills in ${...} references using smartconfig, and expands ~ in
|
||||
// paths.
|
||||
func Parse(data []byte) (*Config, error) {
|
||||
// smartconfig drops line numbers, so keys are checked in the data as
|
||||
// written.
|
||||
var root yaml.Node
|
||||
|
||||
err := yaml.Unmarshal(data, &root)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse config: %w", err)
|
||||
}
|
||||
|
||||
err = checkKeys(&root, reflect.TypeFor[Config](), "",
|
||||
map[*yaml.Node]bool{}, map[nodeAndType]bool{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Load config using smartconfig for interpolation
|
||||
sc, err := smartconfig.NewFromReader(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load config file: %w", err)
|
||||
}
|
||||
|
||||
// A ${...} reference can bring in keys that are not in the file, and
|
||||
// is the value of a size setting only once it is filled in.
|
||||
err = checkFilledConfig(sc.Data())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cfg := &Config{
|
||||
// Set defaults
|
||||
BlobSizeLimit: defaultBlobSizeLimit,
|
||||
@@ -361,7 +396,11 @@ func Parse(data []byte) (*Config, error) {
|
||||
// never the value. An empty list is accepted, because only snapshot
|
||||
// create needs a recipient and it checks for one itself
|
||||
// - At least one snapshot must be configured with at least one path
|
||||
// - Every exclude pattern, global or in a snapshot, must compile; errors
|
||||
// name the setting and quote the pattern
|
||||
// - Storage must be configured (either storage_url or s3.* fields)
|
||||
// - The index path must be absolute: a relative one would resolve against
|
||||
// the working directory, so each directory would get its own index
|
||||
// - Chunk size must be at least 1MB
|
||||
// - Blob size limit must be at least the largest chunk the chunker can emit
|
||||
// (chunk_size times chunker.ChunkSizeSpread), so a single-chunk blob never
|
||||
@@ -379,22 +418,26 @@ func (c *Config) Validate() error {
|
||||
}
|
||||
}
|
||||
|
||||
if len(c.Snapshots) == 0 {
|
||||
return errNoSnapshots
|
||||
err := c.validateSnapshots()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for name, snap := range c.Snapshots {
|
||||
if len(snap.Paths) == 0 {
|
||||
return fmt.Errorf("%w: %q", errSnapshotNoPaths, name)
|
||||
}
|
||||
err = c.validateExcludes()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Validate storage configuration
|
||||
err := c.validateStorage()
|
||||
err = c.validateStorage()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !filepath.IsAbs(c.IndexPath) {
|
||||
return fmt.Errorf("%w: %q", errIndexPathNotAbsolute, c.IndexPath)
|
||||
}
|
||||
|
||||
if c.ChunkSize.Int64() < minChunkSize {
|
||||
return errChunkSizeTooSmall
|
||||
}
|
||||
@@ -425,6 +468,22 @@ func (c *Config) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateSnapshots refuses a config with no snapshot or with a snapshot
|
||||
// that has no path.
|
||||
func (c *Config) validateSnapshots() error {
|
||||
if len(c.Snapshots) == 0 {
|
||||
return errNoSnapshots
|
||||
}
|
||||
|
||||
for name, snap := range c.Snapshots {
|
||||
if len(snap.Paths) == 0 {
|
||||
return fmt.Errorf("%w: %q", errSnapshotNoPaths, name)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateAgeRecipient parses one age_recipients entry with the age library
|
||||
// and returns a value-free error on failure. A recipient string can be
|
||||
// sensitive (an operator may paste a secret key by mistake), so neither the
|
||||
@@ -488,11 +547,11 @@ func (c *Config) validateStorage() error {
|
||||
}
|
||||
|
||||
if c.S3.AccessKeyID == "" {
|
||||
return errS3KeyIDRequired
|
||||
return ErrS3KeyIDRequired
|
||||
}
|
||||
|
||||
if c.S3.SecretAccessKey == "" {
|
||||
return errS3SecretRequired
|
||||
return ErrS3SecretRequired
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -519,11 +578,11 @@ func (c *Config) validateStorageURL() error {
|
||||
case strings.HasPrefix(c.StorageURL, "s3://"):
|
||||
// S3 storage needs credentials
|
||||
if c.S3.AccessKeyID == "" {
|
||||
return fmt.Errorf("%w for s3:// URLs", errS3KeyIDRequired)
|
||||
return fmt.Errorf("%w for s3:// URLs", ErrS3KeyIDRequired)
|
||||
}
|
||||
|
||||
if c.S3.SecretAccessKey == "" {
|
||||
return fmt.Errorf("%w for s3:// URLs", errS3SecretRequired)
|
||||
return fmt.Errorf("%w for s3:// URLs", ErrS3SecretRequired)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -90,12 +90,12 @@ func TestConfigLoad(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestExampleConfigIsScrubbedAndLoads checks that the shipped
|
||||
// config.example.yml carries only neutral placeholders (no real credentials,
|
||||
// private addresses, or internal host names) and still parses.
|
||||
// configs/config.example.yml carries only neutral placeholders (no real
|
||||
// credentials, private addresses, or internal host names) and still parses.
|
||||
func TestExampleConfigIsScrubbedAndLoads(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
examplePath := filepath.Join("..", "..", "config.example.yml")
|
||||
examplePath := filepath.Join("..", "..", "configs", "config.example.yml")
|
||||
|
||||
cfg, err := Load(examplePath)
|
||||
if err != nil {
|
||||
@@ -165,6 +165,7 @@ func TestValidateBlobSizeLimit(t *testing.T) {
|
||||
AgeRecipients: []string{testSneakAgePublicKey},
|
||||
Snapshots: map[string]SnapshotConfig{"test": {Paths: []string{"/tmp/src"}}},
|
||||
StorageURL: "file:///tmp/vaultik-test-store",
|
||||
IndexPath: "/tmp/vaultik-test-index.sqlite",
|
||||
ChunkSize: chunkSize,
|
||||
BlobSizeLimit: blobLimit,
|
||||
CompressionLevel: 3,
|
||||
@@ -361,7 +362,7 @@ func TestDocumentedSizeDefaults(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestDocumentedSizeExamples checks the 1MB minimum chunk_size and that
|
||||
// every size the README, config.example.yml and the config init template
|
||||
// every size the README, configs/config.example.yml and the config init template
|
||||
// list as an example is accepted.
|
||||
func TestDocumentedSizeExamples(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -415,7 +416,8 @@ func TestDocumentedSizeExamples(t *testing.T) {
|
||||
|
||||
// TestValidatePruneGracePeriod checks that a negative prune_grace_period
|
||||
// is refused rather than acting like 0s, which deletes unreferenced blobs
|
||||
// at once, and that 0s and the documented examples are accepted.
|
||||
// and directories under metadata/ with no manifest at once, and that 0s
|
||||
// and the documented examples are accepted.
|
||||
func TestValidatePruneGracePeriod(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -509,6 +511,65 @@ func TestLoadRejectsRelativeFileURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadIndexPath checks that a relative index_path fails at load, naming
|
||||
// the value. Accepted, it would resolve against the working directory, so a
|
||||
// backup run from another directory would start from an empty index. A path
|
||||
// starting with ~ is expanded and loads.
|
||||
func TestLoadIndexPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
t.Fatalf("home directory: %v", err)
|
||||
}
|
||||
|
||||
load := func(t *testing.T, indexPath string) (*Config, error) {
|
||||
t.Helper()
|
||||
|
||||
data := "snapshots:\n" +
|
||||
" test:\n" +
|
||||
" paths: [/tmp/vaultik-test-source]\n" +
|
||||
"storage_url: file:///tmp/vaultik-test-storage\n" +
|
||||
"index_path: " + indexPath + "\n"
|
||||
|
||||
path := filepath.Join(t.TempDir(), "config.yml")
|
||||
|
||||
err := os.WriteFile(path, []byte(data), 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
return Load(path)
|
||||
}
|
||||
|
||||
t.Run("relative path is rejected", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := load(t, "relative/index.sqlite")
|
||||
if !errors.Is(err, errIndexPathNotAbsolute) {
|
||||
t.Fatalf("Load() error = %v, want errIndexPathNotAbsolute", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), `"relative/index.sqlite"`) {
|
||||
t.Errorf("error %q does not name the value", err.Error())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("path starting with ~ is expanded", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg, err := load(t, "~/vaultik/index.sqlite")
|
||||
if err != nil {
|
||||
t.Fatalf("Load() unexpected error: %v", err)
|
||||
}
|
||||
|
||||
want := filepath.Join(home, "vaultik", "index.sqlite")
|
||||
if cfg.IndexPath != want {
|
||||
t.Errorf("IndexPath = %q, want %q", cfg.IndexPath, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -521,6 +582,7 @@ func TestValidateAgeRecipients(t *testing.T) {
|
||||
AgeRecipients: recipients,
|
||||
Snapshots: map[string]SnapshotConfig{"test": {Paths: []string{"/tmp/src"}}},
|
||||
StorageURL: "file:///tmp/vaultik-test-store",
|
||||
IndexPath: "/tmp/vaultik-test-index.sqlite",
|
||||
ChunkSize: Size(10 * 1024 * 1024),
|
||||
BlobSizeLimit: Size(10 * 1024 * 1024 * 1024),
|
||||
CompressionLevel: 3,
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"sneak.berlin/go/vaultik/internal/exclude"
|
||||
)
|
||||
|
||||
// validateExcludes refuses an exclude pattern, global or in a snapshot,
|
||||
// that does not compile, naming the setting and quoting the pattern.
|
||||
func (c *Config) validateExcludes() error {
|
||||
err := validateExcludeList("exclude", c.Exclude)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, name := range c.SnapshotNames() {
|
||||
err = validateExcludeList("snapshots."+name+".exclude",
|
||||
c.Snapshots[name].Exclude)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateExcludeList refuses the first pattern in patterns that does not
|
||||
// compile. setting is the name of the list, as in "exclude".
|
||||
func validateExcludeList(setting string, patterns []string) error {
|
||||
for i, pattern := range patterns {
|
||||
_, _, err := exclude.Compile(pattern)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s[%d]: %q %w", setting, i, pattern, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package config_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/vaultik/internal/config"
|
||||
)
|
||||
|
||||
// TestLoadRefusesBadExcludePattern checks that an exclude pattern that does
|
||||
// not compile, global or in a snapshot, fails at load with the setting and
|
||||
// the pattern named, rather than the backup storing what it meant to leave
|
||||
// out.
|
||||
func TestLoadRefusesBadExcludePattern(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const storage = "storage_url: file:///tmp/vaultik-test-storage\n"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
config string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "global",
|
||||
config: "exclude: ['*.tmp', '.cache', 'build[']\n" +
|
||||
"snapshots:\n" +
|
||||
" home:\n" +
|
||||
" paths: [/tmp/vaultik-test-source]\n" + storage,
|
||||
wantErr: `exclude[2]: "build[" is not a valid exclude pattern`,
|
||||
},
|
||||
{
|
||||
name: "snapshot",
|
||||
config: "snapshots:\n" +
|
||||
" home:\n" +
|
||||
" paths: [/tmp/vaultik-test-source]\n" +
|
||||
" exclude: ['/a/**/x[']\n" + storage,
|
||||
wantErr: `snapshots.home.exclude[0]: "/a/**/x[" is not a valid exclude pattern`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "config.yml")
|
||||
|
||||
err := os.WriteFile(path, []byte(tt.config), 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
_, err = config.Load(path)
|
||||
if err == nil {
|
||||
t.Fatalf("Load() returned nil, want an error containing %q", tt.wantErr)
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("Load() error = %q, want it to contain %q", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// yamlMergeTag is the tag YAML gives a merge key (<<), which adds the keys
|
||||
// of the map it names to the map it sits in.
|
||||
const yamlMergeTag = "!!merge"
|
||||
|
||||
// EnvSection is the top-level key of a map of environment variables that
|
||||
// smartconfig sets while it loads the config. It is not a vaultik setting,
|
||||
// so its keys are not checked.
|
||||
const EnvSection = "env"
|
||||
|
||||
var (
|
||||
errUnknownKey = errors.New("unknown config key")
|
||||
errAnchorContainsItself = errors.New("anchor contains itself")
|
||||
)
|
||||
|
||||
// nodeAndType is a node of a parsed config and the setting type it is
|
||||
// checked against.
|
||||
type nodeAndType struct {
|
||||
node *yaml.Node
|
||||
typ reflect.Type
|
||||
}
|
||||
|
||||
// SettingType returns the type of the setting that the dotted key names in
|
||||
// Config, following the fields' yaml tags, as for s3.bucket or
|
||||
// snapshots.home.exclude.0. A snapshot name or a list index may be any
|
||||
// word. It returns an unknown key error when no setting has that key.
|
||||
func SettingType(keys []string) (reflect.Type, error) {
|
||||
typ := reflect.TypeFor[Config]()
|
||||
|
||||
for _, key := range keys {
|
||||
switch {
|
||||
case typ.Kind() == reflect.Map || typ.Kind() == reflect.Slice:
|
||||
typ = typ.Elem()
|
||||
case typ.Kind() == reflect.Struct:
|
||||
field, ok := yamlField(typ, key)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w %q", errUnknownKey, strings.Join(keys, "."))
|
||||
}
|
||||
|
||||
typ = field.Type
|
||||
default:
|
||||
return nil, fmt.Errorf("%w %q", errUnknownKey, strings.Join(keys, "."))
|
||||
}
|
||||
}
|
||||
|
||||
return typ, nil
|
||||
}
|
||||
|
||||
// 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 && name != "-" {
|
||||
return field, true
|
||||
}
|
||||
}
|
||||
|
||||
return reflect.StructField{}, false
|
||||
}
|
||||
|
||||
// checkKeys returns an error naming the first key in node, a parsed config
|
||||
// or part of one at the dotted path, that no setting in typ has. The error
|
||||
// gives the key's line in the file, when it has one. A value of the wrong
|
||||
// type is left for the YAML decoder to report. following holds the aliases
|
||||
// the walk is inside. Meeting one of them again means its anchor contains
|
||||
// itself, which is an error. checked holds each anchored node the walk has
|
||||
// checked completely, with the type it was checked against. An alias to
|
||||
// that node is not followed again for that type.
|
||||
func checkKeys(node *yaml.Node, typ reflect.Type, path string,
|
||||
following map[*yaml.Node]bool, checked map[nodeAndType]bool,
|
||||
) error {
|
||||
switch node.Kind {
|
||||
case yaml.DocumentNode:
|
||||
for _, child := range node.Content {
|
||||
err := checkKeys(child, typ, path, following, checked)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case yaml.AliasNode:
|
||||
if following[node] {
|
||||
return fmt.Errorf("%w: %q on line %d",
|
||||
errAnchorContainsItself, node.Value, node.Line)
|
||||
}
|
||||
|
||||
target := nodeAndType{node.Alias, typ}
|
||||
if checked[target] {
|
||||
return nil
|
||||
}
|
||||
|
||||
following[node] = true
|
||||
defer delete(following, node)
|
||||
|
||||
err := checkKeys(node.Alias, typ, path, following, checked)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
checked[target] = true
|
||||
case yaml.SequenceNode:
|
||||
if typ.Kind() != reflect.Slice {
|
||||
return nil
|
||||
}
|
||||
|
||||
for i, child := range node.Content {
|
||||
err := checkKeys(child, typ.Elem(), joinKey(path, strconv.Itoa(i)),
|
||||
following, checked)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case yaml.MappingNode:
|
||||
return checkMappingKeys(node, typ, path, following, checked)
|
||||
case yaml.ScalarNode:
|
||||
// A scalar has no keys.
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkFilledConfig checks data, the config after smartconfig has filled in
|
||||
// its ${...} references: its keys with checkKeys, then its size settings
|
||||
// with checkSizes. A key that a reference brings in is not in the file, and
|
||||
// the copy that Encode makes has no line numbers.
|
||||
func checkFilledConfig(data map[string]any) error {
|
||||
var filled yaml.Node
|
||||
|
||||
err := filled.Encode(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal config data: %w", err)
|
||||
}
|
||||
|
||||
err = checkKeys(&filled, reflect.TypeFor[Config](), "",
|
||||
map[*yaml.Node]bool{}, map[nodeAndType]bool{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return checkSizes(&filled, nil)
|
||||
}
|
||||
|
||||
// checkMappingKeys is checkKeys for a map.
|
||||
func checkMappingKeys(node *yaml.Node, typ reflect.Type, path string,
|
||||
following map[*yaml.Node]bool, checked map[nodeAndType]bool,
|
||||
) error {
|
||||
for i := 0; i+1 < len(node.Content); i += 2 {
|
||||
key, value := node.Content[i], node.Content[i+1]
|
||||
|
||||
if key.Tag == yamlMergeTag {
|
||||
// The value is one map or a list of maps.
|
||||
merged := []*yaml.Node{value}
|
||||
if value.Kind == yaml.SequenceNode {
|
||||
merged = value.Content
|
||||
}
|
||||
|
||||
for _, m := range merged {
|
||||
err := checkKeys(m, typ, path, following, checked)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
keyPath := joinKey(path, key.Value)
|
||||
if keyPath == EnvSection {
|
||||
continue
|
||||
}
|
||||
|
||||
var valueType reflect.Type
|
||||
|
||||
switch {
|
||||
case typ.Kind() == reflect.Map:
|
||||
valueType = typ.Elem()
|
||||
case typ.Kind() == reflect.Struct:
|
||||
field, ok := yamlField(typ, key.Value)
|
||||
if !ok && key.Line == 0 {
|
||||
// A key that a ${...} reference brought in has no line.
|
||||
return fmt.Errorf("%w %q", errUnknownKey, keyPath)
|
||||
}
|
||||
|
||||
if !ok {
|
||||
return fmt.Errorf("%w %q on line %d",
|
||||
errUnknownKey, keyPath, key.Line)
|
||||
}
|
||||
|
||||
valueType = field.Type
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
err := checkKeys(value, valueType, keyPath, following, checked)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// joinKey appends key to the dotted path.
|
||||
func joinKey(path, key string) string {
|
||||
if path == "" {
|
||||
return key
|
||||
}
|
||||
|
||||
return path + "." + key
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package config //nolint:testpackage // checks the unexported errUnknownKey
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// keysTestConfig loads; each case below adds one line to it.
|
||||
const keysTestConfig = "storage_url: file:///tmp/vaultik-test-storage\n" +
|
||||
"s3:\n" +
|
||||
" region: us-east-1\n" +
|
||||
"snapshots:\n" +
|
||||
" test:\n" +
|
||||
" paths: [/tmp/vaultik-test-source]\n"
|
||||
|
||||
// TestParseRefusesUnknownKey checks that a key no setting has, at any
|
||||
// depth, fails the parse with an error naming the key and its line. A key
|
||||
// that a ${...} reference brings in is not in the file, so its error gives
|
||||
// no line.
|
||||
func TestParseRefusesUnknownKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
snapshotPath := filepath.Join(t.TempDir(), "snapshot.yml")
|
||||
|
||||
err := os.WriteFile(snapshotPath, []byte("other:\n"+
|
||||
" paths: [/tmp/vaultik-test-source]\n"+
|
||||
" exlude: [/tmp/cache]\n"), 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("write snapshot: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
yaml string
|
||||
want string
|
||||
}{
|
||||
{"top level", keysTestConfig + "compression_lvl: 9\n",
|
||||
`unknown config key "compression_lvl" on line 7`},
|
||||
{"in a snapshot", keysTestConfig + " exlude: [/tmp/cache]\n",
|
||||
`unknown config key "snapshots.test.exlude" on line 7`},
|
||||
{"in s3", strings.Replace(keysTestConfig, " region:",
|
||||
" bukcet: b\n region:", 1),
|
||||
`unknown config key "s3.bukcet" on line 3`},
|
||||
{"from a reference",
|
||||
keysTestConfig + " other: ${YAML:" + snapshotPath + ":other}\n",
|
||||
`unknown config key "snapshots.other.exlude"`},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := Parse([]byte(tt.yaml))
|
||||
if !errors.Is(err, errUnknownKey) {
|
||||
t.Fatalf("Parse() error = %v, want errUnknownKey", err)
|
||||
}
|
||||
|
||||
if err.Error() != tt.want {
|
||||
t.Errorf("Parse() error = %q, want %q", err.Error(), tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseRefusesAnchorThatContainsItself checks that a YAML merge key (<<)
|
||||
// naming the anchor of the map it sits in fails the parse with an error.
|
||||
func TestParseRefusesAnchorThatContainsItself(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
data := strings.Replace(keysTestConfig, " test:", " test: &test", 1) +
|
||||
" <<: *test\n"
|
||||
|
||||
_, err := Parse([]byte(data))
|
||||
if !errors.Is(err, errAnchorContainsItself) {
|
||||
t.Fatalf("Parse() error = %v, want errAnchorContainsItself", err)
|
||||
}
|
||||
|
||||
want := `anchor contains itself: "test" on line 7`
|
||||
if err.Error() != want {
|
||||
t.Errorf("Parse() error = %q, want %q", err.Error(), want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseRefusesExcessiveAliasing checks that Parse returns an error for
|
||||
// a file whose merge keys (<<) fan out. Each of eight snapshots merges the
|
||||
// one before it ten times. A key check that followed every alias would walk
|
||||
// the first snapshot over 10^8 times and run past the package's test
|
||||
// timeout.
|
||||
func TestParseRefusesExcessiveAliasing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const snapshots, merges = 8, 10
|
||||
|
||||
var data strings.Builder
|
||||
|
||||
data.WriteString(strings.Replace(keysTestConfig, " test:", " test: &s0", 1))
|
||||
|
||||
for i := 1; i <= snapshots; i++ {
|
||||
aliases := slices.Repeat([]string{"*s" + strconv.Itoa(i-1)}, merges)
|
||||
fmt.Fprintf(&data, " s%d: &s%d\n <<: [%s]\n",
|
||||
i, i, strings.Join(aliases, ", "))
|
||||
}
|
||||
|
||||
_, err := Parse([]byte(data.String()))
|
||||
if err == nil {
|
||||
t.Fatal("Parse() returned no error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseAcceptsKnownKeys checks that a config with a ${...} reference
|
||||
// and a YAML merge key (<<) still parses.
|
||||
func TestParseAcceptsKnownKeys(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
levelPath := filepath.Join(t.TempDir(), "level")
|
||||
|
||||
err := os.WriteFile(levelPath, []byte("9"), 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("write level: %v", err)
|
||||
}
|
||||
|
||||
data := strings.Replace(keysTestConfig, " test:", " test: &test", 1) +
|
||||
" other:\n" +
|
||||
" <<: *test\n" +
|
||||
" exclude: [/tmp/vaultik-test-source/cache]\n" +
|
||||
"compression_level: ${FILE:" + levelPath + "}\n"
|
||||
|
||||
cfg, err := Parse([]byte(data))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if cfg.CompressionLevel != 9 {
|
||||
t.Errorf("compression_level = %d, want 9", cfg.CompressionLevel)
|
||||
}
|
||||
|
||||
other := cfg.Snapshots["other"]
|
||||
if len(other.Paths) != 1 || len(other.Exclude) != 1 {
|
||||
t.Errorf("snapshot other = %+v, want the paths of test and one exclude",
|
||||
other)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseAcceptsEnvSection checks that a config with a top-level env
|
||||
// section still parses. smartconfig sets the environment variables it
|
||||
// lists, so its keys are not settings.
|
||||
func TestParseAcceptsEnvSection(t *testing.T) {
|
||||
const name = "VAULTIK_TEST_ENV_SECTION"
|
||||
|
||||
// Parse sets the variable; t.Setenv restores it after the test.
|
||||
t.Setenv(name, "")
|
||||
|
||||
_, err := Parse([]byte(keysTestConfig + "env:\n " + name + ": set\n"))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
+77
-11
@@ -4,13 +4,20 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/dustin/go-humanize"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var (
|
||||
errSizeType = errors.New("size must be a number or string")
|
||||
errSizeTooLarge = errors.New("size exceeds maximum supported value")
|
||||
errNotASize = errors.New(
|
||||
"is not a size; use a number of bytes or a unit such as 10MB")
|
||||
)
|
||||
|
||||
// Size represents a byte size that can be specified in configuration files.
|
||||
@@ -32,6 +39,15 @@ func (s *Size) UnmarshalYAML(unmarshal func(any) error) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// A number too large for an int64 decodes as a float64. Its text can be
|
||||
// 1e+20, which is not a size string.
|
||||
var floatVal float64
|
||||
|
||||
err = unmarshal(&floatVal)
|
||||
if err == nil && floatVal > math.MaxInt64 {
|
||||
return errSizeTooLarge
|
||||
}
|
||||
|
||||
// Try to unmarshal as string
|
||||
var strVal string
|
||||
|
||||
@@ -40,17 +56,66 @@ func (s *Size) UnmarshalYAML(unmarshal func(any) error) error {
|
||||
return errSizeType
|
||||
}
|
||||
|
||||
// Parse the string using go-humanize
|
||||
bytes, err := humanize.ParseBytes(strVal)
|
||||
size, err := ParseSize(strVal)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid size format: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if bytes > math.MaxInt64 {
|
||||
return fmt.Errorf("%w: %s", errSizeTooLarge, strVal)
|
||||
*s = size
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkSizes returns an error naming the first size setting in node whose
|
||||
// value is not a size. node is the config, or the part of it at the dotted
|
||||
// keys, after smartconfig has filled in its ${...} references.
|
||||
func checkSizes(node *yaml.Node, keys []string) error {
|
||||
typ, err := SettingType(keys)
|
||||
if err != nil {
|
||||
// Unknown keys are refused before this check, so keys is in one of
|
||||
// two places. The env section holds no settings. A value of the
|
||||
// wrong shape, such as a map given for hostname or inside exclude,
|
||||
// is reported later by the YAML decoder.
|
||||
return nil //nolint:nilerr // not an error here
|
||||
}
|
||||
|
||||
*s = Size(bytes)
|
||||
if typ == reflect.TypeFor[Size]() {
|
||||
var size Size
|
||||
|
||||
err = node.Decode(&size)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", strings.Join(keys, "."), err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
switch node.Kind {
|
||||
case yaml.DocumentNode:
|
||||
for _, child := range node.Content {
|
||||
err = checkSizes(child, keys)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case yaml.MappingNode:
|
||||
for i := 0; i+1 < len(node.Content); i += 2 {
|
||||
err = checkSizes(node.Content[i+1],
|
||||
slices.Concat(keys, []string{node.Content[i].Value}))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case yaml.SequenceNode:
|
||||
for i, child := range node.Content {
|
||||
err = checkSizes(child, slices.Concat(keys, []string{strconv.Itoa(i)}))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case yaml.ScalarNode, yaml.AliasNode:
|
||||
// A scalar holds no settings, and the filled config has no aliases.
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -70,16 +135,17 @@ func (s Size) String() string {
|
||||
return humanize.Bytes(uint64(s))
|
||||
}
|
||||
|
||||
// ParseSize parses a size string into a Size value
|
||||
// ParseSize parses a size string, such as "10MB", into a Size value. An
|
||||
// error quotes s.
|
||||
func ParseSize(s string) (Size, error) {
|
||||
bytes, err := humanize.ParseBytes(s)
|
||||
bytes, err := humanize.ParseBigBytes(s)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid size format: %w", err)
|
||||
return 0, fmt.Errorf("%q %w", s, errNotASize)
|
||||
}
|
||||
|
||||
if bytes > math.MaxInt64 {
|
||||
if !bytes.IsInt64() {
|
||||
return 0, fmt.Errorf("%w: %s", errSizeTooLarge, s)
|
||||
}
|
||||
|
||||
return Size(bytes), nil
|
||||
return Size(bytes.Int64()), nil
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package config //nolint:testpackage // checks errNotASize and errSizeTooLarge
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestParseRefusesValueThatIsNotASize checks that a size setting whose
|
||||
// value is not a size fails the parse with an error naming the setting and
|
||||
// quoting the value, also when a ${...} reference fills the value in.
|
||||
func TestParseRefusesValueThatIsNotASize(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sizePath := filepath.Join(t.TempDir(), "size")
|
||||
|
||||
err := os.WriteFile(sizePath, []byte("lots"), 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("write size: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
yaml string
|
||||
want string
|
||||
}{
|
||||
{"chunk_size", keysTestConfig + "chunk_size: banana\n",
|
||||
`chunk_size: "banana" is not a size; ` +
|
||||
"use a number of bytes or a unit such as 10MB"},
|
||||
{"blob_size_limit", keysTestConfig + "blob_size_limit: 10XB\n",
|
||||
`blob_size_limit: "10XB" is not a size; ` +
|
||||
"use a number of bytes or a unit such as 10MB"},
|
||||
{"s3.part_size", strings.Replace(keysTestConfig, " region:",
|
||||
" part_size: big\n region:", 1),
|
||||
`s3.part_size: "big" is not a size; ` +
|
||||
"use a number of bytes or a unit such as 10MB"},
|
||||
{"from a reference",
|
||||
keysTestConfig + "chunk_size: ${FILE:" + sizePath + "}\n",
|
||||
`chunk_size: "lots" is not a size; ` +
|
||||
"use a number of bytes or a unit such as 10MB"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := Parse([]byte(tt.yaml))
|
||||
if !errors.Is(err, errNotASize) {
|
||||
t.Fatalf("Parse() error = %v, want errNotASize", err)
|
||||
}
|
||||
|
||||
if err.Error() != tt.want {
|
||||
t.Errorf("Parse() error = %q, want %q", err.Error(), tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseRefusesSizeTooLarge checks that a size setting whose value is
|
||||
// too large for a size fails the parse with an error naming the setting.
|
||||
// YAML reads the second value as a float.
|
||||
func TestParseRefusesSizeTooLarge(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
value string
|
||||
want string
|
||||
}{
|
||||
{"100EB", "chunk_size: size exceeds maximum supported value: 100EB"},
|
||||
{"99999999999999999999",
|
||||
"chunk_size: size exceeds maximum supported value"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.value, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := Parse([]byte(keysTestConfig + "chunk_size: " + tt.value + "\n"))
|
||||
if !errors.Is(err, errSizeTooLarge) {
|
||||
t.Fatalf("Parse() error = %v, want errSizeTooLarge", err)
|
||||
}
|
||||
|
||||
if err.Error() != tt.want {
|
||||
t.Errorf("Parse() error = %q, want %q", err.Error(), tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseAcceptsSizes checks that size settings given as a number of
|
||||
// bytes, as a unit, and through a ${...} reference still parse.
|
||||
func TestParseAcceptsSizes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sizePath := filepath.Join(t.TempDir(), "size")
|
||||
|
||||
err := os.WriteFile(sizePath, []byte("20MB"), 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("write size: %v", err)
|
||||
}
|
||||
|
||||
data := strings.Replace(keysTestConfig, " region:",
|
||||
" part_size: 6000000\n region:", 1) +
|
||||
"chunk_size: ${FILE:" + sizePath + "}\n" +
|
||||
"blob_size_limit: 1GB\n"
|
||||
|
||||
cfg, err := Parse([]byte(data))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if cfg.ChunkSize != 20_000_000 || cfg.BlobSizeLimit != 1_000_000_000 ||
|
||||
cfg.S3.PartSize != 6_000_000 {
|
||||
t.Errorf("chunk_size, blob_size_limit, s3.part_size = %d, %d, %d, "+
|
||||
"want 20000000, 1000000000, 6000000",
|
||||
cfg.ChunkSize, cfg.BlobSizeLimit, cfg.S3.PartSize)
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,17 @@ const indexDirPerm = 0o700
|
||||
// backed-up path and chunk hash and must stay private.
|
||||
const indexFilePerm = 0o600
|
||||
|
||||
// IndexFiles returns the path of the index at indexPath and the paths of
|
||||
// the side files SQLite keeps beside it while it writes.
|
||||
func IndexFiles(indexPath string) []string {
|
||||
return []string{
|
||||
indexPath,
|
||||
indexPath + "-wal",
|
||||
indexPath + "-shm",
|
||||
indexPath + "-journal",
|
||||
}
|
||||
}
|
||||
|
||||
// ensureIndexFileMode makes the index file owner-only before the SQLite
|
||||
// driver opens it: it creates the file 0600 if absent, or chmods an existing
|
||||
// one to 0600. Doing this first matters because SQLite creates its -wal and
|
||||
|
||||
@@ -123,6 +123,16 @@ CREATE TABLE IF NOT EXISTS snapshot_blobs (
|
||||
-- Index for efficient blob lookups (used in orphan detection)
|
||||
CREATE INDEX IF NOT EXISTS idx_snapshot_blobs_blob_id ON snapshot_blobs(blob_id);
|
||||
|
||||
-- Snapshot mount points table: the directories at or below a configured
|
||||
-- path that the snapshot's backup found on a different filesystem from
|
||||
-- their parent directory, as a mount point is, and found an entry in
|
||||
CREATE TABLE IF NOT EXISTS snapshot_mount_points (
|
||||
snapshot_id TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
PRIMARY KEY (snapshot_id, path),
|
||||
FOREIGN KEY (snapshot_id) REFERENCES snapshots(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Uploads table: tracks blob upload metrics
|
||||
CREATE TABLE IF NOT EXISTS uploads (
|
||||
blob_hash TEXT PRIMARY KEY,
|
||||
|
||||
@@ -24,7 +24,8 @@ var (
|
||||
)
|
||||
|
||||
// SnapshotRepository provides access to the snapshots table and its
|
||||
// snapshot_files / snapshot_blobs association tables.
|
||||
// snapshot_files / snapshot_blobs association tables, and to the mount
|
||||
// points each snapshot's backup found (snapshot_mount_points).
|
||||
type SnapshotRepository struct {
|
||||
db *DB
|
||||
}
|
||||
@@ -494,33 +495,63 @@ func (r *SnapshotRepository) GetBlobHashes(
|
||||
ctx context.Context, snapshotID string,
|
||||
) ([]string, error) {
|
||||
query := `
|
||||
SELECT sb.blob_hash
|
||||
SELECT sb.blob_hash
|
||||
FROM snapshot_blobs sb
|
||||
WHERE sb.snapshot_id = ?
|
||||
ORDER BY sb.blob_hash
|
||||
`
|
||||
|
||||
rows, err := r.db.conn.QueryContext(ctx, query, snapshotID)
|
||||
blobs, err := r.queryStrings(ctx, query, snapshotID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("querying blob hashes: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = rows.Close() }()
|
||||
return blobs, nil
|
||||
}
|
||||
|
||||
var blobs []string
|
||||
// AddMountPoint records dir as a directory that the backup of a snapshot
|
||||
// found on a different filesystem from its parent directory, as a mount
|
||||
// point is, using tx when non-nil.
|
||||
func (r *SnapshotRepository) AddMountPoint(
|
||||
ctx context.Context, tx *sql.Tx, snapshotID string, dir string,
|
||||
) error {
|
||||
query := `
|
||||
INSERT OR IGNORE INTO snapshot_mount_points (snapshot_id, path)
|
||||
VALUES (?, ?)
|
||||
`
|
||||
|
||||
for rows.Next() {
|
||||
var blobHash string
|
||||
|
||||
err := rows.Scan(&blobHash)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scanning blob hash: %w", err)
|
||||
}
|
||||
|
||||
blobs = append(blobs, blobHash)
|
||||
var err error
|
||||
if tx != nil {
|
||||
_, err = tx.ExecContext(ctx, query, snapshotID, dir)
|
||||
} else {
|
||||
_, err = r.db.ExecWithLog(ctx, query, snapshotID, dir)
|
||||
}
|
||||
|
||||
return blobs, rows.Err()
|
||||
if err != nil {
|
||||
return fmt.Errorf("adding mount point to snapshot: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetMountPoints returns the directories AddMountPoint recorded for a
|
||||
// snapshot, sorted.
|
||||
func (r *SnapshotRepository) GetMountPoints(
|
||||
ctx context.Context, snapshotID string,
|
||||
) ([]string, error) {
|
||||
query := `
|
||||
SELECT path
|
||||
FROM snapshot_mount_points
|
||||
WHERE snapshot_id = ?
|
||||
ORDER BY path
|
||||
`
|
||||
|
||||
dirs, err := r.queryStrings(ctx, query, snapshotID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("querying mount points: %w", err)
|
||||
}
|
||||
|
||||
return dirs, nil
|
||||
}
|
||||
|
||||
// GetSnapshotTotalCompressedSize returns the total compressed size of all
|
||||
@@ -709,6 +740,34 @@ func (r *SnapshotRepository) DeleteSnapshotUploads(
|
||||
return nil
|
||||
}
|
||||
|
||||
// queryStrings runs query, which selects one text column, and returns
|
||||
// its values in the order of the rows.
|
||||
func (r *SnapshotRepository) queryStrings(
|
||||
ctx context.Context, query string, args ...any,
|
||||
) ([]string, error) {
|
||||
rows, err := r.db.conn.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var values []string
|
||||
|
||||
for rows.Next() {
|
||||
var value string
|
||||
|
||||
err := rows.Scan(&value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
values = append(values, value)
|
||||
}
|
||||
|
||||
return values, rows.Err()
|
||||
}
|
||||
|
||||
// extendedCompressionRatio computes the compression ratio for a snapshot
|
||||
// from its stored blob_size and the given uncompressed size. Returns 1.0
|
||||
// when the uncompressed size is zero.
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -422,3 +423,52 @@ func TestSnapshotRepositoryDuplicate(t *testing.T) {
|
||||
t.Error("expected error for duplicate snapshot")
|
||||
}
|
||||
}
|
||||
|
||||
// Deleting a snapshot from the local index deletes the mount points its
|
||||
// backup recorded, and no other snapshot's. See
|
||||
// https://git.eeqj.de/sneak/vaultik/issues/395.
|
||||
func TestSnapshotRepositoryDeleteRemovesMountPoints(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
repo := database.NewSnapshotRepository(db)
|
||||
|
||||
for _, id := range []types.SnapshotID{"kept", "deleted"} {
|
||||
err := repo.Create(ctx, nil, &database.Snapshot{
|
||||
ID: id,
|
||||
Hostname: testHostname,
|
||||
VaultikVersion: testVersion,
|
||||
StartedAt: time.Now().Truncate(time.Second),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create snapshot: %v", err)
|
||||
}
|
||||
|
||||
err = repo.AddMountPoint(ctx, nil, id.String(), "/data/nas")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to add mount point: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
err := repo.Delete(ctx, "deleted")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to delete snapshot: %v", err)
|
||||
}
|
||||
|
||||
for id, want := range map[string][]string{
|
||||
"kept": {"/data/nas"},
|
||||
"deleted": nil,
|
||||
} {
|
||||
got, err := repo.GetMountPoints(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read mount points: %v", err)
|
||||
}
|
||||
|
||||
if !slices.Equal(got, want) {
|
||||
t.Errorf("mount points of %s: got %v, want %v", id, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// Package exclude compiles exclude patterns into globs.
|
||||
package exclude
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gobwas/glob"
|
||||
)
|
||||
|
||||
var errBadExcludePattern = errors.New("is not a valid exclude pattern")
|
||||
|
||||
// Compile compiles one exclude pattern into the globs that match it.
|
||||
// anchored reports whether the pattern matches only from the root of the
|
||||
// source directory. An empty pattern yields no globs. Config.Validate and
|
||||
// the scanner both compile patterns with it, so a pattern that loads is one
|
||||
// the scanner can use.
|
||||
func Compile(pattern string) ([]glob.Glob, bool, error) {
|
||||
if pattern == "" {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
// A leading "/" anchors the pattern to the source directory.
|
||||
trimmed, anchored := strings.CutPrefix(pattern, "/")
|
||||
|
||||
// A trailing "/" marks a directory and is not matched.
|
||||
trimmed = strings.TrimSuffix(trimmed, "/")
|
||||
|
||||
// A leading "**/" demands a "/" before the name and so misses an
|
||||
// entry directly under the source path. It means "at every depth",
|
||||
// which is what an unanchored pattern already does, so drop the
|
||||
// prefix and match unanchored: as in gitignore, "**/x" and
|
||||
// "/**/x" then also match a top-level "x".
|
||||
if rest, found := strings.CutPrefix(trimmed, "**/"); found {
|
||||
trimmed = rest
|
||||
anchored = false
|
||||
}
|
||||
|
||||
// Likewise "/**/" inside a pattern demands two slashes, so
|
||||
// "a/**/*.log" misses "a/x.log". As in gitignore it stands for
|
||||
// zero or more directories, so also compile the pattern with it
|
||||
// made a single "/". The glob library's {a,b} is no help: with a
|
||||
// "**" inside it in mid-pattern, it misses paths it should match.
|
||||
var globs []glob.Glob
|
||||
|
||||
for _, variant := range doubleStarVariants(trimmed) {
|
||||
g, err := glob.Compile(variant, '/')
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("%w: %w", errBadExcludePattern, err)
|
||||
}
|
||||
|
||||
globs = append(globs, g)
|
||||
}
|
||||
|
||||
return globs, anchored, nil
|
||||
}
|
||||
|
||||
// doubleStarVariants returns pattern once for every way of keeping each
|
||||
// "/**/" in it or making it a single "/".
|
||||
func doubleStarVariants(pattern string) []string {
|
||||
const doubleStar = "/**/"
|
||||
|
||||
before, after, found := strings.Cut(pattern, doubleStar)
|
||||
if !found {
|
||||
return []string{pattern}
|
||||
}
|
||||
|
||||
var variants []string
|
||||
|
||||
for _, rest := range doubleStarVariants(after) {
|
||||
variants = append(variants, before+doubleStar+rest, before+"/"+rest)
|
||||
}
|
||||
|
||||
return variants
|
||||
}
|
||||
@@ -1,19 +1,24 @@
|
||||
// Package globals holds application-wide metadata (name, version,
|
||||
// commit) that is populated at build time via linker flags.
|
||||
// commit) that is populated at build time via linker flags, or from the
|
||||
// build info Go records in the binary when the linker flags set none.
|
||||
package globals
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/mod/module"
|
||||
)
|
||||
|
||||
// Appname is the application name, populated from main().
|
||||
var Appname = "vaultik" //nolint:gochecknoglobals // set via -ldflags at build time
|
||||
|
||||
// DevVersion is the version a binary reports when it was built without
|
||||
// git metadata: script/version emits it outside a git checkout, and an
|
||||
// unstamped `go build` keeps it. goreleaser's snapshot template stamps
|
||||
// git metadata: script/version emits it outside a git checkout, and a
|
||||
// binary keeps it when neither its linker flags nor its Go build info
|
||||
// name a version. goreleaser's snapshot template stamps
|
||||
// it followed by "-" and the commit it was built from. It is
|
||||
// deliberately not a number.
|
||||
const DevVersion = "dev"
|
||||
@@ -32,6 +37,54 @@ var Commit = Unknown //nolint:gochecknoglobals // set via -ldflags at build time
|
||||
// CommitDate is the ISO-8601 date of the commit, populated from main().
|
||||
var CommitDate = Unknown //nolint:gochecknoglobals // set via -ldflags at build time
|
||||
|
||||
// develVersion is the main module version Go records when it cannot
|
||||
// tell which version it built.
|
||||
const develVersion = "(devel)"
|
||||
|
||||
// FillUnsetFromBuildInfo applies FillUnset to Version, Commit and
|
||||
// CommitDate, with the build info of the running binary.
|
||||
func FillUnsetFromBuildInfo() {
|
||||
info, ok := debug.ReadBuildInfo()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
Version, Commit, CommitDate = FillUnset(
|
||||
info, Version, Commit, CommitDate)
|
||||
}
|
||||
|
||||
// FillUnset returns version, commit and commitDate. Each one that
|
||||
// still holds its default ("dev" or empty for the version, "unknown"
|
||||
// for the others) is replaced by the value Go recorded in info. A
|
||||
// `go install` of a tag sets no linker flags, but Go records the tag
|
||||
// as the main module's version. Go records the commit and its time
|
||||
// only when it builds from a git checkout. The time is cut to a date,
|
||||
// as the Makefile stamps it.
|
||||
func FillUnset(
|
||||
info *debug.BuildInfo, version, commit, commitDate string,
|
||||
) (string, string, string) {
|
||||
if (version == "" || version == DevVersion) &&
|
||||
info.Main.Version != "" && info.Main.Version != develVersion {
|
||||
version = info.Main.Version
|
||||
}
|
||||
|
||||
for _, s := range info.Settings {
|
||||
switch s.Key {
|
||||
case "vcs.revision":
|
||||
if commit == Unknown {
|
||||
commit = s.Value
|
||||
}
|
||||
case "vcs.time":
|
||||
t, err := time.Parse(time.RFC3339, s.Value)
|
||||
if commitDate == Unknown && err == nil {
|
||||
commitDate = t.UTC().Format(time.DateOnly)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return version, commit, commitDate
|
||||
}
|
||||
|
||||
// Author identifies the upstream author of vaultik.
|
||||
const Author = "Jeffrey Paul <sneak@sneak.berlin>"
|
||||
|
||||
@@ -68,8 +121,10 @@ func New() (*Globals, error) {
|
||||
// a release. "dev" and goreleaser's snapshot "dev-<sha>" count, and so
|
||||
// does what `git describe --tags --always --dirty` gives a make or
|
||||
// docker build of an untagged commit: the bare short commit, or
|
||||
// tag-N-gHASH on a commit after a tag. Any version ending in "-dirty"
|
||||
// counts, a modified checkout of a tag ("v1.0.0-dirty") included.
|
||||
// tag-N-gHASH on a commit after a tag. A Go pseudo-version counts: Go
|
||||
// records one for a build of an untagged commit without linker flags.
|
||||
// Any version ending in "-dirty" or "+dirty" counts, a modified
|
||||
// checkout of a tag ("v1.0.0-dirty", "v1.0.0+dirty") included.
|
||||
// A plain tag such as "v1.0.0" or "1.0.0" is a release.
|
||||
//
|
||||
// The empty string counts too. Nothing that knows its version reports
|
||||
@@ -82,7 +137,8 @@ func New() (*Globals, error) {
|
||||
func IsDevVersion(v string) bool {
|
||||
if v == "" || v == Unknown || v == DevVersion ||
|
||||
strings.HasPrefix(v, DevVersion+"-") ||
|
||||
strings.HasSuffix(v, "-dirty") {
|
||||
strings.HasSuffix(v, "-dirty") || strings.HasSuffix(v, "+dirty") ||
|
||||
module.IsPseudoVersion(v) {
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package globals_test
|
||||
|
||||
import (
|
||||
"runtime/debug"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/vaultik/internal/globals"
|
||||
@@ -59,12 +60,20 @@ func TestIsDevVersion(t *testing.T) {
|
||||
{"1.0.0-rc.1-12-g877eb2f", true},
|
||||
// A tagged commit with uncommitted changes is not that tag.
|
||||
{"v1.0.0-dirty", true},
|
||||
// What Go records for a build without linker flags: a
|
||||
// pseudo-version for an untagged commit, "+dirty" for a
|
||||
// modified checkout.
|
||||
{"v1.0.0-rc3.0.20261111001226-abcdefabcdef", true},
|
||||
{"v0.0.0-20261111001226-abcdefabcdef", true},
|
||||
{"v1.0.1-0.20261111001226-abcdefabcdef", true},
|
||||
{"v1.0.0+dirty", true},
|
||||
// What a tagged build produces (goreleaser's .Version strips
|
||||
// the leading "v"; script/version keeps it).
|
||||
{"1.0.0", false},
|
||||
{"0.1.0", false},
|
||||
{"1.0.0-rc.1", false},
|
||||
{"v1.0.0", false},
|
||||
{"v1.0.0-rc3", false},
|
||||
// A release must not be mistaken for a dev build just because
|
||||
// the string happens to contain "dev".
|
||||
{"1.0.0-dev", false},
|
||||
@@ -86,10 +95,9 @@ func TestIsDevVersion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultVersionIsDev pins the linker-flag contract: an unstamped
|
||||
// binary (no -ldflags at all, which is what `go build ./...` and `go
|
||||
// install` produce) must report itself as a development build rather
|
||||
// than as some default release number.
|
||||
// TestDefaultVersionIsDev pins the default: a binary whose linker flags
|
||||
// and build info name no version must report itself as a development
|
||||
// build rather than as some default release number.
|
||||
func TestDefaultVersionIsDev(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -98,3 +106,104 @@ func TestDefaultVersionIsDev(t *testing.T) {
|
||||
globals.DevVersion)
|
||||
}
|
||||
}
|
||||
|
||||
// buildInfo returns build info with the given main module version and,
|
||||
// when revision is not empty, the commit settings Go records for a
|
||||
// build from a git checkout.
|
||||
func buildInfo(version, revision string) *debug.BuildInfo {
|
||||
info := &debug.BuildInfo{
|
||||
Main: debug.Module{Path: "sneak.berlin/go/vaultik", Version: version},
|
||||
}
|
||||
|
||||
if revision != "" {
|
||||
info.Settings = []debug.BuildSetting{
|
||||
{Key: "vcs.revision", Value: revision},
|
||||
{Key: "vcs.time", Value: "2026-11-10T23:12:26Z"},
|
||||
}
|
||||
}
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
// TestFillUnset covers a binary from `go install` of a tag, which
|
||||
// carries no linker flags: it must report the tag. Values the linker
|
||||
// flags set are kept.
|
||||
func TestFillUnset(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
tag = "v1.0.0-rc3"
|
||||
rev = "0123456789abcdef0123456789abcdef01234567"
|
||||
)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
info *debug.BuildInfo
|
||||
version, commit, date string
|
||||
wantVersion, wantCommit string
|
||||
wantDate string
|
||||
}{
|
||||
{
|
||||
name: "go install of a tag",
|
||||
info: buildInfo(tag, ""),
|
||||
version: globals.DevVersion,
|
||||
commit: globals.Unknown,
|
||||
date: globals.Unknown,
|
||||
wantVersion: tag,
|
||||
wantCommit: globals.Unknown,
|
||||
wantDate: globals.Unknown,
|
||||
},
|
||||
{
|
||||
name: "empty version",
|
||||
info: buildInfo(tag, ""),
|
||||
version: "",
|
||||
commit: globals.Unknown,
|
||||
date: globals.Unknown,
|
||||
wantVersion: tag,
|
||||
wantCommit: globals.Unknown,
|
||||
wantDate: globals.Unknown,
|
||||
},
|
||||
{
|
||||
name: "go build in a git checkout",
|
||||
info: buildInfo(tag, rev),
|
||||
version: globals.DevVersion,
|
||||
commit: globals.Unknown,
|
||||
date: globals.Unknown,
|
||||
wantVersion: tag,
|
||||
wantCommit: rev,
|
||||
wantDate: "2026-11-10",
|
||||
},
|
||||
{
|
||||
name: "Go could not tell the version",
|
||||
info: buildInfo("(devel)", ""),
|
||||
version: globals.DevVersion,
|
||||
commit: globals.Unknown,
|
||||
date: globals.Unknown,
|
||||
wantVersion: globals.DevVersion,
|
||||
wantCommit: globals.Unknown,
|
||||
wantDate: globals.Unknown,
|
||||
},
|
||||
{
|
||||
name: "linker flags set",
|
||||
info: buildInfo(tag, rev),
|
||||
version: "v1.0.0-rc2-4-gabcdef1",
|
||||
commit: "abcdef1",
|
||||
date: "2026-11-01",
|
||||
wantVersion: "v1.0.0-rc2-4-gabcdef1",
|
||||
wantCommit: "abcdef1",
|
||||
wantDate: "2026-11-01",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
version, commit, date := globals.FillUnset(
|
||||
tc.info, tc.version, tc.commit, tc.date)
|
||||
|
||||
if version != tc.wantVersion || commit != tc.wantCommit ||
|
||||
date != tc.wantDate {
|
||||
t.Errorf("%s: got %q, %q, %q; want %q, %q, %q", tc.name,
|
||||
version, commit, date,
|
||||
tc.wantVersion, tc.wantCommit, tc.wantDate)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/adrg/xdg"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
@@ -29,8 +30,20 @@ const (
|
||||
lockDirPerm = 0o700
|
||||
// pidFilePerm is the mode for the PID file (owner-only).
|
||||
pidFilePerm = 0o600
|
||||
// pidFileName is the name of the PID file in the lock directory.
|
||||
pidFileName = "vaultik.pid"
|
||||
)
|
||||
|
||||
// Dir returns the directory that holds vaultik's PID file.
|
||||
func Dir() string {
|
||||
return filepath.Join(xdg.DataHome, "vaultik")
|
||||
}
|
||||
|
||||
// Path returns the path of the PID file that Acquire uses in lockDir.
|
||||
func Path(lockDir string) string {
|
||||
return filepath.Join(lockDir, pidFileName)
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -42,7 +55,7 @@ func Acquire(lockDir string) (*Lock, error) {
|
||||
return nil, fmt.Errorf("creating lock directory: %w", err)
|
||||
}
|
||||
|
||||
lockPath := filepath.Join(lockDir, "vaultik.pid")
|
||||
lockPath := Path(lockDir)
|
||||
|
||||
// No O_TRUNC: the file may hold the PID of the process that has the
|
||||
// lock, which the error below reports.
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
//nolint:testpackage // exercises the unexported deviceNumberFromStat
|
||||
package snapshot
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// The device number a real backup reads is the same for a directory and
|
||||
// a file in it, and differs between the temp directory and /dev.
|
||||
// Without it no mount point is ever found. See
|
||||
// https://git.eeqj.de/sneak/vaultik/issues/395.
|
||||
func TestDeviceNumberFromStat(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
file := filepath.Join(dir, "file.txt")
|
||||
require.NoError(t, os.WriteFile(file, nil, 0o600))
|
||||
|
||||
deviceOf := func(path string) uint64 {
|
||||
t.Helper()
|
||||
|
||||
info, err := os.Stat(path)
|
||||
require.NoError(t, err)
|
||||
|
||||
device, ok := deviceNumberFromStat(info)
|
||||
require.True(t, ok, "no device number read for %s", path)
|
||||
|
||||
return device
|
||||
}
|
||||
|
||||
assert.Equal(t, deviceOf(dir), deviceOf(file))
|
||||
|
||||
// /dev is a different filesystem from the temp dir on Linux, macOS and in Docker.
|
||||
assert.NotEqual(t, deviceOf(dir), deviceOf("/dev"))
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package snapshot_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/database"
|
||||
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||
)
|
||||
|
||||
// A backup of the directory that holds vaultik's index and PID file
|
||||
// stores neither, with no exclude pattern set, and still stores the
|
||||
// files beside them. See https://git.eeqj.de/sneak/vaultik/issues/421.
|
||||
func TestScanSkipsIndexAndLockFiles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const dataDir = "/home/user/.local/share/vaultik"
|
||||
|
||||
indexAndLockFiles := append(database.IndexFiles(dataDir+"/index.sqlite"),
|
||||
dataDir+"/vaultik.pid")
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
for _, p := range append(indexAndLockFiles, dataDir+"/notes.txt") {
|
||||
require.NoError(t, afero.WriteFile(fs, p, []byte("data"), 0o600))
|
||||
}
|
||||
|
||||
db, err := database.NewTestDB()
|
||||
require.NoError(t, err)
|
||||
|
||||
defer func() { _ = db.Close() }()
|
||||
|
||||
repos := database.NewRepositories(db)
|
||||
|
||||
scanner := snapshot.NewScanner(snapshot.ScannerConfig{
|
||||
FS: fs,
|
||||
ChunkSize: 64 * 1024,
|
||||
Repositories: repos,
|
||||
MaxBlobSize: 1024 * 1024,
|
||||
CompressionLevel: 3,
|
||||
AgeRecipients: []string{
|
||||
"age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p"},
|
||||
IndexAndLockFiles: indexAndLockFiles,
|
||||
})
|
||||
require.NotNil(t, scanner)
|
||||
|
||||
ctx := context.Background()
|
||||
createSnapshotRecord(ctx, t, repos, "test-snapshot")
|
||||
|
||||
result, err := scanner.Scan(ctx, "/home/user", "test-snapshot")
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, 1, result.FilesScanned)
|
||||
|
||||
stored, err := repos.Files.GetByPath(ctx, dataDir+"/notes.txt")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, stored, "a file beside the index is stored")
|
||||
|
||||
for _, p := range indexAndLockFiles {
|
||||
f, err := repos.Files.GetByPath(ctx, p)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, f, "%s is not stored", p)
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
package snapshot
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"go.uber.org/fx"
|
||||
"sneak.berlin/go/vaultik/internal/config"
|
||||
"sneak.berlin/go/vaultik/internal/database"
|
||||
"sneak.berlin/go/vaultik/internal/pidlock"
|
||||
"sneak.berlin/go/vaultik/internal/storage"
|
||||
"sneak.berlin/go/vaultik/internal/ui"
|
||||
)
|
||||
@@ -19,6 +23,9 @@ type ScannerParams struct {
|
||||
// PreviousSnapshotID is the most recent completed snapshot of the
|
||||
// same name, "" for none.
|
||||
PreviousSnapshotID string
|
||||
// DeviceNumber is ScannerConfig.DeviceNumber; nil reads it from the
|
||||
// file's stat.
|
||||
DeviceNumber func(info os.FileInfo) (uint64, bool)
|
||||
}
|
||||
|
||||
// Module exports backup functionality as an fx module.
|
||||
@@ -57,8 +64,52 @@ func provideScannerFactory(
|
||||
EnableProgress: params.EnableProgress,
|
||||
UI: params.UI,
|
||||
Exclude: excludes,
|
||||
IndexAndLockFiles: indexAndLockFiles(cfg.IndexPath, pidlock.Dir()),
|
||||
SkipErrors: params.SkipErrors,
|
||||
PreviousSnapshotID: params.PreviousSnapshotID,
|
||||
DeviceNumber: params.DeviceNumber,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// indexAndLockFiles returns the paths of the index at indexPath, its side
|
||||
// files and the PID file in lockDir. Symlinks in their directories are
|
||||
// resolved, as they are in the snapshot's source paths, so the scan finds
|
||||
// them under the same names. If indexPath is a symlink, the file it points
|
||||
// to and that file's side files are returned too.
|
||||
func indexAndLockFiles(indexPath, lockDir string) []string {
|
||||
paths := append(database.IndexFiles(indexPath), pidlock.Path(lockDir))
|
||||
for i, p := range paths {
|
||||
paths[i] = resolveParentDir(p)
|
||||
}
|
||||
|
||||
// SQLite writes the file a symlinked index points to and keeps the
|
||||
// side files beside that file. The database package creates the index
|
||||
// before the scanner is built, so the link resolves.
|
||||
index := resolveParentDir(indexPath)
|
||||
|
||||
target, err := filepath.EvalSymlinks(index)
|
||||
if err == nil && target != index {
|
||||
paths = append(paths, database.IndexFiles(target)...)
|
||||
}
|
||||
|
||||
return paths
|
||||
}
|
||||
|
||||
// resolveParentDir returns the absolute form of path with the symlinks in
|
||||
// its parent directory resolved. If the parent directory cannot be
|
||||
// resolved, it returns the absolute form unresolved. If path has no
|
||||
// absolute form, it returns path.
|
||||
func resolveParentDir(path string) string {
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return path
|
||||
}
|
||||
|
||||
dir, err := filepath.EvalSymlinks(filepath.Dir(abs))
|
||||
if err != nil {
|
||||
return abs
|
||||
}
|
||||
|
||||
return filepath.Join(dir, filepath.Base(abs))
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
//nolint:testpackage // exercises the unexported indexAndLockFiles helper
|
||||
package snapshot
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// The scan walks source paths with symlinks resolved, so vaultik's own
|
||||
// files are named the same way, or a symlinked home would back them up.
|
||||
func TestIndexAndLockFilesResolvesSymlinkedDirectory(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
realDir, err := filepath.EvalSymlinks(t.TempDir())
|
||||
require.NoError(t, err)
|
||||
|
||||
link := filepath.Join(t.TempDir(), "link")
|
||||
require.NoError(t, os.Symlink(realDir, link))
|
||||
|
||||
paths := indexAndLockFiles(filepath.Join(link, "index.sqlite"), link)
|
||||
|
||||
require.Equal(t, []string{
|
||||
filepath.Join(realDir, "index.sqlite"),
|
||||
filepath.Join(realDir, "index.sqlite-wal"),
|
||||
filepath.Join(realDir, "index.sqlite-shm"),
|
||||
filepath.Join(realDir, "index.sqlite-journal"),
|
||||
filepath.Join(realDir, "vaultik.pid"),
|
||||
}, paths)
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package snapshot_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/database"
|
||||
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||
)
|
||||
|
||||
// A scan records with the snapshot each directory on a different device
|
||||
// from its parent directory that the walk found an entry in, as a mount
|
||||
// point is, the scanned path included. A filesystem that gives no device
|
||||
// numbers, as the in-memory one, records none. See
|
||||
// https://git.eeqj.de/sneak/vaultik/issues/395.
|
||||
func TestScanRecordsMountPoints(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
scannedPath = "/data/source"
|
||||
snapshotID = "mount-points"
|
||||
)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
// onVolume names the directories on the volume's device; every
|
||||
// other directory is on another one. nil leaves the device
|
||||
// numbers to the in-memory filesystem, which has none.
|
||||
onVolume []string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "volume below the scanned path",
|
||||
onVolume: []string{"nas"},
|
||||
want: []string{scannedPath + "/nas"},
|
||||
},
|
||||
{
|
||||
name: "volume at the scanned path",
|
||||
onVolume: []string{"source", "nas"},
|
||||
want: []string{scannedPath},
|
||||
},
|
||||
{name: "no device numbers", want: nil},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
fs := afero.NewMemMapFs()
|
||||
|
||||
for _, path := range []string{
|
||||
scannedPath + "/notes.txt", scannedPath + "/nas/a.txt",
|
||||
} {
|
||||
require.NoError(t, fs.MkdirAll(filepath.Dir(path), 0o755))
|
||||
require.NoError(t, afero.WriteFile(fs, path, []byte(path), 0o644))
|
||||
}
|
||||
|
||||
db, err := database.NewTestDB()
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
repos := database.NewRepositories(db)
|
||||
createTestSnapshotRecord(ctx, t, repos, snapshotID)
|
||||
|
||||
cfg := snapshot.ScannerConfig{
|
||||
FS: fs,
|
||||
ChunkSize: int64(1024 * 16),
|
||||
Repositories: repos,
|
||||
MaxBlobSize: int64(1024 * 1024),
|
||||
CompressionLevel: 3,
|
||||
AgeRecipients: []string{testAgePublicKey},
|
||||
}
|
||||
if tt.onVolume != nil {
|
||||
cfg.DeviceNumber = func(info os.FileInfo) (uint64, bool) {
|
||||
if slices.Contains(tt.onVolume, info.Name()) {
|
||||
return 2, true
|
||||
}
|
||||
|
||||
return 1, true
|
||||
}
|
||||
}
|
||||
|
||||
_, err = snapshot.NewScanner(cfg).Scan(ctx, scannedPath, snapshotID)
|
||||
require.NoError(t, err)
|
||||
|
||||
got, err := repos.Snapshots.GetMountPoints(ctx, snapshotID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// automountFs is an in-memory filesystem with a volume at mountPoint
|
||||
// that is mounted on first access: opening mountPoint to list it mounts
|
||||
// the volume or, if it cannot be mounted, fails with "no such file or
|
||||
// directory", as with the autofs daemon. Stat records the device an
|
||||
// entry is on at that moment, as a real stat does: unmountedDevice for
|
||||
// mountPoint until the volume is mounted; the volume's, 2, for
|
||||
// mountPoint once it is and for everything in it; 1 for everything else.
|
||||
type automountFs struct {
|
||||
afero.Fs
|
||||
|
||||
mountPoint string
|
||||
unmountedDevice uint64
|
||||
canMount bool
|
||||
mounted bool
|
||||
}
|
||||
|
||||
//nolint:ireturn // afero.Fs.Open is defined to return the interface.
|
||||
func (fs *automountFs) Open(name string) (afero.File, error) {
|
||||
if name == fs.mountPoint && !fs.canMount {
|
||||
return nil, &os.PathError{Op: "open", Path: name, Err: syscall.ENOENT}
|
||||
}
|
||||
|
||||
if name == fs.mountPoint {
|
||||
fs.mounted = true
|
||||
}
|
||||
|
||||
return fs.Fs.Open(name)
|
||||
}
|
||||
|
||||
func (fs *automountFs) Stat(name string) (os.FileInfo, error) {
|
||||
info, err := fs.Fs.Stat(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
device := uint64(1)
|
||||
|
||||
switch {
|
||||
case name == fs.mountPoint && !fs.mounted:
|
||||
device = fs.unmountedDevice
|
||||
case name == fs.mountPoint || strings.HasPrefix(name, fs.mountPoint+"/"):
|
||||
device = 2
|
||||
}
|
||||
|
||||
return statWithDevice{FileInfo: info, device: device}, nil
|
||||
}
|
||||
|
||||
// statWithDevice is an entry automountFs.Stat looked up, with the device
|
||||
// it was on then.
|
||||
type statWithDevice struct {
|
||||
os.FileInfo
|
||||
|
||||
device uint64
|
||||
}
|
||||
|
||||
// deviceAtLookup returns the device automountFs.Stat recorded for info.
|
||||
func deviceAtLookup(info os.FileInfo) (uint64, bool) {
|
||||
stat, ok := info.(statWithDevice)
|
||||
|
||||
return stat.device, ok
|
||||
}
|
||||
|
||||
// A volume mounted on first access is mounted when the walk lists its
|
||||
// mount point. Until then the mount point is on the automounter's own
|
||||
// device (a systemd automount) or on its parent's (an autofs map that
|
||||
// shows its mount points before mounting them). Either way only the
|
||||
// mount point is recorded, by the backup that mounts the volume and by a
|
||||
// later one that finds it mounted. Listing an automount point whose
|
||||
// volume cannot be mounted fails; it is not recorded, because the walk
|
||||
// finds no entry in it. See https://git.eeqj.de/sneak/vaultik/issues/395.
|
||||
func TestScanRecordsOnlyTheMountPointOfAnAutomountedVolume(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const scannedPath = "/data/source"
|
||||
|
||||
mountPoint := scannedPath + "/nas"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
unmountedDevice uint64
|
||||
canMount bool
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "on the automounter's device until listed",
|
||||
unmountedDevice: 3, canMount: true, want: []string{mountPoint},
|
||||
},
|
||||
{
|
||||
name: "on its parent's device until listed",
|
||||
unmountedDevice: 1, canMount: true, want: []string{mountPoint},
|
||||
},
|
||||
{
|
||||
name: "volume that cannot be mounted",
|
||||
unmountedDevice: 1, canMount: false, want: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
fs := &automountFs{
|
||||
Fs: afero.NewMemMapFs(), mountPoint: mountPoint,
|
||||
unmountedDevice: tt.unmountedDevice, canMount: tt.canMount,
|
||||
}
|
||||
|
||||
require.NoError(t, fs.MkdirAll(mountPoint+"/photos", 0o755))
|
||||
|
||||
for _, path := range []string{
|
||||
scannedPath + "/notes.txt", mountPoint + "/photos/a.jpg",
|
||||
} {
|
||||
require.NoError(t, afero.WriteFile(fs, path, []byte(path), 0o644))
|
||||
}
|
||||
|
||||
db, err := database.NewTestDB()
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
repos := database.NewRepositories(db)
|
||||
|
||||
for _, snapshotID := range []string{"first", "later"} {
|
||||
createTestSnapshotRecord(ctx, t, repos, snapshotID)
|
||||
|
||||
scanner := snapshot.NewScanner(snapshot.ScannerConfig{
|
||||
FS: fs,
|
||||
ChunkSize: int64(1024 * 16),
|
||||
Repositories: repos,
|
||||
MaxBlobSize: int64(1024 * 1024),
|
||||
CompressionLevel: 3,
|
||||
AgeRecipients: []string{testAgePublicKey},
|
||||
DeviceNumber: deviceAtLookup,
|
||||
})
|
||||
|
||||
_, err = scanner.Scan(ctx, scannedPath, snapshotID)
|
||||
require.NoError(t, err)
|
||||
|
||||
got, err := repos.Snapshots.GetMountPoints(ctx, snapshotID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.want, got, "mount points of the %s backup", snapshotID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+213
-85
@@ -20,6 +20,7 @@ import (
|
||||
"sneak.berlin/go/vaultik/internal/blob"
|
||||
"sneak.berlin/go/vaultik/internal/chunker"
|
||||
"sneak.berlin/go/vaultik/internal/database"
|
||||
"sneak.berlin/go/vaultik/internal/exclude"
|
||||
"sneak.berlin/go/vaultik/internal/log"
|
||||
"sneak.berlin/go/vaultik/internal/storage"
|
||||
"sneak.berlin/go/vaultik/internal/types"
|
||||
@@ -67,12 +68,18 @@ type Scanner struct {
|
||||
currentSourcePath string
|
||||
exclude []string // Glob patterns for files/directories to exclude
|
||||
compiledExclude []compiledPattern // Compiled glob patterns
|
||||
// indexAndLockFiles holds the paths of vaultik's own working files,
|
||||
// which the scan treats as excluded (ScannerConfig.IndexAndLockFiles).
|
||||
indexAndLockFiles map[string]struct{}
|
||||
progress *ProgressReporter
|
||||
// skipErrors skips files that cannot be opened or read (logged loudly);
|
||||
// packer, database, encryption, and upload errors still abort the run.
|
||||
skipErrors bool
|
||||
// ui is the user-facing output; never nil (defaults to a discarding writer).
|
||||
ui *ui.Writer
|
||||
// deviceNumber returns the number of the device that holds a file;
|
||||
// never nil (defaults to deviceNumberFromStat).
|
||||
deviceNumber func(info os.FileInfo) (uint64, bool)
|
||||
|
||||
// In-memory cache of known chunk hashes for fast existence checks
|
||||
knownChunks map[string]struct{}
|
||||
@@ -130,12 +137,21 @@ type ScannerConfig struct {
|
||||
EnableProgress bool // Enable the live progress reporter (ETAs, throughput)
|
||||
UI *ui.Writer // Where user-facing scanner messages go; nil = discard
|
||||
Exclude []string // Glob patterns for files/directories to exclude
|
||||
// IndexAndLockFiles are the absolute paths of vaultik's own working
|
||||
// files: the index, its side files and the PID file. The scan treats
|
||||
// them as excluded, whatever Exclude says, since a copy taken while
|
||||
// they are being written is torn.
|
||||
IndexAndLockFiles []string
|
||||
// SkipErrors skips files that cannot be opened or read (log loudly but
|
||||
// continue); packer, database, encryption, and upload errors still abort.
|
||||
SkipErrors bool
|
||||
// PreviousSnapshotID is the most recent completed snapshot of the
|
||||
// same name, "" for none.
|
||||
PreviousSnapshotID string
|
||||
// DeviceNumber returns the number of the device that holds a file,
|
||||
// and false when there is none to read. nil reads it from the file's
|
||||
// stat; tests replace it, since the in-memory test filesystem has none.
|
||||
DeviceNumber func(info os.FileInfo) (uint64, bool)
|
||||
}
|
||||
|
||||
// ScanResult contains the results of a scan operation. Files and bytes
|
||||
@@ -168,6 +184,12 @@ type ScanResult struct {
|
||||
// skipped because it could not be read or listed; the rest are files
|
||||
// removed after the walk listed them.
|
||||
FilesUnreadable int
|
||||
|
||||
// EntriesFound counts the entries the walk found and could look up
|
||||
// that no exclude pattern matches, leaving out the scanned path
|
||||
// itself when it is a directory. It is zero for an empty directory,
|
||||
// such as the mount point of a volume that is not mounted.
|
||||
EntriesFound int
|
||||
}
|
||||
|
||||
// NewScanner creates a new scanner instance
|
||||
@@ -200,14 +222,28 @@ func NewScanner(cfg ScannerConfig) *Scanner {
|
||||
progress = NewProgressReporter()
|
||||
}
|
||||
|
||||
// Compile exclude patterns
|
||||
compiledExclude := compileExcludePatterns(cfg.Exclude)
|
||||
compiledExclude, err := compileExcludePatterns(cfg.Exclude)
|
||||
if err != nil {
|
||||
log.Error("Failed to compile exclude patterns", "error", err)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
uiw := cfg.UI
|
||||
if uiw == nil {
|
||||
uiw = ui.NewWithColor(io.Discard, false)
|
||||
}
|
||||
|
||||
deviceNumber := cfg.DeviceNumber
|
||||
if deviceNumber == nil {
|
||||
deviceNumber = deviceNumberFromStat
|
||||
}
|
||||
|
||||
indexAndLockFiles := make(map[string]struct{}, len(cfg.IndexAndLockFiles))
|
||||
for _, p := range cfg.IndexAndLockFiles {
|
||||
indexAndLockFiles[filepath.Clean(p)] = struct{}{}
|
||||
}
|
||||
|
||||
return &Scanner{
|
||||
fs: cfg.FS,
|
||||
chunker: chunker.NewChunker(cfg.ChunkSize),
|
||||
@@ -219,10 +255,12 @@ func NewScanner(cfg ScannerConfig) *Scanner {
|
||||
ageRecipient: strings.Join(cfg.AgeRecipients, ","),
|
||||
exclude: cfg.Exclude,
|
||||
compiledExclude: compiledExclude,
|
||||
indexAndLockFiles: indexAndLockFiles,
|
||||
progress: progress,
|
||||
skipErrors: cfg.SkipErrors,
|
||||
previousSnapshotID: cfg.PreviousSnapshotID,
|
||||
ui: uiw,
|
||||
deviceNumber: deviceNumber,
|
||||
pendingChunkHashes: make(map[string]struct{}),
|
||||
}
|
||||
}
|
||||
@@ -291,7 +329,7 @@ func (s *Scanner) Scan(
|
||||
}
|
||||
}
|
||||
|
||||
err = s.recordDirectories(ctx, scanResult.Directories)
|
||||
err = s.recordDirectories(ctx, scanResult)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -869,6 +907,11 @@ type ScanPhaseResult struct {
|
||||
FilesToProcess []*FileToProcess
|
||||
UnchangedFileIDs []types.FileID // IDs of unchanged files to associate with snapshot
|
||||
Directories []*FileToProcess // Every directory found, to record
|
||||
// MountPoints are the directories, the scanned path included, that
|
||||
// the walk found an entry in and, once it had listed them, on a
|
||||
// different device from their parent directory (see
|
||||
// recordIfMountPoint).
|
||||
MountPoints []string
|
||||
}
|
||||
|
||||
// scanPhase performs the initial directory scan to identify files to process
|
||||
@@ -888,7 +931,7 @@ func (s *Scanner) scanPhase(
|
||||
// estimate leaves them out.
|
||||
estimatedTotal := countNonDirectories(knownFiles)
|
||||
|
||||
collector := &scanCollector{}
|
||||
collector := &scanCollector{dirsWithEntries: make(map[string]struct{})}
|
||||
|
||||
// Set up periodic status output
|
||||
startTime := time.Now()
|
||||
@@ -928,6 +971,13 @@ func (s *Scanner) scanPhase(
|
||||
default:
|
||||
}
|
||||
|
||||
// The scanned directory itself is not an entry found in it.
|
||||
if filePath != path || !info.IsDir() {
|
||||
result.EntriesFound++
|
||||
}
|
||||
|
||||
s.recordIfMountPoint(filePath, path, collector)
|
||||
|
||||
// Handle symlinks and directories
|
||||
handled, err := s.recordSpecialEntry(
|
||||
filePath, info, existingFiles, knownFiles, collector, result)
|
||||
@@ -940,18 +990,8 @@ func (s *Scanner) scanPhase(
|
||||
return nil
|
||||
}
|
||||
|
||||
// Track this file as existing (for deletion detection)
|
||||
existingFiles[filePath] = struct{}{}
|
||||
|
||||
// Check file against in-memory map (no DB query!)
|
||||
file := s.buildRegularFileEntry(filePath, info)
|
||||
needsProcessing := s.checkFileInMemory(file, knownFiles)
|
||||
|
||||
filesScanned, changedCount := collector.recordFile(
|
||||
filePath, info, file, needsProcessing)
|
||||
|
||||
// Update result stats
|
||||
s.updateScanEntryStats(result, needsProcessing, info)
|
||||
filesScanned, changedCount := s.recordRegularFile(
|
||||
filePath, info, existingFiles, knownFiles, collector, result)
|
||||
|
||||
// Output periodic status
|
||||
if time.Since(lastStatusTime) >= scanStatusInterval {
|
||||
@@ -972,6 +1012,7 @@ func (s *Scanner) scanPhase(
|
||||
FilesToProcess: collector.filesToProcess,
|
||||
UnchangedFileIDs: collector.unchangedFileIDs,
|
||||
Directories: collector.directories,
|
||||
MountPoints: collector.mountPoints,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -1027,6 +1068,30 @@ func (s *Scanner) recordSpecialEntry(
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// recordRegularFile records a regular file as recordSpecialEntry does a
|
||||
// symlink and returns the running scanned and changed counts.
|
||||
func (s *Scanner) recordRegularFile(
|
||||
filePath string, info os.FileInfo,
|
||||
existingFiles map[string]struct{},
|
||||
knownFiles map[string]*database.File,
|
||||
collector *scanCollector, result *ScanResult,
|
||||
) (int64, int) {
|
||||
// Track this file as existing (for deletion detection)
|
||||
existingFiles[filePath] = struct{}{}
|
||||
|
||||
// Check file against in-memory map (no DB query!)
|
||||
file := s.buildRegularFileEntry(filePath, info)
|
||||
needsProcessing := s.checkFileInMemory(file, knownFiles)
|
||||
|
||||
filesScanned, changedCount := collector.recordFile(
|
||||
filePath, info, file, needsProcessing)
|
||||
|
||||
// Update result stats
|
||||
s.updateScanEntryStats(result, needsProcessing, info)
|
||||
|
||||
return filesScanned, changedCount
|
||||
}
|
||||
|
||||
// countRemovedDuringWalk counts an entry that was in its directory's
|
||||
// listing but was gone when the walk looked it up, like a file removed
|
||||
// before phase 2 reads it: as examined and failed, and not as an error,
|
||||
@@ -1074,6 +1139,29 @@ type scanCollector struct {
|
||||
unchangedFileIDs []types.FileID // Just IDs - no new records needed
|
||||
directories []*FileToProcess
|
||||
filesScanned int64
|
||||
// dirsWithEntries holds each directory the walk has found an entry in.
|
||||
dirsWithEntries map[string]struct{}
|
||||
mountPoints []string
|
||||
}
|
||||
|
||||
// firstEntryIn notes that the walk found an entry in directory dir and
|
||||
// reports whether it is the first one.
|
||||
func (c *scanCollector) firstEntryIn(dir string) bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
_, seen := c.dirsWithEntries[dir]
|
||||
c.dirsWithEntries[dir] = struct{}{}
|
||||
|
||||
return !seen
|
||||
}
|
||||
|
||||
// addMountPoint sets a mount point aside to be recorded.
|
||||
func (c *scanCollector) addMountPoint(dir string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
c.mountPoints = append(c.mountPoints, dir)
|
||||
}
|
||||
|
||||
// addDirectory sets a directory aside to be recorded. It is not counted
|
||||
@@ -1265,20 +1353,100 @@ func (s *Scanner) buildDirectoryEntry(path string, info os.FileInfo) *database.F
|
||||
}
|
||||
}
|
||||
|
||||
// recordDirectories records every directory the walk found. A directory
|
||||
// has no data to back up, so it is not among the files counted and
|
||||
// backed up.
|
||||
// recordDirectories records every directory the walk found, and its
|
||||
// mount points (see recordMountPoints). A directory has no data to back
|
||||
// up, so it is not among the files counted and backed up.
|
||||
func (s *Scanner) recordDirectories(
|
||||
ctx context.Context, directories []*FileToProcess,
|
||||
ctx context.Context, scanned *ScanPhaseResult,
|
||||
) error {
|
||||
for _, dir := range directories {
|
||||
for _, dir := range scanned.Directories {
|
||||
err := s.recordNonRegularFile(ctx, dir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("recording directory %s: %w", dir.Path, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
return s.recordMountPoints(ctx, scanned.MountPoints)
|
||||
}
|
||||
|
||||
// recordMountPoints records with the snapshot the mount points the walk
|
||||
// found (see ScanPhaseResult.MountPoints).
|
||||
func (s *Scanner) recordMountPoints(
|
||||
ctx context.Context, mountPoints []string,
|
||||
) error {
|
||||
return s.repos.WithTx(ctx, func(txCtx context.Context, tx *sql.Tx) error {
|
||||
for _, dir := range mountPoints {
|
||||
log.Info("Found a mount point", "path", dir)
|
||||
|
||||
err := s.repos.Snapshots.AddMountPoint(txCtx, tx, s.snapshotID, dir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("recording mount point %s: %w", dir, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// recordIfMountPoint sets the directory that holds entry, a path the
|
||||
// walk found, aside to be recorded as a mount point if it is on a
|
||||
// different device from its parent directory. The walk calls it for each
|
||||
// entry it finds, and it decides on the first entry in a directory, so a
|
||||
// directory the walk found no entry in is never recorded. By then the
|
||||
// walk has listed the directory, which mounts a volume mounted there on
|
||||
// first access, so the directory and its parent are looked up again:
|
||||
// before the listing, the directory can be on the automounter's own
|
||||
// device (a systemd automount) or on its parent's (an autofs map that
|
||||
// shows its mount points before mounting them). The scanned path is in
|
||||
// no listing the walk made, so it is skipped as an entry. A directory
|
||||
// that cannot be looked up then, or whose parent cannot, is not
|
||||
// recorded, so this snapshot does not stop the prune of a later backup
|
||||
// that misses its volume.
|
||||
func (s *Scanner) recordIfMountPoint(
|
||||
entry, scannedPath string, collector *scanCollector,
|
||||
) {
|
||||
dir := filepath.Dir(entry)
|
||||
|
||||
if entry == scannedPath || !collector.firstEntryIn(dir) {
|
||||
return
|
||||
}
|
||||
|
||||
dirDevice, ok := s.deviceNumberAt(dir)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
parentDevice, ok := s.deviceNumberAt(filepath.Dir(dir))
|
||||
if ok && dirDevice != parentDevice {
|
||||
collector.addMountPoint(dir)
|
||||
}
|
||||
}
|
||||
|
||||
// deviceNumberAt looks path up now and returns the number of the device
|
||||
// that holds it, and false when the lookup fails (logged) or gives no
|
||||
// device number.
|
||||
func (s *Scanner) deviceNumberAt(path string) (uint64, bool) {
|
||||
info, err := s.fs.Stat(path)
|
||||
if err != nil {
|
||||
log.Warn("Could not look up a directory to check for a mount point",
|
||||
"path", path, "error", err)
|
||||
|
||||
return 0, false
|
||||
}
|
||||
|
||||
return s.deviceNumber(info)
|
||||
}
|
||||
|
||||
// deviceNumberFromStat returns the number of the device that holds the
|
||||
// file info describes, and false when info carries no stat, as from the
|
||||
// in-memory test filesystem.
|
||||
func deviceNumberFromStat(info os.FileInfo) (uint64, bool) {
|
||||
stat, ok := info.Sys().(*syscall.Stat_t)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
return uint64(stat.Dev), true //nolint:unconvert // Dev is an int32 on darwin
|
||||
}
|
||||
|
||||
// recordNonRegularFile writes a symlink or directory entry to the database
|
||||
@@ -1712,7 +1880,13 @@ func (s *Scanner) uploadBlobIfNeeded(
|
||||
err = s.storage.PutWithProgress(ctx, blobPath, blobWithReader.Reader,
|
||||
finishedBlob.Compressed, progressCallback)
|
||||
if err != nil {
|
||||
log.Error("Failed to upload blob", "hash", finishedBlob.Hash, "error", err)
|
||||
// The record is at info level: the returned error ends the backup
|
||||
// and is printed as its ERROR: line. Only an interrupt cancels
|
||||
// ctx, and the user is told about it, so an upload it stopped is
|
||||
// not logged as a storage failure.
|
||||
if ctx.Err() == nil {
|
||||
log.Info("Failed to upload blob", "hash", finishedBlob.Hash, "error", err)
|
||||
}
|
||||
|
||||
return 0, false, fmt.Errorf("uploading blob to storage: %w", err)
|
||||
}
|
||||
@@ -2099,8 +2273,9 @@ func (s *Scanner) detectDeletedFiles(
|
||||
|
||||
// excludedOrInExcludedDir reports whether filePath, which is at or
|
||||
// beneath rootPath, or any directory between rootPath and it, matches an
|
||||
// exclude pattern. A pattern such as */.local/share/Trash matches the
|
||||
// directory but not the files inside it, which the walk skips too.
|
||||
// exclude pattern or is one of vaultik's own files. A pattern such as
|
||||
// */.local/share/Trash matches the directory but not the files inside it,
|
||||
// which the walk skips too.
|
||||
func (s *Scanner) excludedOrInExcludedDir(filePath, rootPath string) bool {
|
||||
for p := filePath; len(p) > len(rootPath); p = filepath.Dir(p) {
|
||||
if s.shouldExclude(p, rootPath) {
|
||||
@@ -2148,51 +2323,18 @@ func wrapPermissionError(path string, err error) error {
|
||||
"with --skip-errors to continue past unreadable files)", path, err)
|
||||
}
|
||||
|
||||
// compileExcludePatterns compiles the exclude patterns into glob matchers
|
||||
func compileExcludePatterns(patterns []string) []compiledPattern {
|
||||
// compileExcludePatterns compiles the exclude patterns into glob matchers.
|
||||
// Config.Validate has already refused a pattern that does not compile.
|
||||
func compileExcludePatterns(patterns []string) ([]compiledPattern, error) {
|
||||
var compiled []compiledPattern
|
||||
|
||||
for _, p := range patterns {
|
||||
if p == "" {
|
||||
continue
|
||||
globs, anchored, err := exclude.Compile(p)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%q %w", p, err)
|
||||
}
|
||||
|
||||
// Check if pattern is anchored (starts with /)
|
||||
anchored := strings.HasPrefix(p, "/")
|
||||
|
||||
pattern := p
|
||||
if anchored {
|
||||
pattern = p[1:] // Remove leading /
|
||||
}
|
||||
|
||||
// Remove trailing slash if present (directory indicator)
|
||||
pattern = strings.TrimSuffix(pattern, "/")
|
||||
|
||||
// A leading "**/" demands a "/" before the name and so misses an
|
||||
// entry directly under the source path. It means "at every depth",
|
||||
// which is what an unanchored pattern already does, so drop the
|
||||
// prefix and match unanchored: as in gitignore, "**/x" and
|
||||
// "/**/x" then also match a top-level "x".
|
||||
if rest, found := strings.CutPrefix(pattern, "**/"); found {
|
||||
pattern = rest
|
||||
anchored = false
|
||||
}
|
||||
|
||||
// Likewise "/**/" inside a pattern demands two slashes, so
|
||||
// "a/**/*.log" misses "a/x.log". As in gitignore it stands for
|
||||
// zero or more directories, so also compile the pattern with it
|
||||
// made a single "/". The glob library's {a,b} is no help: with a
|
||||
// "**" inside it in mid-pattern, it misses paths it should match.
|
||||
for _, variant := range doubleStarVariants(pattern) {
|
||||
// For patterns without path separators, we need to match them as components
|
||||
// e.g., ".git" should match ".git" anywhere in the path
|
||||
g, err := glob.Compile(variant, '/')
|
||||
if err != nil {
|
||||
log.Warn("Invalid exclude pattern, skipping", "pattern", p, "error", err)
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
for _, g := range globs {
|
||||
compiled = append(compiled, compiledPattern{
|
||||
pattern: g,
|
||||
anchored: anchored,
|
||||
@@ -2201,32 +2343,18 @@ func compileExcludePatterns(patterns []string) []compiledPattern {
|
||||
}
|
||||
}
|
||||
|
||||
return compiled
|
||||
}
|
||||
|
||||
// doubleStarVariants returns pattern once for every way of keeping each
|
||||
// "/**/" in it or making it a single "/".
|
||||
func doubleStarVariants(pattern string) []string {
|
||||
const doubleStar = "/**/"
|
||||
|
||||
before, after, found := strings.Cut(pattern, doubleStar)
|
||||
if !found {
|
||||
return []string{pattern}
|
||||
}
|
||||
|
||||
var variants []string
|
||||
|
||||
for _, rest := range doubleStarVariants(after) {
|
||||
variants = append(variants, before+doubleStar+rest, before+"/"+rest)
|
||||
}
|
||||
|
||||
return variants
|
||||
return compiled, nil
|
||||
}
|
||||
|
||||
// shouldExclude checks if a path should be excluded based on exclude patterns
|
||||
// or because it is one of vaultik's own working files.
|
||||
// filePath is the full path to the file
|
||||
// rootPath is the root of the backup source directory
|
||||
func (s *Scanner) shouldExclude(filePath, rootPath string) bool {
|
||||
if _, ok := s.indexAndLockFiles[filePath]; ok {
|
||||
return true
|
||||
}
|
||||
|
||||
if len(s.compiledExclude) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -271,16 +271,18 @@ func (sm *SnapshotManager) CompleteSnapshot(
|
||||
return sm.MarkSnapshotComplete(ctx, snapshotID)
|
||||
}
|
||||
|
||||
// ExportSnapshotMetadata exports snapshot metadata to S3
|
||||
// ExportSnapshotMetadata exports snapshot metadata to the backup destination store
|
||||
//
|
||||
// This method executes the complete snapshot metadata export process:
|
||||
// 1. Creates a temporary directory for working files
|
||||
// 2. Copies the main database to preserve its state
|
||||
// 3. Cleans the copy to contain only current snapshot data
|
||||
// 4. Dumps the cleaned database to SQL
|
||||
// 5. Compresses the SQL dump with zstd
|
||||
// 6. Encrypts the compressed data with age (always; recipients are required)
|
||||
// 7. Uploads to S3 at: snapshots/{snapshot-id}.sql.zst[.age]
|
||||
// 4. Vacuums the copy to remove the deleted data
|
||||
// 5. Compresses the copy with zstd
|
||||
// 6. Encrypts the compressed copy with age (always; recipients are required)
|
||||
// 7. Builds the blob manifest from the copy, compressed but not encrypted
|
||||
// 8. Uploads the database to metadata/{remote-key}/db.zst.age
|
||||
// 9. Uploads the manifest to metadata/{remote-key}/manifest.json.zst
|
||||
//
|
||||
// The only caller (finalizeSnapshotMetadata) does not close the main database
|
||||
// before calling this method: the index is copied at dbPath while it is still
|
||||
@@ -518,8 +520,8 @@ type CleanupStats struct {
|
||||
//
|
||||
// The cleanup is performed in a specific order to maintain referential integrity:
|
||||
// 1. Delete other snapshots
|
||||
// 2. Delete orphaned snapshot associations (snapshot_files, snapshot_blobs)
|
||||
// for deleted snapshots
|
||||
// 2. Delete orphaned snapshot associations (snapshot_files, snapshot_blobs,
|
||||
// snapshot_mount_points) for deleted snapshots
|
||||
// 3. Delete orphaned files (not in the current snapshot)
|
||||
// 4. Delete orphaned chunk-to-file mappings (references to deleted files)
|
||||
// 5. Delete orphaned blobs (not in the current snapshot)
|
||||
@@ -948,8 +950,8 @@ func (sm *SnapshotManager) deleteOtherSnapshots(
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteOrphanedSnapshotAssociations deletes snapshot_files and
|
||||
// snapshot_blobs for deleted snapshots
|
||||
// deleteOrphanedSnapshotAssociations deletes snapshot_files,
|
||||
// snapshot_blobs and snapshot_mount_points for deleted snapshots
|
||||
func (sm *SnapshotManager) deleteOrphanedSnapshotAssociations(
|
||||
ctx context.Context, tx *sql.Tx, currentSnapshotID string,
|
||||
) error {
|
||||
@@ -983,6 +985,19 @@ func (sm *SnapshotManager) deleteOrphanedSnapshotAssociations(
|
||||
log.Debug("[Temp DB Cleanup] Deleted snapshot_blobs associations",
|
||||
"count", rowsAffected)
|
||||
|
||||
// Delete the mount points other snapshots recorded
|
||||
query := "DELETE FROM snapshot_mount_points WHERE snapshot_id != ?"
|
||||
database.LogSQL("Execute", query, currentSnapshotID)
|
||||
|
||||
result, err = tx.ExecContext(ctx, query, currentSnapshotID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("deleting orphaned snapshot_mount_points: %w", err)
|
||||
}
|
||||
|
||||
rowsAffected, _ = result.RowsAffected()
|
||||
log.Debug("[Temp DB Cleanup] Deleted snapshot_mount_points rows",
|
||||
"count", rowsAffected)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -7,12 +7,14 @@ import (
|
||||
"database/sql"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"sneak.berlin/go/vaultik/internal/config"
|
||||
"sneak.berlin/go/vaultik/internal/database"
|
||||
"sneak.berlin/go/vaultik/internal/log"
|
||||
"sneak.berlin/go/vaultik/internal/types"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -387,6 +389,77 @@ func TestCleanSnapshotDBNonExistentSnapshot(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The exported metadata database keeps only the mount points that the
|
||||
// exported snapshot recorded. See
|
||||
// https://git.eeqj.de/sneak/vaultik/issues/395.
|
||||
func TestCleanSnapshotDBKeepsOnlyTheSnapshotsMountPoints(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
dbPath := filepath.Join(t.TempDir(), "export.db")
|
||||
|
||||
db, err := database.New(ctx, dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create database: %v", err)
|
||||
}
|
||||
|
||||
repos := database.NewRepositories(db)
|
||||
|
||||
for _, id := range []types.SnapshotID{"exported", "other"} {
|
||||
err = repos.Snapshots.Create(ctx, nil, &database.Snapshot{ID: id})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create snapshot: %v", err)
|
||||
}
|
||||
|
||||
err = repos.Snapshots.AddMountPoint(ctx, nil, id.String(), "/data/"+id.String())
|
||||
if err != nil {
|
||||
t.Fatalf("failed to add mount point: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
err = db.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to close database: %v", err)
|
||||
}
|
||||
|
||||
sm := &SnapshotManager{
|
||||
config: &config.Config{
|
||||
CompressionLevel: 3,
|
||||
AgeRecipients: []string{testAgeRecipient},
|
||||
},
|
||||
fs: afero.NewOsFs(),
|
||||
}
|
||||
|
||||
_, err = sm.cleanSnapshotDB(ctx, dbPath, "exported")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to clean snapshot database: %v", err)
|
||||
}
|
||||
|
||||
cleaned, err := database.New(ctx, dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open cleaned database: %v", err)
|
||||
}
|
||||
|
||||
defer func() { _ = cleaned.Close() }()
|
||||
|
||||
cleanedRepos := database.NewRepositories(cleaned)
|
||||
|
||||
for id, want := range map[string][]string{
|
||||
"exported": {"/data/exported"},
|
||||
"other": nil,
|
||||
} {
|
||||
got, err := cleanedRepos.Snapshots.GetMountPoints(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read mount points: %v", err)
|
||||
}
|
||||
|
||||
if !slices.Equal(got, want) {
|
||||
t.Errorf("mount points of %s: got %v, want %v", id, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
//nolint:testpackage // exercises the unexported uploadBlobIfNeeded
|
||||
package snapshot
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/vaultik/internal/blob"
|
||||
"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"
|
||||
)
|
||||
|
||||
const uploadFailedLogMessage = "Failed to upload blob"
|
||||
|
||||
// uploadTestBlob uploads a small blob through store.
|
||||
func uploadTestBlob(ctx context.Context, store storage.Storer) error {
|
||||
data := []byte("blob data")
|
||||
s := &Scanner{storage: store, ui: ui.NewWithColor(io.Discard, false)}
|
||||
|
||||
_, _, err := s.uploadBlobIfNeeded(ctx, "blobs/test", &blob.WithReader{
|
||||
FinishedBlob: &blob.FinishedBlob{
|
||||
Hash: "test",
|
||||
Compressed: int64(len(data)),
|
||||
},
|
||||
Reader: bytes.NewReader(data),
|
||||
}, time.Now(), &ScanResult{})
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// captureLog runs fn and returns what the logger wrote to stderr
|
||||
// meanwhile, at the --verbose level. 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 captureLog(t *testing.T, fn func()) string {
|
||||
t.Helper()
|
||||
|
||||
stderrPath := filepath.Join(t.TempDir(), "stderr")
|
||||
|
||||
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{Verbose: true})
|
||||
|
||||
fn()
|
||||
|
||||
os.Stderr = previous
|
||||
|
||||
log.Initialize(log.Config{})
|
||||
|
||||
_ = stderrFile.Close()
|
||||
|
||||
captured, err := os.ReadFile(stderrPath) //nolint:gosec // G304: test temp path
|
||||
if err != nil {
|
||||
t.Fatalf("reading stderr file: %v", err)
|
||||
}
|
||||
|
||||
return string(captured)
|
||||
}
|
||||
|
||||
// An upload stopped by Ctrl-C or SIGTERM is not logged as an error; the
|
||||
// interrupted message is all the user sees. See
|
||||
// https://git.eeqj.de/sneak/vaultik/issues/434.
|
||||
//
|
||||
//nolint:paralleltest // captureLog replaces os.Stderr
|
||||
func TestInterruptedUploadIsNotLoggedAsError(t *testing.T) {
|
||||
inner, err := storage.NewFileStorer(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
|
||||
// The user interrupts mid-upload. The injected error does not wrap
|
||||
// context.Canceled, so only ctx shows why the upload failed.
|
||||
store := faultstore.New(inner)
|
||||
store.OnPut = func(string) faultstore.PutAction {
|
||||
cancel()
|
||||
|
||||
return faultstore.PutFail
|
||||
}
|
||||
|
||||
var uploadErr error
|
||||
|
||||
stderr := captureLog(t, func() { uploadErr = uploadTestBlob(ctx, store) })
|
||||
|
||||
if uploadErr == nil {
|
||||
t.Fatal("expected the upload to fail")
|
||||
}
|
||||
|
||||
if strings.Contains(stderr, uploadFailedLogMessage) {
|
||||
t.Errorf("interrupted upload logged as a failure: %q", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
// An upload that fails for any other reason is still logged.
|
||||
//
|
||||
//nolint:paralleltest // captureLog replaces os.Stderr
|
||||
func TestFailedUploadIsLogged(t *testing.T) {
|
||||
inner, err := storage.NewFileStorer(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
store := faultstore.New(inner)
|
||||
store.OnPut = func(string) faultstore.PutAction { return faultstore.PutFail }
|
||||
|
||||
var uploadErr error
|
||||
|
||||
stderr := captureLog(t, func() {
|
||||
uploadErr = uploadTestBlob(context.Background(), store)
|
||||
})
|
||||
|
||||
if uploadErr == nil {
|
||||
t.Fatal("expected the upload to fail")
|
||||
}
|
||||
|
||||
if !strings.Contains(stderr, uploadFailedLogMessage) {
|
||||
t.Errorf("failed upload not logged: %q", stderr)
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,17 @@
|
||||
// faults on demand, so tests can reproduce the failure modes a real
|
||||
// backend exhibits: an upload that fails partway, a backend that reports
|
||||
// success while storing nothing, reads that return corrupt or truncated
|
||||
// bytes, and a delete the backend refuses. It is the seam called for by
|
||||
// the fault-injection tests (sneak/vaultik issue 72) and is meant to be
|
||||
// reused by future tests rather than re-implemented per case.
|
||||
// bytes, and a delete or a stat the backend refuses. It is the seam
|
||||
// called for by the fault-injection tests (sneak/vaultik issue 72) and
|
||||
// is meant to be reused by future tests rather than re-implemented per
|
||||
// case.
|
||||
//
|
||||
// The wrapper delegates every method to the inner Storer. Three hooks
|
||||
// The wrapper delegates every method to the inner Storer. Four hooks
|
||||
// change that: OnPut decides the fate of each write, OnGet decides how
|
||||
// each read's bytes are returned, and OnDelete decides whether each
|
||||
// delete fails. All are keyed by the object key, so a test can fault
|
||||
// only blobs, only metadata, or a single object.
|
||||
// each read's bytes are returned, OnDelete decides whether each delete
|
||||
// fails, and OnStat whether each stat fails. All are keyed by the object
|
||||
// key, so a test can fault only blobs, only metadata, or a single
|
||||
// object.
|
||||
package faultstore
|
||||
|
||||
import (
|
||||
@@ -30,6 +32,11 @@ var ErrInjectedUpload = errors.New("faultstore: injected upload failure")
|
||||
// fail.
|
||||
var ErrInjectedDelete = errors.New("faultstore: injected delete failure")
|
||||
|
||||
// ErrInjectedStat is returned by a Stat the OnStat hook chose to fail.
|
||||
// It is not storage.ErrNotFound: the backend refused the request, as S3
|
||||
// refuses a HEAD to credentials that allow only PUT.
|
||||
var ErrInjectedStat = errors.New("faultstore: injected stat failure")
|
||||
|
||||
// PutAction is the disposition OnPut assigns to a write.
|
||||
type PutAction int
|
||||
|
||||
@@ -76,6 +83,10 @@ type Storer struct {
|
||||
// object key. Returning true fails the delete and leaves the object
|
||||
// in place.
|
||||
OnDelete func(key string) bool
|
||||
|
||||
// OnStat, when set, is consulted before every Stat with the object
|
||||
// key. Returning true fails the stat with ErrInjectedStat.
|
||||
OnStat func(key string) bool
|
||||
}
|
||||
|
||||
// New wraps inner. inner must be non-nil.
|
||||
@@ -159,8 +170,12 @@ func damage(fault GetFault, data []byte) []byte {
|
||||
return out
|
||||
}
|
||||
|
||||
// Stat delegates unchanged.
|
||||
// Stat returns the object's info unless OnStat fails the stat.
|
||||
func (f *Storer) Stat(ctx context.Context, key string) (*storage.ObjectInfo, error) {
|
||||
if f.OnStat != nil && f.OnStat(key) {
|
||||
return nil, fmt.Errorf("%w for %q", ErrInjectedStat, key)
|
||||
}
|
||||
|
||||
return f.inner.Stat(ctx, key)
|
||||
}
|
||||
|
||||
|
||||
@@ -100,8 +100,9 @@ func TestS3StorerMissingKeyMapsToErrNotFound(t *testing.T) {
|
||||
// TestS3ListingCutShortIsAnError checks that List and ListStream fail on a
|
||||
// listing page marked truncated that carries no continuation token, as a
|
||||
// server answering V2 listings V1-style sends it. Taken as the last page,
|
||||
// it would hide every later key, and prune would treat the blobs of the
|
||||
// snapshots listed there as unreferenced.
|
||||
// it would hide every later key: prune would treat the blobs of the
|
||||
// snapshots listed there as unreferenced, and a directory under metadata/
|
||||
// whose manifest is listed there as one with no manifest.
|
||||
func TestS3ListingCutShortIsAnError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -22,6 +22,10 @@ var errBlobHashMismatch = errors.New("blob hash mismatch")
|
||||
var errBlobNotFullyRead = errors.New(
|
||||
"blob closed before fully read; hash not verified")
|
||||
|
||||
// errWrongPrivateKeyForBlob is returned when the configured key parses
|
||||
// but is not one of the keys a blob was encrypted to.
|
||||
var errWrongPrivateKeyForBlob = errors.New("cannot decrypt this blob")
|
||||
|
||||
// hashVerifyReader wraps a blobgen.Reader and verifies the double-SHA-256 hash
|
||||
// of decrypted plaintext when Close is called. It reuses the hash that
|
||||
// blobgen.Reader already computes internally via its TeeReader, avoiding
|
||||
@@ -87,10 +91,14 @@ func (v *Vaultik) FetchAndDecryptBlob(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
reader, err := blobgen.NewReader(rc, identities...)
|
||||
reader, err := v.newBlobReader(rc, identities)
|
||||
if err != nil {
|
||||
_ = rc.Close()
|
||||
|
||||
if errors.Is(err, errWrongPrivateKeyForBlob) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("creating blob reader: %w", err)
|
||||
}
|
||||
|
||||
@@ -102,6 +110,28 @@ func (v *Vaultik) FetchAndDecryptBlob(
|
||||
}, nil
|
||||
}
|
||||
|
||||
// newBlobReader opens the decrypting reader for a blob. When none of the
|
||||
// identities can decrypt it, the error says in plain words that the
|
||||
// configured private key is not one of the keys the blob was encrypted
|
||||
// to and names where the key was configured.
|
||||
func (v *Vaultik) newBlobReader(
|
||||
r io.Reader, identities []age.Identity,
|
||||
) (*blobgen.Reader, error) {
|
||||
blobReader, err := blobgen.NewReader(r, identities...)
|
||||
|
||||
if _, ok := errors.AsType[*age.NoIdentityMatchError](err); ok {
|
||||
return nil, fmt.Errorf(
|
||||
"%w: the private key in %s is not one of the keys the blob was encrypted to",
|
||||
errWrongPrivateKeyForBlob, v.Config.AgeSecretKeySourceName())
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return blobReader, nil
|
||||
}
|
||||
|
||||
// FetchBlob downloads a blob and returns a reader for the encrypted data.
|
||||
// The reader is closed as soon as ctx is cancelled, which ends a read
|
||||
// blocked inside it. A storage error is returned as is: restore and deep
|
||||
|
||||
@@ -5,14 +5,16 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"sneak.berlin/go/vaultik/internal/log"
|
||||
"sneak.berlin/go/vaultik/internal/storage"
|
||||
)
|
||||
|
||||
// destinationMarkerKey is the object every backup writes at the root of
|
||||
// the backup destination store. While the local index holds a snapshot
|
||||
// stored there, a destination without it is treated as not present: an
|
||||
// unplugged volume leaves an empty directory at its mount point, which
|
||||
// would otherwise read as a store whose snapshots are all gone.
|
||||
// stored there, a destination that answers a look-up of it with not
|
||||
// found 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
|
||||
@@ -20,16 +22,24 @@ const destinationMarkerKey = "vaultik-destination.txt"
|
||||
const destinationMarkerText = "This is a vaultik backup destination store.\n"
|
||||
|
||||
// errDestinationNotPresent is returned when the local index holds a
|
||||
// snapshot stored on the backup destination store and the store has no
|
||||
// marker.
|
||||
// snapshot stored on the backup destination store and the store answers
|
||||
// that it has no marker.
|
||||
var errDestinationNotPresent = errors.New("backup destination store is not present")
|
||||
|
||||
// checkDestinationMarker returns errDestinationNotPresent when the local
|
||||
// index holds a snapshot stored on the backup destination store and the
|
||||
// store has no marker. With no snapshot in the local index, as on a
|
||||
// first backup or on another machine, there is nothing to check; nor
|
||||
// when the local index is bound to a different destination than the
|
||||
// configured one, since its snapshots are stored there.
|
||||
// store answers that it has no marker. With no snapshot in the local
|
||||
// index, as on a first backup or on another machine, there is nothing to
|
||||
// check; nor when the local index is bound to a different destination
|
||||
// than the configured one, since its snapshots are stored there.
|
||||
//
|
||||
// A look-up of the marker that fails for any other reason than not
|
||||
// found is logged and otherwise ignored, because the check could not be
|
||||
// made: S3 refuses it to credentials that allow only PUT, which is all
|
||||
// a backup needs. The check exists for a file:// volume's empty mount
|
||||
// point, which always answers not found, and a destination that cannot
|
||||
// be reached fails on the next request made to it, such as the backup's
|
||||
// PUT of the marker.
|
||||
func (v *Vaultik) checkDestinationMarker() error {
|
||||
ids, err := v.localSnapshotIDs()
|
||||
if err != nil {
|
||||
@@ -41,7 +51,7 @@ func (v *Vaultik) checkDestinationMarker() error {
|
||||
}
|
||||
|
||||
// Config is nil only in tests, which then record no binding (see
|
||||
// EnsureStorageBinding).
|
||||
// checkStorageBinding).
|
||||
if v.Config != nil {
|
||||
stored, configured, err := v.storageBinding()
|
||||
if err != nil {
|
||||
@@ -63,18 +73,29 @@ func (v *Vaultik) checkDestinationMarker() error {
|
||||
destinationMarkerKey)
|
||||
}
|
||||
|
||||
// Info, not a warning: --cron must stay silent on a successful
|
||||
// backup from a host whose credentials allow only PUT.
|
||||
if err != nil {
|
||||
return fmt.Errorf("checking for %s: %w", destinationMarkerKey, err)
|
||||
log.Info("Could not check the backup destination store for its marker",
|
||||
"key", destinationMarkerKey, "error", 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.
|
||||
// markDestination refuses a local index bound to a different
|
||||
// destination, checks the backup destination store for its marker,
|
||||
// writes the marker, and then records the binding. A first backup marks
|
||||
// its destination and a later one writes the same text again. The
|
||||
// binding is recorded last, so a first backup that cannot write to the
|
||||
// destination leaves the local index unbound.
|
||||
func (v *Vaultik) markDestination() error {
|
||||
err := v.checkDestinationMarker()
|
||||
err := v.checkStorageBinding()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = v.checkDestinationMarker()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -86,5 +107,5 @@ func (v *Vaultik) markDestination() error {
|
||||
destinationMarkerKey, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
return v.EnsureStorageBinding()
|
||||
}
|
||||
@@ -13,15 +13,16 @@ import (
|
||||
"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/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.
|
||||
// snapshots, a destination that answers that it has no
|
||||
// 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.
|
||||
@@ -93,6 +94,39 @@ func TestBackupRefusesDestinationWithoutMarker(t *testing.T) {
|
||||
assertUntouched(ctx, t, repos, storeDir)
|
||||
}
|
||||
|
||||
// TestBackupGoesOnWhenMarkerCannotBeChecked checks that a backup goes on
|
||||
// and writes the marker when looking it up fails with an error other
|
||||
// than not found, as S3 answers credentials that allow only PUT
|
||||
// (https://git.eeqj.de/sneak/vaultik/issues/389), and that a marker
|
||||
// that is not found still stops it.
|
||||
func TestBackupGoesOnWhenMarkerCannotBeChecked(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
storeDir := t.TempDir()
|
||||
v, repos, _ := backUpToFileDestination(ctx, t, storeDir)
|
||||
|
||||
// Removed so the test sees the backup write it again: with the
|
||||
// look-up failing, the backup cannot tell that it is gone.
|
||||
markerPath := filepath.Join(storeDir, markerFile)
|
||||
require.NoError(t, os.Remove(markerPath))
|
||||
|
||||
store := faultstore.New(v.Storage)
|
||||
store.OnStat = func(key string) bool { return key == markerFile }
|
||||
v.Storage = store
|
||||
|
||||
require.NoError(t, backUp(v, "second"))
|
||||
localSnapshotID(ctx, t, repos, "second")
|
||||
assert.FileExists(t, markerPath)
|
||||
|
||||
require.NoError(t, os.Remove(markerPath))
|
||||
|
||||
store.OnStat = nil
|
||||
|
||||
require.ErrorContains(t, backUp(v, "third"), markerFile)
|
||||
}
|
||||
|
||||
// TestBackupChecksMarkerBeforeDroppingIncompleteSnapshots checks that
|
||||
// snapshot create writes nothing when the local index holds only an
|
||||
// incomplete snapshot and the destination has no marker. The backup drops
|
||||
|
||||
@@ -679,6 +679,7 @@ func faultScannerFactory(
|
||||
Exclude: params.Exclude,
|
||||
SkipErrors: params.SkipErrors,
|
||||
PreviousSnapshotID: params.PreviousSnapshotID,
|
||||
DeviceNumber: params.DeviceNumber,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,7 +246,7 @@ func (v *Vaultik) RemoteInfo(jsonOutput bool) error {
|
||||
v.stdoutf("Type: %s\n", storageInfo.Type)
|
||||
v.stdoutf("Location: %s\n", storageInfo.Location)
|
||||
v.printlnStdout()
|
||||
v.stdoutf("Scanning snapshot metadata...\n")
|
||||
v.UI.Beginf("Scanning snapshot metadata...")
|
||||
}
|
||||
|
||||
snapshotMetadata, snapshotIDs, skippedManifestCount, err := v.collectSnapshotMetadata()
|
||||
@@ -265,7 +265,7 @@ func (v *Vaultik) RemoteInfo(jsonOutput bool) error {
|
||||
}
|
||||
}
|
||||
|
||||
v.stdoutf("Downloading %d manifest(s)...\n", manifestCount)
|
||||
v.UI.Beginf("Downloading %d manifest(s)...", manifestCount)
|
||||
}
|
||||
|
||||
referencedBlobs, unreadableManifests := v.collectReferencedBlobsFromManifests(
|
||||
@@ -447,7 +447,7 @@ func (v *Vaultik) scanRemoteBlobStorage(
|
||||
result *RemoteInfoResult, referencedBlobs map[string]int64, showText bool,
|
||||
) error {
|
||||
if showText {
|
||||
v.stdoutf("Scanning blobs...\n")
|
||||
v.UI.Beginf("Scanning blobs...")
|
||||
}
|
||||
|
||||
// Use prune's listing, so a file under blobs/ that prune would never
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
@@ -24,19 +25,28 @@ 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.
|
||||
// newFileDestinationVaultik writes the fault source tree and returns a
|
||||
// Vaultik that backs it up to a file:// destination at storeDir.
|
||||
func newFileDestinationVaultik(
|
||||
ctx context.Context, t *testing.T, storeDir string,
|
||||
) (*vaultik.Vaultik, *database.Repositories) {
|
||||
t.Helper()
|
||||
|
||||
osFs := afero.NewOsFs()
|
||||
tempDir := t.TempDir()
|
||||
dataDir := filepath.Join(tempDir, "src")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
writeFaultSourceTree(t, afero.NewOsFs(), dataDir)
|
||||
|
||||
writeFaultSourceTree(t, osFs, dataDir)
|
||||
return openFileDestinationVaultik(ctx, t, storeDir, dataDir,
|
||||
filepath.Join(tempDir, "index.sqlite"))
|
||||
}
|
||||
|
||||
// openFileDestinationVaultik returns a Vaultik that backs up dataDir to a
|
||||
// file:// destination at storeDir, with its local index at dbPath. The
|
||||
// index is closed when the test ends.
|
||||
func openFileDestinationVaultik(
|
||||
ctx context.Context, t *testing.T, storeDir, dataDir, dbPath string,
|
||||
) (*vaultik.Vaultik, *database.Repositories) {
|
||||
t.Helper()
|
||||
|
||||
store, err := storage.NewFileStorer(storeDir)
|
||||
require.NoError(t, err)
|
||||
@@ -48,22 +58,84 @@ func newFileDestinationVaultik(
|
||||
repos := database.NewRepositories(db)
|
||||
cfg := changedFileConfig(dataDir, dbPath)
|
||||
|
||||
return newBackupVaultik(ctx, cfg, store, repos, db, osFs), repos
|
||||
return newBackupVaultik(ctx, cfg, store, repos, db, afero.NewOsFs()), repos
|
||||
}
|
||||
|
||||
// firstBackup is one backup of the snapshot named "first" to a file://
|
||||
// destination. dir holds its source tree in src, its store in store and
|
||||
// its closed local index in index/index.sqlite. The first test that
|
||||
// needs it makes it, and TestMain removes it. Every test that copies it
|
||||
// backs up the same source tree, so no test may change that tree.
|
||||
//
|
||||
//nolint:gochecknoglobals // made once and shared by the tests that copy it
|
||||
var firstBackup struct {
|
||||
once sync.Once
|
||||
dir string
|
||||
ready bool
|
||||
}
|
||||
|
||||
// TestMain removes firstBackup once every test has run.
|
||||
func TestMain(m *testing.M) {
|
||||
code := m.Run()
|
||||
|
||||
_ = os.RemoveAll(firstBackup.dir)
|
||||
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
// makeFirstBackup writes the fault source tree and backs it up as the
|
||||
// snapshot named "first", all in a new directory that it records in
|
||||
// firstBackup.
|
||||
func makeFirstBackup(ctx context.Context, t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
dir, err := os.MkdirTemp("", "vaultik-first-backup-")
|
||||
require.NoError(t, err)
|
||||
|
||||
firstBackup.dir = dir
|
||||
storeDir := filepath.Join(dir, "store")
|
||||
dataDir := filepath.Join(dir, "src")
|
||||
indexDir := filepath.Join(dir, "index")
|
||||
|
||||
require.NoError(t, os.Mkdir(storeDir, 0o750))
|
||||
require.NoError(t, os.Mkdir(indexDir, 0o750))
|
||||
writeFaultSourceTree(t, afero.NewOsFs(), dataDir)
|
||||
|
||||
v, _ := openFileDestinationVaultik(ctx, t, storeDir, dataDir,
|
||||
filepath.Join(indexDir, "index.sqlite"))
|
||||
require.NoError(t, backUp(v, "first"))
|
||||
require.NoError(t, v.DB.Close())
|
||||
|
||||
firstBackup.ready = true
|
||||
}
|
||||
|
||||
// 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.
|
||||
// before the first backup, and returns a Vaultik whose file://
|
||||
// destination there holds a backup of the snapshot named "first". The
|
||||
// store and the local index are copies of firstBackup's, which is faster
|
||||
// than backing up in every test. The returned Vaultik backs up a source
|
||||
// tree shared with every other caller, so no test may change that tree.
|
||||
// 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))
|
||||
firstBackup.once.Do(func() { makeFirstBackup(ctx, t) })
|
||||
require.True(t, firstBackup.ready, "another test failed to make firstBackup")
|
||||
|
||||
v, repos := newFileDestinationVaultik(ctx, t, storeDir)
|
||||
require.NoError(t, backUp(v, "first"))
|
||||
require.NoError(t, os.MkdirAll(storeDir, 0o750))
|
||||
require.NoError(t, os.CopyFS(storeDir,
|
||||
os.DirFS(filepath.Join(firstBackup.dir, "store"))))
|
||||
|
||||
indexDir := t.TempDir()
|
||||
require.NoError(t, os.CopyFS(indexDir,
|
||||
os.DirFS(filepath.Join(firstBackup.dir, "index"))))
|
||||
|
||||
v, repos := openFileDestinationVaultik(ctx, t, storeDir,
|
||||
filepath.Join(firstBackup.dir, "src"),
|
||||
filepath.Join(indexDir, "index.sqlite"))
|
||||
|
||||
out := &bytes.Buffer{}
|
||||
v.Stdout = out
|
||||
|
||||
@@ -76,7 +76,7 @@ func TestNukeRemoteLeavesNoFiles(t *testing.T) {
|
||||
// TestNukeRemoteFailsWhenADeleteFails checks that remote nuke returns an
|
||||
// error when a snapshot's metadata or a blob cannot be deleted, does not
|
||||
// report the store empty, and advises running nuke again: prune deletes
|
||||
// no metadata.
|
||||
// no snapshot metadata that has its manifest.
|
||||
func TestNukeRemoteFailsWhenADeleteFails(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
+248
-64
@@ -4,10 +4,12 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/vaultik/internal/log"
|
||||
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||
"sneak.berlin/go/vaultik/internal/storage"
|
||||
)
|
||||
|
||||
@@ -24,6 +26,11 @@ var errNukeRequiresForce = errors.New(
|
||||
// errBlobDeleteFailed reports unreferenced blobs prune could not delete.
|
||||
var errBlobDeleteFailed = errors.New("blob(s) could not be deleted")
|
||||
|
||||
// errMetadataDirDeleteFailed reports directories under metadata/ with no
|
||||
// manifest that prune could not delete.
|
||||
var errMetadataDirDeleteFailed = errors.New(
|
||||
"metadata director(ies) with no manifest could not be deleted")
|
||||
|
||||
// errObjectsLeft reports objects remote nuke still finds on the backup
|
||||
// destination store after deleting.
|
||||
var errObjectsLeft = errors.New(
|
||||
@@ -31,7 +38,8 @@ var errObjectsLeft = errors.New(
|
||||
|
||||
// nukeCommandHint is the command suggested when a remote nuke could not
|
||||
// delete everything: running it again deletes what is left, which
|
||||
// `vaultik prune` does not do for snapshot metadata.
|
||||
// `vaultik prune` does not do for snapshot metadata that has its
|
||||
// manifest.
|
||||
const nukeCommandHint = "vaultik remote nuke --force"
|
||||
|
||||
// metadataDirName is the top-level remote directory holding snapshot
|
||||
@@ -58,9 +66,9 @@ func (v *Vaultik) NukeRemote(force bool) error {
|
||||
|
||||
// Checked before anything is deleted: running nuke again would fail
|
||||
// the same way, so the advice below must not follow these errors.
|
||||
// The listing also fails on a destination without the marker while
|
||||
// the local index holds snapshots.
|
||||
err := v.EnsureStorageBinding()
|
||||
// The listing also fails on a destination that answers that it has
|
||||
// no marker while the local index holds snapshots.
|
||||
err := v.checkStorageBinding()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -169,16 +177,22 @@ type PruneBlobsResult struct {
|
||||
// newer than the prune grace period, or of unknown age. BlobsFound
|
||||
// does not include them.
|
||||
BlobsTooNew int `json:"blobs_too_new"`
|
||||
// MetadataDirsDeleted counts the directories under metadata/ with no
|
||||
// manifest that prune deleted, and MetadataDirsFailed those it could
|
||||
// not delete.
|
||||
MetadataDirsDeleted int `json:"metadata_dirs_deleted"`
|
||||
MetadataDirsFailed int `json:"metadata_dirs_failed,omitempty"`
|
||||
}
|
||||
|
||||
// Prune removes orphaned data from the local index database AND
|
||||
// unreferenced blobs from the backup destination store. This is the
|
||||
// single user-facing prune entry point — the split between local and
|
||||
// remote cleanup is an implementation detail. Calling code should
|
||||
// prefer this method over PruneDatabase or PruneBlobs individually
|
||||
// unless it specifically wants one half.
|
||||
// Prune removes orphaned data from the local index database AND, from the
|
||||
// backup destination store, unreferenced blobs and the directories under
|
||||
// metadata/ with no manifest. This is the single user-facing prune entry
|
||||
// point — the split between local and remote cleanup is an
|
||||
// implementation detail. Calling code should prefer this method over
|
||||
// PruneDatabase or PruneBlobs individually unless it specifically wants
|
||||
// one half.
|
||||
func (v *Vaultik) Prune(opts *PruneOptions) error {
|
||||
err := v.EnsureStorageBinding()
|
||||
err := v.checkStorageBinding()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -200,11 +214,17 @@ func (v *Vaultik) Prune(opts *PruneOptions) error {
|
||||
return v.PruneBlobs(opts)
|
||||
}
|
||||
|
||||
// PruneBlobs removes unreferenced blobs from storage
|
||||
// PruneBlobs removes unreferenced blobs from storage, and each directory
|
||||
// under metadata/ that a backup left with no manifest.
|
||||
func (v *Vaultik) PruneBlobs(opts *PruneOptions) error {
|
||||
log.Info("Starting prune operation")
|
||||
|
||||
allBlobsReferenced, err := v.collectReferencedBlobs()
|
||||
metadataObjects, err := v.listMetadataObjects()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
allBlobsReferenced, err := v.collectReferencedBlobs(metadataObjects)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -217,6 +237,11 @@ func (v *Vaultik) PruneBlobs(opts *PruneOptions) error {
|
||||
unreferencedBlobs, totalSize, tooNew := v.findUnreferencedBlobs(
|
||||
allBlobs, allBlobsReferenced)
|
||||
|
||||
dirsWithoutManifest, err := v.findMetadataDirsWithoutManifest(metadataObjects)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result := &PruneBlobsResult{
|
||||
BlobsFound: len(unreferencedBlobs),
|
||||
BlobsTooNew: tooNew,
|
||||
@@ -224,7 +249,7 @@ func (v *Vaultik) PruneBlobs(opts *PruneOptions) error {
|
||||
|
||||
v.reportBlobsTooNew(tooNew, opts)
|
||||
|
||||
if len(unreferencedBlobs) == 0 {
|
||||
if len(unreferencedBlobs) == 0 && len(dirsWithoutManifest) == 0 {
|
||||
log.Info("No unreferenced blobs found")
|
||||
|
||||
if opts.JSON {
|
||||
@@ -237,38 +262,16 @@ func (v *Vaultik) PruneBlobs(opts *PruneOptions) error {
|
||||
}
|
||||
|
||||
log.Info("Found unreferenced blobs",
|
||||
"count", len(unreferencedBlobs), "total_size", ubytes(totalSize))
|
||||
"count", len(unreferencedBlobs), "total_size", ubytes(totalSize),
|
||||
"metadata_dirs_without_manifest", len(dirsWithoutManifest))
|
||||
|
||||
if opts.Force && !opts.JSON {
|
||||
v.UI.Infof("Found %d unreferenced blob(s) totaling %s.",
|
||||
len(unreferencedBlobs), ubytes(totalSize))
|
||||
}
|
||||
|
||||
if !opts.Force && !opts.JSON {
|
||||
// The count, the prompt and its answer bypass the UI: they are an
|
||||
// interactive exchange the operator must see, so --quiet does not
|
||||
// silence them.
|
||||
v.stdoutf("Found %d unreferenced blob(s) totaling %s.\n",
|
||||
len(unreferencedBlobs), ubytes(totalSize))
|
||||
v.stdoutf("\nDelete %d unreferenced blob(s)? [y/N] ", len(unreferencedBlobs))
|
||||
|
||||
var confirm string
|
||||
|
||||
_, err = v.scanStdin(&confirm)
|
||||
if err != nil {
|
||||
v.printlnStdout("Cancelled")
|
||||
|
||||
return nil //nolint:nilerr // read failure means no confirmation
|
||||
}
|
||||
|
||||
if strings.ToLower(confirm) != "y" {
|
||||
v.printlnStdout("Cancelled")
|
||||
|
||||
return nil
|
||||
}
|
||||
if !v.confirmPrune(opts, len(unreferencedBlobs), totalSize,
|
||||
dirsWithoutManifest) {
|
||||
return ErrNotConfirmed
|
||||
}
|
||||
|
||||
v.deleteUnreferencedBlobs(unreferencedBlobs, allBlobs, result)
|
||||
v.deleteMetadataDirs(dirsWithoutManifest, result)
|
||||
|
||||
if opts.JSON {
|
||||
err = v.outputPruneBlobsJSON(result)
|
||||
@@ -276,33 +279,142 @@ func (v *Vaultik) PruneBlobs(opts *PruneOptions) error {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
v.reportPruneDeletions(result, len(dirsWithoutManifest))
|
||||
}
|
||||
|
||||
var failures []error
|
||||
|
||||
if result.BlobsFailed > 0 {
|
||||
failures = append(failures,
|
||||
fmt.Errorf("%d %w", result.BlobsFailed, errBlobDeleteFailed))
|
||||
}
|
||||
|
||||
if result.MetadataDirsFailed > 0 {
|
||||
failures = append(failures, fmt.Errorf("%d %w",
|
||||
result.MetadataDirsFailed, errMetadataDirDeleteFailed))
|
||||
}
|
||||
|
||||
return errors.Join(failures...)
|
||||
}
|
||||
|
||||
// confirmPrune states what prune found to delete, unreferenced blobs and
|
||||
// directories under metadata/ with no manifest, naming each directory by
|
||||
// its remote key, and, unless --force is given, asks the operator to
|
||||
// confirm. Under --json it prints nothing and does not ask. It reports
|
||||
// whether to go ahead.
|
||||
func (v *Vaultik) confirmPrune(
|
||||
opts *PruneOptions, blobCount int, blobsSize int64, dirs []string,
|
||||
) bool {
|
||||
if opts.JSON {
|
||||
return true
|
||||
}
|
||||
|
||||
dirCount := len(dirs)
|
||||
blobsFound := fmt.Sprintf("Found %d unreferenced blob(s) totaling %s.",
|
||||
blobCount, ubytes(blobsSize))
|
||||
dirsFound := fmt.Sprintf("Found %d metadata director(ies) left by "+
|
||||
"backups that stopped before their manifest:", dirCount)
|
||||
|
||||
if opts.Force {
|
||||
if blobCount > 0 {
|
||||
v.UI.Infof("%s", blobsFound)
|
||||
}
|
||||
|
||||
if dirCount > 0 {
|
||||
v.UI.Infof("%s", dirsFound)
|
||||
|
||||
for _, remoteKey := range dirs {
|
||||
v.UI.Detailf("metadata/%s/", remoteKey)
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// The counts, the names, the prompt and its answer bypass the UI:
|
||||
// they are an interactive exchange the operator must see, so --quiet
|
||||
// does not silence them.
|
||||
if blobCount > 0 {
|
||||
v.stdoutf("%s\n", blobsFound)
|
||||
}
|
||||
|
||||
if dirCount > 0 {
|
||||
v.stdoutf("%s\n", dirsFound)
|
||||
|
||||
for _, remoteKey := range dirs {
|
||||
v.stdoutf(" metadata/%s/\n", remoteKey)
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case dirCount == 0:
|
||||
v.stdoutf("\nDelete %d unreferenced blob(s)? [y/N] ", blobCount)
|
||||
case blobCount == 0:
|
||||
v.stdoutf("\nDelete %d metadata director(ies)? [y/N] ", dirCount)
|
||||
default:
|
||||
v.stdoutf("\nDelete %d unreferenced blob(s) and %d metadata "+
|
||||
"director(ies)? [y/N] ", blobCount, dirCount)
|
||||
}
|
||||
|
||||
var confirm string
|
||||
|
||||
// A failure to read the answer means no confirmation.
|
||||
_, err := v.scanStdin(&confirm)
|
||||
if err != nil || strings.ToLower(confirm) != "y" {
|
||||
v.printlnStdout("Cancelled")
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// reportPruneDeletions says how many blobs and metadata directories
|
||||
// prune deleted, naming each kind only when it found some to delete.
|
||||
func (v *Vaultik) reportPruneDeletions(result *PruneBlobsResult, dirCount int) {
|
||||
if result.BlobsFound > 0 {
|
||||
v.UI.Infof("Deleted %d blob(s) totaling %s.",
|
||||
result.BlobsDeleted, ubytes(result.BytesFreed))
|
||||
}
|
||||
|
||||
if result.BlobsFailed > 0 {
|
||||
return fmt.Errorf("%d %w", result.BlobsFailed, errBlobDeleteFailed)
|
||||
if dirCount > 0 {
|
||||
v.UI.Infof("Deleted %d metadata director(ies) with no manifest.",
|
||||
result.MetadataDirsDeleted)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// collectReferencedBlobs downloads all manifests and returns the set of
|
||||
// referenced blob hashes.
|
||||
// listMetadataObjects lists every object under metadata/ on the
|
||||
// destination store.
|
||||
func (v *Vaultik) listMetadataObjects() ([]storage.ObjectInfo, error) {
|
||||
log.Info("Listing remote snapshots")
|
||||
|
||||
var objects []storage.ObjectInfo
|
||||
|
||||
for object := range v.Storage.ListStream(v.ctx, "metadata/") {
|
||||
if object.Err != nil {
|
||||
return nil, fmt.Errorf("listing metadata objects: %w", object.Err)
|
||||
}
|
||||
|
||||
objects = append(objects, object)
|
||||
}
|
||||
|
||||
return objects, nil
|
||||
}
|
||||
|
||||
// collectReferencedBlobs downloads every manifest among the listed
|
||||
// metadata objects and returns the set of referenced blob hashes.
|
||||
//
|
||||
// Every manifest must be read successfully. A manifest that cannot be
|
||||
// downloaded or decoded means its snapshot's blobs are unknown, so
|
||||
// treating them as unreferenced would let prune delete data a snapshot
|
||||
// still needs. Rather than risk that silent loss, any failure returns an
|
||||
// error naming the remote key and prune deletes nothing.
|
||||
func (v *Vaultik) collectReferencedBlobs() (map[string]bool, error) {
|
||||
log.Info("Listing remote snapshots")
|
||||
// IDs returned by listUniqueSnapshotIDs are remote keys (hashed
|
||||
func (v *Vaultik) collectReferencedBlobs(
|
||||
metadataObjects []storage.ObjectInfo,
|
||||
) (map[string]bool, error) {
|
||||
// IDs returned by uniqueSnapshotIDs are remote keys (hashed
|
||||
// subdirectories under metadata/), not human snapshot IDs.
|
||||
remoteKeys, err := v.listUniqueSnapshotIDs()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listing snapshot keys: %w", err)
|
||||
}
|
||||
remoteKeys := uniqueSnapshotIDs(metadataObjects)
|
||||
|
||||
log.Info("Found manifests in remote storage", "count", len(remoteKeys))
|
||||
|
||||
@@ -327,18 +439,14 @@ func (v *Vaultik) collectReferencedBlobs() (map[string]bool, error) {
|
||||
return allBlobsReferenced, nil
|
||||
}
|
||||
|
||||
// listUniqueSnapshotIDs returns deduplicated snapshot IDs from remote metadata
|
||||
func (v *Vaultik) listUniqueSnapshotIDs() ([]string, error) {
|
||||
objectCh := v.Storage.ListStream(v.ctx, "metadata/")
|
||||
// uniqueSnapshotIDs returns deduplicated snapshot IDs from the listed
|
||||
// metadata objects.
|
||||
func uniqueSnapshotIDs(metadataObjects []storage.ObjectInfo) []string {
|
||||
seen := make(map[string]bool)
|
||||
|
||||
var snapshotIDs []string
|
||||
|
||||
for object := range objectCh {
|
||||
if object.Err != nil {
|
||||
return nil, fmt.Errorf("listing metadata objects: %w", object.Err)
|
||||
}
|
||||
|
||||
for _, object := range metadataObjects {
|
||||
parts := strings.Split(object.Key, "/")
|
||||
if len(parts) >= minSnapshotIDParts &&
|
||||
parts[0] == metadataDirName && parts[1] != "" {
|
||||
@@ -353,7 +461,7 @@ func (v *Vaultik) listUniqueSnapshotIDs() ([]string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
return snapshotIDs, nil
|
||||
return snapshotIDs
|
||||
}
|
||||
|
||||
// listAllRemoteBlobs returns a map of all blob hashes in remote storage
|
||||
@@ -427,6 +535,82 @@ func (v *Vaultik) findUnreferencedBlobs(
|
||||
return unreferenced, totalSize, tooNew
|
||||
}
|
||||
|
||||
// findMetadataDirsWithoutManifest returns the remote key of each
|
||||
// directory under metadata/ that has no manifest.json.zst, such as one a
|
||||
// backup leaves when its manifest upload fails after db.zst.age was
|
||||
// uploaded. It leaves out a directory a local snapshot record claims,
|
||||
// and one holding an object newer than the prune grace period, or of
|
||||
// unknown age because the listing gave no time for it: a backup still
|
||||
// running may be writing there. A name that is not a remote key is
|
||||
// never returned.
|
||||
func (v *Vaultik) findMetadataDirsWithoutManifest(
|
||||
metadataObjects []storage.ObjectInfo,
|
||||
) ([]string, error) {
|
||||
localIDs, err := v.localSnapshotIDs()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listing local snapshots: %w", err)
|
||||
}
|
||||
|
||||
keep := make(map[string]bool, len(localIDs))
|
||||
for _, id := range localIDs {
|
||||
keep[snapshot.RemoteSnapshotKey(id)] = true
|
||||
}
|
||||
|
||||
cutoff := time.Now().UTC().Add(-v.Config.PruneGracePeriod)
|
||||
listed := make(map[string]bool)
|
||||
|
||||
for _, object := range metadataObjects {
|
||||
parts := strings.Split(object.Key, "/")
|
||||
if len(parts) < metadataKeyParts || !isBlobHash(parts[1]) {
|
||||
continue
|
||||
}
|
||||
|
||||
remoteKey := parts[1]
|
||||
listed[remoteKey] = true
|
||||
|
||||
if parts[2] == "manifest.json.zst" ||
|
||||
object.ModTime.IsZero() || object.ModTime.After(cutoff) {
|
||||
keep[remoteKey] = true
|
||||
}
|
||||
}
|
||||
|
||||
var remoteKeys []string
|
||||
|
||||
for remoteKey := range listed {
|
||||
if !keep[remoteKey] {
|
||||
remoteKeys = append(remoteKeys, remoteKey)
|
||||
}
|
||||
}
|
||||
|
||||
sort.Strings(remoteKeys)
|
||||
|
||||
return remoteKeys, nil
|
||||
}
|
||||
|
||||
// deleteMetadataDirs deletes everything in the given directories under
|
||||
// metadata/ and counts in the result the directories deleted and those
|
||||
// it could not delete.
|
||||
func (v *Vaultik) deleteMetadataDirs(
|
||||
remoteKeys []string, result *PruneBlobsResult,
|
||||
) {
|
||||
for _, remoteKey := range remoteKeys {
|
||||
_, err := v.deleteRemoteSnapshotByKey(remoteKey)
|
||||
if err != nil {
|
||||
log.Error("Failed to delete metadata directory with no manifest",
|
||||
"remote_key", remoteKey, "error", err)
|
||||
|
||||
result.MetadataDirsFailed++
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
log.Info("Deleted metadata directory with no manifest",
|
||||
"remote_key", remoteKey)
|
||||
|
||||
result.MetadataDirsDeleted++
|
||||
}
|
||||
}
|
||||
|
||||
// reportBlobsTooNew says how many unreferenced blobs prune kept for being
|
||||
// newer than the grace period or of unknown age. Under --json the
|
||||
// document carries the count instead.
|
||||
|
||||
@@ -24,8 +24,9 @@ import (
|
||||
|
||||
// TestPruneDuringBackupKeepsItsBlobs runs a prune from another host
|
||||
// sharing the destination store at the moment a backup has uploaded its
|
||||
// blobs but not yet its manifest, so no manifest lists those blobs. The
|
||||
// prune must leave them, and the backup must then restore.
|
||||
// blobs and its db.zst.age but not yet its manifest, so no manifest lists
|
||||
// those blobs. The prune must leave the blobs and the db.zst.age, and the
|
||||
// backup must then restore.
|
||||
func TestPruneDuringBackupKeepsItsBlobs(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
@@ -79,7 +80,8 @@ func TestPruneDuringBackupKeepsItsBlobs(t *testing.T) {
|
||||
SnapshotID: id,
|
||||
TargetDir: restoreDir,
|
||||
Verify: true,
|
||||
}), "a prune during the backup must not delete the backup's blobs")
|
||||
}), "a prune during the backup must not delete the backup's blobs "+
|
||||
"or its db.zst.age")
|
||||
|
||||
assertRestoredTree(t, fs, restoreDir, testFiles)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
package vaultik_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/config"
|
||||
"sneak.berlin/go/vaultik/internal/log"
|
||||
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||
"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"
|
||||
)
|
||||
|
||||
// metadataObjectKey returns the key of the named file in the metadata
|
||||
// directory of the snapshot with the given human ID.
|
||||
func metadataObjectKey(snapshotID, name string) string {
|
||||
return "metadata/" + snapshot.RemoteSnapshotKey(snapshotID) + "/" + name
|
||||
}
|
||||
|
||||
// TestPruneDeletesMetadataDirWithoutManifestOlderThanGracePeriod checks
|
||||
// that prune deletes a directory under metadata/ holding only
|
||||
// db.zst.age, as a backup leaves when its manifest upload fails, once it
|
||||
// is older than the grace period, and keeps one newer than that. A
|
||||
// directory with a manifest is never deleted, however old.
|
||||
func TestPruneDeletesMetadataDirWithoutManifestOlderThanGracePeriod(
|
||||
t *testing.T,
|
||||
) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
storeDir := t.TempDir()
|
||||
|
||||
store, err := storage.NewFileStorer(storeDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
const (
|
||||
oldID = "testhost_home_2026-01-01T00:00:00Z"
|
||||
newID = "testhost_home_2026-01-02T00:00:00Z"
|
||||
completeID = "testhost_home_2026-01-03T00:00:00Z"
|
||||
)
|
||||
|
||||
oldDB := metadataObjectKey(oldID, "db.zst.age")
|
||||
newDB := metadataObjectKey(newID, "db.zst.age")
|
||||
completeDB := metadataObjectKey(completeID, "db.zst.age")
|
||||
completeManifest := metadataObjectKey(completeID, "manifest.json.zst")
|
||||
|
||||
manifest, err := snapshot.EncodeManifest(&snapshot.Manifest{
|
||||
SnapshotID: snapshot.RemoteSnapshotKey(completeID),
|
||||
}, 3)
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, key := range []string{oldDB, newDB, completeDB} {
|
||||
require.NoError(t, store.Put(ctx, key, strings.NewReader("db")))
|
||||
}
|
||||
|
||||
require.NoError(t, store.Put(ctx, completeManifest,
|
||||
bytes.NewReader(manifest)))
|
||||
|
||||
gracePeriod := time.Hour
|
||||
beforeGracePeriod := time.Now().Add(-gracePeriod - time.Minute)
|
||||
|
||||
for _, key := range []string{oldDB, completeDB, completeManifest} {
|
||||
require.NoError(t, os.Chtimes(filepath.Join(storeDir, key),
|
||||
beforeGracePeriod, beforeGracePeriod))
|
||||
}
|
||||
|
||||
stdout := &bytes.Buffer{}
|
||||
v := &vaultik.Vaultik{
|
||||
Config: &config.Config{PruneGracePeriod: gracePeriod},
|
||||
Storage: store,
|
||||
Stdout: stdout,
|
||||
UI: ui.NewWithColor(stdout, false),
|
||||
}
|
||||
v.SetContext(ctx)
|
||||
|
||||
require.NoError(t, v.PruneBlobs(&vaultik.PruneOptions{JSON: true}))
|
||||
|
||||
_, err = store.Stat(ctx, oldDB)
|
||||
require.ErrorIs(t, err, storage.ErrNotFound,
|
||||
"a directory with no manifest older than the grace period "+
|
||||
"must be deleted")
|
||||
|
||||
for _, key := range []string{newDB, completeDB, completeManifest} {
|
||||
_, err = store.Stat(ctx, key)
|
||||
require.NoError(t, err, "%s must be kept", key)
|
||||
}
|
||||
|
||||
var result vaultik.PruneBlobsResult
|
||||
|
||||
require.NoError(t, json.Unmarshal(stdout.Bytes(), &result))
|
||||
assert.Equal(t, 1, result.MetadataDirsDeleted)
|
||||
}
|
||||
|
||||
// TestPruneKeepsMetadataDirClaimedUnknownAgeOrNotRemoteKey checks that
|
||||
// prune --force keeps a directory under metadata/ with no manifest when a
|
||||
// local snapshot record claims it, when the listing gives no time for an
|
||||
// object in it, or when its name is not a remote key. A fourth such
|
||||
// directory is named, deleted and reported, although there are no
|
||||
// unreferenced blobs to delete.
|
||||
func TestPruneKeepsMetadataDirClaimedUnknownAgeOrNotRemoteKey(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
env := newListEnv(t)
|
||||
|
||||
const (
|
||||
claimedID = "testhost_home_2026-01-01T00:00:00Z"
|
||||
leftID = "testhost_home_2026-01-03T00:00:00Z"
|
||||
)
|
||||
|
||||
env.addLocal(t, claimedID, time.Now().Add(-time.Hour))
|
||||
|
||||
claimedDB := metadataObjectKey(claimedID, "db.zst.age")
|
||||
unknownAgeDB := metadataObjectKey("testhost_home_2026-01-02T00:00:00Z",
|
||||
"db.zst.age")
|
||||
leftDB := metadataObjectKey(leftID, "db.zst.age")
|
||||
notRemoteKeyDB := "metadata/not-a-remote-key/db.zst.age"
|
||||
|
||||
for _, key := range []string{claimedDB, leftDB, notRemoteKeyDB} {
|
||||
require.NoError(t, env.store.Put(ctx, key, strings.NewReader("db")))
|
||||
}
|
||||
|
||||
// Written into the mock store's map directly rather than through
|
||||
// Put, so its listing gives no time.
|
||||
env.store.data[unknownAgeDB] = []byte("db")
|
||||
|
||||
require.NoError(t, env.v.PruneBlobs(&vaultik.PruneOptions{Force: true}))
|
||||
|
||||
assert.True(t, env.store.hasKey(claimedDB),
|
||||
"a directory a local snapshot record claims must be kept")
|
||||
assert.True(t, env.store.hasKey(unknownAgeDB),
|
||||
"a directory holding an object of unknown age must be kept")
|
||||
assert.True(t, env.store.hasKey(notRemoteKeyDB),
|
||||
"a directory whose name is not a remote key must be kept")
|
||||
assert.False(t, env.store.hasKey(leftDB),
|
||||
"a directory with no manifest, unclaimed and old, must be deleted")
|
||||
|
||||
output := env.stdout.String()
|
||||
|
||||
assert.Contains(t, output, "Found 1 metadata director(ies) left by "+
|
||||
"backups that stopped before their manifest:")
|
||||
assert.Contains(t, output,
|
||||
"metadata/"+snapshot.RemoteSnapshotKey(leftID)+"/")
|
||||
assert.NotContains(t, output, "not-a-remote-key")
|
||||
assert.Contains(t, output,
|
||||
"Deleted 1 metadata director(ies) with no manifest.")
|
||||
assert.NotContains(t, output, "No unreferenced blobs to remove.")
|
||||
}
|
||||
|
||||
// TestPruneDeclinedKeepsMetadataDirWithoutManifest checks that the prompt
|
||||
// names a directory under metadata/ with no manifest that prune is about
|
||||
// to delete, and that answering no keeps it.
|
||||
func TestPruneDeclinedKeepsMetadataDirWithoutManifest(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
env := newListEnv(t)
|
||||
|
||||
const leftID = "testhost_home_2026-01-01T00:00:00Z"
|
||||
|
||||
leftDB := metadataObjectKey(leftID, "db.zst.age")
|
||||
require.NoError(t, env.store.Put(context.Background(), leftDB,
|
||||
strings.NewReader("db")))
|
||||
|
||||
env.v.Stdin = strings.NewReader("n\n")
|
||||
|
||||
require.ErrorIs(t, env.v.PruneBlobs(&vaultik.PruneOptions{}),
|
||||
vaultik.ErrNotConfirmed)
|
||||
|
||||
assert.True(t, env.store.hasKey(leftDB),
|
||||
"a declined prune must keep the directory")
|
||||
|
||||
output := env.stdout.String()
|
||||
|
||||
assert.Contains(t, output,
|
||||
" metadata/"+snapshot.RemoteSnapshotKey(leftID)+"/\n")
|
||||
assert.Contains(t, output, "Delete 1 metadata director(ies)? [y/N] ")
|
||||
assert.Contains(t, output, "Cancelled")
|
||||
assert.NotContains(t, output, "Deleted")
|
||||
}
|
||||
|
||||
// TestPruneFailedMetadataDirDeleteReturnsError checks that prune --json
|
||||
// counts a directory under metadata/ with no manifest that it cannot
|
||||
// delete, and then returns an error, so the command exits non-zero.
|
||||
func TestPruneFailedMetadataDirDeleteReturnsError(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
env := newListEnv(t)
|
||||
|
||||
leftDB := metadataObjectKey("testhost_home_2026-01-01T00:00:00Z",
|
||||
"db.zst.age")
|
||||
require.NoError(t, env.store.Put(context.Background(), leftDB,
|
||||
strings.NewReader("db")))
|
||||
|
||||
store := faultstore.New(env.store)
|
||||
store.OnDelete = func(key string) bool {
|
||||
return strings.HasPrefix(key, "metadata/")
|
||||
}
|
||||
env.v.Storage = store
|
||||
|
||||
err := env.v.PruneBlobs(&vaultik.PruneOptions{JSON: true})
|
||||
|
||||
require.ErrorContains(t, err,
|
||||
"1 metadata director(ies) with no manifest could not be deleted")
|
||||
assert.True(t, env.store.hasKey(leftDB))
|
||||
|
||||
var result vaultik.PruneBlobsResult
|
||||
|
||||
require.NoError(t, json.Unmarshal(env.stdout.Bytes(), &result),
|
||||
"stdout must hold the JSON document, got:\n%s", env.stdout.String())
|
||||
assert.Equal(t, 1, result.MetadataDirsFailed)
|
||||
assert.Equal(t, 0, result.MetadataDirsDeleted)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/log"
|
||||
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||
"sneak.berlin/go/vaultik/internal/ui"
|
||||
)
|
||||
|
||||
// testBlobHashB is a blob that the manifest written by addRemote does
|
||||
@@ -259,3 +260,24 @@ func TestRemoteInfo_DirectoryWithoutManifestLeavesOrphansKnown(t *testing.T) {
|
||||
assert.NotContains(t, doc, "unreadable_manifests")
|
||||
assert.NotContains(t, doc, "skipped_manifest_count")
|
||||
}
|
||||
|
||||
// TestRemoteInfo_StatusLineUsesUIMarker checks that the remote info
|
||||
// status lines go through internal/ui: marker-prefixed, and silenced by
|
||||
// --quiet.
|
||||
func TestRemoteInfo_StatusLineUsesUIMarker(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))
|
||||
|
||||
const statusLine = ui.Marker + " Scanning snapshot metadata...\n"
|
||||
|
||||
require.NoError(t, env.v.RemoteInfo(false))
|
||||
assert.Contains(t, env.stdout.String(), statusLine)
|
||||
|
||||
env.stdout.Reset()
|
||||
env.v.UI.SetQuiet(true)
|
||||
require.NoError(t, env.v.RemoteInfo(false))
|
||||
assert.NotContains(t, env.stdout.String(), "Scanning snapshot metadata")
|
||||
}
|
||||
@@ -54,9 +54,10 @@ func TestPurgeSnapshots_QuietPromptListsSnapshots(t *testing.T) {
|
||||
v.UI.SetQuiet(true)
|
||||
|
||||
// Stdin is empty, so the prompt reads no answer and cancels.
|
||||
require.NoError(t, v.PurgeSnapshotsWithOptions(&vaultik.SnapshotPurgeOptions{
|
||||
err := v.PurgeSnapshotsWithOptions(&vaultik.SnapshotPurgeOptions{
|
||||
KeepLatest: true,
|
||||
}))
|
||||
})
|
||||
require.ErrorIs(t, err, vaultik.ErrNotConfirmed)
|
||||
|
||||
assert.Contains(t, out.String(), snapHomeT0)
|
||||
assert.Contains(t, out.String(), "Delete 1 snapshot(s)? [y/N]")
|
||||
@@ -77,7 +78,8 @@ func TestPruneBlobs_QuietPromptShowsBlobTotal(t *testing.T) {
|
||||
addBlob(t, env.store.testStorer, testBlobHashA)
|
||||
|
||||
// Stdin is empty, so the prompt reads no answer and cancels.
|
||||
require.NoError(t, env.v.PruneBlobs(&vaultik.PruneOptions{}))
|
||||
require.ErrorIs(t, env.v.PruneBlobs(&vaultik.PruneOptions{}),
|
||||
vaultik.ErrNotConfirmed)
|
||||
|
||||
assert.Contains(t, env.stdout.String(),
|
||||
"Found 1 unreferenced blob(s) totaling")
|
||||
|
||||
@@ -337,6 +337,27 @@ func TestRemoveSnapshot_DefaultRemovesMetadataNotBlobs(t *testing.T) {
|
||||
assert.Contains(t, out, "vaultik prune")
|
||||
}
|
||||
|
||||
// TestRemoveSnapshot_DeclinedIsError checks that answering no to the
|
||||
// prompt keeps the snapshot and returns ErrNotConfirmed, so the command
|
||||
// exits non-zero.
|
||||
func TestRemoveSnapshot_DeclinedIsError(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, "snapshot-001")
|
||||
tv.Stdin.WriteString("n\n")
|
||||
|
||||
_, err := tv.RemoveSnapshot("snapshot-001", &vaultik.RemoveOptions{})
|
||||
|
||||
require.ErrorIs(t, err, vaultik.ErrNotConfirmed)
|
||||
assert.True(t, store.hasKey(remoteKeyPath("snapshot-001")))
|
||||
assert.Contains(t, tv.Stdout.String(), "Cancelled")
|
||||
}
|
||||
|
||||
func TestRemoveSnapshot_DryRun(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
+99
-28
@@ -40,6 +40,9 @@ var (
|
||||
// which is secret, so the message is safe to print and log.
|
||||
errInvalidAgeSecretKey = errors.New(
|
||||
"configured age secret key holds no usable age identity")
|
||||
// errWrongPrivateKey is returned when the configured key parses but
|
||||
// is not one of the keys the snapshot was encrypted to.
|
||||
errWrongPrivateKey = errors.New("cannot decrypt this snapshot")
|
||||
errBlobMissingFromIndex = errors.New("blob hash missing from blob index")
|
||||
errChunkNotInAnyBlob = errors.New("chunk not found in any blob")
|
||||
errBlobIDNotInHashIndex = errors.New("blob id missing from hash index")
|
||||
@@ -106,7 +109,12 @@ type RestoreResult struct {
|
||||
BytesDownloaded int64
|
||||
Duration time.Duration
|
||||
FilesFailed int
|
||||
FailedFiles []string // Paths of files that failed to restore
|
||||
FailedFiles []string // Paths of files that failed to restore for their own reason
|
||||
// Blobs that could not be downloaded, and the paths of the files
|
||||
// skipped because they need one of them. Those files are counted in
|
||||
// FilesFailed but not listed in the summary.
|
||||
BlobsFailed int
|
||||
FilesNeedingFailedBlob []string
|
||||
// Verification results (only populated if Verify option is set)
|
||||
FilesVerified int
|
||||
BytesVerified int64
|
||||
@@ -128,10 +136,19 @@ func (v *Vaultik) Restore(opts *RestoreOptions) error {
|
||||
"paths", opts.Paths,
|
||||
)
|
||||
|
||||
remoteKey, err := v.resolveSnapshotRemoteKey(opts.SnapshotID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 1: Download and decrypt the snapshot metadata database
|
||||
log.Info("Downloading snapshot metadata...")
|
||||
|
||||
tempDB, tempDir, err := v.downloadSnapshotDB(opts.SnapshotID, identities)
|
||||
tempDB, tempDir, err := v.downloadSnapshotDB(opts.SnapshotID, remoteKey, identities)
|
||||
if errors.Is(err, errWrongPrivateKey) {
|
||||
return err
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("downloading snapshot database: %w", err)
|
||||
}
|
||||
@@ -226,6 +243,11 @@ func (v *Vaultik) finishRestore(
|
||||
if result.FilesFailed > 0 {
|
||||
v.UI.Warningf("%d file(s) failed to restore:", result.FilesFailed)
|
||||
|
||||
if result.BlobsFailed > 0 {
|
||||
v.UI.Detailf("%d of them need a blob that could not be read (%d blob(s) in all).",
|
||||
len(result.FilesNeedingFailedBlob), result.BlobsFailed)
|
||||
}
|
||||
|
||||
for _, path := range result.FailedFiles {
|
||||
v.UI.Detailf("%s", v.UI.Path(path))
|
||||
}
|
||||
@@ -323,6 +345,28 @@ func (v *Vaultik) restoreIdentities() ([]age.Identity, error) {
|
||||
return identities, nil
|
||||
}
|
||||
|
||||
// newSnapshotDBReader opens the decrypting reader for a snapshot
|
||||
// database. When none of the identities can decrypt it, the error says
|
||||
// in plain words that the configured private key is the wrong one and
|
||||
// names where it was configured.
|
||||
func (v *Vaultik) newSnapshotDBReader(
|
||||
r io.Reader, identities []age.Identity,
|
||||
) (*blobgen.Reader, error) {
|
||||
blobReader, err := blobgen.NewReader(r, identities...)
|
||||
|
||||
if _, ok := errors.AsType[*age.NoIdentityMatchError](err); ok {
|
||||
return nil, fmt.Errorf(
|
||||
"%w: the private key in %s is not one of the keys the backup was encrypted to",
|
||||
errWrongPrivateKey, v.Config.AgeSecretKeySourceName())
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return blobReader, nil
|
||||
}
|
||||
|
||||
// restoreAllFiles processes files in blob-locality order: drain every
|
||||
// file whose blob set is on disk, download the missing blobs for the
|
||||
// pending file with the smallest uncached count, repeat. This keeps
|
||||
@@ -494,8 +538,8 @@ func (v *Vaultik) runRestoreLoop(
|
||||
// Returns false when nothing is pending download (the caller stops).
|
||||
//
|
||||
// A blob that cannot be downloaded is reported through
|
||||
// handleRestoreFileError for every pending file that references it, so
|
||||
// it aborts the restore unless --skip-errors is set.
|
||||
// handleRestoreBlobError, so it aborts the restore unless --skip-errors
|
||||
// is set.
|
||||
func (s *restoreSession) downloadNextBlobSet(
|
||||
plan *restorePlan, filesByID map[types.FileID]*database.File,
|
||||
) (bool, error) {
|
||||
@@ -528,12 +572,11 @@ func (s *restoreSession) downloadNextBlobSet(
|
||||
return false, err
|
||||
}
|
||||
|
||||
for _, fileID := range plan.filesReferencingBlob(hash) {
|
||||
fileErr := s.v.handleRestoreFileError(
|
||||
plan, s.opts, s.result, filesByID[fileID], fileID, err)
|
||||
if fileErr != nil {
|
||||
return false, fileErr
|
||||
}
|
||||
blobErr := s.v.handleRestoreBlobError(
|
||||
plan, s.opts, s.result, filesByID,
|
||||
plan.filesReferencingBlob(hash), err)
|
||||
if blobErr != nil {
|
||||
return false, blobErr
|
||||
}
|
||||
|
||||
// next is among the failed files, so the rest of its blob set
|
||||
@@ -624,19 +667,19 @@ func (v *Vaultik) restoreProgressTick(
|
||||
|
||||
// handleRestoreFileError records a per-file restore failure: fatal unless
|
||||
// --skip-errors is set, in which case the file is counted as failed and
|
||||
// dropped from the plan.
|
||||
// dropped from the plan. A fatal failure is not logged here: the returned
|
||||
// error is printed as the command's ERROR: line.
|
||||
func (v *Vaultik) handleRestoreFileError(
|
||||
plan *restorePlan, opts *RestoreOptions, result *RestoreResult,
|
||||
file *database.File, fileID types.FileID, err error,
|
||||
) error {
|
||||
log.Error("Failed to restore file", "path", file.Path, "error", err)
|
||||
|
||||
if !opts.SkipErrors {
|
||||
return fmt.Errorf(
|
||||
"restoring %s: %w (pass --skip-errors to continue past "+
|
||||
"restore failures)", file.Path, err)
|
||||
}
|
||||
|
||||
log.Error("Failed to restore file", "path", file.Path, "error", err)
|
||||
v.UI.Errorf("Failed to restore %s: %v. Skipping (--skip-errors).",
|
||||
v.UI.Path(file.Path.String()), err)
|
||||
|
||||
@@ -648,6 +691,41 @@ func (v *Vaultik) handleRestoreFileError(
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleRestoreBlobError records a blob that could not be downloaded.
|
||||
// fileIDs are the pending files that need it, never empty. Without
|
||||
// --skip-errors the first of them fails the restore. With it, the blob is
|
||||
// reported once, and its files are counted as failed and dropped from
|
||||
// the plan without being reported one by one.
|
||||
func (v *Vaultik) handleRestoreBlobError(
|
||||
plan *restorePlan, opts *RestoreOptions, result *RestoreResult,
|
||||
filesByID map[types.FileID]*database.File, fileIDs []types.FileID,
|
||||
err error,
|
||||
) error {
|
||||
if !opts.SkipErrors {
|
||||
return v.handleRestoreFileError(
|
||||
plan, opts, result, filesByID[fileIDs[0]], fileIDs[0], err)
|
||||
}
|
||||
|
||||
log.Error("Failed to restore files", "files", len(fileIDs), "error", err)
|
||||
v.UI.Errorf("Failed to restore %d file(s): %v. Skipping them (--skip-errors).",
|
||||
len(fileIDs), err)
|
||||
|
||||
result.BlobsFailed++
|
||||
|
||||
for _, fileID := range fileIDs {
|
||||
path := filesByID[fileID].Path.String()
|
||||
|
||||
log.Debug("Skipped file that needs a failed blob", "path", path)
|
||||
|
||||
result.FilesFailed++
|
||||
result.FilesNeedingFailedBlob = append(result.FilesNeedingFailedBlob, path)
|
||||
|
||||
plan.finishFile(fileID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// printRestoreProgress emits a periodic restore-phase status line via
|
||||
// the UI writer, mirroring scanner.printProcessingProgress so the two
|
||||
// long-running commands have the same on-screen rhythm.
|
||||
@@ -719,8 +797,8 @@ func (v *Vaultik) handleRestoreVerification(
|
||||
) error {
|
||||
// A file that failed to restore was not written and is already
|
||||
// reported as such, so it is not verified.
|
||||
failedToRestore := make(map[string]bool, len(result.FailedFiles))
|
||||
for _, path := range result.FailedFiles {
|
||||
failedToRestore := make(map[string]bool, result.FilesFailed)
|
||||
for _, path := range slices.Concat(result.FailedFiles, result.FilesNeedingFailedBlob) {
|
||||
failedToRestore[path] = true
|
||||
}
|
||||
|
||||
@@ -754,19 +832,12 @@ func (v *Vaultik) handleRestoreVerification(
|
||||
return nil
|
||||
}
|
||||
|
||||
// downloadSnapshotDB downloads and decrypts the snapshot metadata
|
||||
// database. The identifier is resolved to the snapshot's remote key: a
|
||||
// human ID is hashed, and a remote key (or its abbreviation, as printed
|
||||
// for a remote-only snapshot) is used as-is, so a host with no local
|
||||
// index can restore the snapshots it can only see on the store.
|
||||
// downloadSnapshotDB downloads and decrypts the metadata database of the
|
||||
// snapshot at remoteKey, which resolveSnapshotRemoteKey returned for
|
||||
// snapshotID.
|
||||
func (v *Vaultik) downloadSnapshotDB(
|
||||
snapshotID string, identities []age.Identity,
|
||||
snapshotID, remoteKey string, identities []age.Identity,
|
||||
) (*database.DB, string, error) {
|
||||
remoteKey, err := v.resolveSnapshotRemoteKey(snapshotID)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
// Download encrypted database from storage
|
||||
dbKey := fmt.Sprintf("metadata/%s/db.zst.age", remoteKey)
|
||||
|
||||
@@ -783,9 +854,9 @@ func (v *Vaultik) downloadSnapshotDB(
|
||||
// the plaintext to a temp file. Neither the encrypted bytes nor the
|
||||
// decrypted database is ever held whole in memory; a snapshot database
|
||||
// can be large.
|
||||
blobReader, err := blobgen.NewReader(reader, identities...)
|
||||
blobReader, err := v.newSnapshotDBReader(reader, identities)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("creating decryption reader: %w", err)
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
defer func() { _ = blobReader.Close() }()
|
||||
|
||||
@@ -46,11 +46,11 @@ import (
|
||||
// slice out a few KB — currently the dominant cost in restore.
|
||||
//
|
||||
// The test deliberately constructs an adversarial scenario: three
|
||||
// blobs A/B/C of ~6 MB each, nine files distributed across them, and
|
||||
// path-ordered names that interleave the blobs (a1, b1, c1, a2, b2,
|
||||
// c2, …) so naive path-order processing would touch every blob before
|
||||
// finishing any of them.
|
||||
// localitySource is one 1 MiB fixture file used by the locality test.
|
||||
// blobs A/B/C of five 64 KiB chunks each, nine files distributed across
|
||||
// them, and path-ordered names that interleave the blobs (a1, b1, c1,
|
||||
// a2, b2, c2, …) so naive path-order processing would touch every blob
|
||||
// before finishing any of them.
|
||||
// localitySource is one 64 KiB fixture file used by the locality test.
|
||||
type localitySource struct {
|
||||
path string
|
||||
data []byte
|
||||
@@ -65,10 +65,10 @@ type localityCopy struct {
|
||||
|
||||
// localitySourceBytes is the size of each source file of the locality
|
||||
// fixture.
|
||||
const localitySourceBytes = 1024 * 1024
|
||||
const localitySourceBytes = 64 * 1024
|
||||
|
||||
// buildLocalityFixture writes the adversarial source layout described
|
||||
// in TestRestoreLocalityAndReadAt: 15 sources of 1 MiB each (which the
|
||||
// in TestRestoreLocalityAndReadAt: 15 sources of 64 KiB each (which the
|
||||
// backup packs into 3 blobs of 5 chunks) plus 9 interleaved-name copies
|
||||
// (3 per blob group).
|
||||
func buildLocalityFixture(
|
||||
|
||||
@@ -51,27 +51,72 @@ func TestRestoreSkipErrorsSkipsFilesOfMissingBlob(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
assertRestoreSkipsLostFiles(ctx, t, backupThenDeleteOneBlob(ctx, t))
|
||||
backup := backupThenDeleteOneBlob(ctx, t)
|
||||
out := assertRestoreSkipsLostFiles(ctx, t, backup)
|
||||
|
||||
assertLostFilesCountedNotListed(t, backup, out)
|
||||
}
|
||||
|
||||
// TestRestoreSkipErrorsReportsUnreadableBlobOnce restores with SkipErrors
|
||||
// after one blob of a two-blob snapshot was overwritten with other bytes
|
||||
// of the same size. The blob must be reported once, by its hash, as not a
|
||||
// valid age stream, without quoting its bytes, and its files must be
|
||||
// counted, not listed.
|
||||
func TestRestoreSkipErrorsReportsUnreadableBlobOnce(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
backup, lostBlob := backupThenOverwriteOneBlob(ctx, t)
|
||||
out := assertRestoreSkipsLostFiles(ctx, t, backup)
|
||||
|
||||
assert.Equal(t, 1, strings.Count(out, "not a valid age stream"), out)
|
||||
assert.Contains(t, out, lostBlob[:16])
|
||||
assert.NotContains(t, out, overwriteBlobPattern)
|
||||
assertLostFilesCountedNotListed(t, backup, out)
|
||||
}
|
||||
|
||||
// TestRestoreSkipErrorsSkipsFilesWithChunkInNoBlob restores with
|
||||
// SkipErrors a snapshot whose metadata lists chunks that no blob holds, as
|
||||
// earlier backup defects left behind.
|
||||
// earlier backup defects left behind. Each such file fails for its own
|
||||
// reason, so each is listed.
|
||||
func TestRestoreSkipErrorsSkipsFilesWithChunkInNoBlob(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
assertRestoreSkipsLostFiles(ctx, t, backupThenDropOneBlobFromMetadata(ctx, t))
|
||||
backup := backupThenDropOneBlobFromMetadata(ctx, t)
|
||||
out := assertRestoreSkipsLostFiles(ctx, t, backup)
|
||||
|
||||
for path := range backup.lost {
|
||||
assert.Containsf(t, out, path, "%s must be reported", path)
|
||||
}
|
||||
}
|
||||
|
||||
// assertRestoreSkipsLostFiles restores backup with SkipErrors. Every file
|
||||
// stored in the lost blob must be reported as failed and left absent,
|
||||
// every other file must be restored intact, and Restore must still return
|
||||
// an error.
|
||||
// assertLostFilesCountedNotListed checks that the restore output out
|
||||
// counts the files of the lost blob and names none of them.
|
||||
func assertLostFilesCountedNotListed(
|
||||
t *testing.T, backup *missingBlobBackup, out string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
assert.Contains(t, out, fmt.Sprintf(
|
||||
"%d of them need a blob that could not be read (1 blob(s) in all)",
|
||||
len(backup.lost)))
|
||||
|
||||
for path := range backup.lost {
|
||||
assert.NotContainsf(t, out, path,
|
||||
"%s needs the lost blob and must be counted, not listed", path)
|
||||
}
|
||||
}
|
||||
|
||||
// assertRestoreSkipsLostFiles restores backup with SkipErrors and returns
|
||||
// what it printed. Every file stored in the lost blob must be left
|
||||
// absent, every other file must be restored intact, and Restore must
|
||||
// still return an error.
|
||||
func assertRestoreSkipsLostFiles(
|
||||
ctx context.Context, t *testing.T, backup *missingBlobBackup,
|
||||
) {
|
||||
) string {
|
||||
t.Helper()
|
||||
|
||||
var out bytes.Buffer
|
||||
@@ -93,8 +138,6 @@ func assertRestoreSkipsLostFiles(
|
||||
restored := filepath.Join(backup.restoreDir, path)
|
||||
|
||||
if backup.lost[path] {
|
||||
assert.Containsf(t, out.String(), path,
|
||||
"%s needs the lost blob and must be reported", path)
|
||||
assert.NoFileExists(t, restored)
|
||||
|
||||
continue
|
||||
@@ -104,6 +147,8 @@ func assertRestoreSkipsLostFiles(
|
||||
require.NoErrorf(t, err, "%s does not need the lost blob", path)
|
||||
assert.Equalf(t, content, got, "%s restored with wrong content", path)
|
||||
}
|
||||
|
||||
return out.String()
|
||||
}
|
||||
|
||||
// TestRestoreSkipErrorsVerifyChecksOnlyRestoredFiles restores with
|
||||
@@ -144,9 +189,9 @@ func TestRestoreSkipErrorsVerifyChecksOnlyRestoredFiles(t *testing.T) {
|
||||
// TestRestoreSkipErrorsVerifyReportsFailuresApart restores with
|
||||
// SkipErrors and Verify after one blob of a two-blob snapshot was
|
||||
// deleted, while one file that does restore is written one byte too long.
|
||||
// The restore warning must list only the files of the deleted blob, the
|
||||
// verification summary only the long file, each path once, and Restore
|
||||
// must return an error.
|
||||
// The restore warning must count the files of the deleted blob without
|
||||
// listing them, the verification summary must list only the long file,
|
||||
// once, and Restore must return an error.
|
||||
func TestRestoreSkipErrorsVerifyReportsFailuresApart(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
@@ -186,9 +231,9 @@ func TestRestoreSkipErrorsVerifyReportsFailuresApart(t *testing.T) {
|
||||
"Verification failed: 1 files did not match")
|
||||
require.Truef(t, found, "no verification summary in:\n%s", out.String())
|
||||
|
||||
assertLostFilesCountedNotListed(t, backup, warning)
|
||||
|
||||
for path := range backup.lost {
|
||||
assert.Equalf(t, 1, strings.Count(warning, path),
|
||||
"%s must be listed once as failed to restore", path)
|
||||
assert.NotContainsf(t, summary, path,
|
||||
"%s was not restored and must not fail verification", path)
|
||||
}
|
||||
@@ -292,6 +337,35 @@ func backupThenDeleteOneBlob(
|
||||
return backup
|
||||
}
|
||||
|
||||
// overwriteBlobPattern repeats, each time followed by a newline, through
|
||||
// the bytes that backupThenOverwriteOneBlob writes over a blob. age's own
|
||||
// error quotes the data's first line only when it reads a whole line.
|
||||
const overwriteBlobPattern = "overwritten-blob-bytes-"
|
||||
|
||||
// backupThenOverwriteOneBlob backs up missingBlobFileCount single-chunk
|
||||
// files, then overwrites the blob holding the first of them with as many
|
||||
// bytes of overwriteBlobPattern lines. It also returns that blob's hash.
|
||||
func backupThenOverwriteOneBlob(
|
||||
ctx context.Context, t *testing.T,
|
||||
) (*missingBlobBackup, string) {
|
||||
t.Helper()
|
||||
|
||||
backup, db, lostBlob := backupSingleChunkFiles(ctx, t)
|
||||
require.NoError(t, db.Close())
|
||||
|
||||
key := fmt.Sprintf("blobs/%s/%s/%s", lostBlob[:2], lostBlob[2:4], lostBlob)
|
||||
|
||||
info, err := backup.storer.Stat(ctx, key)
|
||||
require.NoError(t, err)
|
||||
|
||||
line := overwriteBlobPattern + "\n"
|
||||
pattern := bytes.Repeat([]byte(line), int(info.Size)/len(line)+1)
|
||||
require.NoError(t, backup.storer.Put(
|
||||
ctx, key, bytes.NewReader(pattern[:info.Size])))
|
||||
|
||||
return backup, lostBlob
|
||||
}
|
||||
|
||||
// backupThenDropOneBlobFromMetadata backs up missingBlobFileCount
|
||||
// single-chunk files, then removes the blob holding the first of them from
|
||||
// the snapshot and exports the snapshot metadata again. The exported
|
||||
|
||||
@@ -27,10 +27,10 @@ import (
|
||||
// TestRestoreSweeperEvictsBlobs exercises the reference-counted blob
|
||||
// disk cache eviction during restore.
|
||||
//
|
||||
// The scenario: 30 unique 1 MB random files plus 10 duplicates of those
|
||||
// (40 files total, 30 MB of unique content) get backed up with a 10 MB
|
||||
// blob_size_limit. After backup the snapshot's encrypted blobs are
|
||||
// restored through Vaultik.Restore, and per-key Get counts on the
|
||||
// The scenario: 30 unique 128 KiB random files plus 10 duplicates of
|
||||
// those (40 files total, 3.75 MiB of unique content) get backed up with
|
||||
// a 1 MiB blob_size_limit. After backup the snapshot's encrypted blobs
|
||||
// are restored through Vaultik.Restore, and per-key Get counts on the
|
||||
// storage layer are recorded. Each blob in the snapshot MUST be
|
||||
// downloaded exactly once — re-downloads would mean the sweeper either
|
||||
// evicted a blob that was still needed (LRU regression) or that the
|
||||
@@ -41,7 +41,7 @@ import (
|
||||
// original AND every duplicate referencing its chunks have been
|
||||
// restored.
|
||||
// buildSweeperFixture writes the source layout for the sweeper test: 30
|
||||
// unique 1 MB random files plus 10 duplicates of a random subset. The
|
||||
// unique 128 KiB random files plus 10 duplicates of a random subset. The
|
||||
// PRNG seed is fixed so failures are reproducible; the entropy is what
|
||||
// matters here — the FastCDC chunker needs realistic-looking data to
|
||||
// pick chunk boundaries naturally. Returns the expected content by
|
||||
@@ -51,7 +51,7 @@ func buildSweeperFixture(
|
||||
) map[string][]byte {
|
||||
t.Helper()
|
||||
|
||||
const fileSize = 1 * 1024 * 1024
|
||||
const fileSize = 128 * 1024
|
||||
|
||||
rng := rand.New(rand.NewSource(42)) //nolint:gosec // G404: fixture data only
|
||||
|
||||
@@ -159,7 +159,7 @@ func runSweeperBackup(
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, uniqueFiles+duplicateFiles, scanResult.FilesScanned)
|
||||
require.Greater(t, scanResult.BlobsCreated, 1,
|
||||
"30 MB of unique data at 10 MB blob size should yield multiple blobs")
|
||||
"3.75 MiB of unique data at 1 MiB blob size should yield multiple blobs")
|
||||
|
||||
require.NoError(t, sm.CompleteSnapshot(ctx, snapshotID))
|
||||
require.NoError(t, sm.ExportSnapshotMetadata(ctx, dbPath, snapshotID))
|
||||
@@ -199,8 +199,8 @@ func TestRestoreSweeperEvictsBlobs(t *testing.T) {
|
||||
|
||||
expected := buildSweeperFixture(t, fs, dataDir, uniqueFiles, duplicateFiles)
|
||||
|
||||
chunkSize := int64(64 * 1024)
|
||||
maxBlobSize := int64(10 * 1024 * 1024)
|
||||
chunkSize := int64(16 * 1024)
|
||||
maxBlobSize := int64(1024 * 1024)
|
||||
|
||||
storer, err := storage.NewFileStorer(storeDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
package vaultik_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/blobgen"
|
||||
"sneak.berlin/go/vaultik/internal/config"
|
||||
"sneak.berlin/go/vaultik/internal/log"
|
||||
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||
"sneak.berlin/go/vaultik/internal/ui"
|
||||
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||
)
|
||||
|
||||
// TestWrongPrivateKeyError checks that restore and deep verify, given a
|
||||
// valid private key the backup was not encrypted to, fail with one plain
|
||||
// sentence that names where the key was configured.
|
||||
func TestWrongPrivateKeyError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
store := NewMockStorer()
|
||||
|
||||
const snapshotID = "test-host_2026-01-01T00:00:00Z"
|
||||
|
||||
remoteKey := snapshot.RemoteSnapshotKey(snapshotID)
|
||||
|
||||
manifest, err := snapshot.EncodeManifest(
|
||||
&snapshot.Manifest{SnapshotID: remoteKey}, 3)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, store.Put(ctx,
|
||||
"metadata/"+remoteKey+"/manifest.json.zst", bytes.NewReader(manifest)))
|
||||
|
||||
// The database is encrypted to the test recipient only.
|
||||
var db bytes.Buffer
|
||||
|
||||
writer, err := blobgen.NewWriter(&db, 1, []string{testAgePublicKey})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = writer.Write([]byte("snapshot database"))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, writer.Close())
|
||||
require.NoError(t, store.Put(ctx,
|
||||
"metadata/"+remoteKey+"/db.zst.age", &db))
|
||||
|
||||
otherKey, err := age.GenerateX25519Identity()
|
||||
require.NoError(t, err)
|
||||
|
||||
v := &vaultik.Vaultik{
|
||||
Config: &config.Config{AgeSecretKey: otherKey.String()},
|
||||
Storage: store,
|
||||
Fs: afero.NewMemMapFs(),
|
||||
Stdout: io.Discard,
|
||||
Stderr: io.Discard,
|
||||
UI: ui.NewWithColor(io.Discard, false),
|
||||
}
|
||||
v.SetContext(ctx)
|
||||
|
||||
// A Config built directly has no recorded source, so the error names
|
||||
// the config-file field.
|
||||
const want = "cannot decrypt this snapshot: the private key in " +
|
||||
"age_secret_key is not one of the keys the backup was encrypted to"
|
||||
|
||||
err = v.Restore(&vaultik.RestoreOptions{
|
||||
SnapshotID: snapshotID,
|
||||
TargetDir: t.TempDir(),
|
||||
})
|
||||
require.EqualError(t, err, want)
|
||||
|
||||
err = v.RunDeepVerify(snapshotID, &vaultik.VerifyOptions{Deep: true})
|
||||
require.EqualError(t, err, want)
|
||||
}
|
||||
|
||||
// TestWrongPrivateKeyForBlobError backs up a file, then backs it up again
|
||||
// from an empty local index with a second recipient added. The second
|
||||
// snapshot reuses the stored blob, which stays encrypted to the first
|
||||
// recipient only. With only the added key configured, restore and deep
|
||||
// verify read the database but fail on the blob with one plain sentence
|
||||
// that names where the key was configured.
|
||||
func TestWrongPrivateKeyForBlobError(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
tempDir := t.TempDir()
|
||||
dataDir := filepath.Join(tempDir, "source")
|
||||
dataPath := filepath.Join(dataDir, "data.bin")
|
||||
storeDir := t.TempDir()
|
||||
secondDBPath := filepath.Join(tempDir, "second.sqlite")
|
||||
|
||||
chunkSize := int64(64 * 1024)
|
||||
maxBlobSize := int64(512 * 1024)
|
||||
|
||||
require.NoError(t, fs.MkdirAll(dataDir, 0o755))
|
||||
require.NoError(t, afero.WriteFile(fs, dataPath,
|
||||
bytesPattern("key-", int(chunkSize)), 0o644))
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
runFileStorageBackup(ctx, t, fs, dataDir, storeDir,
|
||||
filepath.Join(tempDir, "first.sqlite"), chunkSize, maxBlobSize)
|
||||
|
||||
env := setupDedupBackupEnv(
|
||||
ctx, t, fs, storeDir, secondDBPath, chunkSize, maxBlobSize)
|
||||
|
||||
defer func() { _ = env.db.Close() }()
|
||||
|
||||
addedKey, err := age.GenerateX25519Identity()
|
||||
require.NoError(t, err)
|
||||
|
||||
env.cfg.AgeRecipients = append(env.cfg.AgeRecipients,
|
||||
addedKey.Recipient().String())
|
||||
|
||||
snapshotID, result := runDedupSnapshot(ctx, t, env.sm, env.makeScanner(),
|
||||
env.cfg.Hostname, "second", dataDir, secondDBPath)
|
||||
require.Zero(t, result.BlobsUploaded,
|
||||
"the second backup should reuse the stored blob")
|
||||
|
||||
require.NoError(t, env.db.Close())
|
||||
|
||||
blobKeys, err := env.storer.List(ctx, "blobs/")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, blobKeys, 1)
|
||||
|
||||
blobHash := path.Base(blobKeys[0])
|
||||
|
||||
env.cfg.AgeSecretKey = addedKey.String()
|
||||
|
||||
v := &vaultik.Vaultik{
|
||||
Config: env.cfg,
|
||||
Storage: env.storer,
|
||||
Fs: fs,
|
||||
Stdout: io.Discard,
|
||||
Stderr: io.Discard,
|
||||
UI: ui.NewWithColor(io.Discard, false),
|
||||
}
|
||||
v.SetContext(ctx)
|
||||
|
||||
const sentence = "cannot decrypt this blob: the private key in " +
|
||||
"age_secret_key is not one of the keys the blob was encrypted to"
|
||||
|
||||
// Restore names the blob by the first 16 characters of its hash.
|
||||
err = v.Restore(&vaultik.RestoreOptions{
|
||||
SnapshotID: snapshotID,
|
||||
TargetDir: t.TempDir(),
|
||||
})
|
||||
require.EqualError(t, err, "restoring "+dataPath+": downloading blob "+
|
||||
blobHash[:16]+": "+sentence+
|
||||
" (pass --skip-errors to continue past restore failures)")
|
||||
|
||||
err = v.RunDeepVerify(snapshotID, &vaultik.VerifyOptions{Deep: true})
|
||||
require.EqualError(t, err,
|
||||
"blob "+blobHash+" verification failed: "+sentence)
|
||||
}
|
||||
+225
-80
@@ -7,6 +7,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -44,6 +45,25 @@ var ErrPostBackupPruneFailed = errors.New("post-backup prune failed")
|
||||
// kept, so the caller can report it apart from a failed backup.
|
||||
var ErrFilesUnreadable = errors.New("file(s) could not be read")
|
||||
|
||||
// ErrSourcePathEmpty is returned when --prune skipped the post-backup
|
||||
// prune because the scan found nothing in a configured path of a
|
||||
// snapshot, as in the mount point of a volume that is not mounted. The
|
||||
// snapshots are complete and kept, so the caller can report it apart
|
||||
// from a failed backup. A run that also skipped unreadable files
|
||||
// returns ErrFilesUnreadable instead.
|
||||
var ErrSourcePathEmpty = errors.New("source path(s) were empty")
|
||||
|
||||
// ErrVolumeNotMounted is returned when --prune skipped the post-backup
|
||||
// prune because a directory that an earlier snapshot of the same name
|
||||
// found as a mount point was not found as one in this run: the volume
|
||||
// mounted there is not mounted, the run found no entry in it, or the
|
||||
// directory is no longer backed up. The snapshots are complete and
|
||||
// kept, so the caller can report it apart from a failed backup. A run
|
||||
// that also skipped unreadable files or found a configured path empty
|
||||
// returns ErrFilesUnreadable or ErrSourcePathEmpty instead.
|
||||
var ErrVolumeNotMounted = errors.New(
|
||||
"mount point(s) an earlier snapshot recorded were not found")
|
||||
|
||||
// listRecentLimit caps how many snapshot rows are fetched from the
|
||||
// local index for listing and purge operations.
|
||||
const listRecentLimit = 10000
|
||||
@@ -95,14 +115,9 @@ func (v *Vaultik) CreateSnapshot(opts *SnapshotCreateOptions) error {
|
||||
"index_path", v.Config.IndexPath,
|
||||
)
|
||||
|
||||
err := v.EnsureStorageBinding()
|
||||
if err != nil {
|
||||
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()
|
||||
err := v.markDestination()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -134,17 +149,10 @@ func (v *Vaultik) CreateSnapshot(opts *SnapshotCreateOptions) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Process each named snapshot
|
||||
filesUnreadable := 0
|
||||
|
||||
for snapIdx, snapName := range snapshotNames {
|
||||
unreadable, err := v.createNamedSnapshot(
|
||||
opts, hostname, snapName, snapIdx+1, len(snapshotNames))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
filesUnreadable += unreadable
|
||||
filesUnreadable, emptyPaths, unmountedPaths, err := v.createNamedSnapshots(
|
||||
opts, hostname, snapshotNames)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Print overall summary if multiple snapshots
|
||||
@@ -154,13 +162,10 @@ func (v *Vaultik) CreateSnapshot(opts *SnapshotCreateOptions) error {
|
||||
}
|
||||
|
||||
switch {
|
||||
case opts.Prune && filesUnreadable > 0:
|
||||
// An older snapshot may be the only one that still holds a file
|
||||
// this run could not read.
|
||||
log.Warn("Skipping post-backup prune",
|
||||
"files_unreadable", filesUnreadable)
|
||||
v.UI.Warningf("Skipped the post-backup prune because %d file(s) "+
|
||||
"could not be read; older snapshots are kept.", filesUnreadable)
|
||||
case opts.Prune && (filesUnreadable > 0 || len(emptyPaths) > 0 ||
|
||||
len(unmountedPaths) > 0):
|
||||
return v.skipPostBackupPrune(
|
||||
filesUnreadable, emptyPaths, unmountedPaths)
|
||||
case opts.Prune:
|
||||
err = v.runPostBackupPrune(snapshotNames, opts.KeepNewerThan)
|
||||
if err != nil {
|
||||
@@ -171,6 +176,84 @@ func (v *Vaultik) CreateSnapshot(opts *SnapshotCreateOptions) error {
|
||||
return v.finishCreateSnapshot(filesUnreadable)
|
||||
}
|
||||
|
||||
// createNamedSnapshots creates each named snapshot in turn. It returns
|
||||
// the number of entries --skip-errors skipped because it could not read
|
||||
// or list them, each configured path in which the scan found nothing,
|
||||
// and, under --prune, each directory where an earlier snapshot found a
|
||||
// volume mounted and this run did not, the paths named with their
|
||||
// snapshot.
|
||||
func (v *Vaultik) createNamedSnapshots(
|
||||
opts *SnapshotCreateOptions, hostname string, snapshotNames []string,
|
||||
) (int, []string, []string, error) {
|
||||
filesUnreadable := 0
|
||||
|
||||
var emptyPaths, unmountedPaths []string
|
||||
|
||||
for snapIdx, snapName := range snapshotNames {
|
||||
stats, err := v.createNamedSnapshot(
|
||||
opts, hostname, snapName, snapIdx+1, len(snapshotNames))
|
||||
if err != nil {
|
||||
return 0, nil, nil, err
|
||||
}
|
||||
|
||||
filesUnreadable += stats.totalFilesUnreadable
|
||||
|
||||
for _, dir := range stats.emptyPaths {
|
||||
emptyPaths = append(emptyPaths, dir+" of snapshot "+snapName)
|
||||
}
|
||||
|
||||
for _, dir := range stats.unmountedPaths {
|
||||
unmountedPaths = append(unmountedPaths,
|
||||
dir+" of snapshot "+snapName)
|
||||
}
|
||||
}
|
||||
|
||||
return filesUnreadable, emptyPaths, unmountedPaths, nil
|
||||
}
|
||||
|
||||
// skipPostBackupPrune says why the post-backup prune does not run, naming
|
||||
// every cause, and returns the error the run ends with. An older snapshot
|
||||
// may be the only one that still holds a file this run could not read,
|
||||
// or what an empty path or a volume that is not mounted held. The
|
||||
// previous snapshot is no guide: after one such run it lacks them too.
|
||||
func (v *Vaultik) skipPostBackupPrune(
|
||||
filesUnreadable int, emptyPaths, unmountedPaths []string,
|
||||
) error {
|
||||
log.Warn("Skipping post-backup prune",
|
||||
"files_unreadable", filesUnreadable, "empty_paths", emptyPaths,
|
||||
"unmounted_paths", unmountedPaths)
|
||||
|
||||
if len(emptyPaths) > 0 {
|
||||
v.UI.Warningf("Skipped the post-backup prune because nothing was "+
|
||||
"found in %s; older snapshots are kept.",
|
||||
strings.Join(emptyPaths, ", "))
|
||||
}
|
||||
|
||||
if len(unmountedPaths) > 0 {
|
||||
v.UI.Warningf("Skipped the post-backup prune because this backup "+
|
||||
"did not find a mount point at %s, where an earlier snapshot "+
|
||||
"found one: the volume is not mounted, this backup found no "+
|
||||
"entry in it, or the directory is no longer backed up. Older "+
|
||||
"snapshots are kept. Mount the volume again, or, if that is on "+
|
||||
"purpose, remove the older snapshots by hand with "+
|
||||
"'vaultik snapshot purge'.",
|
||||
strings.Join(unmountedPaths, ", "))
|
||||
}
|
||||
|
||||
if filesUnreadable > 0 {
|
||||
v.UI.Warningf("Skipped the post-backup prune because %d file(s) "+
|
||||
"could not be read; older snapshots are kept.", filesUnreadable)
|
||||
|
||||
return fmt.Errorf("%d %w", filesUnreadable, ErrFilesUnreadable)
|
||||
}
|
||||
|
||||
if len(emptyPaths) > 0 {
|
||||
return fmt.Errorf("%d %w", len(emptyPaths), ErrSourcePathEmpty)
|
||||
}
|
||||
|
||||
return fmt.Errorf("%d %w", len(unmountedPaths), ErrVolumeNotMounted)
|
||||
}
|
||||
|
||||
// finishCreateSnapshot ends a run whose snapshots are all complete and
|
||||
// kept. Like finishRestore, it returns an error when --skip-errors
|
||||
// skipped anything it could not read or list; otherwise it prints the
|
||||
@@ -219,9 +302,10 @@ func (v *Vaultik) snapshotNamesToCreate(requested []string) ([]string, error) {
|
||||
}
|
||||
|
||||
// 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
|
||||
// snapshot of each name is kept.
|
||||
// from remote storage the orphan blobs and the directories under
|
||||
// metadata/ with no manifest, as PruneBlobs does. If keepNewerThan is set
|
||||
// (e.g. "4w"), snapshots newer than that duration are kept. Otherwise only
|
||||
// the latest snapshot of each name is kept.
|
||||
func (v *Vaultik) runPostBackupPrune(
|
||||
snapshotNames []string, keepNewerThan string,
|
||||
) error {
|
||||
@@ -248,7 +332,7 @@ func (v *Vaultik) runPostBackupPrune(
|
||||
|
||||
err = v.PruneBlobs(&PruneOptions{Force: true})
|
||||
if err != nil {
|
||||
return fmt.Errorf("pruning orphaned blobs: %w", err)
|
||||
return fmt.Errorf("pruning the destination store: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -274,6 +358,13 @@ type snapshotStats struct {
|
||||
// could not be read or listed.
|
||||
totalFilesUnreadable int
|
||||
|
||||
// The configured paths in which the scan found nothing.
|
||||
emptyPaths []string
|
||||
|
||||
// Under --prune, the directories where an earlier snapshot of the
|
||||
// same name found a volume mounted and this one did not.
|
||||
unmountedPaths []string
|
||||
|
||||
// The sizes of all blobs the snapshot references, set by
|
||||
// finalizeSnapshotMetadata once snapshot_blobs is populated.
|
||||
blobSize int64
|
||||
@@ -281,11 +372,10 @@ type snapshotStats struct {
|
||||
}
|
||||
|
||||
// createNamedSnapshot creates a single named snapshot and returns the
|
||||
// number of entries --skip-errors skipped because it could not read or
|
||||
// list them.
|
||||
// statistics of its scan.
|
||||
func (v *Vaultik) createNamedSnapshot(
|
||||
opts *SnapshotCreateOptions, hostname, snapName string, idx, total int,
|
||||
) (int, error) {
|
||||
) (*snapshotStats, error) {
|
||||
snapshotStartTime := time.Now()
|
||||
|
||||
if total > 1 {
|
||||
@@ -294,12 +384,12 @@ func (v *Vaultik) createNamedSnapshot(
|
||||
|
||||
resolvedDirs, err := v.resolveSnapshotPaths(snapName)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
previousSnapshotID, err := v.previousSnapshotID(snapName)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
scanner := v.ScannerFactory(snapshot.ScannerParams{
|
||||
@@ -314,7 +404,7 @@ func (v *Vaultik) createNamedSnapshot(
|
||||
snapshotID, err := v.SnapshotManager.CreateSnapshotWithName(
|
||||
v.ctx, hostname, snapName, v.Globals.Version, v.Globals.Commit)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("creating snapshot: %w", err)
|
||||
return nil, fmt.Errorf("creating snapshot: %w", err)
|
||||
}
|
||||
|
||||
log.Info("Beginning snapshot", "snapshot_id", snapshotID, "name", snapName)
|
||||
@@ -322,12 +412,12 @@ func (v *Vaultik) createNamedSnapshot(
|
||||
|
||||
stats, err := v.scanAllDirectories(scanner, resolvedDirs, snapshotID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = v.finalizeSnapshotMetadata(snapshotID, stats)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Info("Snapshot complete",
|
||||
@@ -340,7 +430,55 @@ func (v *Vaultik) createNamedSnapshot(
|
||||
|
||||
v.printSnapshotSummary(snapshotID, snapshotStartTime, stats)
|
||||
|
||||
return stats.totalFilesUnreadable, nil
|
||||
if opts.Prune {
|
||||
stats.unmountedPaths, err = v.findUnmountedPaths(snapName, snapshotID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// findUnmountedPaths returns each directory that an earlier snapshot
|
||||
// named snapName in the local index recorded as a mount point and
|
||||
// snapshot snapshotID did not, as when the volume mounted there is not
|
||||
// mounted.
|
||||
func (v *Vaultik) findUnmountedPaths(
|
||||
snapName, snapshotID string,
|
||||
) ([]string, error) {
|
||||
found, err := v.Repositories.Snapshots.GetMountPoints(v.ctx, snapshotID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
snapshots, err := v.Repositories.Snapshots.ListRecent(v.ctx, listRecentLimit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listing snapshots: %w", err)
|
||||
}
|
||||
|
||||
var unmounted []string
|
||||
|
||||
for _, s := range snapshots {
|
||||
if s.ID.String() == snapshotID ||
|
||||
parseSnapshotName(s.ID.String(), s.Hostname.String()) != snapName {
|
||||
continue
|
||||
}
|
||||
|
||||
recorded, err := v.Repositories.Snapshots.GetMountPoints(
|
||||
v.ctx, s.ID.String())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, dir := range recorded {
|
||||
if !slices.Contains(found, dir) && !slices.Contains(unmounted, dir) {
|
||||
unmounted = append(unmounted, dir)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return unmounted, nil
|
||||
}
|
||||
|
||||
// previousSnapshotID returns the ID of the most recent completed snapshot
|
||||
@@ -362,7 +500,8 @@ func (v *Vaultik) previousSnapshotID(snapName string) (string, error) {
|
||||
}
|
||||
|
||||
// resolveSnapshotPaths resolves source directories to absolute paths
|
||||
// with symlink resolution.
|
||||
// with symlink resolution. A path that is a symlink whose target does
|
||||
// not exist is an error.
|
||||
func (v *Vaultik) resolveSnapshotPaths(snapName string) ([]string, error) {
|
||||
snapConfig := v.Config.Snapshots[snapName]
|
||||
resolvedDirs := make([]string, 0, len(snapConfig.Paths))
|
||||
@@ -375,12 +514,18 @@ func (v *Vaultik) resolveSnapshotPaths(snapName string) ([]string, error) {
|
||||
|
||||
resolvedPath, err := filepath.EvalSymlinks(absPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
resolvedPath = absPath
|
||||
} else {
|
||||
// A path that does not exist is left for the scan to report.
|
||||
// One that exists is a symlink whose target is gone, as one
|
||||
// into a volume that is not mounted: the scan would record
|
||||
// only the link, and --prune would then drop the older
|
||||
// snapshots that hold the files.
|
||||
_, lstatErr := os.Lstat(absPath)
|
||||
if !os.IsNotExist(err) || lstatErr == nil {
|
||||
return nil, fmt.Errorf(
|
||||
"failed to resolve symlinks for %s: %w", absPath, err)
|
||||
}
|
||||
|
||||
resolvedPath = absPath
|
||||
}
|
||||
|
||||
resolvedDirs = append(resolvedDirs, resolvedPath)
|
||||
@@ -434,6 +579,10 @@ func (v *Vaultik) scanAllDirectories(
|
||||
stats.totalBytesUploaded += result.BytesUploaded
|
||||
stats.uploadDuration += result.UploadDuration
|
||||
|
||||
if result.EntriesFound == 0 {
|
||||
stats.emptyPaths = append(stats.emptyPaths, dir)
|
||||
}
|
||||
|
||||
log.Info("Directory scan complete",
|
||||
"path", dir,
|
||||
"files", result.FilesScanned,
|
||||
@@ -595,7 +744,7 @@ type SnapshotPurgeOptions struct {
|
||||
// snapshot name, not the latest globally. This prevents `home` and `system`
|
||||
// snapshots from cannibalizing each other.
|
||||
func (v *Vaultik) PurgeSnapshotsWithOptions(opts *SnapshotPurgeOptions) error {
|
||||
err := v.EnsureStorageBinding()
|
||||
err := v.checkStorageBinding()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -727,17 +876,12 @@ func (v *Vaultik) confirmAndExecutePurge(
|
||||
|
||||
var confirm string
|
||||
|
||||
// A failure to read the answer means no confirmation.
|
||||
_, err := v.scanStdin(&confirm)
|
||||
if err != nil {
|
||||
if err != nil || strings.ToLower(confirm) != "y" {
|
||||
v.printlnStdout("Cancelled")
|
||||
|
||||
return nil //nolint:nilerr // treat EOF or read error as "no"
|
||||
}
|
||||
|
||||
if strings.ToLower(confirm) != "y" {
|
||||
v.printlnStdout("Cancelled")
|
||||
|
||||
return nil
|
||||
return ErrNotConfirmed
|
||||
}
|
||||
} else if !quiet {
|
||||
v.UI.Infof("The following snapshots will be deleted:")
|
||||
@@ -815,25 +959,22 @@ func (v *Vaultik) VerifySnapshotWithOptions(
|
||||
Mode: "shallow",
|
||||
}
|
||||
|
||||
v.printVerifyHeader(snapshotID, opts)
|
||||
|
||||
// Resolve the identifier to the snapshot's remote key. A human ID is
|
||||
// hashed; a remote key (or its abbreviation, as printed for a
|
||||
// remote-only snapshot) is used as-is, so a host with no local index
|
||||
// can verify a snapshot it can only see on the store. The key is kept
|
||||
// so we can also check for the snapshot's encrypted database below.
|
||||
// The key is kept so we can also check for the snapshot's encrypted
|
||||
// database below.
|
||||
remoteKey, err := v.resolveSnapshotRemoteKey(snapshotID)
|
||||
if err != nil {
|
||||
if opts.JSON {
|
||||
result.Status = verifyStatusFailed
|
||||
result.ErrorMessage = fmt.Sprintf("resolving snapshot identifier: %v", err)
|
||||
result.ErrorMessage = err.Error()
|
||||
|
||||
return v.outputVerifyJSON(result)
|
||||
}
|
||||
|
||||
return fmt.Errorf("resolving snapshot identifier: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
v.printVerifyHeader(snapshotID, opts)
|
||||
|
||||
manifest, err := v.downloadManifestByKey(remoteKey)
|
||||
if err != nil {
|
||||
if opts.JSON {
|
||||
@@ -865,7 +1006,7 @@ func (v *Vaultik) VerifySnapshotWithOptions(
|
||||
v.printlnStdout()
|
||||
|
||||
// Check each blob is present with the size the manifest records.
|
||||
v.stdoutf("Checking blob presence and sizes...\n")
|
||||
v.UI.Beginf("Checking blob presence and sizes...")
|
||||
}
|
||||
|
||||
v.checkSnapshotDatabase(remoteKey, result)
|
||||
@@ -1131,7 +1272,7 @@ func (v *Vaultik) outputVerifyJSON(result *VerifyResult) error {
|
||||
// jq` fail (issue #108). The narration is duplicated as log records,
|
||||
// which go to stderr and so cannot corrupt the document.
|
||||
func (v *Vaultik) CleanupLocalSnapshots(opts *PruneOptions) error {
|
||||
err := v.EnsureStorageBinding()
|
||||
err := v.checkStorageBinding()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1301,7 +1442,7 @@ 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.
|
||||
// `vaultik prune` never does while the manifest is there.
|
||||
const snapshotRemoveCommandHint = "vaultik snapshot remove"
|
||||
|
||||
// RemoveSnapshot removes a snapshot from the local index database and,
|
||||
@@ -1321,12 +1462,12 @@ func (v *Vaultik) RemoveSnapshot(
|
||||
SnapshotID: snapshotID,
|
||||
}
|
||||
|
||||
err := v.EnsureStorageBinding()
|
||||
err := v.checkStorageBinding()
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
inLocalIndex, err := v.findSnapshotToRemove(snapshotID, opts)
|
||||
inLocalIndex, err := v.checkSnapshotExists(snapshotID, opts.LocalOnly)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
@@ -1336,7 +1477,7 @@ func (v *Vaultik) RemoveSnapshot(
|
||||
}
|
||||
|
||||
if !v.confirmRemoveSnapshot(snapshotID, opts) {
|
||||
return result, nil
|
||||
return result, ErrNotConfirmed
|
||||
}
|
||||
|
||||
// Checked before the local record is deleted: it may be the last
|
||||
@@ -1389,17 +1530,20 @@ 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,
|
||||
// and only after checkDestinationMarker passes, so an unplugged volume's
|
||||
// empty mount point is not taken for a store without the snapshot.
|
||||
func (v *Vaultik) findSnapshotToRemove(
|
||||
snapshotID string, opts *RemoveOptions,
|
||||
) (bool, error) {
|
||||
// checkSnapshotExists returns errSnapshotNotFound when the local index
|
||||
// has no row for snapshotID and, unless localOnly is set, the
|
||||
// destination store has no metadata for it either. inLocalIndex reports
|
||||
// whether the local index has that row. 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, and only after checkDestinationMarker passes, so an
|
||||
// unplugged volume's empty mount point is not taken for a store without
|
||||
// the snapshot.
|
||||
//
|
||||
//nolint:nonamedreturns // the result name says what the bool means
|
||||
func (v *Vaultik) checkSnapshotExists(
|
||||
snapshotID string, localOnly bool,
|
||||
) (inLocalIndex bool, err error) {
|
||||
if v.Repositories != nil {
|
||||
snap, err := v.Repositories.Snapshots.GetByID(v.ctx, snapshotID)
|
||||
if err != nil {
|
||||
@@ -1411,12 +1555,12 @@ func (v *Vaultik) findSnapshotToRemove(
|
||||
}
|
||||
}
|
||||
|
||||
if opts.LocalOnly {
|
||||
if localOnly {
|
||||
return false, fmt.Errorf("%w in the local index: %s",
|
||||
errSnapshotNotFound, snapshotID)
|
||||
}
|
||||
|
||||
err := v.checkDestinationMarker()
|
||||
err = v.checkDestinationMarker()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
@@ -1504,7 +1648,8 @@ func (v *Vaultik) confirmRemoveSnapshot(snapshotID string, opts *RemoveOptions)
|
||||
// the local index, its removal there has already happened, so a failure
|
||||
// is only a warning telling the user to run `vaultik snapshot remove`
|
||||
// for the snapshot again once the destination store is reachable
|
||||
// (`vaultik prune` never removes snapshot metadata).
|
||||
// (`vaultik prune` never removes snapshot metadata that has its
|
||||
// manifest).
|
||||
func (v *Vaultik) removeSnapshotRemote(
|
||||
snapshotID string, inLocalIndex bool, markerErr error,
|
||||
opts *RemoveOptions,
|
||||
@@ -1557,7 +1702,7 @@ func (v *Vaultik) removeSnapshotRemote(
|
||||
// before it removes anything when the destination store cannot be
|
||||
// listed or is not present by checkDestinationMarker.
|
||||
func (v *Vaultik) RemoveAllSnapshots(opts *RemoveOptions) (*RemoveResult, error) {
|
||||
err := v.EnsureStorageBinding()
|
||||
err := v.checkStorageBinding()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -28,8 +28,9 @@ var (
|
||||
// Two forms are accepted, matching the two things a host can know:
|
||||
//
|
||||
// - A human snapshot ID (hostname_name_timestamp), which a host holding
|
||||
// the local index has. It is hashed to its remote key; the store is
|
||||
// not consulted.
|
||||
// the local index has. It is hashed to its remote key. An ID that
|
||||
// names no snapshot in the local index or on the destination store
|
||||
// is an error (see checkSnapshotExists).
|
||||
// - A remote key, or the leading part of one, which is all a host with
|
||||
// no local index can know — it is exactly what `snapshot list` prints
|
||||
// for a remote-only snapshot (see formatRemoteOnlyID). It is resolved
|
||||
@@ -41,6 +42,11 @@ var (
|
||||
// RFC3339 timestamp).
|
||||
func (v *Vaultik) resolveSnapshotRemoteKey(identifier string) (string, error) {
|
||||
if !isRemoteKeyOrPrefix(identifier) {
|
||||
_, err := v.checkSnapshotExists(identifier, false)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return snapshot.RemoteSnapshotKey(identifier), nil
|
||||
}
|
||||
|
||||
|
||||
@@ -187,6 +187,17 @@ func (e *listEnv) addRemoteRawTimestamp(
|
||||
t.Helper()
|
||||
|
||||
remoteKey := snapshot.RemoteSnapshotKey(snapshotID)
|
||||
e.putRemoteManifest(t, remoteKey,
|
||||
encodeRemoteManifest(t, remoteKey, timestamp))
|
||||
|
||||
return remoteKey
|
||||
}
|
||||
|
||||
// encodeRemoteManifest returns the encoded manifest of a one-blob
|
||||
// snapshot whose remote key is remoteKey.
|
||||
func encodeRemoteManifest(t *testing.T, remoteKey, timestamp string) []byte {
|
||||
t.Helper()
|
||||
|
||||
manifest := &snapshot.Manifest{
|
||||
// Note: the hashed key, never the human ID. That is precisely
|
||||
// why a remote-only snapshot cannot be named.
|
||||
@@ -202,11 +213,17 @@ func (e *listEnv) addRemoteRawTimestamp(
|
||||
data, err := snapshot.EncodeManifest(manifest, 3)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = e.store.Put(context.Background(),
|
||||
return data
|
||||
}
|
||||
|
||||
// putRemoteManifest stores an encoded manifest on the destination store
|
||||
// under remoteKey.
|
||||
func (e *listEnv) putRemoteManifest(t *testing.T, remoteKey string, data []byte) {
|
||||
t.Helper()
|
||||
|
||||
err := e.store.Put(context.Background(),
|
||||
"metadata/"+remoteKey+"/manifest.json.zst", bytes.NewReader(data))
|
||||
require.NoError(t, err)
|
||||
|
||||
return remoteKey
|
||||
}
|
||||
|
||||
// Fixtures shared across the listing tests.
|
||||
@@ -345,8 +362,9 @@ func TestSnapshotListAndPurgePrintSizesLikeRemoteInfo(t *testing.T) {
|
||||
// Without --force and with nothing on stdin the purge lists what it
|
||||
// would delete, reads no confirmation, and deletes nothing.
|
||||
env.stdout.Reset()
|
||||
require.NoError(t, env.v.PurgeSnapshotsWithOptions(
|
||||
&vaultik.SnapshotPurgeOptions{OlderThan: "1d"}))
|
||||
require.ErrorIs(t, env.v.PurgeSnapshotsWithOptions(
|
||||
&vaultik.SnapshotPurgeOptions{OlderThan: "1d"}),
|
||||
vaultik.ErrNotConfirmed)
|
||||
assert.Contains(t, findTableRow(t, env.stdout.String(), listLocalID),
|
||||
wantSize, "snapshot purge")
|
||||
}
|
||||
@@ -791,9 +809,17 @@ func TestListSnapshots_JSONReportsTruncation(t *testing.T) {
|
||||
|
||||
timestamp := time.Date(2026, 3, 2, 11, 22, 33, 0, time.UTC)
|
||||
|
||||
// One past the cap, so exactly one snapshot is omitted.
|
||||
// One past the cap, so exactly one snapshot is omitted. The listing
|
||||
// names a snapshot by the key its manifest is stored under, never by
|
||||
// the manifest's own ID. So all of them share one encoded manifest:
|
||||
// encoding 1001 took seconds.
|
||||
data := encodeRemoteManifest(t,
|
||||
snapshot.RemoteSnapshotKey("otherhost_bulk"),
|
||||
timestamp.Format(time.RFC3339))
|
||||
|
||||
for i := range maxRemoteOnlyRowsForTest + 1 {
|
||||
env.addRemote(t, fmt.Sprintf("otherhost_bulk_%04d", i), timestamp)
|
||||
env.putRemoteManifest(t, snapshot.RemoteSnapshotKey(
|
||||
fmt.Sprintf("otherhost_bulk_%04d", i)), data)
|
||||
}
|
||||
|
||||
stderr := captureProcessStderr(t, func() {
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
package vaultik_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/config"
|
||||
"sneak.berlin/go/vaultik/internal/log"
|
||||
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||
)
|
||||
|
||||
@@ -25,3 +35,379 @@ func TestSnapshotCreateOptions_PruneFlag(t *testing.T) {
|
||||
t.Error("Expected Prune to be false")
|
||||
}
|
||||
}
|
||||
|
||||
// A backup under --prune that finds nothing in a configured path, as in
|
||||
// the mount point of a volume that is not mounted, does not prune: run
|
||||
// after run, the snapshot that holds the files is kept with everything
|
||||
// it stored, and the run fails. That holds whether the empty path is the
|
||||
// snapshot's only path or one of two. See
|
||||
// https://git.eeqj.de/sneak/vaultik/issues/388.
|
||||
func TestBackupWithEmptySourcePathDoesNotPrune(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
// emptied is the path, relative to the source directory, that is
|
||||
// emptied after the first backup.
|
||||
const (
|
||||
snapshotName = "media"
|
||||
emptied = "music"
|
||||
)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
paths []string // relative to the source directory
|
||||
}{
|
||||
{name: "only path", paths: []string{emptied}},
|
||||
{name: "one of two paths", paths: []string{emptied, "docs"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
env := newDeletedCountEnv(t, map[string][]byte{
|
||||
emptied + "/a.mp3": []byte("song a"),
|
||||
emptied + "/b.mp3": []byte("song b"),
|
||||
"docs/notes.txt": []byte("notes"),
|
||||
})
|
||||
|
||||
paths := make([]string, 0, len(tt.paths))
|
||||
|
||||
for _, rel := range tt.paths {
|
||||
paths = append(paths, filepath.Join(env.src, rel))
|
||||
}
|
||||
|
||||
env.v.Config.Snapshots[snapshotName] = config.SnapshotConfig{
|
||||
Paths: paths,
|
||||
}
|
||||
env.backUp(t, snapshotName)
|
||||
|
||||
firstID := localSnapshotID(ctx, t, env.v.Repositories, snapshotName)
|
||||
stored, err := env.v.Storage.List(ctx, "")
|
||||
require.NoError(t, err)
|
||||
|
||||
// A volume that is not mounted leaves its mount point behind
|
||||
// as an empty directory.
|
||||
emptiedDir := filepath.Join(env.src, emptied)
|
||||
require.NoError(t, os.RemoveAll(emptiedDir))
|
||||
require.NoError(t, os.Mkdir(emptiedDir, 0o750))
|
||||
|
||||
for range 2 {
|
||||
env.out.Reset()
|
||||
|
||||
err := env.v.CreateSnapshot(&vaultik.SnapshotCreateOptions{
|
||||
Prune: true,
|
||||
Snapshots: []string{snapshotName},
|
||||
})
|
||||
require.ErrorIs(t, err, vaultik.ErrSourcePathEmpty)
|
||||
assert.Contains(t, env.out.String(),
|
||||
"Skipped the post-backup prune because nothing was found in ")
|
||||
assert.Contains(t, env.out.String(),
|
||||
emptied+" of snapshot "+snapshotName+";")
|
||||
assert.NotContains(t, env.out.String(), "Running post-backup prune.")
|
||||
}
|
||||
|
||||
snapshots, err := env.v.Repositories.Snapshots.ListRecent(
|
||||
ctx, listRecentTestLimit)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, snapshots, 3)
|
||||
assert.Equal(t, firstID, snapshots[2].ID.String(),
|
||||
"the snapshot that holds the files is kept")
|
||||
|
||||
for _, key := range stored {
|
||||
_, err := env.v.Storage.Stat(ctx, key)
|
||||
require.NoError(t, err, "%s is kept", key)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A backup under --prune of a configured path that is a symlink whose
|
||||
// target is gone, as one into a volume that is not mounted, fails before
|
||||
// anything is pruned: the snapshot that holds the files is kept with
|
||||
// everything it stored. See https://git.eeqj.de/sneak/vaultik/issues/388.
|
||||
func TestBackupThroughSymlinkWhoseTargetIsGoneDoesNotPrune(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
const snapshotName = "photos"
|
||||
|
||||
ctx := context.Background()
|
||||
env := newDeletedCountEnv(t, map[string][]byte{
|
||||
"volume/a.jpg": []byte("photo a"),
|
||||
"volume/b.jpg": []byte("photo b"),
|
||||
})
|
||||
|
||||
target := filepath.Join(env.src, "volume")
|
||||
link := filepath.Join(env.src, "photos")
|
||||
require.NoError(t, os.Symlink(target, link))
|
||||
env.setSnapshot(snapshotName, "photos")
|
||||
env.backUp(t, snapshotName)
|
||||
|
||||
firstID := localSnapshotID(ctx, t, env.v.Repositories, snapshotName)
|
||||
stored, err := env.v.Storage.List(ctx, "")
|
||||
require.NoError(t, err)
|
||||
|
||||
// The volume is not mounted, so the symlink points at nothing.
|
||||
require.NoError(t, os.RemoveAll(target))
|
||||
|
||||
env.out.Reset()
|
||||
err = env.v.CreateSnapshot(&vaultik.SnapshotCreateOptions{
|
||||
Prune: true,
|
||||
Snapshots: []string{snapshotName},
|
||||
})
|
||||
require.ErrorIs(t, err, os.ErrNotExist)
|
||||
assert.Contains(t, err.Error(), link)
|
||||
assert.NotContains(t, env.out.String(), "Running post-backup prune.")
|
||||
|
||||
snapshots, err := env.v.Repositories.Snapshots.ListRecent(
|
||||
ctx, listRecentTestLimit)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, snapshots, 1)
|
||||
assert.Equal(t, firstID, snapshots[0].ID.String(),
|
||||
"the snapshot that holds the files is kept")
|
||||
|
||||
for _, key := range stored {
|
||||
_, err := env.v.Storage.Stat(ctx, key)
|
||||
require.NoError(t, err, "%s is kept", key)
|
||||
}
|
||||
}
|
||||
|
||||
// A backup under --skip-errors --prune that both skipped a file it could
|
||||
// not read and found nothing in a configured path names the empty path
|
||||
// too when it skips the prune. See
|
||||
// https://git.eeqj.de/sneak/vaultik/issues/388.
|
||||
func TestBackupWithEmptySourcePathAndUnreadableFileNamesBoth(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
const snapshotName = "media"
|
||||
|
||||
env := newDeletedCountEnv(t, map[string][]byte{
|
||||
"docs/notes.txt": []byte("notes"),
|
||||
})
|
||||
require.NoError(t, os.Mkdir(filepath.Join(env.src, "music"), 0o750))
|
||||
env.v.Config.Snapshots[snapshotName] = config.SnapshotConfig{
|
||||
Paths: []string{
|
||||
filepath.Join(env.src, "docs"),
|
||||
filepath.Join(env.src, "music"),
|
||||
},
|
||||
}
|
||||
|
||||
// A mode does not lock out root, who runs the tests, so opening the
|
||||
// file is made to fail instead.
|
||||
env.v.Fs = &openFailFs{
|
||||
path: filepath.Join(env.src, "docs", "notes.txt"),
|
||||
err: os.ErrPermission,
|
||||
}
|
||||
|
||||
err := env.v.CreateSnapshot(&vaultik.SnapshotCreateOptions{
|
||||
Prune: true,
|
||||
SkipErrors: true,
|
||||
Snapshots: []string{snapshotName},
|
||||
})
|
||||
require.ErrorIs(t, err, vaultik.ErrFilesUnreadable)
|
||||
assert.Contains(t, env.out.String(),
|
||||
"Skipped the post-backup prune because 1 file(s) could not be read")
|
||||
assert.Contains(t, env.out.String(),
|
||||
"Skipped the post-backup prune because nothing was found in ")
|
||||
assert.Contains(t, env.out.String(), "music of snapshot "+snapshotName+";")
|
||||
assert.NotContains(t, env.out.String(), "Running post-backup prune.")
|
||||
}
|
||||
|
||||
// A backup under --prune that finds something in each configured path
|
||||
// prunes the previous snapshot, also when a path is a single file. See
|
||||
// https://git.eeqj.de/sneak/vaultik/issues/388.
|
||||
func TestBackupWithNoEmptySourcePathPrunes(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
snapshotName = "album"
|
||||
file = "pictures/one.jpg" // relative to the source directory
|
||||
)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string // relative to the source directory
|
||||
}{
|
||||
{name: "directory", path: filepath.Dir(file)},
|
||||
{name: "single file", path: file},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
env := newDeletedCountEnv(t, map[string][]byte{
|
||||
file: []byte("picture"),
|
||||
})
|
||||
env.setSnapshot(snapshotName, tt.path)
|
||||
env.backUp(t, snapshotName)
|
||||
|
||||
firstID := localSnapshotID(ctx, t, env.v.Repositories, snapshotName)
|
||||
|
||||
require.NoError(t, env.v.CreateSnapshot(&vaultik.SnapshotCreateOptions{
|
||||
Prune: true,
|
||||
Snapshots: []string{snapshotName},
|
||||
}))
|
||||
|
||||
snapshots, err := env.v.Repositories.Snapshots.ListRecent(
|
||||
ctx, listRecentTestLimit)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, snapshots, 1, "the previous snapshot is pruned")
|
||||
assert.NotEqual(t, firstID, snapshots[0].ID.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// automountFs is the real filesystem with a volume at volumeDir that is
|
||||
// mounted on first access, as by an autofs map that shows its mount
|
||||
// points before mounting them: listing volumeDir mounts the volume or,
|
||||
// while cannotMount, fails with "no such file or directory".
|
||||
type automountFs struct {
|
||||
afero.OsFs
|
||||
|
||||
volumeDir string
|
||||
cannotMount bool
|
||||
mounted bool
|
||||
}
|
||||
|
||||
//nolint:ireturn // afero.Fs.Open is defined to return the interface.
|
||||
func (f *automountFs) Open(name string) (afero.File, error) {
|
||||
if name == f.volumeDir && f.cannotMount {
|
||||
return nil, &os.PathError{Op: "open", Path: name, Err: syscall.ENOENT}
|
||||
}
|
||||
|
||||
if name == f.volumeDir {
|
||||
f.mounted = true
|
||||
}
|
||||
|
||||
return f.OsFs.Open(name)
|
||||
}
|
||||
|
||||
// A backup under --prune in which a volume that an earlier snapshot found
|
||||
// mounted below the configured path is missing does not prune: run after
|
||||
// run, the snapshot that holds the volume's files is kept with everything
|
||||
// it stored, and the run fails. Once the volume is back, the prune runs.
|
||||
// The volume is mounted on first access and every backup starts with it
|
||||
// not mounted, so its directory is on its parent's device until the walk
|
||||
// lists it. While the volume is missing, its directory is left empty, or
|
||||
// listing it fails with "no such file or directory", as when an autofs
|
||||
// volume cannot be mounted, which the walk counts as a directory removed
|
||||
// during the backup. See https://git.eeqj.de/sneak/vaultik/issues/395.
|
||||
func TestBackupWithVolumeNotMountedDoesNotPrune(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
listingFails bool
|
||||
}{
|
||||
{name: "directory left empty", listingFails: false},
|
||||
{name: "listing fails", listingFails: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
backUpWithVolumeNotMounted(t, tt.listingFails)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// backUpWithVolumeNotMounted runs the backups of
|
||||
// TestBackupWithVolumeNotMountedDoesNotPrune. While the volume is
|
||||
// missing, listing its directory fails if listingFails; otherwise the
|
||||
// directory is empty.
|
||||
func backUpWithVolumeNotMounted(t *testing.T, listingFails bool) {
|
||||
t.Helper()
|
||||
|
||||
// volume is the directory, below the configured path, where the
|
||||
// volume is mounted.
|
||||
const (
|
||||
snapshotName = "data"
|
||||
volume = "nas"
|
||||
)
|
||||
|
||||
ctx := context.Background()
|
||||
env := newDeletedCountEnv(t, map[string][]byte{
|
||||
volume + "/a.txt": []byte("file a"),
|
||||
"notes.txt": []byte("notes"),
|
||||
})
|
||||
env.setSnapshot(snapshotName, "")
|
||||
prune := &vaultik.SnapshotCreateOptions{
|
||||
Prune: true, Snapshots: []string{snapshotName},
|
||||
}
|
||||
|
||||
// The volume's directory is on the volume's device, 2, while the
|
||||
// volume is mounted. Everything else is on device 1.
|
||||
volumeDir := filepath.Join(env.src, volume)
|
||||
fs := &automountFs{volumeDir: volumeDir}
|
||||
newScanner := env.v.ScannerFactory
|
||||
env.v.ScannerFactory = func(params snapshot.ScannerParams) *snapshot.Scanner {
|
||||
fs.mounted = false
|
||||
params.Fs = fs
|
||||
params.DeviceNumber = func(info os.FileInfo) (uint64, bool) {
|
||||
if info.Name() == volume && fs.mounted {
|
||||
return 2, true
|
||||
}
|
||||
|
||||
return 1, true
|
||||
}
|
||||
|
||||
return newScanner(params)
|
||||
}
|
||||
|
||||
env.backUp(t, snapshotName)
|
||||
|
||||
firstID := localSnapshotID(ctx, t, env.v.Repositories, snapshotName)
|
||||
stored, err := env.v.Storage.List(ctx, "")
|
||||
require.NoError(t, err)
|
||||
|
||||
// The volume goes missing.
|
||||
fs.cannotMount = listingFails
|
||||
|
||||
if !listingFails {
|
||||
require.NoError(t, os.RemoveAll(volumeDir))
|
||||
require.NoError(t, os.Mkdir(volumeDir, 0o750))
|
||||
}
|
||||
|
||||
for range 2 {
|
||||
env.out.Reset()
|
||||
|
||||
err := env.v.CreateSnapshot(prune)
|
||||
require.ErrorIs(t, err, vaultik.ErrVolumeNotMounted)
|
||||
assert.Contains(t, env.out.String(),
|
||||
"did not find a mount point at "+volumeDir+" of snapshot "+snapshotName)
|
||||
assert.Contains(t, env.out.String(), "'vaultik snapshot purge'")
|
||||
assert.NotContains(t, env.out.String(), "Running post-backup prune.")
|
||||
}
|
||||
|
||||
snapshots, err := env.v.Repositories.Snapshots.ListRecent(
|
||||
ctx, listRecentTestLimit)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, snapshots, 3)
|
||||
assert.Equal(t, firstID, snapshots[2].ID.String(),
|
||||
"the snapshot that holds the volume's files is kept")
|
||||
|
||||
for _, key := range stored {
|
||||
_, err := env.v.Storage.Stat(ctx, key)
|
||||
require.NoError(t, err, "%s is kept", key)
|
||||
}
|
||||
|
||||
// The volume is back.
|
||||
fs.cannotMount = false
|
||||
|
||||
require.NoError(t, os.WriteFile(
|
||||
filepath.Join(volumeDir, "a.txt"), []byte("file a"), 0o600))
|
||||
require.NoError(t, env.v.CreateSnapshot(prune))
|
||||
|
||||
snapshots, err = env.v.Repositories.Snapshots.ListRecent(
|
||||
ctx, listRecentTestLimit)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, snapshots, 1, "the older snapshots are pruned")
|
||||
}
|
||||
@@ -13,35 +13,30 @@ import (
|
||||
var errStorageBindingMismatch = errors.New(
|
||||
"local index is bound to a different backup destination")
|
||||
|
||||
// EnsureStorageBinding guarantees that the local index database is
|
||||
// bound to the currently-configured storage destination. Every mutating
|
||||
// command must call this before touching either the local index or the
|
||||
// destination store, because the two live in lockstep: the local index
|
||||
// records which chunks/blobs already exist at the destination, and
|
||||
// mismatched destination + local index produces silent corruption (the
|
||||
// scanner sees "known" chunks and skips uploads, then writes snapshots
|
||||
// whose manifests reference blobs that aren't on the new destination).
|
||||
// checkStorageBinding refuses when the local index database is bound to
|
||||
// a different destination than the configured one, with an error that
|
||||
// tells the user how to recover (revert the config, or run `vaultik
|
||||
// database delete`). Every mutating command calls it before touching
|
||||
// either the local index or the destination store, because the two live
|
||||
// in lockstep: the local index records which chunks/blobs already exist
|
||||
// at the destination, and a mismatched pair produces silent corruption
|
||||
// (the scanner sees "known" chunks and skips uploads, then writes
|
||||
// snapshots whose manifests reference blobs that aren't on the new
|
||||
// destination).
|
||||
//
|
||||
// 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
|
||||
// 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
|
||||
// purge` to discard the local index and rebuild against the new
|
||||
// destination on the next backup).
|
||||
// It records nothing. Only a backup binds the local index, through
|
||||
// EnsureStorageBinding, and it does so before adding anything to it. An
|
||||
// unbound local index therefore holds nothing from any destination.
|
||||
//
|
||||
// Read-only inspection commands (remote info, snapshot list, etc.)
|
||||
// deliberately don't call this: they can be run against a bare
|
||||
// destination store without any binding state.
|
||||
func (v *Vaultik) EnsureStorageBinding() error {
|
||||
func (v *Vaultik) checkStorageBinding() error {
|
||||
if v.Repositories == nil || v.Config == nil {
|
||||
// NewForTesting builds a Vaultik with no DB or config;
|
||||
// there's nothing to bind and no binding to check. Callers
|
||||
// exercising the bind path use a real DB and populate Config
|
||||
// explicitly (see storage_bind_test.go).
|
||||
// there's no binding to check. Callers exercising the bind
|
||||
// path use a real DB and populate Config explicitly (see
|
||||
// storage_bind_test.go).
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -50,6 +45,24 @@ func (v *Vaultik) EnsureStorageBinding() error {
|
||||
return err
|
||||
}
|
||||
|
||||
if stored == "" || stored == configured {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("%w\n%s",
|
||||
errStorageBindingMismatch, buildBindingMismatchMessage(stored, configured))
|
||||
}
|
||||
|
||||
// EnsureStorageBinding records the configured destination (storage_url,
|
||||
// or the s3.* endpoint, bucket and prefix) as the binding of an unbound
|
||||
// local index. It refuses as checkStorageBinding does when the local
|
||||
// index is bound to a different destination.
|
||||
func (v *Vaultik) EnsureStorageBinding() error {
|
||||
stored, configured, err := v.storageBinding()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if stored == "" {
|
||||
err = v.Repositories.LocalMeta.Set(
|
||||
v.ctx, database.LocalMetaKeyStorageURL, configured)
|
||||
@@ -62,12 +75,12 @@ func (v *Vaultik) EnsureStorageBinding() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
if stored == configured {
|
||||
return nil
|
||||
if stored != configured {
|
||||
return fmt.Errorf("%w\n%s",
|
||||
errStorageBindingMismatch, buildBindingMismatchMessage(stored, configured))
|
||||
}
|
||||
|
||||
return fmt.Errorf("%w\n%s",
|
||||
errStorageBindingMismatch, buildBindingMismatchMessage(stored, configured))
|
||||
return nil
|
||||
}
|
||||
|
||||
// storageBinding returns the destination the local index is bound to
|
||||
|
||||
@@ -2,6 +2,7 @@ package vaultik_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -9,9 +10,126 @@ import (
|
||||
"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/vaultik"
|
||||
)
|
||||
|
||||
// storageBindingOf returns the destination the local index is bound to,
|
||||
// or "" when it is not bound.
|
||||
func storageBindingOf(
|
||||
ctx context.Context, t *testing.T, repos *database.Repositories,
|
||||
) string {
|
||||
t.Helper()
|
||||
|
||||
got, err := repos.LocalMeta.Get(ctx, database.LocalMetaKeyStorageURL)
|
||||
require.NoError(t, err)
|
||||
|
||||
return got
|
||||
}
|
||||
|
||||
// TestFailedFirstBackupLeavesIndexUnbound checks that a first backup
|
||||
// the destination refuses, as a mistyped bucket would, leaves the local
|
||||
// index unbound, so a backup with the corrected destination succeeds.
|
||||
func TestFailedFirstBackupLeavesIndexUnbound(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
storeDir := t.TempDir()
|
||||
v, repos := newFileDestinationVaultik(ctx, t, storeDir)
|
||||
working := v.Storage
|
||||
|
||||
refusing := faultstore.New(working)
|
||||
refusing.OnPut = func(string) faultstore.PutAction { return faultstore.PutFail }
|
||||
refusing.OnStat = func(string) bool { return true }
|
||||
v.Storage = refusing
|
||||
v.Config.StorageURL = "file://" + filepath.Join(t.TempDir(), "typo")
|
||||
|
||||
require.ErrorIs(t, backUp(v, "first"), faultstore.ErrInjectedUpload)
|
||||
assert.Empty(t, storageBindingOf(ctx, t, repos))
|
||||
|
||||
v.Storage = working
|
||||
v.Config.StorageURL = "file://" + storeDir
|
||||
|
||||
require.NoError(t, backUp(v, "first"))
|
||||
assert.Equal(t, "file://"+storeDir, storageBindingOf(ctx, t, repos))
|
||||
}
|
||||
|
||||
// TestBackupToOtherDestinationWritesNoMarker checks that a backup
|
||||
// refuses a local index bound to a different destination before it
|
||||
// writes the marker to the configured one.
|
||||
func TestBackupToOtherDestinationWritesNoMarker(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
firstDir := t.TempDir()
|
||||
v, _ := newFileDestinationVaultik(ctx, t, firstDir)
|
||||
v.Config.StorageURL = "file://" + firstDir
|
||||
|
||||
require.NoError(t, backUp(v, "first"))
|
||||
|
||||
secondDir := t.TempDir()
|
||||
secondStore, err := storage.NewFileStorer(secondDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
v.Storage = secondStore
|
||||
v.Config.StorageURL = "file://" + secondDir
|
||||
|
||||
require.ErrorContains(t, backUp(v, "first"),
|
||||
"local index is bound to a different backup destination")
|
||||
assert.NoFileExists(t, filepath.Join(secondDir, markerFile))
|
||||
}
|
||||
|
||||
// TestCommandsOtherThanBackupLeaveIndexUnbound checks that commands
|
||||
// other than a backup do not bind an unbound local index, whether or not
|
||||
// they fail. Each runs on a fresh local index.
|
||||
func TestCommandsOtherThanBackupLeaveIndexUnbound(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
run func(v *vaultik.Vaultik) error
|
||||
}{
|
||||
{"prune", func(v *vaultik.Vaultik) error {
|
||||
return v.Prune(&vaultik.PruneOptions{Force: true})
|
||||
}},
|
||||
{"snapshot purge", func(v *vaultik.Vaultik) error {
|
||||
return v.PurgeSnapshotsWithOptions(
|
||||
&vaultik.SnapshotPurgeOptions{KeepLatest: true, Force: true})
|
||||
}},
|
||||
{"snapshot remove", func(v *vaultik.Vaultik) error {
|
||||
_, err := v.RemoveSnapshot("host_first_2026-01-02T03:04:05Z",
|
||||
&vaultik.RemoveOptions{Force: true})
|
||||
|
||||
return err
|
||||
}},
|
||||
{"snapshot remove --all", func(v *vaultik.Vaultik) error {
|
||||
_, err := v.RemoveAllSnapshots(&vaultik.RemoveOptions{Force: true})
|
||||
|
||||
return err
|
||||
}},
|
||||
{"remote nuke", func(v *vaultik.Vaultik) error {
|
||||
return v.NukeRemote(true)
|
||||
}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
v, repos := newFileDestinationVaultik(ctx, t, t.TempDir())
|
||||
|
||||
_ = tt.run(v)
|
||||
|
||||
assert.Empty(t, storageBindingOf(ctx, t, repos))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// buildBindTestVaultik returns a minimal Vaultik wired with a real
|
||||
// in-memory DB and a config carrying the given StorageURL. Enough to
|
||||
// exercise EnsureStorageBinding without spinning up storage or fx.
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package vaultik_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/config"
|
||||
"sneak.berlin/go/vaultik/internal/log"
|
||||
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||
)
|
||||
|
||||
// TestUnknownSnapshotID checks that restore and verify, given an ID that
|
||||
// names no snapshot in the local index or on the destination store, say
|
||||
// so in the words `snapshot remove` uses and print nothing else.
|
||||
func TestUnknownSnapshotID(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
const unknownID = "host1_home_2026-01-01T00:00:00Z"
|
||||
|
||||
commands := map[string]func(v *vaultik.Vaultik) error{
|
||||
"restore": func(v *vaultik.Vaultik) error {
|
||||
return v.Restore(&vaultik.RestoreOptions{
|
||||
SnapshotID: unknownID,
|
||||
TargetDir: t.TempDir(),
|
||||
})
|
||||
},
|
||||
"verify": func(v *vaultik.Vaultik) error {
|
||||
return v.VerifySnapshotWithOptions(unknownID, &vaultik.VerifyOptions{})
|
||||
},
|
||||
"verify --deep": func(v *vaultik.Vaultik) error {
|
||||
return v.VerifySnapshotWithOptions(unknownID,
|
||||
&vaultik.VerifyOptions{Deep: true})
|
||||
},
|
||||
}
|
||||
|
||||
for name, run := range commands {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := newTestStorer()
|
||||
addManifest(t, store, "snapshot-001", nil)
|
||||
|
||||
// A backup wrote to this destination, so it carries the marker.
|
||||
require.NoError(t, store.Put(context.Background(), markerFile,
|
||||
bytes.NewReader(nil)))
|
||||
|
||||
tv := vaultik.NewForTesting(store)
|
||||
tv.Config = &config.Config{AgeSecretKey: testAgeSecretKey}
|
||||
tv.Repositories = newLocalIndex(t, "snapshot-001")
|
||||
|
||||
err := run(tv.Vaultik)
|
||||
|
||||
require.EqualError(t, err, "snapshot not found in the local "+
|
||||
"index or on the destination store: "+unknownID)
|
||||
assert.Empty(t, tv.Stdout.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ package vaultik
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@@ -184,6 +185,12 @@ func (v *Vaultik) printlnStdout(args ...any) {
|
||||
_, _ = fmt.Fprintln(v.Stdout, args...)
|
||||
}
|
||||
|
||||
// ErrNotConfirmed is returned when a confirmation prompt gets any answer
|
||||
// but the one it asks for (`y` or `Y`, or `yes` for `database delete`), or
|
||||
// stdin ends first. Nothing the prompt asked about is deleted, and the
|
||||
// command exits non-zero.
|
||||
var ErrNotConfirmed = errors.New("deletion not confirmed")
|
||||
|
||||
// scanStdin reads a line of input from stdin.
|
||||
func (v *Vaultik) scanStdin(a ...any) (int, error) {
|
||||
return fmt.Fscanln(v.Stdin, a...)
|
||||
|
||||
+31
-26
@@ -120,12 +120,17 @@ func (v *Vaultik) RunDeepVerify(snapshotID string, opts *VerifyOptions) error {
|
||||
|
||||
log.Info("Starting snapshot verification", "snapshot_id", snapshotID, "mode", "deep")
|
||||
|
||||
remoteKey, err := v.resolveSnapshotRemoteKey(snapshotID)
|
||||
if err != nil {
|
||||
return v.deepVerifyFailure(result, opts, err.Error(), err)
|
||||
}
|
||||
|
||||
if !opts.JSON && !v.UI.Quiet() {
|
||||
v.stdoutf("Deep verification of snapshot: %s\n\n", snapshotID)
|
||||
}
|
||||
|
||||
manifest, tempDB, dbBlobs, err := v.loadVerificationData(
|
||||
snapshotID, opts, result, identities)
|
||||
snapshotID, remoteKey, opts, result, identities)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -170,27 +175,19 @@ func (v *Vaultik) RunDeepVerify(snapshotID string, opts *VerifyOptions) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadVerificationData downloads manifest, database, and blob list for verification
|
||||
// loadVerificationData downloads manifest, database, and blob list for
|
||||
// verification of the snapshot at remoteKey, which
|
||||
// resolveSnapshotRemoteKey returned for snapshotID.
|
||||
func (v *Vaultik) loadVerificationData(
|
||||
snapshotID string, opts *VerifyOptions, result *VerifyResult,
|
||||
snapshotID, remoteKey string, opts *VerifyOptions, result *VerifyResult,
|
||||
identities []age.Identity,
|
||||
) (*snapshot.Manifest, *tempDB, []snapshot.BlobInfo, error) {
|
||||
// Resolve the identifier to the snapshot's remote key. A human ID is
|
||||
// hashed; a remote key (or its abbreviation, as printed for a
|
||||
// remote-only snapshot) is used as-is, so a host with no local index
|
||||
// can verify a snapshot it can only see on the store.
|
||||
remoteKey, err := v.resolveSnapshotRemoteKey(snapshotID)
|
||||
if err != nil {
|
||||
return nil, nil, nil, v.deepVerifyFailure(result, opts,
|
||||
fmt.Sprintf("resolving snapshot identifier: %v", err), err)
|
||||
}
|
||||
|
||||
// Download manifest. downloadManifestByKey is the single reader for
|
||||
// remote manifests; see its doc comment.
|
||||
log.Info("Downloading manifest", "remote_key", remoteKey)
|
||||
|
||||
if !opts.JSON && !v.UI.Quiet() {
|
||||
v.stdoutf("Downloading manifest...\n")
|
||||
v.UI.Beginf("Downloading manifest...")
|
||||
}
|
||||
|
||||
manifest, err := v.downloadManifestByKey(remoteKey)
|
||||
@@ -205,9 +202,9 @@ func (v *Vaultik) loadVerificationData(
|
||||
"manifest_total_size", ubytes(manifest.TotalCompressedSize))
|
||||
|
||||
if !opts.JSON && !v.UI.Quiet() {
|
||||
v.stdoutf("Manifest loaded: %d blobs (%s)\n",
|
||||
v.UI.Completef("Manifest loaded: %d blobs (%s)",
|
||||
manifest.BlobCount, ubytes(manifest.TotalCompressedSize))
|
||||
v.stdoutf("Downloading and decrypting database...\n")
|
||||
v.UI.Beginf("Downloading and decrypting database...")
|
||||
}
|
||||
|
||||
tdb, err := v.downloadVerifiedSnapshotDB(
|
||||
@@ -235,7 +232,7 @@ func (v *Vaultik) loadVerificationData(
|
||||
"db_total_size", ubytes(dbTotalSize))
|
||||
|
||||
if !opts.JSON && !v.UI.Quiet() {
|
||||
v.stdoutf("Database loaded: %d blobs (%s)\n",
|
||||
v.UI.Completef("Database loaded: %d blobs (%s)",
|
||||
len(dbBlobs), ubytes(dbTotalSize))
|
||||
}
|
||||
|
||||
@@ -267,6 +264,10 @@ func (v *Vaultik) downloadVerifiedSnapshotDB(
|
||||
defer func() { _ = dbReader.Close() }()
|
||||
|
||||
tdb, err := v.decryptAndLoadDatabase(dbReader, identities)
|
||||
if errors.Is(err, errWrongPrivateKey) {
|
||||
return nil, v.deepVerifyFailure(result, opts, err.Error(), err)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, v.deepVerifyFailure(result, opts,
|
||||
fmt.Sprintf("failed to decrypt database: %v", err),
|
||||
@@ -295,7 +296,7 @@ func (v *Vaultik) runVerificationSteps(
|
||||
identities []age.Identity,
|
||||
) error {
|
||||
if !opts.JSON && !v.UI.Quiet() {
|
||||
v.stdoutf("Verifying manifest against database...\n")
|
||||
v.UI.Beginf("Verifying manifest against database...")
|
||||
}
|
||||
|
||||
err := v.verifyManifestAgainstDatabase(manifest, dbBlobs)
|
||||
@@ -304,8 +305,8 @@ func (v *Vaultik) runVerificationSteps(
|
||||
}
|
||||
|
||||
if !opts.JSON && !v.UI.Quiet() {
|
||||
v.stdoutf("Manifest verified.\n")
|
||||
v.stdoutf("Checking blob existence in remote storage...\n")
|
||||
v.UI.Completef("Manifest verified.")
|
||||
v.UI.Beginf("Checking blob existence in remote storage...")
|
||||
}
|
||||
|
||||
err = v.verifyBlobExistenceFromDB(dbBlobs, result)
|
||||
@@ -314,8 +315,8 @@ func (v *Vaultik) runVerificationSteps(
|
||||
}
|
||||
|
||||
if !opts.JSON && !v.UI.Quiet() {
|
||||
v.stdoutf("All blobs exist.\n")
|
||||
v.stdoutf("Downloading and verifying blob contents (%d blobs, %s)...\n",
|
||||
v.UI.Completef("All blobs exist.")
|
||||
v.UI.Beginf("Downloading and verifying blob contents (%d blobs, %s)...",
|
||||
len(dbBlobs), ubytes(totalSize))
|
||||
}
|
||||
|
||||
@@ -351,9 +352,9 @@ func (v *Vaultik) decryptAndLoadDatabase(
|
||||
reader io.ReadCloser, identities []age.Identity,
|
||||
) (*tempDB, error) {
|
||||
// Decrypt and decompress through the shared blobgen reader.
|
||||
blobReader, err := blobgen.NewReader(reader, identities...)
|
||||
blobReader, err := v.newSnapshotDBReader(reader, identities)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create decryption reader: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer func() { _ = blobReader.Close() }()
|
||||
@@ -426,7 +427,11 @@ func (v *Vaultik) verifyBlob(
|
||||
// the plaintext as it is read. A blob's hash — its remote name — is the
|
||||
// double SHA-256 of that plaintext (see blobgen.DoubleSHA256), not of the
|
||||
// encrypted bytes.
|
||||
blobReader, err := blobgen.NewReader(reader, identities...)
|
||||
blobReader, err := v.newBlobReader(reader, identities)
|
||||
if errors.Is(err, errWrongPrivateKeyForBlob) {
|
||||
return err
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create blob reader: %w", err)
|
||||
}
|
||||
@@ -790,7 +795,7 @@ func (v *Vaultik) performDeepVerificationFromDB(
|
||||
)
|
||||
|
||||
if !opts.JSON && !v.UI.Quiet() {
|
||||
v.stdoutf(" Verified %d/%d blobs (%d remaining) - %s/%s - elapsed %s, eta %s\n",
|
||||
v.UI.Progressf("Verified %d/%d blobs (%d remaining) - %s/%s - elapsed %s, eta %s",
|
||||
i+1, len(blobs), remaining,
|
||||
ubytes(bytesProcessed),
|
||||
ubytes(totalBytesExpected),
|
||||
|
||||
@@ -100,3 +100,51 @@ func TestVerify_QuietSuppressesReport(t *testing.T) {
|
||||
require.Empty(t, stdout.String(),
|
||||
"a failed verify must write no report under --quiet")
|
||||
}
|
||||
|
||||
// TestVerify_DeepStatusLineUsesUIMarker checks that the deep verify
|
||||
// status lines go through internal/ui: marker-prefixed, and silenced by
|
||||
// --quiet.
|
||||
func TestVerify_DeepStatusLineUsesUIMarker(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
tempDir := t.TempDir()
|
||||
|
||||
dataDir := filepath.Join(tempDir, "source")
|
||||
storeDir := t.TempDir()
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
require.NoError(t, fs.MkdirAll(dataDir, 0o755))
|
||||
require.NoError(t, afero.WriteFile(fs,
|
||||
filepath.Join(dataDir, "data.bin"),
|
||||
bytesPattern("marker-", 64*1024), 0o644))
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
cfg, storer, snapshotID := runFileStorageBackup(
|
||||
ctx, t, fs, dataDir, storeDir, dbPath, 32*1024, 128*1024)
|
||||
|
||||
var stdout bytes.Buffer
|
||||
|
||||
v := &vaultik.Vaultik{
|
||||
Config: cfg,
|
||||
Storage: storer,
|
||||
Fs: fs,
|
||||
Stdout: &stdout,
|
||||
Stderr: io.Discard,
|
||||
UI: ui.NewWithColor(&stdout, false),
|
||||
}
|
||||
v.SetContext(ctx)
|
||||
|
||||
opts := &vaultik.VerifyOptions{Deep: true}
|
||||
|
||||
require.NoError(t, v.VerifySnapshotWithOptions(snapshotID, opts))
|
||||
require.Contains(t, stdout.String(),
|
||||
"\n"+ui.Marker+" Downloading manifest...\n")
|
||||
|
||||
stdout.Reset()
|
||||
v.UI.SetQuiet(true)
|
||||
require.NoError(t, v.VerifySnapshotWithOptions(snapshotID, opts))
|
||||
require.NotContains(t, stdout.String(), "Downloading manifest")
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"devDependencies": {
|
||||
"prettier": "3.8.1"
|
||||
}
|
||||
}
|
||||
+80
-1
@@ -3,11 +3,20 @@
|
||||
# this repo. Idempotent: every install is guarded by a check so already
|
||||
# installed tools are skipped. Base tooling comes from nix, apt, brew,
|
||||
# or apk (detected in that order); assumes NOTHING is present (not git,
|
||||
# make, or go).
|
||||
# make, or go). Node is used directly if installed; otherwise it is
|
||||
# installed at a pinned version via nvm (installing nvm itself first,
|
||||
# from a hash-verified release archive, never curl | sh).
|
||||
set -eu
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
|
||||
# Pinned versions, 2026-07-06
|
||||
NODE_VERSION="22.17.0"
|
||||
NVM_VERSION="0.40.3"
|
||||
# sha256 of https://github.com/nvm-sh/nvm/archive/refs/tags/v0.40.3.tar.gz
|
||||
NVM_SHA256="5f4d6aaa04a177dc93c985e31dbc411ab6b8c6e1e21d8015dbc1372625fcd1d0"
|
||||
YARN_VERSION="1.22.22"
|
||||
|
||||
PKGMGR=""
|
||||
SUDO=""
|
||||
|
||||
@@ -48,6 +57,69 @@ missing() {
|
||||
! command -v "$1" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
# verify_sha256 <file> <expected-hash>
|
||||
verify_sha256() {
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
actual="$(sha256sum "$1" | cut -d' ' -f1)"
|
||||
else
|
||||
actual="$(shasum -a 256 "$1" | cut -d' ' -f1)"
|
||||
fi
|
||||
if [ "$actual" != "$2" ]; then
|
||||
echo "bootstrap: sha256 mismatch for $1" >&2
|
||||
echo " expected: $2" >&2
|
||||
echo " actual: $actual" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# nvm is a bash script; run a command in a bash with nvm loaded
|
||||
nvm_sh() {
|
||||
bash -c ". \"\$HOME/.nvm/nvm.sh\" && $*"
|
||||
}
|
||||
|
||||
ensure_nvm() {
|
||||
[ -s "$HOME/.nvm/nvm.sh" ] && return 0
|
||||
# nvm prerequisites; nvm itself requires bash
|
||||
if missing bash; then pkg_install bash bash bash bash; fi
|
||||
if missing curl; then pkg_install curl curl curl curl; fi
|
||||
if missing git; then pkg_install git git git git; fi
|
||||
tmp="$(mktemp -d)"
|
||||
curl -fsSL -o "$tmp/nvm.tar.gz" \
|
||||
"https://github.com/nvm-sh/nvm/archive/refs/tags/v${NVM_VERSION}.tar.gz"
|
||||
verify_sha256 "$tmp/nvm.tar.gz" "$NVM_SHA256"
|
||||
mkdir -p "$HOME/.nvm"
|
||||
tar -xzf "$tmp/nvm.tar.gz" -C "$HOME/.nvm" --strip-components=1
|
||||
rm -rf "$tmp"
|
||||
}
|
||||
|
||||
ensure_node() {
|
||||
if ! missing node; then return 0; fi
|
||||
ensure_nvm
|
||||
nvm_sh "nvm install $NODE_VERSION"
|
||||
}
|
||||
|
||||
ensure_yarn() {
|
||||
if ! missing yarn; then return 0; fi
|
||||
if ! missing corepack; then
|
||||
corepack enable
|
||||
corepack prepare "yarn@$YARN_VERSION" --activate
|
||||
elif [ -s "$HOME/.nvm/nvm.sh" ]; then
|
||||
nvm_sh "nvm use $NODE_VERSION >/dev/null && corepack enable && \
|
||||
corepack prepare yarn@$YARN_VERSION --activate"
|
||||
else
|
||||
npm install -g "yarn@$YARN_VERSION"
|
||||
fi
|
||||
}
|
||||
|
||||
install_js_deps() {
|
||||
if missing yarn && [ -s "$HOME/.nvm/nvm.sh" ]; then
|
||||
nvm_sh "nvm use $NODE_VERSION >/dev/null && cd \"$ROOT\" && \
|
||||
yarn install --frozen-lockfile"
|
||||
else
|
||||
yarn install --frozen-lockfile
|
||||
fi
|
||||
}
|
||||
|
||||
# Docker is a hard requirement, not a nice-to-have: script/lint and
|
||||
# script/test build the lint and test phases of the Dockerfile, the only
|
||||
# place the linter and the tests run, and script/check and
|
||||
@@ -103,6 +175,8 @@ main() {
|
||||
# Base tooling (every repo)
|
||||
if missing git; then pkg_install git git git git; fi
|
||||
if missing make; then pkg_install gnumake make make make; fi
|
||||
# script/install-go, script/install-goreleaser and nvm download with it.
|
||||
if missing curl; then pkg_install curl curl curl curl; fi
|
||||
|
||||
# Go toolchain: the host's own, or else the version go.mod names,
|
||||
# hash-verified, in .tool/go. That directory is not on the caller's
|
||||
@@ -131,6 +205,11 @@ main() {
|
||||
|
||||
go mod download
|
||||
|
||||
# prettier, which script/fmt and script/fmt-check run on Markdown.
|
||||
ensure_node
|
||||
ensure_yarn
|
||||
install_js_deps
|
||||
|
||||
# Last, so that everything installable is installed before the one
|
||||
# thing this script cannot install decides the outcome.
|
||||
require_docker
|
||||
|
||||
+20
@@ -4,11 +4,31 @@ set -eu
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
|
||||
# Must match the pin in script/bootstrap.
|
||||
NODE_VERSION="22.17.0"
|
||||
|
||||
# script/bootstrap installs node and yarn under nvm and leaves neither
|
||||
# on the PATH of the shell that called it, so resolve the pinned
|
||||
# toolchain here the way bootstrap's own install step does. nvm is a
|
||||
# bash script, hence the subshell.
|
||||
run_yarn() {
|
||||
if command -v yarn >/dev/null 2>&1; then
|
||||
exec yarn "$@"
|
||||
fi
|
||||
if [ ! -s "$HOME/.nvm/nvm.sh" ]; then
|
||||
echo "fmt: no yarn; run script/bootstrap first" >&2
|
||||
exit 1
|
||||
fi
|
||||
exec bash -c '. "$HOME/.nvm/nvm.sh" && nvm use "$1" >/dev/null &&
|
||||
shift && exec yarn "$@"' bash "$NODE_VERSION" "$@"
|
||||
}
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
# Where script/bootstrap installs Go when the host has none.
|
||||
PATH="$PATH:$ROOT/.tool/go/bin"
|
||||
go fmt ./...
|
||||
run_yarn run prettier --write '**/*.md' --tab-width 4 --prose-wrap always
|
||||
}
|
||||
|
||||
main "$@"
|
||||
+21
-1
@@ -3,11 +3,30 @@
|
||||
# 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`.
|
||||
# only this reports with `gofmt -w`. Markdown is checked with prettier.
|
||||
set -eu
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
|
||||
# Must match the pin in script/bootstrap.
|
||||
NODE_VERSION="22.17.0"
|
||||
|
||||
# script/bootstrap installs node and yarn under nvm and leaves neither
|
||||
# on the PATH of the shell that called it, so resolve the pinned
|
||||
# toolchain here the way bootstrap's own install step does. nvm is a
|
||||
# bash script, hence the subshell.
|
||||
run_yarn() {
|
||||
if command -v yarn >/dev/null 2>&1; then
|
||||
exec yarn "$@"
|
||||
fi
|
||||
if [ ! -s "$HOME/.nvm/nvm.sh" ]; then
|
||||
echo "fmt-check: no yarn; run script/bootstrap first" >&2
|
||||
exit 1
|
||||
fi
|
||||
exec bash -c '. "$HOME/.nvm/nvm.sh" && nvm use "$1" >/dev/null &&
|
||||
shift && exec yarn "$@"' bash "$NODE_VERSION" "$@"
|
||||
}
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
# Where script/bootstrap installs Go when the host has none.
|
||||
@@ -23,6 +42,7 @@ main() {
|
||||
echo "$unformatted" >&2
|
||||
exit 1
|
||||
fi
|
||||
run_yarn run prettier --check '**/*.md' --tab-width 4 --prose-wrap always
|
||||
}
|
||||
|
||||
main "$@"
|
||||
+2
-2
@@ -11,8 +11,8 @@
|
||||
# when no tag is reachable, each with a "-dirty" suffix when tracked
|
||||
# files have uncommitted changes. Untracked files are ignored: a stray
|
||||
# scratch file does not change what was compiled. Outside a git checkout
|
||||
# (release tarball, `go install`), or in one with no commits, it is
|
||||
# "dev".
|
||||
# (such as an unpacked release tarball), or in one with no commits, it
|
||||
# is "dev".
|
||||
#
|
||||
# Nothing here ever invents a version number.
|
||||
set -eu
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||
# yarn lockfile v1
|
||||
|
||||
|
||||
prettier@3.8.1:
|
||||
version "3.8.1"
|
||||
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.8.1.tgz#edf48977cf991558f4fcbd8a3ba6015ba2a3a173"
|
||||
integrity sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==
|
||||
Reference in new issue
Block a user