Compare commits
38
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c40ff647c3 | ||
|
|
88a2aa7953 | ||
|
|
098ed80990 | ||
|
|
aae7fa17ae | ||
|
|
9236c0727c | ||
|
|
4418dc3e97 | ||
|
|
5a0da6da12 | ||
|
|
13a35a986a | ||
|
|
5abacbc5c1 | ||
|
|
ff50a137dd | ||
|
|
e9b17cc5fd | ||
|
|
850e2de25a | ||
|
|
6a57750a7b | ||
|
|
2590f49583 | ||
|
|
5b8d84e38a | ||
|
|
7a950ea2aa | ||
|
|
16b03dfda5 | ||
|
|
2125a9d451 | ||
|
|
a0de8bfdb7 | ||
|
|
291c39174f | ||
|
|
65036bba1b | ||
|
|
54ad279dd6 | ||
|
|
d156a02c49 | ||
|
|
aeac897805 | ||
|
|
d8f4f62f41 | ||
|
|
9fd88a34c7 | ||
|
|
5db2e920e5 | ||
|
|
66ea7f5f45 | ||
|
|
dd3667c8e9 | ||
|
|
30285c7a8c | ||
|
|
8d5e6f3399 | ||
|
|
abad8161bc | ||
|
|
05a2c86d65 | ||
|
|
d8f78a1f45 | ||
|
|
2c087be577 | ||
|
|
37d525c322 | ||
|
|
f89fac34f7 | ||
|
|
5d8c515192 |
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
|
# 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
|
- Come in hot with strong opinions, even if they are contrary to the direction I
|
||||||
direction I am headed.
|
am headed.
|
||||||
|
|
||||||
* If either you or I are possibly wrong, say so and explain your point of
|
- If either you or I are possibly wrong, say so and explain your point of view.
|
||||||
view.
|
|
||||||
|
|
||||||
* Point out great alternatives I haven't thought of, even when I'm not
|
- Point out great alternatives I haven't thought of, even when I'm not asking
|
||||||
asking for them.
|
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.
|
conversation, and deliver the absolute best recommendations.
|
||||||
|
|
||||||
* I want excellence, so always be on the lookout for divergences from good
|
- I want excellence, so always be on the lookout for divergences from good data
|
||||||
data model design or best practices for object oriented development.
|
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.
|
Deliver professional-level results, not prototypes.
|
||||||
|
|
||||||
* Please read and understand the `README.md` file in the root of the repo
|
- Please read and understand the `README.md` file in the root of the repo for
|
||||||
for project-specific contextual information, including development
|
project-specific contextual information, including development policies,
|
||||||
policies, practices, and current implementation status.
|
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.
|
diverge from best practices for clean, modular, maintainable code.
|
||||||
|
|
||||||
# Policies
|
# Policies
|
||||||
|
|
||||||
1. Before committing, tests must pass (`make test`), linting must pass
|
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
|
makefile targets should use `go fmt` and `go test -v ./...` and
|
||||||
`golangci-lint run`. Each Makefile target does exactly one thing — to
|
`golangci-lint run`. Each Makefile target does exactly one thing — to run
|
||||||
run lint + fmt-check + test together (the standard pre-commit gate),
|
lint + fmt-check + test together (the standard pre-commit gate), use
|
||||||
use `make check`.
|
`make check`.
|
||||||
|
|
||||||
2. Always write a `Makefile` with the default target being `test`, and with
|
2. Always write a `Makefile` with the default target being `test`, and with a
|
||||||
a `fmt` target that formats the code. The `test` target should run all
|
`fmt` target that formats the code. The `test` target should run all tests in
|
||||||
tests in the project, and the `fmt` target should format the code.
|
the project, and the `fmt` target should format the code. `test` should also
|
||||||
`test` should also have a prerequisite target `lint` that should run any
|
have a prerequisite target `lint` that should run any linters that are
|
||||||
linters that are configured for the project.
|
configured for the project.
|
||||||
|
|
||||||
3. After each completed bugfix or feature, the code must be committed. Do
|
3. After each completed bugfix or feature, the code must be committed. Do all of
|
||||||
all of the pre-commit checks (test, lint, fmt) before committing, of
|
the pre-commit checks (test, lint, fmt) before committing, of course.
|
||||||
course.
|
|
||||||
|
|
||||||
4. When creating a very simple test script for testing out a new feature,
|
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
|
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.
|
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
|
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
|
tests, or change the tests specifically to make them pass (unless there is a
|
||||||
is a bug in the test). This is cheating, and it is bad. You should only
|
bug in the test). This is cheating, and it is bad. You should only be
|
||||||
be modifying the test if it is incorrect or if the test is no longer
|
modifying the test if it is incorrect or if the test is no longer relevant.
|
||||||
relevant. In almost all cases, you should be fixing the code that is
|
In almost all cases, you should be fixing the code that is being tested, or
|
||||||
being tested, or updating the tests to match a refactored implementation.
|
updating the tests to match a refactored implementation.
|
||||||
|
|
||||||
6. When dealing with dates and times or timestamps, always use, display, and
|
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
|
store UTC. Set the local timezone to UTC on startup. If the user needs to see
|
||||||
to see the time in a different timezone, store the user's timezone in a
|
the time in a different timezone, store the user's timezone in a separate
|
||||||
separate field and convert the UTC time to the user's timezone when
|
field and convert the UTC time to the user's timezone when displaying it. For
|
||||||
displaying it. For internal use and internal applications and
|
internal use and internal applications and administrative purposes, always
|
||||||
administrative purposes, always display UTC.
|
display UTC.
|
||||||
|
|
||||||
7. Always write tests, even if they are extremely simple and just check for
|
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
|
correct syntax (ability to compile/import). If you are writing a new feature,
|
||||||
feature, write a test for it. You don't need to target complete
|
write a test for it. You don't need to target complete coverage, but you
|
||||||
coverage, but you should at least test any new functionality you add. If
|
should at least test any new functionality you add. If you are fixing a bug,
|
||||||
you are fixing a bug, write a test first that reproduces the bug, and
|
write a test first that reproduces the bug, and then fix the bug in the code.
|
||||||
then fix the bug in the code.
|
|
||||||
|
|
||||||
8. When implementing new features, be aware of potential side-effects (such
|
8. When implementing new features, be aware of potential side-effects (such as
|
||||||
as state files on disk, data in the database, etc.) and ensure that it is
|
state files on disk, data in the database, etc.) and ensure that it is
|
||||||
possible to mock or stub these side-effects in tests.
|
possible to mock or stub these side-effects in tests.
|
||||||
|
|
||||||
9. Always use structured logging. Log any relevant state/context with the
|
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,
|
messages (but do not log secrets). If the log stream is not a terminal,
|
||||||
output the structured logs in jsonl format.
|
output the structured logs in jsonl format.
|
||||||
|
|
||||||
10. Avoid using bare strings or numbers in code, especially if they appear
|
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
|
anywhere more than once. Always define a constant (usually at the top of the
|
||||||
of the file) and give it a descriptive name, then use that constant in
|
file) and give it a descriptive name, then use that constant in the code
|
||||||
the code instead of the bare string or number.
|
instead of the bare string or number.
|
||||||
|
|
||||||
11. You do not need to summarize your changes in the chat after making them.
|
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
|
Making the changes and committing them is sufficient. If anything out of the
|
||||||
of the ordinary happened, please explain it, but in the normal case
|
ordinary happened, please explain it, but in the normal case where you found
|
||||||
where you found and fixed the bug, or implemented the feature, there is
|
and fixed the bug, or implemented the feature, there is no need for the
|
||||||
no need for the end-of-change summary.
|
end-of-change summary.
|
||||||
|
|
||||||
12. Do not create additional files in the root directory of the project
|
12. Do not create additional files in the root directory of the project without
|
||||||
without asking permission first. Configuration files, documentation, and
|
asking permission first. Configuration files, documentation, and build files
|
||||||
build files are acceptable in the root, but source code and other files
|
are acceptable in the root, but source code and other files should be
|
||||||
should be organized in appropriate subdirectories.
|
organized in appropriate subdirectories.
|
||||||
|
|
||||||
13. Pre-1.0: NEVER add a database migration. Migrations are supported, but
|
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
|
nothing is installed anywhere yet, so there is nothing to migrate. To change
|
||||||
change the schema, edit `internal/database/schema/001.sql` (and any
|
the schema, edit `internal/database/schema/001.sql` (and any code that
|
||||||
code that touches the affected tables) directly. After 1.0, each schema
|
touches the affected tables) directly. After 1.0, each schema change is a
|
||||||
change is a new numbered file in that directory and a released file is
|
new numbered file in that directory and a released file is never edited; an
|
||||||
never edited; an existing local database is then migrated when vaultik
|
existing local database is then migrated when vaultik is updated. Before
|
||||||
is updated. Before 1.0, a local database left on an older schema is
|
1.0, a local database left on an older schema is deleted and re-created by a
|
||||||
deleted and re-created by a full backup. See
|
full backup. See [`docs/DATAMODEL.md`](docs/DATAMODEL.md#schema-migrations).
|
||||||
[`docs/DATAMODEL.md`](docs/DATAMODEL.md#schema-migrations).
|
|
||||||
|
|
||||||
14. Never use `git add -A`. Stage only the files you intentionally
|
14. Never use `git add -A`. Stage only the files you intentionally changed.
|
||||||
changed.
|
|
||||||
|
|
||||||
15. Commit messages carry no attribution or advertising trailers for the
|
15. Commit messages carry no attribution or advertising trailers for the tool
|
||||||
tool that helped write the code, or for its vendor: the owner is the
|
that helped write the code, or for its vendor: the owner is the sole author
|
||||||
sole author of code written with a tool.
|
of code written with a tool.
|
||||||
|
|
||||||
16. Run the whole test suite with `make test` every time, and read its full
|
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
|
output. Never run `go test`, a single test or a single package, and never
|
||||||
never grep the output.
|
grep the output.
|
||||||
|
|
||||||
17. Do not stop working on a task until the definition of done given in the
|
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.
|
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
|
18. For estimates: backing up over 2.5Gbit/s ethernet to an S3 server backed by
|
||||||
backed by 2000MB/sec SSD takes about 4 seconds per gigabyte.
|
2000MB/sec SSD takes about 4 seconds per gigabyte.
|
||||||
@@ -302,7 +302,8 @@ storage_url: "rclone://myremote/path/to/backups"
|
|||||||
|
|
||||||
# Average chunk size for content-defined chunking
|
# Average chunk size for content-defined chunking
|
||||||
# Smaller chunks = better deduplication but more metadata
|
# Smaller chunks = better deduplication but more metadata
|
||||||
# Supports: 10MB, 5M, 1GB, 500KB, 64MiB, etc.
|
# Minimum 1MB
|
||||||
|
# Supports: 10MB, 5M, 1GB, 64MiB, etc.
|
||||||
# Default: 10MB
|
# Default: 10MB
|
||||||
#chunk_size: 10MB
|
#chunk_size: 10MB
|
||||||
|
|
||||||
@@ -327,6 +328,20 @@ storage_url: "rclone://myremote/path/to/backups"
|
|||||||
# Higher = better compression but slower
|
# Higher = better compression but slower
|
||||||
# Default: 3
|
# Default: 3
|
||||||
compression_level: 5
|
compression_level: 5
|
||||||
|
|
||||||
|
# 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 such blobs and directories at once
|
||||||
|
# Default: 24h
|
||||||
|
#prune_grace_period: 24h
|
||||||
|
|
||||||
# Hostname to use in backup metadata
|
# Hostname to use in backup metadata
|
||||||
# Default: system hostname
|
# Default: system hostname
|
||||||
#hostname: myserver
|
#hostname: myserver
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
# Vaultik Architecture
|
# 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
|
## 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
|
## Data Flow
|
||||||
|
|
||||||
@@ -53,57 +57,88 @@ The database tracks five primary entities and their relationships:
|
|||||||
### Entity Descriptions
|
### Entity Descriptions
|
||||||
|
|
||||||
#### File (`database.File`)
|
#### 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
|
- Path, mtime
|
||||||
- Size, mode, ownership (uid, gid)
|
- Size, mode, ownership (uid, gid)
|
||||||
- Symlink target (if applicable)
|
- 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`)
|
#### 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)
|
- `ChunkHash`: SHA256 hash of chunk content (primary key)
|
||||||
- `Size`: Chunk size in bytes
|
- `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`)
|
#### FileChunk (`database.FileChunk`)
|
||||||
|
|
||||||
Maps files to their constituent chunks:
|
Maps files to their constituent chunks:
|
||||||
|
|
||||||
- `FileID`: Reference to the file
|
- `FileID`: Reference to the file
|
||||||
- `Idx`: Position of this chunk within the file (0-indexed)
|
- `Idx`: Position of this chunk within the file (0-indexed)
|
||||||
- `ChunkHash`: Reference to the chunk
|
- `ChunkHash`: Reference to the chunk
|
||||||
|
|
||||||
#### Blob (`database.Blob`)
|
#### 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
|
- `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
|
- `UncompressedSize`: Total raw chunk data before compression
|
||||||
- `CompressedSize`: Size after zstd compression and age encryption
|
- `CompressedSize`: Size after zstd compression and age encryption
|
||||||
- `CreatedTS`, `FinishedTS`, `UploadedTS`: Lifecycle timestamps
|
- `CreatedTS`, `FinishedTS`, `UploadedTS`: Lifecycle timestamps
|
||||||
|
|
||||||
Blob creation process:
|
Blob creation process:
|
||||||
|
|
||||||
1. Chunks are accumulated (up to MaxBlobSize, typically 10GB)
|
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
|
2. As each chunk is added, its uncompressed bytes are fed to a running SHA-256
|
||||||
3. Concurrently, the same bytes are compressed with zstd, then encrypted with age (recipients configured in config), and written to a temporary file
|
3. Concurrently, the same bytes are compressed with zstd, then encrypted with
|
||||||
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
|
age (recipients configured in config), and written to a temporary file
|
||||||
5. The finished file is uploaded to `blobs/{hash[0:2]}/{hash[2:4]}/{hash}` and then deleted
|
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`)
|
#### BlobChunk (`database.BlobChunk`)
|
||||||
|
|
||||||
Maps chunks to their position within blobs:
|
Maps chunks to their position within blobs:
|
||||||
|
|
||||||
- `BlobID`: Reference to the blob
|
- `BlobID`: Reference to the blob
|
||||||
- `ChunkHash`: Reference to the chunk
|
- `ChunkHash`: Reference to the chunk
|
||||||
- `Offset`: Byte offset within the uncompressed blob
|
- `Offset`: Byte offset within the uncompressed blob
|
||||||
- `Length`: Chunk size
|
- `Length`: Chunk size
|
||||||
|
|
||||||
#### Snapshot (`database.Snapshot`)
|
#### Snapshot (`database.Snapshot`)
|
||||||
|
|
||||||
Represents a point-in-time backup:
|
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
|
- Tracks file count, chunk count, blob count, sizes, compression ratio
|
||||||
- `CompletedAt`: Null until snapshot finishes successfully
|
- `CompletedAt`: Null until snapshot finishes successfully
|
||||||
|
|
||||||
#### SnapshotFile / SnapshotBlob
|
#### SnapshotFile / SnapshotBlob
|
||||||
|
|
||||||
Join tables linking snapshots to their files and blobs.
|
Join tables linking snapshots to their files and blobs.
|
||||||
|
|
||||||
### Relationship Summary
|
### Relationship Summary
|
||||||
@@ -140,21 +175,27 @@ fx.New(
|
|||||||
### Key Type Instantiation Points
|
### Key Type Instantiation Points
|
||||||
|
|
||||||
#### 1. Config (`config.Config`)
|
#### 1. Config (`config.Config`)
|
||||||
|
|
||||||
- **Created by**: `config.Module` via `config.LoadConfig()`
|
- **Created by**: `config.Module` via `config.LoadConfig()`
|
||||||
- **When**: Application startup (fx DI)
|
- **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`)
|
#### 2. Database (`database.DB`)
|
||||||
|
|
||||||
- **Created by**: `database.Module` via `database.New()`
|
- **Created by**: `database.Module` via `database.New()`
|
||||||
- **When**: Application startup (fx DI)
|
- **When**: Application startup (fx DI)
|
||||||
- **Contains**: SQLite connection, path reference
|
- **Contains**: SQLite connection, path reference
|
||||||
|
|
||||||
#### 3. Repositories (`database.Repositories`)
|
#### 3. Repositories (`database.Repositories`)
|
||||||
|
|
||||||
- **Created by**: `database.Module` via `database.NewRepositories()`
|
- **Created by**: `database.Module` via `database.NewRepositories()`
|
||||||
- **When**: Application startup (fx DI)
|
- **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`)
|
#### 4. Vaultik (`vaultik.Vaultik`)
|
||||||
|
|
||||||
- **Created by**: `vaultik.New(VaultikParams)`
|
- **Created by**: `vaultik.New(VaultikParams)`
|
||||||
- **When**: Application startup (fx DI)
|
- **When**: Application startup (fx DI)
|
||||||
- **Contains**: All dependencies for backup operations
|
- **Contains**: All dependencies for backup operations
|
||||||
@@ -176,11 +217,13 @@ type Vaultik struct {
|
|||||||
```
|
```
|
||||||
|
|
||||||
#### 5. SnapshotManager (`snapshot.SnapshotManager`)
|
#### 5. SnapshotManager (`snapshot.SnapshotManager`)
|
||||||
|
|
||||||
- **Created by**: `snapshot.Module` via `snapshot.NewSnapshotManager()`
|
- **Created by**: `snapshot.Module` via `snapshot.NewSnapshotManager()`
|
||||||
- **When**: Application startup (fx DI)
|
- **When**: Application startup (fx DI)
|
||||||
- **Responsibility**: Creates/completes snapshots, exports metadata to S3
|
- **Responsibility**: Creates/completes snapshots, exports metadata to S3
|
||||||
|
|
||||||
#### 6. Scanner (`snapshot.Scanner`)
|
#### 6. Scanner (`snapshot.Scanner`)
|
||||||
|
|
||||||
- **Created by**: `ScannerFactory(ScannerParams)`
|
- **Created by**: `ScannerFactory(ScannerParams)`
|
||||||
- **When**: Each `CreateSnapshot()` call
|
- **When**: Each `CreateSnapshot()` call
|
||||||
- **Contains**: Chunker, Packer, progress reporter
|
- **Contains**: Chunker, Packer, progress reporter
|
||||||
@@ -194,20 +237,22 @@ scanner := v.ScannerFactory(snapshot.ScannerParams{
|
|||||||
```
|
```
|
||||||
|
|
||||||
#### 7. Chunker (`chunker.Chunker`)
|
#### 7. Chunker (`chunker.Chunker`)
|
||||||
|
|
||||||
- **Created by**: `chunker.NewChunker(avgChunkSize)`
|
- **Created by**: `chunker.NewChunker(avgChunkSize)`
|
||||||
- **When**: Inside `snapshot.NewScanner()`
|
- **When**: Inside `snapshot.NewScanner()`
|
||||||
- **Configuration**:
|
- **Configuration**:
|
||||||
- `avgChunkSize`: From config (default 10MB)
|
- `avgChunkSize`: From config (default 10MB)
|
||||||
- `minChunkSize`: avgChunkSize / 4
|
- `minChunkSize`: avgChunkSize / 4
|
||||||
- `maxChunkSize`: avgChunkSize * 4
|
- `maxChunkSize`: avgChunkSize \* 4
|
||||||
|
|
||||||
#### 8. Packer (`blob.Packer`)
|
#### 8. Packer (`blob.Packer`)
|
||||||
|
|
||||||
- **Created by**: `blob.NewPacker(PackerConfig)`
|
- **Created by**: `blob.NewPacker(PackerConfig)`
|
||||||
- **When**: Inside `snapshot.NewScanner()`
|
- **When**: Inside `snapshot.NewScanner()`
|
||||||
- **Configuration**:
|
- **Configuration**:
|
||||||
- `MaxBlobSize`: Maximum blob size before finalization (typically 10GB)
|
- `MaxBlobSize`: Maximum blob size before finalization (typically 10GB)
|
||||||
- `CompressionLevel`: zstd level (1-19)
|
- `CompressionLevel`: zstd level (1-19)
|
||||||
- `Recipients`: age public keys for encryption
|
- `Recipients`: age public keys for encryption
|
||||||
|
|
||||||
```go
|
```go
|
||||||
// snapshot/scanner.go: NewScanner()
|
// snapshot/scanner.go: NewScanner()
|
||||||
@@ -224,17 +269,22 @@ packer, err := blob.NewPacker(packerCfg)
|
|||||||
## Module Responsibilities
|
## Module Responsibilities
|
||||||
|
|
||||||
### `internal/cli`
|
### `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:
|
Key functions:
|
||||||
|
|
||||||
- `NewApp(AppOptions)` → Creates fx.App with all modules
|
- `NewApp(AppOptions)` → Creates fx.App with all modules
|
||||||
- `RunApp(ctx, app)` → Starts app, handles graceful shutdown
|
- `RunApp(ctx, app)` → Starts app, handles graceful shutdown
|
||||||
- `RunWithApp(ctx, opts)` → Convenience wrapper
|
- `RunWithApp(ctx, opts)` → Convenience wrapper
|
||||||
|
|
||||||
### `internal/vaultik`
|
### `internal/vaultik`
|
||||||
|
|
||||||
Main orchestrator containing all dependencies and command implementations.
|
Main orchestrator containing all dependencies and command implementations.
|
||||||
|
|
||||||
Key methods:
|
Key methods:
|
||||||
|
|
||||||
- `New(VaultikParams)` → Constructor (fx DI)
|
- `New(VaultikParams)` → Constructor (fx DI)
|
||||||
- `CreateSnapshot(opts)` → Main backup operation
|
- `CreateSnapshot(opts)` → Main backup operation
|
||||||
- `ListSnapshots(jsonOutput)` → List available snapshots
|
- `ListSnapshots(jsonOutput)` → List available snapshots
|
||||||
@@ -242,27 +292,34 @@ Key methods:
|
|||||||
- `PurgeSnapshots(...)` → Remove old snapshots
|
- `PurgeSnapshots(...)` → Remove old snapshots
|
||||||
|
|
||||||
### `internal/chunker`
|
### `internal/chunker`
|
||||||
|
|
||||||
Content-defined chunking using FastCDC algorithm.
|
Content-defined chunking using FastCDC algorithm.
|
||||||
|
|
||||||
Key types:
|
Key types:
|
||||||
|
|
||||||
- `Chunk` → Hash, Data, Offset, Size
|
- `Chunk` → Hash, Data, Offset, Size
|
||||||
- `Chunker` → avgChunkSize, minChunkSize, maxChunkSize
|
- `Chunker` → avgChunkSize, minChunkSize, maxChunkSize
|
||||||
|
|
||||||
Key methods:
|
Key methods:
|
||||||
|
|
||||||
- `NewChunker(avgChunkSize)` → Constructor
|
- `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)
|
- `ChunkReader(reader)` → Return all chunks at once (memory-intensive)
|
||||||
|
|
||||||
### `internal/blob`
|
### `internal/blob`
|
||||||
|
|
||||||
Blob packing: accumulates chunks, compresses, encrypts, tracks metadata.
|
Blob packing: accumulates chunks, compresses, encrypts, tracks metadata.
|
||||||
|
|
||||||
Key types:
|
Key types:
|
||||||
|
|
||||||
- `Packer` → Thread-safe blob accumulator
|
- `Packer` → Thread-safe blob accumulator
|
||||||
- `ChunkRef` → Hash + Data for adding to packer
|
- `ChunkRef` → Hash + Data for adding to packer
|
||||||
- `FinishedBlob` → Completed blob ready for upload
|
- `FinishedBlob` → Completed blob ready for upload
|
||||||
- `BlobWithReader` → FinishedBlob + io.Reader for streaming upload
|
- `BlobWithReader` → FinishedBlob + io.Reader for streaming upload
|
||||||
|
|
||||||
Key methods:
|
Key methods:
|
||||||
|
|
||||||
- `NewPacker(PackerConfig)` → Constructor
|
- `NewPacker(PackerConfig)` → Constructor
|
||||||
- `AddChunk(ChunkRef)` → Add chunk to current blob
|
- `AddChunk(ChunkRef)` → Add chunk to current blob
|
||||||
- `FinalizeBlob()` → Compress, encrypt, hash current blob
|
- `FinalizeBlob()` → Compress, encrypt, hash current blob
|
||||||
@@ -272,35 +329,48 @@ Key methods:
|
|||||||
### `internal/snapshot`
|
### `internal/snapshot`
|
||||||
|
|
||||||
#### Scanner
|
#### Scanner
|
||||||
|
|
||||||
Orchestrates the backup process for a directory.
|
Orchestrates the backup process for a directory.
|
||||||
|
|
||||||
Key methods:
|
Key methods:
|
||||||
|
|
||||||
- `NewScanner(ScannerConfig)` → Constructor (creates Chunker + Packer)
|
- `NewScanner(ScannerConfig)` → Constructor (creates Chunker + Packer)
|
||||||
- `Scan(ctx, path, snapshotID)` → Main scan operation
|
- `Scan(ctx, path, snapshotID)` → Main scan operation
|
||||||
|
|
||||||
Scan phases:
|
Scan phases:
|
||||||
1. **Phase 0**: Detect deleted files from previous snapshots
|
|
||||||
2. **Phase 1**: Walk directory, identify files needing processing
|
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
|
||||||
3. **Phase 2**: Process files (chunk → pack → upload)
|
3. **Phase 2**: Process files (chunk → pack → upload)
|
||||||
|
|
||||||
#### SnapshotManager
|
#### SnapshotManager
|
||||||
|
|
||||||
Manages snapshot lifecycle and metadata export.
|
Manages snapshot lifecycle and metadata export.
|
||||||
|
|
||||||
Key methods:
|
Key methods:
|
||||||
|
|
||||||
- `CreateSnapshot(ctx, hostname, version, commit)` → Create snapshot record
|
- `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
|
- `ExportSnapshotMetadata(ctx, dbPath, snapshotID)` → Export to S3
|
||||||
- `MarkSnapshotComplete(ctx, snapshotID)` → Record completion, only after a successful export
|
- `MarkSnapshotComplete(ctx, snapshotID)` → Record completion, only after a
|
||||||
- `CompleteSnapshot(ctx, snapshotID)` → Convenience: populate blobs, then mark complete (no export between)
|
successful export
|
||||||
|
- `CompleteSnapshot(ctx, snapshotID)` → Convenience: populate blobs, then mark
|
||||||
|
complete (no export between)
|
||||||
|
|
||||||
### `internal/database`
|
### `internal/database`
|
||||||
|
|
||||||
SQLite database for local index. Single-writer mode for thread safety.
|
SQLite database for local index. Single-writer mode for thread safety.
|
||||||
|
|
||||||
Key types:
|
Key types:
|
||||||
|
|
||||||
- `DB` → Database connection wrapper
|
- `DB` → Database connection wrapper
|
||||||
- `Repositories` → Collection of all repository interfaces
|
- `Repositories` → Collection of all repository interfaces
|
||||||
|
|
||||||
Repository interfaces:
|
Repository interfaces:
|
||||||
|
|
||||||
- `FilesRepository` → CRUD for File records
|
- `FilesRepository` → CRUD for File records
|
||||||
- `ChunksRepository` → CRUD for Chunk records
|
- `ChunksRepository` → CRUD for Chunk records
|
||||||
- `BlobsRepository` → CRUD for Blob records
|
- `BlobsRepository` → CRUD for Blob records
|
||||||
@@ -311,6 +381,8 @@ Repository interfaces:
|
|||||||
|
|
||||||
```
|
```
|
||||||
CreateSnapshot(opts)
|
CreateSnapshot(opts)
|
||||||
|
│
|
||||||
|
├─► markDestination() // Check the marker (see Storage Layout in S3), then write it
|
||||||
│
|
│
|
||||||
├─► PruneDatabase() // Critical: avoid dedup errors
|
├─► PruneDatabase() // Critical: avoid dedup errors
|
||||||
│
|
│
|
||||||
@@ -320,13 +392,13 @@ CreateSnapshot(opts)
|
|||||||
│ │
|
│ │
|
||||||
│ ├─► scanner.Scan(ctx, path, snapshotID)
|
│ ├─► scanner.Scan(ctx, path, snapshotID)
|
||||||
│ │ │
|
│ │ │
|
||||||
│ │ ├─► Phase 0: detectDeletedFiles()
|
|
||||||
│ │ │
|
|
||||||
│ │ ├─► Phase 1: scanPhase()
|
│ │ ├─► Phase 1: scanPhase()
|
||||||
│ │ │ Walk directory
|
│ │ │ Walk directory
|
||||||
│ │ │ Check file metadata changes
|
│ │ │ Check file metadata changes
|
||||||
│ │ │ Build list of files to process
|
│ │ │ Build list of files to process
|
||||||
│ │ │
|
│ │ │
|
||||||
|
│ │ ├─► Phase 1b: detectDeletedFiles()
|
||||||
|
│ │ │
|
||||||
│ │ └─► Phase 2: processPhase()
|
│ │ └─► Phase 2: processPhase()
|
||||||
│ │ For each file:
|
│ │ For each file:
|
||||||
│ │ chunker.ChunkReaderStreaming()
|
│ │ chunker.ChunkReaderStreaming()
|
||||||
@@ -357,16 +429,23 @@ CreateSnapshot(opts)
|
|||||||
|
|
||||||
## Deduplication Strategy
|
## Deduplication Strategy
|
||||||
|
|
||||||
1. **File-level**: Files unchanged since last backup are skipped (metadata comparison: size, mtime, mode, uid, gid), 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
|
## Storage Layout in S3
|
||||||
|
|
||||||
```
|
```
|
||||||
bucket/
|
bucket/
|
||||||
|
├── vaultik-destination.txt # Marker written by every backup
|
||||||
|
│
|
||||||
├── blobs/
|
├── blobs/
|
||||||
│ └── {hash[0:2]}/
|
│ └── {hash[0:2]}/
|
||||||
│ └── {hash[2:4]}/
|
│ └── {hash[2:4]}/
|
||||||
@@ -378,15 +457,30 @@ bucket/
|
|||||||
└── manifest.json.zst # Blob list (for pruning/verification)
|
└── manifest.json.zst # Blob list (for pruning/verification)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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
|
The `{remote-key}` directory name is a one-way double SHA-256 hash of the human
|
||||||
snapshot ID, so the human ID (hostname, snapshot name, timestamp) is never
|
snapshot ID, so the human ID (hostname, snapshot name, timestamp) is never
|
||||||
written to the store as a directory name. See
|
written to the store as a directory name. See
|
||||||
[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.
|
derivation and a worked example.
|
||||||
|
|
||||||
## Thread Safety
|
## Thread Safety
|
||||||
|
|
||||||
- `Packer`: Thread-safe via mutex. Multiple goroutines can call `AddChunk()`.
|
- `Packer`: Thread-safe via mutex. Multiple goroutines can call `AddChunk()`.
|
||||||
- `Scanner`: Uses `packerMu` mutex to coordinate blob finalization.
|
- `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.
|
- `Repositories.WithTx()`: Handles transaction lifecycle automatically.
|
||||||
+152
-55
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
## Overview
|
## 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:**
|
**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.
|
`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
|
**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
|
database is migrated the first time an updated vaultik opens it. A file that has
|
||||||
has shipped in a release is never edited.
|
shipped in a release is never edited.
|
||||||
|
|
||||||
## Database Tables
|
## Database Tables
|
||||||
|
|
||||||
### 1. `files`
|
### 1. `files`
|
||||||
|
|
||||||
Stores metadata about files in the filesystem being backed up.
|
Stores metadata about files in the filesystem being backed up.
|
||||||
|
|
||||||
**Columns:**
|
**Columns:**
|
||||||
|
|
||||||
- `id` (TEXT PRIMARY KEY) - UUID for the file record
|
- `id` (TEXT PRIMARY KEY) - UUID for the file record
|
||||||
- `path` (TEXT NOT NULL UNIQUE) - Absolute file path
|
- `path` (TEXT NOT NULL UNIQUE) - Absolute file path
|
||||||
- `mtime` (INTEGER NOT NULL) - Modification time, whole seconds since the Unix epoch
|
- `mtime` (INTEGER NOT NULL) - Modification time, whole seconds since the Unix
|
||||||
- `mtime_nsec` (INTEGER NOT NULL) - Nanoseconds within that second, 0 to 999999999
|
epoch
|
||||||
|
- `mtime_nsec` (INTEGER NOT NULL) - Nanoseconds within that second, 0 to
|
||||||
|
999999999
|
||||||
- `size` (INTEGER NOT NULL) - File size in bytes
|
- `size` (INTEGER NOT NULL) - File size in bytes
|
||||||
- `mode` (INTEGER NOT NULL) - Unix file permissions and type
|
- `mode` (INTEGER NOT NULL) - Unix file permissions and type
|
||||||
- `uid` (INTEGER NOT NULL) - User ID of file owner
|
- `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)
|
- `link_target` (TEXT) - Symlink target path (NULL for regular files)
|
||||||
|
|
||||||
**Indexes:**
|
**Indexes:**
|
||||||
|
|
||||||
- `idx_files_path` on `path` for efficient lookups
|
- `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`
|
### 2. `chunks`
|
||||||
|
|
||||||
Stores information about content-defined chunks created from files.
|
Stores information about content-defined chunks created from files.
|
||||||
|
|
||||||
**Columns:**
|
**Columns:**
|
||||||
|
|
||||||
- `chunk_hash` (TEXT PRIMARY KEY) - SHA256 hash of chunk content
|
- `chunk_hash` (TEXT PRIMARY KEY) - SHA256 hash of chunk content
|
||||||
- `size` (INTEGER NOT NULL) - Chunk size in bytes
|
- `size` (INTEGER NOT NULL) - Chunk size in bytes
|
||||||
|
|
||||||
**Purpose:** Enables deduplication by tracking unique chunks across all files.
|
**Purpose:** Enables deduplication by tracking unique chunks across all files.
|
||||||
|
|
||||||
### 3. `file_chunks`
|
### 3. `file_chunks`
|
||||||
|
|
||||||
Maps files to their constituent chunks in order.
|
Maps files to their constituent chunks in order.
|
||||||
|
|
||||||
**Columns:**
|
**Columns:**
|
||||||
|
|
||||||
- `file_id` (TEXT) - File ID (FK to files.id)
|
- `file_id` (TEXT) - File ID (FK to files.id)
|
||||||
- `idx` (INTEGER) - Chunk index within file (0-based)
|
- `idx` (INTEGER) - Chunk index within file (0-based)
|
||||||
- `chunk_hash` (TEXT) - Chunk hash (FK to chunks.chunk_hash)
|
- `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.
|
**Purpose:** Allows reconstruction of files from chunks during restore.
|
||||||
|
|
||||||
### 4. `chunk_files`
|
### 4. `chunk_files`
|
||||||
|
|
||||||
Reverse mapping showing which files contain each chunk.
|
Reverse mapping showing which files contain each chunk.
|
||||||
|
|
||||||
**Columns:**
|
**Columns:**
|
||||||
|
|
||||||
- `chunk_hash` (TEXT) - Chunk hash (FK to chunks.chunk_hash)
|
- `chunk_hash` (TEXT) - Chunk hash (FK to chunks.chunk_hash)
|
||||||
- `file_id` (TEXT) - File ID (FK to files.id)
|
- `file_id` (TEXT) - File ID (FK to files.id)
|
||||||
- `file_offset` (INTEGER) - Byte offset of chunk within file
|
- `file_offset` (INTEGER) - Byte offset of chunk within file
|
||||||
- `length` (INTEGER) - Length of chunk in bytes
|
- `length` (INTEGER) - Length of chunk in bytes
|
||||||
- PRIMARY KEY (`chunk_hash`, `file_id`)
|
- 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`
|
### 5. `blobs`
|
||||||
|
|
||||||
Stores information about packed, compressed, and encrypted blob files.
|
Stores information about packed, compressed, and encrypted blob files.
|
||||||
|
|
||||||
**Columns:**
|
**Columns:**
|
||||||
|
|
||||||
- `id` (TEXT PRIMARY KEY) - UUID assigned when blob creation starts
|
- `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
|
- `created_ts` (INTEGER NOT NULL) - Creation timestamp
|
||||||
- `finished_ts` (INTEGER) - Finalization timestamp (NULL if in progress)
|
- `finished_ts` (INTEGER) - Finalization timestamp (NULL if in progress)
|
||||||
- `uncompressed_size` (INTEGER NOT NULL DEFAULT 0) - Total size of chunks before compression
|
- `uncompressed_size` (INTEGER NOT NULL DEFAULT 0) - Total size of chunks before
|
||||||
- `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
|
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)
|
- `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`
|
### 6. `blob_chunks`
|
||||||
|
|
||||||
Maps chunks to the blobs that contain them.
|
Maps chunks to the blobs that contain them.
|
||||||
|
|
||||||
**Columns:**
|
**Columns:**
|
||||||
|
|
||||||
- `blob_id` (TEXT) - Blob ID (FK to blobs.id)
|
- `blob_id` (TEXT) - Blob ID (FK to blobs.id)
|
||||||
- `chunk_hash` (TEXT) - Chunk hash (FK to chunks.chunk_hash)
|
- `chunk_hash` (TEXT) - Chunk hash (FK to chunks.chunk_hash)
|
||||||
- `offset` (INTEGER) - Byte offset of chunk within blob (before compression)
|
- `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.
|
**Purpose:** Enables chunk retrieval from blobs during restore operations.
|
||||||
|
|
||||||
### 7. `snapshots`
|
### 7. `snapshots`
|
||||||
|
|
||||||
Tracks backup snapshots.
|
Tracks backup snapshots.
|
||||||
|
|
||||||
**Columns:**
|
**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
|
- `hostname` (TEXT) - Hostname where backup was created
|
||||||
- `vaultik_version` (TEXT) - Version of Vaultik used
|
- `vaultik_version` (TEXT) - Version of Vaultik used
|
||||||
- `vaultik_git_revision` (TEXT) - Git revision of Vaultik used
|
- `vaultik_git_revision` (TEXT) - Git revision of Vaultik used
|
||||||
- `started_at` (INTEGER) - Start timestamp
|
- `started_at` (INTEGER) - Start timestamp
|
||||||
- `completed_at` (INTEGER) - Completion timestamp (NULL if in progress)
|
- `completed_at` (INTEGER) - Completion timestamp (NULL if in progress)
|
||||||
- `file_count` (INTEGER) - Number of files in snapshot
|
- `file_count` (INTEGER) - Number of files in snapshot
|
||||||
- `chunk_count` (INTEGER) - Number of chunks this snapshot stored that were not stored before
|
- `chunk_count` (INTEGER) - Number of chunks this snapshot stored that were not
|
||||||
- `new_chunk_size` (INTEGER) - Total size of those chunks before compression; `snapshot list` shows it as NEW CHUNK SIZE
|
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
|
- `blob_count` (INTEGER) - Number of blobs this snapshot created
|
||||||
- `total_size` (INTEGER) - Total size of all files
|
- `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_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_ratio` (REAL) - Compression ratio achieved
|
||||||
- `compression_level` (INTEGER) - Compression level used for this snapshot
|
- `compression_level` (INTEGER) - Compression level used for this snapshot
|
||||||
- `upload_bytes` (INTEGER) - Total bytes uploaded during this snapshot
|
- `upload_bytes` (INTEGER) - Total bytes uploaded during this snapshot
|
||||||
- `upload_duration_ms` (INTEGER) - Total milliseconds spent uploading to S3
|
- `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`
|
### 8. `snapshot_files`
|
||||||
|
|
||||||
Maps snapshots to the files they contain.
|
Maps snapshots to the files they contain.
|
||||||
|
|
||||||
**Columns:**
|
**Columns:**
|
||||||
|
|
||||||
- `snapshot_id` (TEXT) - Snapshot ID (FK to snapshots.id)
|
- `snapshot_id` (TEXT) - Snapshot ID (FK to snapshots.id)
|
||||||
- `file_id` (TEXT) - File ID (FK to files.id)
|
- `file_id` (TEXT) - File ID (FK to files.id)
|
||||||
- PRIMARY KEY (`snapshot_id`, `file_id`)
|
- PRIMARY KEY (`snapshot_id`, `file_id`)
|
||||||
@@ -143,98 +182,146 @@ Maps snapshots to the files they contain.
|
|||||||
**Purpose:** Records which files are included in each snapshot.
|
**Purpose:** Records which files are included in each snapshot.
|
||||||
|
|
||||||
### 9. `snapshot_blobs`
|
### 9. `snapshot_blobs`
|
||||||
|
|
||||||
Maps snapshots to the blobs they reference.
|
Maps snapshots to the blobs they reference.
|
||||||
|
|
||||||
**Columns:**
|
**Columns:**
|
||||||
|
|
||||||
- `snapshot_id` (TEXT) - Snapshot ID (FK to snapshots.id)
|
- `snapshot_id` (TEXT) - Snapshot ID (FK to snapshots.id)
|
||||||
- `blob_id` (TEXT) - Blob ID (FK to blobs.id)
|
- `blob_id` (TEXT) - Blob ID (FK to blobs.id)
|
||||||
- `blob_hash` (TEXT) - Denormalized blob hash for manifest generation
|
- `blob_hash` (TEXT) - Denormalized blob hash for manifest generation
|
||||||
- PRIMARY KEY (`snapshot_id`, `blob_id`)
|
- 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.
|
Tracks blob upload metrics.
|
||||||
|
|
||||||
**Columns:**
|
**Columns:**
|
||||||
|
|
||||||
- `blob_hash` (TEXT PRIMARY KEY) - Hash of uploaded blob
|
- `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
|
- `uploaded_at` (INTEGER) - Upload timestamp
|
||||||
- `size` (INTEGER) - Size of uploaded blob
|
- `size` (INTEGER) - Size of uploaded blob
|
||||||
- `duration_ms` (INTEGER) - Upload duration in milliseconds
|
- `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
|
## Data Flow and Operations
|
||||||
|
|
||||||
### 1. Backup Process
|
### 1. Backup Process
|
||||||
|
|
||||||
1. **File Scanning**
|
1. **File Scanning**
|
||||||
- `INSERT OR REPLACE INTO files` - Update file metadata
|
- `INSERT OR REPLACE INTO files` - Update file metadata
|
||||||
- `SELECT * FROM files WHERE path = ?` - Check if file has changed
|
- `SELECT * FROM files WHERE path = ?` - Check if file has changed
|
||||||
- `INSERT INTO snapshot_files` - Add file to current snapshot
|
- `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)
|
2. **Chunking** (for changed files)
|
||||||
- `INSERT OR IGNORE INTO chunks` - Store new chunks
|
- `INSERT OR IGNORE INTO chunks` - Store new chunks
|
||||||
- `INSERT INTO file_chunks` - Map chunks to file
|
- `INSERT INTO file_chunks` - Map chunks to file
|
||||||
- `INSERT INTO chunk_files` - Create reverse mapping
|
- `INSERT INTO chunk_files` - Create reverse mapping
|
||||||
|
|
||||||
3. **Blob Packing**
|
3. **Blob Packing**
|
||||||
- `INSERT INTO blobs` - Create blob record with UUID (blob_hash NULL)
|
- `INSERT INTO blobs` - Create blob record with UUID (blob_hash NULL)
|
||||||
- `INSERT INTO blob_chunks` - Associate chunks with blob immediately
|
- `INSERT INTO blob_chunks` - Associate chunks with blob immediately
|
||||||
- `UPDATE blobs SET blob_hash = ?, finished_ts = ?` - Finalize blob after packing
|
- `UPDATE blobs SET blob_hash = ?, finished_ts = ?` - Finalize blob after
|
||||||
|
packing
|
||||||
|
|
||||||
4. **Upload**
|
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)
|
- `UPDATE blobs SET uploaded_ts = ?, compressed_size = ?` - Mark blob as
|
||||||
- `INSERT INTO uploads` - Record upload metrics with snapshot_id
|
uploaded, with the size of the object in storage (the upload is skipped
|
||||||
- `INSERT INTO snapshot_blobs` - Associate blob with snapshot
|
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**
|
5. **Snapshot Completion**
|
||||||
- `UPDATE snapshots SET completed_at = ?, stats...` - Finalize snapshot
|
- `UPDATE snapshots SET completed_at = ?, stats...` - Finalize snapshot
|
||||||
- Generate and upload blob manifest from `snapshot_blobs`
|
- Generate and upload blob manifest from `snapshot_blobs`
|
||||||
|
|
||||||
### 2. Incremental Backup
|
### 2. Incremental Backup
|
||||||
|
|
||||||
1. **Change Detection**
|
1. **Change Detection**
|
||||||
- `SELECT * FROM files WHERE path = ?` - Get previous file metadata
|
- `SELECT * FROM files WHERE path = ?` - Get previous file metadata
|
||||||
- Compare mtime, size, mode to detect changes
|
- 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
|
- Re-chunk a file that lists a chunk no uploaded blob holds, even when its
|
||||||
- Skip unchanged files but still add to `snapshot_files`
|
metadata is unchanged
|
||||||
|
- Skip unchanged files but still add to `snapshot_files`
|
||||||
|
|
||||||
2. **Chunk Reuse**
|
2. **Chunk Reuse**
|
||||||
- `SELECT * FROM blob_chunks WHERE chunk_hash = ?` - Find existing chunks
|
- `SELECT * FROM blob_chunks WHERE chunk_hash = ?` - Find existing chunks
|
||||||
- `INSERT INTO snapshot_blobs` - Reference existing blobs for unchanged files
|
- `INSERT INTO snapshot_blobs` - Reference existing blobs for unchanged
|
||||||
|
files
|
||||||
|
|
||||||
### 3. Snapshot Metadata Export
|
### 3. Snapshot Metadata Export
|
||||||
|
|
||||||
After a snapshot is completed:
|
After a snapshot is completed:
|
||||||
|
|
||||||
1. Copy database to temporary file
|
1. Copy database to temporary file
|
||||||
2. Clean temporary database to contain only current snapshot data
|
2. Clean temporary database to contain only current snapshot data
|
||||||
3. VACUUM the trimmed database so deleted rows leave no pages behind
|
3. VACUUM the trimmed database so deleted rows leave no pages behind
|
||||||
4. Compress with zstd and encrypt with age
|
4. Compress with zstd and encrypt with age
|
||||||
5. Upload to S3 as `metadata/{remote-key}/db.zst.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
|
### 4. Restore Process
|
||||||
|
|
||||||
The restore process doesn't use the local database. Instead:
|
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
|
3. Reconstructs files from decrypted and decompressed chunks
|
||||||
|
|
||||||
### 5. Pruning
|
### 5. Pruning
|
||||||
|
|
||||||
1. **Identify Unreferenced Blobs**
|
1. **Identify Unreferenced Blobs**
|
||||||
- Query blobs not referenced by any remaining snapshot
|
- Query blobs not referenced by any remaining snapshot
|
||||||
- Delete from S3 and local database
|
- 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
|
### 6. Incomplete Snapshot Cleanup
|
||||||
|
|
||||||
Before each backup:
|
Before each backup:
|
||||||
|
|
||||||
1. Query incomplete snapshots (where `completed_at IS NULL`)
|
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
|
3. Clean up orphaned files, chunks, and blobs
|
||||||
|
|
||||||
## Repository Pattern
|
## Repository Pattern
|
||||||
@@ -251,11 +338,13 @@ Vaultik uses a repository pattern for database access:
|
|||||||
- `UploadRepository` - Track blob upload metrics
|
- `UploadRepository` - Track blob upload metrics
|
||||||
|
|
||||||
Each repository provides methods like:
|
Each repository provides methods like:
|
||||||
|
|
||||||
- `Create()` - Insert new record
|
- `Create()` - Insert new record
|
||||||
- `GetByID()` / `GetByPath()` / `GetByHash()` - Retrieve records
|
- `GetByID()` / `GetByPath()` / `GetByHash()` - Retrieve records
|
||||||
- `Update()` - Update existing records
|
- `Update()` - Update existing records
|
||||||
- `Delete()` - Remove 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
|
## Transaction Management
|
||||||
|
|
||||||
@@ -268,6 +357,7 @@ err := repos.WithTx(ctx, func(ctx context.Context, tx *sql.Tx) error {
|
|||||||
```
|
```
|
||||||
|
|
||||||
This ensures consistency, especially important for operations like:
|
This ensures consistency, especially important for operations like:
|
||||||
|
|
||||||
- Creating file-chunk mappings
|
- Creating file-chunk mappings
|
||||||
- Associating chunks with blobs
|
- Associating chunks with blobs
|
||||||
- Updating snapshot statistics
|
- Updating snapshot statistics
|
||||||
@@ -275,18 +365,25 @@ This ensures consistency, especially important for operations like:
|
|||||||
## Performance Considerations
|
## Performance Considerations
|
||||||
|
|
||||||
1. **Indexes**:
|
1. **Indexes**:
|
||||||
- Primary keys are automatically indexed
|
- Primary keys are automatically indexed
|
||||||
- `idx_files_path` on `files(path)` for efficient file lookups
|
- `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
|
## 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
|
2. **Unique Constraints**: Chunk hashes, file paths, and blob hashes are unique
|
||||||
3. **Null Handling**: Nullable fields clearly indicate in-progress operations
|
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
|
||||||
+231
-54
@@ -1,10 +1,13 @@
|
|||||||
# Vaultik S3 Repository Structure
|
# 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
|
## Overview
|
||||||
|
|
||||||
Vaultik stores all backup data in an S3-compatible object store. The repository consists of two main components:
|
Vaultik stores all backup data in an S3-compatible object store. The repository
|
||||||
|
consists of two main components, plus a marker file:
|
||||||
|
|
||||||
1. **Blobs** - The actual backup data (content-addressed, encrypted)
|
1. **Blobs** - The actual backup data (content-addressed, encrypted)
|
||||||
2. **Metadata** - Snapshot information and manifests (partially encrypted)
|
2. **Metadata** - Snapshot information and manifests (partially encrypted)
|
||||||
|
|
||||||
@@ -12,6 +15,7 @@ Vaultik stores all backup data in an S3-compatible object store. The repository
|
|||||||
|
|
||||||
```
|
```
|
||||||
<bucket>/<prefix>/
|
<bucket>/<prefix>/
|
||||||
|
├── vaultik-destination.txt
|
||||||
├── blobs/
|
├── blobs/
|
||||||
│ └── <hash[0:2]>/
|
│ └── <hash[0:2]>/
|
||||||
│ └── <hash[2:4]>/
|
│ └── <hash[2:4]>/
|
||||||
@@ -22,114 +26,208 @@ Vaultik stores all backup data in an S3-compatible object store. The repository
|
|||||||
└── manifest.json.zst
|
└── 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. 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/`)
|
## Blobs Directory (`blobs/`)
|
||||||
|
|
||||||
### Structure
|
### Structure
|
||||||
|
|
||||||
- **Path format**: `blobs/<first-2-chars>/<next-2-chars>/<full-hash>`
|
- **Path format**: `blobs/<first-2-chars>/<next-2-chars>/<full-hash>`
|
||||||
- **Example**: `blobs/ca/fe/cafebabe1234567890abcdef1234567890abcdef1234567890abcdef12345678`
|
- **Example**:
|
||||||
- **Sharding**: The two-level directory structure (using the first 4 characters of the hash) prevents any single directory from containing too many objects
|
`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
|
### Content
|
||||||
|
|
||||||
- **What it contains**: Packed collections of content-defined chunks from files
|
- **What it contains**: Packed collections of content-defined chunks from files
|
||||||
- **Format**: Zstandard compressed, then Age encrypted
|
- **Format**: Zstandard compressed, then Age encrypted
|
||||||
- **Encryption**: Always encrypted with Age using the configured recipients
|
- **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
|
### 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/`)
|
## 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
|
### 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.
|
The human-readable snapshot ID is used in CLI arguments, log lines, and the
|
||||||
- **Format**: `<hostname>_<snapshot-name>_<RFC3339>` (or `<hostname>_<RFC3339>` if no
|
local database. It is not written to the destination store.
|
||||||
name was specified)
|
|
||||||
|
- **Format**: `<hostname>_<snapshot-name>_<RFC3339>` (or `<hostname>_<RFC3339>`
|
||||||
|
if no name was specified)
|
||||||
- **Example**: `laptop_home_2024-01-15T14:30:52Z`
|
- **Example**: `laptop_home_2024-01-15T14:30:52Z`
|
||||||
- **Components**:
|
- **Components**:
|
||||||
- Short hostname (everything before the first dot is stripped from the FQDN)
|
- Short hostname (everything before the first dot is stripped from the FQDN)
|
||||||
- Snapshot name from the configured `snapshots:` map (optional)
|
- Snapshot name from the configured `snapshots:` map (optional)
|
||||||
- RFC3339 UTC timestamp
|
- 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
|
### 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:
|
Worked example:
|
||||||
|
|
||||||
- Snapshot ID: `server1_home_2025-06-01T12:00:00Z`
|
- Snapshot ID: `server1_home_2025-06-01T12:00:00Z`
|
||||||
- Remote key: `17f97bcde958748af076b926af59823943db59e80ce7170b40f124dfa28f64aa`
|
- 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
|
### Files in Each Snapshot Directory
|
||||||
|
|
||||||
#### `db.zst.age` - Encrypted Database
|
#### `db.zst.age` - Encrypted Database
|
||||||
|
|
||||||
- **What it contains**: Pruned binary SQLite database for this snapshot
|
- **What it contains**: Pruned binary SQLite database for this snapshot
|
||||||
- **Format**: Binary SQLite → Zstandard compressed → Age encrypted
|
- **Format**: Binary SQLite → Zstandard compressed → Age encrypted
|
||||||
- **Encryption**: Encrypted with Age
|
- **Encryption**: Encrypted with Age
|
||||||
- **Purpose**: Contains full file metadata, chunk mappings, and all relationships
|
- **Purpose**: Contains full file metadata, chunk mappings, and all
|
||||||
- **Why encrypted**: Contains sensitive metadata like file paths, permissions, and ownership
|
relationships
|
||||||
|
- **Why encrypted**: Contains sensitive metadata like file paths, permissions,
|
||||||
|
and ownership
|
||||||
|
|
||||||
#### `manifest.json.zst` - Unencrypted Blob Manifest
|
#### `manifest.json.zst` - Unencrypted Blob Manifest
|
||||||
|
|
||||||
- **What it contains**: JSON list of all blob hashes referenced by this snapshot
|
- **What it contains**: JSON list of all blob hashes referenced by this snapshot
|
||||||
- **Format**: JSON → Zstandard compressed (NOT encrypted)
|
- **Format**: JSON → Zstandard compressed (NOT encrypted)
|
||||||
- **Encryption**: NOT encrypted
|
- **Encryption**: NOT encrypted
|
||||||
- **Purpose**: Enables pruning operations without requiring decryption keys
|
- **Purpose**: Enables pruning operations without requiring decryption keys
|
||||||
- **Structure**:
|
- **Structure**:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"snapshot_id": "17f97bcde958748af076b926af59823943db59e80ce7170b40f124dfa28f64aa",
|
"snapshot_id": "17f97bcde958748af076b926af59823943db59e80ce7170b40f124dfa28f64aa",
|
||||||
"timestamp": "2025-06-01T12:00:00Z",
|
"timestamp": "2025-06-01T12:00:00Z",
|
||||||
"blob_count": 42,
|
"blob_count": 42,
|
||||||
"total_compressed_size": 1048576,
|
"total_compressed_size": 1048576,
|
||||||
"blobs": [
|
"blobs": [
|
||||||
{ "hash": "cafebabe1234567890abcdef1234567890abcdef1234567890abcdef12345678", "compressed_size": 24576 },
|
{
|
||||||
{ "hash": "deadbeef1234567890abcdef1234567890abcdef1234567890abcdef12345678", "compressed_size": 32768 }
|
"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
|
### Why Manifest is Unencrypted
|
||||||
|
|
||||||
The manifest must be readable without the private key to enable:
|
The manifest must be readable without the private key to enable:
|
||||||
|
|
||||||
1. **Pruning operations** - Identifying unreferenced blobs for deletion
|
1. **Pruning operations** - Identifying unreferenced blobs for deletion
|
||||||
2. **Storage analysis** - Understanding space usage without decryption
|
2. **Storage analysis** - Understanding space usage without decryption
|
||||||
3. **Verification** - Checking blob existence 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
|
## Security Considerations
|
||||||
|
|
||||||
### What's Encrypted
|
### What's Encrypted
|
||||||
|
|
||||||
- **All file content** (in blobs)
|
- **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)
|
- **File-to-chunk mappings** (in db.zst.age)
|
||||||
|
|
||||||
### What's Not Encrypted
|
### 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)
|
- **The backup timestamp** (in manifest.json.zst)
|
||||||
- **Blob hashes and their compressed sizes** (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)
|
- **Blob count and total compressed size per snapshot** (in manifest.json.zst)
|
||||||
|
|
||||||
### Privacy Implications
|
### 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:
|
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 names or paths
|
||||||
- File contents
|
- File contents
|
||||||
- File permissions or ownership
|
- File permissions or ownership
|
||||||
@@ -138,39 +236,118 @@ An observer cannot determine:
|
|||||||
|
|
||||||
### Accepted Risks
|
### 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.
|
1. **No proof of authorship.** Restore and `verify --deep` prove that data
|
||||||
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.
|
decrypts with the age private key and matches its unkeyed content hashes.
|
||||||
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.
|
They do not prove who wrote it: anyone who knows a recipient public key and
|
||||||
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.
|
can replace objects on the destination can substitute a snapshot they built.
|
||||||
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.
|
The recipient string is not stored at the destination, but a compromised
|
||||||
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.
|
backed-up host has it. Defences live on the destination side — bucket
|
||||||
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.
|
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
|
## Consistency Guarantees
|
||||||
|
|
||||||
1. **Blobs are immutable** - Once written, a blob is never modified
|
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
|
2. **Blobs are written before metadata** - A snapshot's metadata is only written
|
||||||
3. **Metadata is written atomically** - Both db.zst.age and manifest.json.zst are written as complete files
|
after all its blobs are successfully uploaded
|
||||||
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.)
|
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
|
## Pruning Safety
|
||||||
|
|
||||||
The prune operation is safe because:
|
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
|
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
|
3. If any manifest cannot be downloaded or decoded, prune deletes nothing and
|
||||||
4. Prune requires exclusive access to the destination: running it during a concurrent backup can race a snapshot whose manifest is not yet written, so do not prune while a backup is in progress
|
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
|
## Restoration Requirements
|
||||||
|
|
||||||
To restore from a backup, you need:
|
To restore from a backup, you need:
|
||||||
|
|
||||||
1. **The Age private key** - To decrypt blobs and database
|
1. **The Age private key** - To decrypt blobs and database
|
||||||
2. **The snapshot metadata** - Both files from the snapshot's metadata directory
|
2. **The snapshot metadata** - Both files from the snapshot's metadata directory
|
||||||
3. **All referenced blobs** - As listed in the manifest
|
3. **All referenced blobs** - As listed in the manifest
|
||||||
|
|
||||||
The restoration process:
|
The restoration process:
|
||||||
|
|
||||||
1. Download and decrypt the database dump to understand file structure
|
1. Download and decrypt the database dump to understand file structure
|
||||||
2. Download and decrypt the required blobs
|
2. Download and decrypt the required blobs
|
||||||
3. Reconstruct files from their chunks
|
3. Reconstruct files from their chunks
|
||||||
|
|||||||
@@ -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).
|
||||||
@@ -22,6 +22,7 @@ require (
|
|||||||
github.com/spf13/cobra v1.10.1
|
github.com/spf13/cobra v1.10.1
|
||||||
github.com/spf13/pflag v1.0.10
|
github.com/spf13/pflag v1.0.10
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
|
go.uber.org/dig v1.19.0
|
||||||
go.uber.org/fx v1.24.0
|
go.uber.org/fx v1.24.0
|
||||||
golang.org/x/sync v0.18.0
|
golang.org/x/sync v0.18.0
|
||||||
golang.org/x/sys v0.38.0
|
golang.org/x/sys v0.38.0
|
||||||
@@ -256,7 +257,6 @@ require (
|
|||||||
go.opentelemetry.io/otel/metric v1.38.0 // indirect
|
go.opentelemetry.io/otel/metric v1.38.0 // indirect
|
||||||
go.opentelemetry.io/otel/trace v1.38.0 // indirect
|
go.opentelemetry.io/otel/trace v1.38.0 // indirect
|
||||||
go.shabbyrobe.org/gocovmerge v0.0.0-20230507111327-fa4f82cfbf4d // indirect
|
go.shabbyrobe.org/gocovmerge v0.0.0-20230507111327-fa4f82cfbf4d // indirect
|
||||||
go.uber.org/dig v1.19.0 // indirect
|
|
||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
go.uber.org/zap v1.27.0 // indirect
|
go.uber.org/zap v1.27.0 // indirect
|
||||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||||
|
|||||||
+7
-11
@@ -8,12 +8,12 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/adrg/xdg"
|
"github.com/adrg/xdg"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"go.uber.org/dig"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
"sneak.berlin/go/vaultik/internal/config"
|
"sneak.berlin/go/vaultik/internal/config"
|
||||||
"sneak.berlin/go/vaultik/internal/database"
|
"sneak.berlin/go/vaultik/internal/database"
|
||||||
@@ -90,7 +90,7 @@ func setupGlobals(
|
|||||||
// blank line. Used both from the fx hook (for subcommand invocations) and
|
// blank line. Used both from the fx hook (for subcommand invocations) and
|
||||||
// from the root cobra Run handler (for `vaultik` with no subcommand).
|
// from the root cobra Run handler (for `vaultik` with no subcommand).
|
||||||
func writeStartupBanner(w *ui.Writer, startTime time.Time, shortCommit string) {
|
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,
|
globals.Appname, globals.Version, globals.Author,
|
||||||
shortCommit, globals.CommitDate,
|
shortCommit, globals.CommitDate,
|
||||||
startTime.Format(time.RFC3339))
|
startTime.Format(time.RFC3339))
|
||||||
@@ -146,16 +146,12 @@ func (e *startupError) Error() string {
|
|||||||
// could not build arguments for function "Y" (file:line): failed to build U:
|
// could not build arguments for function "Y" (file:line): failed to build U:
|
||||||
// received non-nil error from function "Z" (file:line): <real error>
|
// received non-nil error from function "Z" (file:line): <real error>
|
||||||
//
|
//
|
||||||
// Users care about the real error, not the DI plumbing. We strip everything
|
// Users care about the real error, not the DI plumbing. dig.RootCause,
|
||||||
// up through the last "): " (which is always the close-paren of an fx
|
// from the library fx is built on, unwraps those layers and returns the
|
||||||
// function-location annotation followed by the wrapped error).
|
// real error whole. Cutting the text at "): " instead would also cut a
|
||||||
|
// real error that contains it, such as the blob_size_limit one.
|
||||||
func cleanStartupError(err error) error {
|
func cleanStartupError(err error) error {
|
||||||
msg := err.Error()
|
return &startupError{msg: dig.RootCause(err).Error()}
|
||||||
if idx := strings.LastIndex(msg, "): "); idx >= 0 {
|
|
||||||
msg = msg[idx+3:]
|
|
||||||
}
|
|
||||||
|
|
||||||
return &startupError{msg: msg}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunApp starts the fx application, blocks until it is asked to stop, and
|
// RunApp starts the fx application, blocks until it is asked to stop, and
|
||||||
|
|||||||
+47
-40
@@ -2,58 +2,65 @@ package cli //nolint:testpackage // needs access to unexported cleanStartupError
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"go.uber.org/fx"
|
||||||
|
"sneak.berlin/go/vaultik/internal/config"
|
||||||
"sneak.berlin/go/vaultik/internal/pidlock"
|
"sneak.berlin/go/vaultik/internal/pidlock"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// TestCleanStartupError starts a real fx app whose config has a
|
||||||
|
// blob_size_limit below the minimum. fx puts annotations naming the
|
||||||
|
// functions it was building in front of the config error; only those
|
||||||
|
// may be removed, although the config error itself contains "): ".
|
||||||
func TestCleanStartupError(t *testing.T) {
|
func TestCleanStartupError(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
tests := []struct {
|
path := filepath.Join(t.TempDir(), "config.yml")
|
||||||
name string
|
|
||||||
in string
|
err := os.WriteFile(path, []byte("snapshots:\n"+
|
||||||
want string
|
" test:\n"+
|
||||||
}{
|
" paths: [/tmp/vaultik-test-source]\n"+
|
||||||
{
|
"storage_url: file:///tmp/vaultik-test-storage\n"+
|
||||||
name: "real fx error chain",
|
"chunk_size: 10MB\n"+
|
||||||
in: `could not build arguments for function ` +
|
"blob_size_limit: 30MB\n"), 0o600)
|
||||||
`"sneak.berlin/go/vaultik/internal/cli".newSnapshotCreateCommand.func1.1 ` +
|
if err != nil {
|
||||||
`(/Users/user/dev/vaultik/internal/cli/snapshot.go:71): ` +
|
t.Fatalf("write config: %v", err)
|
||||||
`failed to build *vaultik.Vaultik: ` +
|
|
||||||
`could not build arguments for function ` +
|
|
||||||
`"sneak.berlin/go/vaultik/internal/vaultik".New ` +
|
|
||||||
`(/Users/user/dev/vaultik/internal/vaultik/vaultik.go:59): ` +
|
|
||||||
`failed to build storage.Storer: ` +
|
|
||||||
`received non-nil error from function ` +
|
|
||||||
`"sneak.berlin/go/vaultik/internal/storage".NewStorer ` +
|
|
||||||
`(/Users/user/dev/vaultik/internal/storage/module.go:23): ` +
|
|
||||||
`creating base path: mkdir /Volumes/BACKUPS: permission denied`,
|
|
||||||
want: `creating base path: mkdir /Volumes/BACKUPS: permission denied`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "no fx wrapping",
|
|
||||||
in: "plain error",
|
|
||||||
want: "plain error",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "single fx wrapping",
|
|
||||||
in: `received non-nil error from function "foo" (file.go:1): underlying problem`,
|
|
||||||
want: "underlying problem",
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
app := fx.New(
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
fx.NopLogger,
|
||||||
t.Parallel()
|
fx.Supply(config.Path(path)),
|
||||||
|
config.Module,
|
||||||
|
fx.Invoke(func(*config.Config) {}),
|
||||||
|
)
|
||||||
|
|
||||||
//nolint:err113 // test constructs errors from table input
|
err = app.Start(t.Context())
|
||||||
got := cleanStartupError(errors.New(tt.in)).Error()
|
if err == nil {
|
||||||
if got != tt.want {
|
t.Fatal("app started; want a blob_size_limit error")
|
||||||
t.Errorf("got %q, want %q", got, tt.want)
|
}
|
||||||
}
|
|
||||||
})
|
want := "failed to load config: invalid config: blob_size_limit must " +
|
||||||
|
"be at least the largest chunk the chunker can emit (chunk_size " +
|
||||||
|
"times the FastCDC size spread): need at least 40000000 bytes"
|
||||||
|
|
||||||
|
got := cleanStartupError(err).Error()
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCleanStartupErrorNotFromFx checks that an error fx did not wrap is
|
||||||
|
// shown unchanged.
|
||||||
|
func TestCleanStartupErrorNotFromFx(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
//nolint:err113 // a one-off error standing in for a startup failure
|
||||||
|
got := cleanStartupError(errors.New("plain error")).Error()
|
||||||
|
if got != "plain error" {
|
||||||
|
t.Errorf("got %q, want %q", got, "plain error")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+177
-64
@@ -8,6 +8,8 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"runtime"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
@@ -29,7 +31,7 @@ const configSetArgs = 2
|
|||||||
// parent config dirs (e.g. ~/.config) are conventionally traversable.
|
// parent config dirs (e.g. ~/.config) are conventionally traversable.
|
||||||
const configDirMode = 0o755
|
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
|
// so `config set` writes the file back with the same indentation rather than
|
||||||
// yaml.Marshal's 4-space default.
|
// yaml.Marshal's 4-space default.
|
||||||
const configYAMLIndent = 2
|
const configYAMLIndent = 2
|
||||||
@@ -44,9 +46,27 @@ var (
|
|||||||
errNeedNumericIndex = errors.New("key is a list; use a numeric index")
|
errNeedNumericIndex = errors.New("key is a list; use a numeric index")
|
||||||
errIndexOutOfRange = errors.New("index out of range")
|
errIndexOutOfRange = errors.New("index out of range")
|
||||||
errNotMapOrList = errors.New("key is not a map or list")
|
errNotMapOrList = errors.New("key is not a map or list")
|
||||||
|
errWouldNotLoad = errors.New(
|
||||||
|
"vaultik would not load the config after this change, " +
|
||||||
|
"so the file was not changed")
|
||||||
)
|
)
|
||||||
|
|
||||||
const defaultConfigTemplate = `# vaultik configuration
|
// 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
|
# Documentation: https://sneak.berlin/go/vaultik
|
||||||
|
|
||||||
# ─── REQUIRED ────────────────────────────────────────────────────────────────
|
# ─── REQUIRED ────────────────────────────────────────────────────────────────
|
||||||
@@ -164,7 +184,10 @@ snapshots:
|
|||||||
- "/Movies/ProxyMedia"
|
- "/Movies/ProxyMedia"
|
||||||
- "/Music/iTunes/Album Artwork"
|
- "/Music/iTunes/Album Artwork"
|
||||||
- "/Pictures/iPod Photo Cache"
|
- "/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
|
# Third-party applications. OS-provided apps live in /System/Applications
|
||||||
# on modern macOS and are never in /Applications, but Apple-installed
|
# on modern macOS and are never in /Applications, but Apple-installed
|
||||||
# App Store apps (Safari, GarageBand, iWork, iMovie) are excluded since
|
# App Store apps (Safari, GarageBand, iWork, iMovie) are excluded since
|
||||||
@@ -185,7 +208,10 @@ snapshots:
|
|||||||
- "/Steam.app"
|
- "/Steam.app"
|
||||||
- "/VirtualBox.app"
|
- "/VirtualBox.app"
|
||||||
- "/Utilities/Adobe Installers"
|
- "/Utilities/Adobe Installers"
|
||||||
|
`
|
||||||
|
|
||||||
|
// defaultConfigTail is the default config after the snapshots.
|
||||||
|
const defaultConfigTail = `
|
||||||
# Storage backend (pick ONE of the three forms below).
|
# Storage backend (pick ONE of the three forms below).
|
||||||
#
|
#
|
||||||
# S3-compatible:
|
# S3-compatible:
|
||||||
@@ -220,7 +246,7 @@ storage_url: ""
|
|||||||
|
|
||||||
# Average chunk size for content-defined chunking (FastCDC).
|
# Average chunk size for content-defined chunking (FastCDC).
|
||||||
# Smaller = better deduplication but more metadata overhead.
|
# Smaller = better deduplication but more metadata overhead.
|
||||||
# Accepts: 1MB, 10M, 64KB, etc.
|
# Minimum 1MB. Accepts: 1MB, 10M, 64MiB, etc.
|
||||||
# Default: 10MB
|
# Default: 10MB
|
||||||
# chunk_size: 10MB
|
# chunk_size: 10MB
|
||||||
|
|
||||||
@@ -235,6 +261,18 @@ storage_url: ""
|
|||||||
# Default: 3
|
# Default: 3
|
||||||
# compression_level: 3
|
# compression_level: 3
|
||||||
|
|
||||||
|
# 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 used in snapshot IDs. Default: system hostname.
|
||||||
# hostname: myserver
|
# hostname: myserver
|
||||||
|
|
||||||
@@ -295,7 +333,8 @@ on macOS, ~/.config/ on Linux, /etc/vaultik/ as root).`,
|
|||||||
return fmt.Errorf("creating config directory %s: %w", dir, err)
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("writing config file: %w", err)
|
return fmt.Errorf("writing config file: %w", err)
|
||||||
}
|
}
|
||||||
@@ -329,8 +368,15 @@ func newConfigEditCommand() *cobra.Command {
|
|||||||
editor = "vi"
|
editor = "vi"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Run $EDITOR through the shell, as git does, so a value
|
||||||
|
// with arguments such as "code --wait" works. The path
|
||||||
|
// goes in as an argument ("$@"), not into the command
|
||||||
|
// text, so a path with spaces or quotes arrives whole.
|
||||||
|
// sh -c takes the word after the command as $0, which
|
||||||
|
// is why editor appears twice.
|
||||||
//nolint:gosec // G204: launching the operator's own $EDITOR is the point
|
//nolint:gosec // G204: launching the operator's own $EDITOR is the point
|
||||||
ed := exec.CommandContext(cmd.Context(), editor, path)
|
ed := exec.CommandContext(cmd.Context(),
|
||||||
|
"sh", "-c", editor+` "$@"`, editor, path)
|
||||||
ed.Stdin = os.Stdin
|
ed.Stdin = os.Stdin
|
||||||
ed.Stdout = os.Stdout
|
ed.Stdout = os.Stdout
|
||||||
ed.Stderr = os.Stderr
|
ed.Stderr = os.Stderr
|
||||||
@@ -391,8 +437,16 @@ func newConfigSetCommand() *cobra.Command {
|
|||||||
Use: "set <key> <value>",
|
Use: "set <key> <value>",
|
||||||
Short: "Set a config value by dotted path (e.g. compression_level 5)",
|
Short: "Set a config value by dotted path (e.g. compression_level 5)",
|
||||||
Long: `Sets a scalar config value addressed by dotted YAML path and writes
|
Long: `Sets a scalar config value addressed by dotted YAML path and writes
|
||||||
the file back, preserving comments and formatting. Intermediate maps
|
the file back, preserving comments and formatting. Missing keys along
|
||||||
are created as needed.
|
the path are created: as a list when the next path element is a
|
||||||
|
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 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:
|
Examples:
|
||||||
vaultik config set storage_url "file:///mnt/backups"
|
vaultik config set storage_url "file:///mnt/backups"
|
||||||
@@ -422,7 +476,21 @@ func writeConfigSet(out *ui.Writer, path, key, value string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = yamlPathSet(root, strings.Split(key, "."), value)
|
oldData, err := marshalConfigYAML(root)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshaling config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -432,6 +500,21 @@ func writeConfigSet(out *ui.Writer, path, key, value string) error {
|
|||||||
return fmt.Errorf("marshaling config: %w", err)
|
return fmt.Errorf("marshaling config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setting, err := yamlPathOnly(root, keys)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
settingData, err := marshalConfigYAML(setting)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshaling config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = checkConfigChange(oldData, data, settingData)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%w: %w", errWouldNotLoad, err)
|
||||||
|
}
|
||||||
|
|
||||||
err = os.WriteFile(path, data, configFileMode)
|
err = os.WriteFile(path, data, configFileMode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("writing config file: %w", err)
|
return fmt.Errorf("writing config file: %w", err)
|
||||||
@@ -453,8 +536,60 @@ func writeConfigSet(out *ui.Writer, path, key, value string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkConfigChange returns the reason vaultik would not load newData, the
|
||||||
|
// config after a change, if the change brings that failure in. If oldData,
|
||||||
|
// the config before it, loads, the new one must load too. A config that
|
||||||
|
// does not load, such as one still being filled in or one with a ${...}
|
||||||
|
// reference that cannot be filled in here, is checked one setting at a
|
||||||
|
// time: settingData, the changed setting on its own, must decode into the
|
||||||
|
// config type.
|
||||||
|
func checkConfigChange(oldData, newData, settingData []byte) error {
|
||||||
|
oldCfg, err := config.Parse(oldData)
|
||||||
|
if err == nil && oldCfg.Validate() == nil {
|
||||||
|
newCfg, err := config.Parse(newData)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return newCfg.Validate()
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = config.Parse(settingData)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// yamlPathOnly returns a document holding only the value at the dotted key
|
||||||
|
// path in root, inside the maps and lists that lead to it there.
|
||||||
|
func yamlPathOnly(root *yaml.Node, keys []string) (*yaml.Node, error) {
|
||||||
|
node, err := yamlPathGet(root, keys)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, key := range slices.Backward(keys) {
|
||||||
|
parent, err := yamlPathGet(root, keys[:i])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if parent.Kind == yaml.SequenceNode {
|
||||||
|
node = &yaml.Node{
|
||||||
|
Kind: yaml.SequenceNode, Content: []*yaml.Node{node},
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
keyNode := &yaml.Node{Kind: yaml.ScalarNode, Value: key}
|
||||||
|
node = &yaml.Node{
|
||||||
|
Kind: yaml.MappingNode, Content: []*yaml.Node{keyNode, node},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &yaml.Node{Kind: yaml.DocumentNode, Content: []*yaml.Node{node}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// marshalConfigYAML renders a config document tree with 2-space indentation,
|
// marshalConfigYAML renders a config document tree with 2-space indentation,
|
||||||
// matching defaultConfigTemplate. yaml.Marshal defaults to 4 spaces, which
|
// matching the default config. yaml.Marshal defaults to 4 spaces, which
|
||||||
// would reindent the whole file on the first `config set` despite the promise
|
// would reindent the whole file on the first `config set` despite the promise
|
||||||
// to preserve formatting.
|
// to preserve formatting.
|
||||||
func marshalConfigYAML(root *yaml.Node) ([]byte, error) {
|
func marshalConfigYAML(root *yaml.Node) ([]byte, error) {
|
||||||
@@ -559,8 +694,8 @@ func yamlPathGet(root *yaml.Node, keys []string) (*yaml.Node, error) {
|
|||||||
return node, nil
|
return node, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// yamlPathSet navigates a dotted key path, creating intermediate maps as
|
// yamlPathSet navigates a dotted key path, creating missing keys as needed,
|
||||||
// needed, and sets the final key to the given scalar value. Numeric path
|
// and sets the final key to the given scalar value. Numeric path
|
||||||
// components index into sequences; an index equal to the sequence length
|
// components index into sequences; an index equal to the sequence length
|
||||||
// appends a new element (e.g. "age_recipients.1" on a 1-element list).
|
// appends a new element (e.g. "age_recipients.1" on a 1-element list).
|
||||||
func yamlPathSet(root *yaml.Node, keys []string, value string) error {
|
func yamlPathSet(root *yaml.Node, keys []string, value string) error {
|
||||||
@@ -573,12 +708,12 @@ func yamlPathSet(root *yaml.Node, keys []string, value string) error {
|
|||||||
node = node.Content[0]
|
node = node.Content[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
for i, key := range keys {
|
for i := range keys {
|
||||||
last := i == len(keys)-1
|
last := i == len(keys)-1
|
||||||
|
|
||||||
switch node.Kind {
|
switch node.Kind {
|
||||||
case yaml.MappingNode:
|
case yaml.MappingNode:
|
||||||
node = yamlSetInMapping(node, key, value, last)
|
node = yamlSetInMapping(node, keys, i, value, last)
|
||||||
case yaml.SequenceNode:
|
case yaml.SequenceNode:
|
||||||
next, err := yamlSetInSequence(node, keys, i, value, last)
|
next, err := yamlSetInSequence(node, keys, i, value, last)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -610,51 +745,23 @@ func yamlPathSet(root *yaml.Node, keys []string, value string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// configKeyIsString reports whether the dotted key names a string in
|
// configKeyIsString reports whether the dotted key names a string in
|
||||||
// config.Config, following the fields' yaml tags, as s3.access_key_id and
|
// config.Config, as s3.access_key_id and snapshots.home.exclude.0 do.
|
||||||
// snapshots.home.exclude.0 do.
|
|
||||||
func configKeyIsString(keys []string) bool {
|
func configKeyIsString(keys []string) bool {
|
||||||
typ := reflect.TypeFor[config.Config]()
|
typ, err := config.SettingType(keys)
|
||||||
|
|
||||||
for _, key := range keys {
|
return err == nil && typ.Kind() == reflect.String
|
||||||
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.
|
// yamlSetInMapping resolves (creating if needed) the value node for the
|
||||||
func yamlField(typ reflect.Type, key string) (reflect.StructField, bool) {
|
// path element keys[i] within a mapping node, setting it to value when it
|
||||||
for field := range typ.Fields() {
|
// is the final path element, and returns the node to descend into.
|
||||||
name, _, _ := strings.Cut(field.Tag.Get("yaml"), ",")
|
func yamlSetInMapping(
|
||||||
if name == key {
|
node *yaml.Node, keys []string, i int, value string, last bool,
|
||||||
return field, true
|
) *yaml.Node {
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return reflect.StructField{}, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// yamlSetInMapping resolves (creating if needed) the value node for key
|
|
||||||
// within a mapping node, setting it to value when it is the final path
|
|
||||||
// element, and returns the node to descend into.
|
|
||||||
func yamlSetInMapping(node *yaml.Node, key, value string, last bool) *yaml.Node {
|
|
||||||
var valueNode *yaml.Node
|
var valueNode *yaml.Node
|
||||||
|
|
||||||
for j := 0; j+1 < len(node.Content); j += 2 {
|
for j := 0; j+1 < len(node.Content); j += 2 {
|
||||||
if node.Content[j].Value == key {
|
if node.Content[j].Value == keys[i] {
|
||||||
valueNode = node.Content[j+1]
|
valueNode = node.Content[j+1]
|
||||||
|
|
||||||
break
|
break
|
||||||
@@ -662,13 +769,8 @@ func yamlSetInMapping(node *yaml.Node, key, value string, last bool) *yaml.Node
|
|||||||
}
|
}
|
||||||
|
|
||||||
if valueNode == nil {
|
if valueNode == nil {
|
||||||
keyNode := &yaml.Node{Kind: yaml.ScalarNode, Value: key}
|
keyNode := &yaml.Node{Kind: yaml.ScalarNode, Value: keys[i]}
|
||||||
|
valueNode = newPathNode(keys, i, value)
|
||||||
valueNode = &yaml.Node{Kind: yaml.MappingNode}
|
|
||||||
if last {
|
|
||||||
valueNode = &yaml.Node{Kind: yaml.ScalarNode, Value: value}
|
|
||||||
}
|
|
||||||
|
|
||||||
node.Content = append(node.Content, keyNode, valueNode)
|
node.Content = append(node.Content, keyNode, valueNode)
|
||||||
} else if last {
|
} else if last {
|
||||||
setScalar(valueNode, value)
|
setScalar(valueNode, value)
|
||||||
@@ -696,12 +798,7 @@ func yamlSetInSequence(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if idx == len(node.Content) {
|
if idx == len(node.Content) {
|
||||||
newNode := &yaml.Node{Kind: yaml.MappingNode}
|
node.Content = append(node.Content, newPathNode(keys, i, value))
|
||||||
if last {
|
|
||||||
newNode = &yaml.Node{Kind: yaml.ScalarNode, Value: value}
|
|
||||||
}
|
|
||||||
|
|
||||||
node.Content = append(node.Content, newNode)
|
|
||||||
} else if last {
|
} else if last {
|
||||||
setScalar(node.Content[idx], value)
|
setScalar(node.Content[idx], value)
|
||||||
}
|
}
|
||||||
@@ -709,6 +806,22 @@ func yamlSetInSequence(
|
|||||||
return node.Content[idx], nil
|
return node.Content[idx], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// newPathNode returns the node to create for the missing path element
|
||||||
|
// keys[i]: the value when it is the final path element, otherwise an empty
|
||||||
|
// list when the next path element is a number and an empty map when not.
|
||||||
|
func newPathNode(keys []string, i int, value string) *yaml.Node {
|
||||||
|
if i == len(keys)-1 {
|
||||||
|
return &yaml.Node{Kind: yaml.ScalarNode, Value: value}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := strconv.Atoi(keys[i+1])
|
||||||
|
if err == nil {
|
||||||
|
return &yaml.Node{Kind: yaml.SequenceNode}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &yaml.Node{Kind: yaml.MappingNode}
|
||||||
|
}
|
||||||
|
|
||||||
// setScalar overwrites a node in place with a plain scalar value.
|
// setScalar overwrites a node in place with a plain scalar value.
|
||||||
func setScalar(n *yaml.Node, value string) {
|
func setScalar(n *yaml.Node, value string) {
|
||||||
n.Kind = yaml.ScalarNode
|
n.Kind = yaml.ScalarNode
|
||||||
|
|||||||
+320
-15
@@ -13,14 +13,15 @@ import (
|
|||||||
"sneak.berlin/go/vaultik/internal/ui"
|
"sneak.berlin/go/vaultik/internal/ui"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestDefaultConfigTemplateParses ensures the init template is valid YAML
|
// TestDefaultConfigParsesOnMacOS ensures the config `config init` writes on
|
||||||
// that unmarshals into the Config struct with the expected snapshots.
|
// macOS is valid YAML that unmarshals into the Config struct with the
|
||||||
func TestDefaultConfigTemplateParses(t *testing.T) {
|
// expected snapshots.
|
||||||
|
func TestDefaultConfigParsesOnMacOS(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
var cfg config.Config
|
var cfg config.Config
|
||||||
|
|
||||||
err := yaml.Unmarshal([]byte(defaultConfigTemplate), &cfg)
|
err := yaml.Unmarshal([]byte(defaultConfigText("darwin")), &cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("default config template is not valid YAML: %v", err)
|
t.Fatalf("default config template is not valid YAML: %v", err)
|
||||||
}
|
}
|
||||||
@@ -58,24 +59,60 @@ func TestDefaultConfigTemplateParses(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestConfigSetRecipientOnFreshConfig follows the README quickstart: on the
|
// TestDefaultConfigOnLinux checks that the config `config init` writes on
|
||||||
// file `config init` writes, `config set age_recipients.0` and
|
// Linux loads with its home snapshot and without the apps snapshot, whose
|
||||||
// `config set storage_url` give a config that loads with that recipient.
|
// /Applications exists only on macOS.
|
||||||
func TestConfigSetRecipientOnFreshConfig(t *testing.T) {
|
func TestDefaultConfigOnLinux(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const recipient = "age1278m9q7dp3chsh2dcy82qk27v047zywyvtxwnj4cvt0z65jw6a7q5dqhfj"
|
|
||||||
|
|
||||||
path := filepath.Join(t.TempDir(), "config.yml")
|
path := filepath.Join(t.TempDir(), "config.yml")
|
||||||
|
|
||||||
err := os.WriteFile(path, []byte(defaultConfigTemplate), configFileMode)
|
err := os.WriteFile(path, []byte(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"
|
||||||
|
|
||||||
|
// TestConfigSetRecipientOnFreshConfig follows the README quickstart: on the
|
||||||
|
// file `config init` writes, `config set age_recipients.0` and
|
||||||
|
// `config set storage_url` give a config that loads with that recipient.
|
||||||
|
// Neither is refused, although the config does not load until both are set;
|
||||||
|
// once it loads, a recipient that is not a valid key is.
|
||||||
|
func TestConfigSetRecipientOnFreshConfig(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), "config.yml")
|
||||||
|
|
||||||
|
err := os.WriteFile(path, []byte(defaultConfigText(macOS)), configFileMode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("write config: %v", err)
|
t.Fatalf("write config: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
out := ui.NewWithColor(&bytes.Buffer{}, false)
|
out := ui.NewWithColor(&bytes.Buffer{}, false)
|
||||||
|
|
||||||
err = writeConfigSet(out, path, "age_recipients.0", recipient)
|
err = writeConfigSet(out, path, "age_recipients.0", testRecipient)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("config set age_recipients.0: %v", err)
|
t.Fatalf("config set age_recipients.0: %v", err)
|
||||||
}
|
}
|
||||||
@@ -90,8 +127,252 @@ func TestConfigSetRecipientOnFreshConfig(t *testing.T) {
|
|||||||
t.Fatalf("config.Load: %v", err)
|
t.Fatalf("config.Load: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(cfg.AgeRecipients) != 1 || cfg.AgeRecipients[0] != recipient {
|
if len(cfg.AgeRecipients) != 1 || cfg.AgeRecipients[0] != testRecipient {
|
||||||
t.Errorf("age_recipients = %v, want [%s]", cfg.AgeRecipients, recipient)
|
t.Errorf("age_recipients = %v, want [%s]", cfg.AgeRecipients, testRecipient)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = writeConfigSet(out, path, "age_recipients.0", "notakey")
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "not a valid recipient") {
|
||||||
|
t.Errorf("config set age_recipients.0 notakey gives error %v, "+
|
||||||
|
"want one saying it is not a valid recipient", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = config.Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("config.Load after the refused change: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// configWithoutRecipients loads, but has no age_recipients key and no
|
||||||
|
// snapshot named work.
|
||||||
|
const configWithoutRecipients = `storage_url: file:///mnt/backups
|
||||||
|
snapshots:
|
||||||
|
home:
|
||||||
|
paths:
|
||||||
|
- "~"
|
||||||
|
`
|
||||||
|
|
||||||
|
// configWithUnsetReference loads only where VAULTIK_TEST_UNSET_SECRET is
|
||||||
|
// set, as a backup job's environment might set it. No test sets it.
|
||||||
|
const configWithUnsetReference = configWithoutRecipients + `s3:
|
||||||
|
secret_access_key: ${ENV:VAULTIK_TEST_UNSET_SECRET}
|
||||||
|
`
|
||||||
|
|
||||||
|
// configWithLevelReference returns configWithoutRecipients with a ${FILE:...}
|
||||||
|
// reference on compression_level to a file holding 9, so that the config
|
||||||
|
// loads once the reference is filled in.
|
||||||
|
func configWithLevelReference(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
levelPath := filepath.Join(t.TempDir(), "level")
|
||||||
|
|
||||||
|
err := os.WriteFile(levelPath, []byte("9"), configFileMode)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write level: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return configWithoutRecipients +
|
||||||
|
"compression_level: ${FILE:" + levelPath + "}\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfigSetIndexUnderMissingKey checks that a list index under a key
|
||||||
|
// the file does not have creates a list, and that the result loads.
|
||||||
|
func TestConfigSetIndexUnderMissingKey(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
key string
|
||||||
|
value string
|
||||||
|
field func(cfg *config.Config) []string
|
||||||
|
}{
|
||||||
|
{"age_recipients.0", testRecipient,
|
||||||
|
func(cfg *config.Config) []string { return cfg.AgeRecipients }},
|
||||||
|
{"snapshots.work.paths.0", "/srv/work",
|
||||||
|
func(cfg *config.Config) []string { return cfg.Snapshots["work"].Paths }},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.key, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), "config.yml")
|
||||||
|
|
||||||
|
err := os.WriteFile(path, []byte(configWithoutRecipients), configFileMode)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = writeConfigSet(ui.NewWithColor(&bytes.Buffer{}, false),
|
||||||
|
path, tt.key, tt.value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("config set %s: %v", tt.key, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("config.Load: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := tt.field(cfg)
|
||||||
|
if len(got) != 1 || got[0] != tt.value {
|
||||||
|
t.Errorf("%s gives %v, want [%s]", tt.key, got, tt.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfigSetKeepsFileThatWouldNotLoad checks that config set refuses a
|
||||||
|
// change after which the config would not load, with an error giving the
|
||||||
|
// reason, and leaves the file as it was. On the file `config init` writes,
|
||||||
|
// which does not load until storage_url is set, and on one that cannot be
|
||||||
|
// read here, only an unknown key or a value of the wrong type is refused.
|
||||||
|
func TestConfigSetKeepsFileThatWouldNotLoad(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
levelReference := configWithLevelReference(t)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
config string
|
||||||
|
key string
|
||||||
|
value string
|
||||||
|
reason string
|
||||||
|
}{
|
||||||
|
// 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"},
|
||||||
|
// A list set to a single value.
|
||||||
|
{configWithoutRecipients, "snapshots.home.paths", "/srv",
|
||||||
|
"into []string"},
|
||||||
|
{configWithoutRecipients, "chunk_size", "fast",
|
||||||
|
"invalid size format"},
|
||||||
|
// Values of the right type that fail validation.
|
||||||
|
{configWithoutRecipients, "compression_level", "99",
|
||||||
|
"compression_level must be between 1 and 19"},
|
||||||
|
{configWithoutRecipients, "chunk_size", "1KB",
|
||||||
|
"chunk_size must be at least 1MB"},
|
||||||
|
// With its reference filled in, the config loads.
|
||||||
|
{levelReference, "blob_size_limit", "1MB",
|
||||||
|
"blob_size_limit must be at least"},
|
||||||
|
// The type check on a config that does not load yet.
|
||||||
|
{defaultConfigText(macOS), "snapshots.apps.paths", "/srv",
|
||||||
|
"into []string"},
|
||||||
|
// The type check on a config that cannot be read here.
|
||||||
|
{configWithUnsetReference, "snapshots.home.paths", "/x",
|
||||||
|
"cannot unmarshal !!str `/x`"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.key+" "+tt.value, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), "config.yml")
|
||||||
|
|
||||||
|
err := os.WriteFile(path, []byte(tt.config), configFileMode)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = writeConfigSet(ui.NewWithColor(&bytes.Buffer{}, false),
|
||||||
|
path, tt.key, tt.value)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), tt.reason) {
|
||||||
|
t.Errorf("config set %s %s gives error %v, want one saying %q",
|
||||||
|
tt.key, tt.value, err, tt.reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path) //nolint:gosec // G304: test-controlled path
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if string(data) != tt.config {
|
||||||
|
t.Errorf("config set %s changed the file to:\n%s",
|
||||||
|
tt.key, data)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfigSetRefusesOnlyItsOwnFailure checks that setting another key
|
||||||
|
// works on a config with a ${...} reference on compression_level, which
|
||||||
|
// loads because loading fills the reference in, on one whose
|
||||||
|
// compression_level already fails to load, and on one with a reference
|
||||||
|
// that cannot be filled in here.
|
||||||
|
func TestConfigSetRefusesOnlyItsOwnFailure(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
configs := map[string]string{
|
||||||
|
"reference": configWithLevelReference(t),
|
||||||
|
"failing": configWithoutRecipients + "compression_level: fast\n",
|
||||||
|
"unset reference": configWithUnsetReference,
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, configYAML := range configs {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), "config.yml")
|
||||||
|
|
||||||
|
err := os.WriteFile(path, []byte(configYAML), configFileMode)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = writeConfigSet(ui.NewWithColor(&bytes.Buffer{}, false),
|
||||||
|
path, "hostname", "myhost")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("config set hostname: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path) //nolint:gosec // G304: test-controlled path
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(string(data), "hostname: myhost") {
|
||||||
|
t.Errorf("config set hostname wrote:\n%s", data)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,7 +455,7 @@ func loadAfterConfigSet(t *testing.T, key, value string) *config.Config {
|
|||||||
|
|
||||||
path := filepath.Join(t.TempDir(), "config.yml")
|
path := filepath.Join(t.TempDir(), "config.yml")
|
||||||
|
|
||||||
err := os.WriteFile(path, []byte(defaultConfigTemplate), configFileMode)
|
err := os.WriteFile(path, []byte(defaultConfigText(macOS)), configFileMode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("write config: %v", err)
|
t.Fatalf("write config: %v", err)
|
||||||
}
|
}
|
||||||
@@ -441,6 +722,30 @@ func TestWriteConfigSetTightensMode(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestConfigEditEditorWithArgument checks that an $EDITOR carrying an
|
||||||
|
// argument works and that a config path with a space reaches it whole.
|
||||||
|
// The "editor" is cp with the replacement file as its argument, so the
|
||||||
|
// config ends up with the replacement's content only if both arrived.
|
||||||
|
func TestConfigEditEditorWithArgument(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := seedFile(t, dir, "my config.yml", "compression_level: 3\n")
|
||||||
|
edited := seedFile(t, dir, "edited.yml", "compression_level: 9\n")
|
||||||
|
|
||||||
|
setRootFlags(t, RootFlags{ConfigPath: path})
|
||||||
|
t.Setenv("EDITOR", "cp '"+edited+"'")
|
||||||
|
|
||||||
|
mustExecute(t, newConfigEditCommand())
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path) //nolint:gosec // G304: test-controlled path
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reading config back: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if string(data) != "compression_level: 9\n" {
|
||||||
|
t.Errorf("config after edit = %q, want the edited content", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func splitPath(s string) []string {
|
func splitPath(s string) []string {
|
||||||
return strings.Split(s, ".")
|
return strings.Split(s, ".")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"github.com/adrg/xdg"
|
"github.com/adrg/xdg"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/vaultik/internal/globals"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Command words and flags used to build argument vectors below. They are
|
// 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,
|
// TestBannerSuppressedInArgs covers the suppression scan directly,
|
||||||
// including the flags that suppressed the banner before --json joined
|
// including the flags that suppressed the banner before --json joined
|
||||||
// them, so that adding --json cannot regress them.
|
// them, so that adding --json cannot regress them.
|
||||||
|
|||||||
@@ -101,7 +101,8 @@ func TestEntryPruneJSONStdoutIsExactlyOneDocument(t *testing.T) {
|
|||||||
// writeHermeticPruneConfig builds a config over a temp directory with an
|
// writeHermeticPruneConfig builds a config over a temp directory with an
|
||||||
// empty destination directory and, if seedStale is set, creates the
|
// empty destination directory and, if seedStale is set, creates the
|
||||||
// index database up front with one snapshot record that has no
|
// index database up front with one snapshot record that has no
|
||||||
// counterpart on the destination store. Returns the config path.
|
// counterpart on the destination store, and gives the destination the
|
||||||
|
// marker a backup writes. Returns the config path.
|
||||||
func writeHermeticPruneConfig(t *testing.T, seedStale bool) string {
|
func writeHermeticPruneConfig(t *testing.T, seedStale bool) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -131,6 +132,11 @@ func writeHermeticPruneConfig(t *testing.T, seedStale bool) string {
|
|||||||
|
|
||||||
if seedStale {
|
if seedStale {
|
||||||
seedStaleSnapshotRecord(t, indexPath)
|
seedStaleSnapshotRecord(t, indexPath)
|
||||||
|
|
||||||
|
// While the local index holds a snapshot, prune fails on a
|
||||||
|
// destination that answers that it has no marker.
|
||||||
|
require.NoError(t, os.WriteFile(
|
||||||
|
filepath.Join(storeDir, "vaultik-destination.txt"), nil, 0o600))
|
||||||
}
|
}
|
||||||
|
|
||||||
return configPath
|
return configPath
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package cli //nolint:testpackage // shares runEntry and the hermetic config helpers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// flagCron is used to build the argument vectors below.
|
||||||
|
const flagCron = "--cron"
|
||||||
|
|
||||||
|
// plantUnreferencedBlob writes a blob that no snapshot references to the
|
||||||
|
// destination store at storeDir, dated long before any prune grace period,
|
||||||
|
// so that a prune deletes it. Returns its path.
|
||||||
|
func plantUnreferencedBlob(t *testing.T, storeDir string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
hash := strings.Repeat("b", 64)
|
||||||
|
path := filepath.Join(storeDir, "blobs", hash[:2], hash[2:4], hash)
|
||||||
|
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o750))
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte("unreferenced"), 0o600))
|
||||||
|
|
||||||
|
longAgo := time.Unix(0, 0)
|
||||||
|
require.NoError(t, os.Chtimes(path, longAgo, longAgo))
|
||||||
|
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEntrySnapshotCreatePruneIsSilent checks that a successful
|
||||||
|
// `snapshot create --prune` under --cron or --quiet prints nothing on
|
||||||
|
// stdout or stderr, whether or not the prune finds a blob to delete.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
|
||||||
|
func TestEntrySnapshotCreatePruneIsSilent(t *testing.T) {
|
||||||
|
for _, testCase := range []struct {
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
plantBlob bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "cron, no blob to delete",
|
||||||
|
args: []string{cmdSnapshot, cmdCreate, flagCron, flagPrune},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "cron, blob to delete",
|
||||||
|
args: []string{cmdSnapshot, cmdCreate, flagCron, flagPrune},
|
||||||
|
plantBlob: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "cron with keep-newer-than",
|
||||||
|
args: []string{
|
||||||
|
cmdSnapshot, cmdCreate, flagCron, flagPrune,
|
||||||
|
flagKeepNewerThan, "4w",
|
||||||
|
},
|
||||||
|
plantBlob: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "quiet",
|
||||||
|
args: []string{flagQuiet, cmdSnapshot, cmdCreate, flagPrune},
|
||||||
|
plantBlob: true,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
|
configPath, storeDir := writeBackupConfig(t)
|
||||||
|
|
||||||
|
var blob string
|
||||||
|
if testCase.plantBlob {
|
||||||
|
blob = plantUnreferencedBlob(t, storeDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
code, stdout, stderr := runEntry(t,
|
||||||
|
append([]string{flagConfig, configPath}, testCase.args...)...)
|
||||||
|
|
||||||
|
assert.Equal(t, 0, code)
|
||||||
|
assert.Empty(t, stdout)
|
||||||
|
assert.Empty(t, stderr)
|
||||||
|
|
||||||
|
if testCase.plantBlob {
|
||||||
|
assert.NoFileExists(t, blob, "the prune must delete the blob")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEntryPruneQuietIsSilent checks that a successful `prune --force`
|
||||||
|
// under --quiet prints nothing on stdout or stderr, both when there is
|
||||||
|
// nothing to remove and when it removes a stale local snapshot record
|
||||||
|
// and an unreferenced blob.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
|
||||||
|
func TestEntryPruneQuietIsSilent(t *testing.T) {
|
||||||
|
for _, testCase := range []struct {
|
||||||
|
name string
|
||||||
|
staleRecordAndBlob bool
|
||||||
|
}{
|
||||||
|
{name: "nothing to remove"},
|
||||||
|
{name: "stale record and blob to remove", staleRecordAndBlob: true},
|
||||||
|
} {
|
||||||
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
|
configPath := writeHermeticPruneConfig(t, testCase.staleRecordAndBlob)
|
||||||
|
|
||||||
|
var blob string
|
||||||
|
if testCase.staleRecordAndBlob {
|
||||||
|
// writeHermeticPruneConfig puts the store beside the config.
|
||||||
|
blob = plantUnreferencedBlob(t,
|
||||||
|
filepath.Join(filepath.Dir(configPath), "store"))
|
||||||
|
}
|
||||||
|
|
||||||
|
code, stdout, stderr := runEntry(t,
|
||||||
|
flagConfig, configPath, flagQuiet, cmdPrune, flagForce)
|
||||||
|
|
||||||
|
assert.Equal(t, 0, code)
|
||||||
|
assert.Empty(t, stdout)
|
||||||
|
assert.Empty(t, stderr)
|
||||||
|
|
||||||
|
if testCase.staleRecordAndBlob {
|
||||||
|
assert.NoFileExists(t, blob, "the prune must delete the blob")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package cli //nolint:testpackage // shares runEntry and the hermetic config helpers
|
package cli //nolint:testpackage // shares runEntry and the hermetic config helpers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -8,6 +9,7 @@ import (
|
|||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/vaultik/internal/database"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Flags of `snapshot create` used to build argument vectors below.
|
// Flags of `snapshot create` used to build argument vectors below.
|
||||||
@@ -87,6 +89,81 @@ func TestEntrySnapshotCreateReportsPostBackupPruneFailure(t *testing.T) {
|
|||||||
"the new snapshot is on the store beside the planted one")
|
"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. 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")
|
||||||
|
|
||||||
|
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. 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")
|
||||||
|
|
||||||
|
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
|
// writeBackupConfig builds a hermetic config with one small file in its
|
||||||
// source directory and an empty destination store directory. Returns the
|
// source directory and an empty destination store directory. Returns the
|
||||||
// config path and the store directory.
|
// config path and the store directory.
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package cli //nolint:testpackage // shares runEntry and the hermetic config helpers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestEntrySnapshotCreateSkipErrorsExitsNonZeroWhenItSkips backs up,
|
||||||
|
// under --skip-errors, a source directory that does not exist, which the
|
||||||
|
// walk skips. The command exits non-zero and reports the backup as
|
||||||
|
// completed, not as a failed snapshot creation. See
|
||||||
|
// https://git.eeqj.de/sneak/vaultik/issues/310.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
|
||||||
|
func TestEntrySnapshotCreateSkipErrorsExitsNonZeroWhenItSkips(t *testing.T) {
|
||||||
|
// writeBackupConfig would create the source directory.
|
||||||
|
configPath := writeHermeticPruneConfig(t, false)
|
||||||
|
|
||||||
|
code, stdout, stderr := runEntry(t,
|
||||||
|
flagConfig, configPath, "--skip-errors", cmdSnapshot, cmdCreate)
|
||||||
|
|
||||||
|
assert.Equal(t, 1, code)
|
||||||
|
assert.Contains(t, stdout,
|
||||||
|
"Files: 1 examined, 0 backed up, 0 unchanged, 1 failed.")
|
||||||
|
assert.NotContains(t, stdout, "Finished successfully.")
|
||||||
|
assert.Contains(t, stderr,
|
||||||
|
"Backup completed, but 1 file(s) could not be read")
|
||||||
|
assert.NotContains(t, stderr, "Snapshot creation failed")
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package cli //nolint:testpackage // shares runEntry and the hermetic config helpers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/vaultik/internal/database"
|
||||||
|
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Words and flags of `snapshot purge` used to build argument vectors
|
||||||
|
// below.
|
||||||
|
const (
|
||||||
|
cmdPurge = "purge"
|
||||||
|
flagOlderThan = "--older-than"
|
||||||
|
flagForce = "--force"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestEntrySnapshotPurgeKeepsMetadataWhenLocalIndexDeleteFails makes
|
||||||
|
// the local index refuse to delete an old snapshot and checks that
|
||||||
|
// `snapshot purge` exits non-zero and leaves that snapshot's metadata on
|
||||||
|
// the destination store. It used to log the error, delete the metadata
|
||||||
|
// and exit 0.
|
||||||
|
//
|
||||||
|
// `snapshot create --prune` runs the same purge, but cannot be driven
|
||||||
|
// this way: the refusal also breaks the backup's metadata export, which
|
||||||
|
// deletes the other snapshots from its copy of the local index.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
|
||||||
|
func TestEntrySnapshotPurgeKeepsMetadataWhenLocalIndexDeleteFails(t *testing.T) {
|
||||||
|
configPath := writeHermeticPruneConfig(t, false)
|
||||||
|
dir := filepath.Dir(configPath)
|
||||||
|
indexPath := filepath.Join(dir, "index.sqlite")
|
||||||
|
|
||||||
|
// seedStaleSnapshotRecord writes an old snapshot to the local index
|
||||||
|
// only; the manifest planted here puts it on the destination store
|
||||||
|
// too, so the purge selects it rather than dropping it as stale.
|
||||||
|
seedStaleSnapshotRecord(t, indexPath)
|
||||||
|
|
||||||
|
// While the local index holds a snapshot, purge fails on a
|
||||||
|
// destination that answers that it has no marker.
|
||||||
|
require.NoError(t, os.WriteFile(
|
||||||
|
filepath.Join(dir, "store", "vaultik-destination.txt"), nil, 0o600))
|
||||||
|
|
||||||
|
manifestDir := filepath.Join(dir, "store", "metadata",
|
||||||
|
snapshot.RemoteSnapshotKey(stalePruneSnapshotID))
|
||||||
|
require.NoError(t, os.MkdirAll(manifestDir, 0o750))
|
||||||
|
|
||||||
|
manifestPath := filepath.Join(manifestDir, "manifest.json.zst")
|
||||||
|
require.NoError(t, os.WriteFile(manifestPath, []byte("stub"), 0o600))
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
db, err := database.New(ctx, indexPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = db.Conn().ExecContext(ctx, `
|
||||||
|
CREATE TRIGGER refuse_snapshot_delete BEFORE DELETE ON snapshots
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'simulated index error'); END`)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, db.Close())
|
||||||
|
|
||||||
|
code, _, stderr := runEntry(t, flagConfig, configPath,
|
||||||
|
cmdSnapshot, cmdPurge, flagOlderThan, "30d", flagForce)
|
||||||
|
|
||||||
|
assert.Equal(t, 1, code)
|
||||||
|
assert.Contains(t, stderr, "simulated index error",
|
||||||
|
"the purge must fail at the local-index delete")
|
||||||
|
assert.FileExists(t, manifestPath,
|
||||||
|
"the snapshot is still in the local index, so its metadata must stay")
|
||||||
|
}
|
||||||
+13
-3
@@ -13,14 +13,24 @@ func NewPruneCommand() *cobra.Command {
|
|||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "prune",
|
Use: "prune",
|
||||||
Short: "Tidy local database and remote storage",
|
Short: "Tidy local database and remote storage",
|
||||||
Long: `Removes orphaned data from both the local index database and
|
Long: `Removes orphaned data from the local index database, and
|
||||||
unreferenced blobs from the backup destination store.
|
unreferenced blobs and directories under metadata/ with no manifest
|
||||||
|
from the backup destination store.
|
||||||
|
|
||||||
Local cleanup drops incomplete snapshots and any files, chunks, or
|
Local cleanup drops incomplete snapshots and any files, chunks, or
|
||||||
blobs no longer referenced by a completed snapshot. Remote cleanup
|
blobs no longer referenced by a completed snapshot. Remote cleanup
|
||||||
scans every snapshot manifest in the destination store, builds the
|
scans every snapshot manifest in the destination store, builds the
|
||||||
set of still-referenced blob hashes, and deletes any blob not in that
|
set of still-referenced blob hashes, and deletes any blob not in that
|
||||||
set.
|
set that is older than prune_grace_period (default 24h). A newer one
|
||||||
|
is kept: it may belong to a backup still running, on this host or
|
||||||
|
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
|
Snapshot create --prune runs the same cleanup automatically; this
|
||||||
command is the manual entry point for the same work (e.g. after a
|
command is the manual entry point for the same work (e.g. after a
|
||||||
|
|||||||
@@ -115,6 +115,15 @@ on Linux), then /etc/vaultik/config.yml.`,
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if errors.Is(err, vaultik.ErrFilesUnreadable) ||
|
||||||
|
errors.Is(err, vaultik.ErrSourcePathEmpty) ||
|
||||||
|
errors.Is(err, vaultik.ErrVolumeNotMounted) {
|
||||||
|
log.Error("Backup completed with an error", "error", err)
|
||||||
|
ReportErrorf("Backup completed, but %v", err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
log.Error("Snapshot creation failed", "error", err)
|
log.Error("Snapshot creation failed", "error", err)
|
||||||
ReportErrorf("Snapshot creation failed: %v", err)
|
ReportErrorf("Snapshot creation failed: %v", err)
|
||||||
})
|
})
|
||||||
@@ -124,8 +133,13 @@ on Linux), then /etc/vaultik/config.yml.`,
|
|||||||
cmd.Flags().BoolVar(&opts.Cron, "cron", false,
|
cmd.Flags().BoolVar(&opts.Cron, "cron", false,
|
||||||
"Run in cron mode (silent unless warning or error)")
|
"Run in cron mode (silent unless warning or error)")
|
||||||
cmd.Flags().BoolVar(&opts.Prune, "prune", false,
|
cmd.Flags().BoolVar(&opts.Prune, "prune", false,
|
||||||
"After backup, drop older snapshots of the same name and remove "+
|
"After backup, drop older snapshots of the same name, remove "+
|
||||||
"orphaned blobs")
|
"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", "",
|
cmd.Flags().StringVar(&opts.KeepNewerThan, "keep-newer-than", "",
|
||||||
"With --prune: keep snapshots newer than this duration "+
|
"With --prune: keep snapshots newer than this duration "+
|
||||||
"(e.g. 30d, 4w, 6mo, 1y; m is minutes, mo is months) "+
|
"(e.g. 30d, 4w, 6mo, 1y; m is minutes, mo is months) "+
|
||||||
@@ -270,8 +284,9 @@ By default, this removes the snapshot from the local index database and
|
|||||||
strips the snapshot's metadata from the backup destination store. Blobs
|
strips the snapshot's metadata from the backup destination store. Blobs
|
||||||
are NOT touched: deleting them requires reading every remaining remote
|
are NOT touched: deleting them requires reading every remaining remote
|
||||||
manifest (the destination store may hold snapshots this host doesn't
|
manifest (the destination store may hold snapshots this host doesn't
|
||||||
know about), which is what 'vaultik prune' does. On success the command
|
know about), which is what 'vaultik prune' does. The command prints the
|
||||||
prints the exact 'vaultik prune' invocation to run as a follow-up.
|
'vaultik prune' invocation to run as a follow-up only when it removed the
|
||||||
|
snapshot's metadata from the destination store, and never under --json.
|
||||||
|
|
||||||
An ID that names no snapshot in the local index or on the destination
|
An ID that names no snapshot in the local index or on the destination
|
||||||
store is an error.
|
store is an error.
|
||||||
@@ -280,10 +295,12 @@ Use --local-only to skip the remote half (e.g. when you want to forget a
|
|||||||
snapshot locally without touching the destination store); the snapshot
|
snapshot locally without touching the destination store); the snapshot
|
||||||
must then be in the local index.
|
must then be in the local index.
|
||||||
|
|
||||||
If the remote is unreachable, the local-database removal still completes
|
If removing the snapshot's metadata from the destination store fails (for
|
||||||
and a warning is emitted; run 'vaultik snapshot remove <snapshot-id>' again
|
example because the remote is unreachable), the local-database removal
|
||||||
once the destination store is reachable to remove the snapshot's metadata
|
still completes and a warning is emitted; run 'vaultik snapshot remove
|
||||||
from it ('vaultik prune' does not).
|
<snapshot-id>' again once the destination store is reachable to remove
|
||||||
|
the snapshot's metadata from it ('vaultik prune' does not). For a
|
||||||
|
snapshot that is only on the destination store, that failure is an error.
|
||||||
|
|
||||||
To wipe the entire destination store and start over, use 'vaultik remote
|
To wipe the entire destination store and start over, use 'vaultik remote
|
||||||
nuke --force' — it is the single supported entry point for that.`,
|
nuke --force' — it is the single supported entry point for that.`,
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ This command will restore files from the specified snapshot to the
|
|||||||
target directory.
|
target directory.
|
||||||
If no paths are specified, all files are restored.
|
If no paths are specified, all files are restored.
|
||||||
If paths are specified, only matching files/directories are restored.
|
If paths are specified, only matching files/directories are restored.
|
||||||
|
A path that matches nothing in the snapshot is named in a warning, and
|
||||||
|
the command exits non-zero after restoring the paths that match.
|
||||||
|
|
||||||
The snapshot may be named by its ID or, when restoring on a host with no
|
The snapshot may be named by its ID or, when restoring on a host with no
|
||||||
local index, by the remote key that 'snapshot list' prints for a
|
local index, by the remote key that 'snapshot list' prints for a
|
||||||
|
|||||||
@@ -37,13 +37,13 @@ func writeVersion(out *ui.Writer) {
|
|||||||
w := out.Out()
|
w := out.Out()
|
||||||
|
|
||||||
_, _ = fmt.Fprintf(w, "vaultik %s\n", globals.Version)
|
_, _ = fmt.Fprintf(w, "vaultik %s\n", globals.Version)
|
||||||
_, _ = fmt.Fprintf(w, " commit: %s\n", globals.Commit)
|
_, _ = fmt.Fprintf(w, " commit: %s\n", globals.Commit)
|
||||||
_, _ = fmt.Fprintf(w, " build date: %s\n", globals.CommitDate)
|
_, _ = fmt.Fprintf(w, " commit date: %s\n", globals.CommitDate)
|
||||||
_, _ = fmt.Fprintf(w, " go: %s\n", runtime.Version())
|
_, _ = fmt.Fprintf(w, " go: %s\n", runtime.Version())
|
||||||
_, _ = fmt.Fprintf(w, " os/arch: %s/%s\n", runtime.GOOS, runtime.GOARCH)
|
_, _ = fmt.Fprintf(w, " os/arch: %s/%s\n", runtime.GOOS, runtime.GOARCH)
|
||||||
_, _ = fmt.Fprintf(w, " author: %s\n", globals.Author)
|
_, _ = fmt.Fprintf(w, " author: %s\n", globals.Author)
|
||||||
_, _ = fmt.Fprintf(w, " homepage: %s\n", globals.Homepage)
|
_, _ = fmt.Fprintf(w, " homepage: %s\n", globals.Homepage)
|
||||||
_, _ = fmt.Fprintf(w, " license: %s\n", globals.License)
|
_, _ = fmt.Fprintf(w, " license: %s\n", globals.License)
|
||||||
|
|
||||||
if globals.IsDevVersion(globals.Version) {
|
if globals.IsDevVersion(globals.Version) {
|
||||||
_, _ = fmt.Fprintln(w)
|
_, _ = fmt.Fprintln(w)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package cli_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"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
|
// TestVersionCommandFlagsDevelopmentBuild is the regression test for
|
||||||
// the thing this command exists to prevent: a build that is not a
|
// 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
|
// release must say so. The notice used to be gated on the version
|
||||||
|
|||||||
+91
-30
@@ -4,12 +4,15 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"filippo.io/age"
|
"filippo.io/age"
|
||||||
"git.eeqj.de/sneak/smartconfig"
|
"git.eeqj.de/sneak/smartconfig"
|
||||||
@@ -29,13 +32,16 @@ const appName = "vaultik"
|
|||||||
//nolint:gosec // G101: marker for detecting a pasted secret key, not a credential
|
//nolint:gosec // G101: marker for detecting a pasted secret key, not a credential
|
||||||
const secretKeyPrefix = "AGE-SECRET-KEY-"
|
const secretKeyPrefix = "AGE-SECRET-KEY-"
|
||||||
|
|
||||||
// Defaults and validation bounds for tunable settings.
|
// Defaults and validation bounds for tunable settings. MB and GB are
|
||||||
|
// 1000-based, as the size parser reads them; the S3 part sizes are S3's own
|
||||||
|
// limits, which are 1024-based (MiB, GiB).
|
||||||
const (
|
const (
|
||||||
defaultBlobSizeLimit = Size(10 * 1024 * 1024 * 1024) // 10GB
|
defaultBlobSizeLimit = Size(10 * 1000 * 1000 * 1000) // 10GB
|
||||||
defaultChunkSize = Size(10 * 1024 * 1024) // 10MB
|
defaultChunkSize = Size(10 * 1000 * 1000) // 10MB
|
||||||
defaultS3PartSize = Size(5 * 1024 * 1024) // 5MiB
|
defaultS3PartSize = Size(5 * 1024 * 1024) // 5MiB
|
||||||
defaultCompressionLevel = 3
|
defaultCompressionLevel = 3
|
||||||
minChunkSize = 1024 * 1024 // 1MB
|
defaultPruneGracePeriod = 24 * time.Hour
|
||||||
|
minChunkSize = 1000 * 1000 // 1MB
|
||||||
minCompressionLevel = 1
|
minCompressionLevel = 1
|
||||||
maxCompressionLevel = 19
|
maxCompressionLevel = 19
|
||||||
// S3 accepts a multipart upload part from 5MiB to 5GiB.
|
// S3 accepts a multipart upload part from 5MiB to 5GiB.
|
||||||
@@ -51,7 +57,7 @@ var (
|
|||||||
errRecipientNotX25519 = errors.New(
|
errRecipientNotX25519 = errors.New(
|
||||||
"not a valid recipient; only X25519 age1... public keys are supported")
|
"not a valid recipient; only X25519 age1... public keys are supported")
|
||||||
errNoSnapshots = errors.New(
|
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")
|
errSnapshotNoPaths = errors.New("snapshot must have at least one path")
|
||||||
errChunkSizeTooSmall = errors.New("chunk_size must be at least 1MB")
|
errChunkSizeTooSmall = errors.New("chunk_size must be at least 1MB")
|
||||||
errBlobSizeTooSmall = errors.New(
|
errBlobSizeTooSmall = errors.New(
|
||||||
@@ -64,6 +70,8 @@ var (
|
|||||||
errFileURLNotAbsolute = errors.New(
|
errFileURLNotAbsolute = errors.New(
|
||||||
"a file:// storage_url needs an absolute path, " +
|
"a file:// storage_url needs an absolute path, " +
|
||||||
"as in file:///path/to/backup")
|
"as in file:///path/to/backup")
|
||||||
|
errNegativePruneGracePeriod = errors.New(
|
||||||
|
"prune_grace_period must not be negative")
|
||||||
errStorageNotConfigured = errors.New(
|
errStorageNotConfigured = errors.New(
|
||||||
"storage not configured; set storage_url or provide s3.endpoint + " +
|
"storage not configured; set storage_url or provide s3.endpoint + " +
|
||||||
"s3.bucket + credentials")
|
"s3.bucket + credentials")
|
||||||
@@ -184,6 +192,15 @@ type Config struct {
|
|||||||
Snapshots map[string]SnapshotConfig `yaml:"snapshots"`
|
Snapshots map[string]SnapshotConfig `yaml:"snapshots"`
|
||||||
CompressionLevel int `yaml:"compression_level"`
|
CompressionLevel int `yaml:"compression_level"`
|
||||||
|
|
||||||
|
// PruneGracePeriod is how old a blob no manifest lists must be before
|
||||||
|
// 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.
|
// StorageURL specifies the storage backend using a URL format.
|
||||||
// Takes precedence over S3Config if set.
|
// Takes precedence over S3Config if set.
|
||||||
// Supported formats:
|
// Supported formats:
|
||||||
@@ -233,24 +250,81 @@ func New(path Path) (*Config, error) {
|
|||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load reads and parses the configuration file from the specified path.
|
// Load reads the configuration file at path, parses it with Parse, warns
|
||||||
// It applies default values for optional fields, performs environment variable
|
// when others can read the file, and validates the configuration.
|
||||||
// substitution using smartconfig, and validates the configuration.
|
// Returns an error if the file cannot be read, parsed, or if validation
|
||||||
// The configuration file should be in YAML format. Returns an error if the file
|
// fails.
|
||||||
// cannot be read, parsed, or if validation fails.
|
|
||||||
func Load(path string) (*Config, error) {
|
func Load(path string) (*Config, error) {
|
||||||
// Load config using smartconfig for interpolation
|
data, err := os.ReadFile(path) //nolint:gosec // G304: config path is operator-supplied
|
||||||
sc, err := smartconfig.NewFromConfigPath(path)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to load config file: %w", err)
|
return nil, fmt.Errorf("failed to load config file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cfg, err := Parse(data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check config file permissions (warn if world or group readable)
|
||||||
|
//nolint:gosec // G703: config path is operator-supplied by design
|
||||||
|
info, statErr := os.Stat(path)
|
||||||
|
if statErr == nil {
|
||||||
|
mode := info.Mode().Perm()
|
||||||
|
if mode&0044 != 0 { // group or world readable
|
||||||
|
log.Warn(cfg.readableByOthersWarning(),
|
||||||
|
"path", path,
|
||||||
|
"mode", fmt.Sprintf("%04o", mode),
|
||||||
|
"recommendation", "chmod 600 "+path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = cfg.Validate()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse reads YAML config data as Load does, without validating it. It
|
||||||
|
// refuses a key that no setting has, 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.
|
||||||
|
err = checkFilledKeys(sc.Data())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
// Set defaults
|
// Set defaults
|
||||||
BlobSizeLimit: defaultBlobSizeLimit,
|
BlobSizeLimit: defaultBlobSizeLimit,
|
||||||
ChunkSize: defaultChunkSize,
|
ChunkSize: defaultChunkSize,
|
||||||
IndexPath: filepath.Join(xdg.DataHome, appName, "index.sqlite"),
|
IndexPath: filepath.Join(xdg.DataHome, appName, "index.sqlite"),
|
||||||
CompressionLevel: defaultCompressionLevel,
|
CompressionLevel: defaultCompressionLevel,
|
||||||
|
PruneGracePeriod: defaultPruneGracePeriod,
|
||||||
S3: S3Config{PartSize: defaultS3PartSize},
|
S3: S3Config{PartSize: defaultS3PartSize},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,24 +376,6 @@ func Load(path string) (*Config, error) {
|
|||||||
cfg.S3.Region = "us-east-1"
|
cfg.S3.Region = "us-east-1"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check config file permissions (warn if world or group readable)
|
|
||||||
//nolint:gosec // G703: config path is operator-supplied by design
|
|
||||||
info, statErr := os.Stat(path)
|
|
||||||
if statErr == nil {
|
|
||||||
mode := info.Mode().Perm()
|
|
||||||
if mode&0044 != 0 { // group or world readable
|
|
||||||
log.Warn(cfg.readableByOthersWarning(),
|
|
||||||
"path", path,
|
|
||||||
"mode", fmt.Sprintf("%04o", mode),
|
|
||||||
"recommendation", "chmod 600 "+path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err = cfg.Validate()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("invalid config: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,6 +393,7 @@ func Load(path string) (*Config, error) {
|
|||||||
// exceeds the configured limit
|
// exceeds the configured limit
|
||||||
// - Compression level must be between 1 and 19
|
// - Compression level must be between 1 and 19
|
||||||
// - S3 part size must be between 5MiB and 5GiB, the part sizes S3 accepts
|
// - S3 part size must be between 5MiB and 5GiB, the part sizes S3 accepts
|
||||||
|
// - Prune grace period must not be negative
|
||||||
//
|
//
|
||||||
// Returns an error describing the first validation failure encountered.
|
// Returns an error describing the first validation failure encountered.
|
||||||
func (c *Config) Validate() error {
|
func (c *Config) Validate() error {
|
||||||
@@ -386,6 +443,10 @@ func (c *Config) Validate() error {
|
|||||||
return errBadS3PartSize
|
return errBadS3PartSize
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if c.PruneGracePeriod < 0 {
|
||||||
|
return errNegativePruneGracePeriod
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/vaultik/internal/chunker"
|
"sneak.berlin/go/vaultik/internal/chunker"
|
||||||
"sneak.berlin/go/vaultik/internal/log"
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
@@ -89,12 +90,12 @@ func TestConfigLoad(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestExampleConfigIsScrubbedAndLoads checks that the shipped
|
// TestExampleConfigIsScrubbedAndLoads checks that the shipped
|
||||||
// config.example.yml carries only neutral placeholders (no real credentials,
|
// configs/config.example.yml carries only neutral placeholders (no real
|
||||||
// private addresses, or internal host names) and still parses.
|
// credentials, private addresses, or internal host names) and still parses.
|
||||||
func TestExampleConfigIsScrubbedAndLoads(t *testing.T) {
|
func TestExampleConfigIsScrubbedAndLoads(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
examplePath := filepath.Join("..", "..", "config.example.yml")
|
examplePath := filepath.Join("..", "..", "configs", "config.example.yml")
|
||||||
|
|
||||||
cfg, err := Load(examplePath)
|
cfg, err := Load(examplePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -337,6 +338,149 @@ func TestLoadS3PartSize(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestDocumentedSizeDefaults checks that the defaults are the documented
|
||||||
|
// chunk_size 10MB and blob_size_limit 10GB as the parser reads those values
|
||||||
|
// (1000-based), so writing a default out in the config file changes nothing.
|
||||||
|
func TestDocumentedSizeDefaults(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cfg, err := Parse([]byte("chunk_size: 10MB\nblob_size_limit: 10GB\n"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Parse() unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.ChunkSize != defaultChunkSize {
|
||||||
|
t.Errorf("chunk_size: 10MB is %d bytes, the default is %d",
|
||||||
|
cfg.ChunkSize, defaultChunkSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.BlobSizeLimit != defaultBlobSizeLimit {
|
||||||
|
t.Errorf("blob_size_limit: 10GB is %d bytes, the default is %d",
|
||||||
|
cfg.BlobSizeLimit, defaultBlobSizeLimit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDocumentedSizeExamples checks the 1MB minimum chunk_size and that
|
||||||
|
// every size the README, configs/config.example.yml and the config init template
|
||||||
|
// list as an example is accepted.
|
||||||
|
func TestDocumentedSizeExamples(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const withoutSizes = "snapshots:\n" +
|
||||||
|
" test:\n" +
|
||||||
|
" paths: [/tmp/vaultik-test-source]\n" +
|
||||||
|
"storage_url: file:///tmp/vaultik-test-storage\n"
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
setting string
|
||||||
|
wantErr error
|
||||||
|
}{
|
||||||
|
{"chunk_size: 1MB", nil},
|
||||||
|
{"chunk_size: 999999", errChunkSizeTooSmall},
|
||||||
|
{"chunk_size: 5M", nil},
|
||||||
|
{"chunk_size: 10M", nil},
|
||||||
|
{"chunk_size: 10MB", nil},
|
||||||
|
{"chunk_size: 64MiB", nil},
|
||||||
|
{"chunk_size: 1GB", nil},
|
||||||
|
// Four times the default chunk_size, the documented minimum.
|
||||||
|
{"blob_size_limit: 40MB", nil},
|
||||||
|
{"blob_size_limit: 500MB", nil},
|
||||||
|
{"blob_size_limit: 1GB", nil},
|
||||||
|
{"blob_size_limit: 1GiB", nil},
|
||||||
|
{"blob_size_limit: 10G", nil},
|
||||||
|
{"blob_size_limit: 10GB", nil},
|
||||||
|
{"s3: {part_size: 5MiB}", nil},
|
||||||
|
{"s3: {part_size: 10MB}", nil},
|
||||||
|
{"s3: {part_size: 16MiB}", nil},
|
||||||
|
{"s3: {part_size: 100MiB}", nil},
|
||||||
|
{"s3: {part_size: 5GiB}", nil},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.setting, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cfg, err := Parse([]byte(withoutSizes + tt.setting + "\n"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Parse() unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = cfg.Validate()
|
||||||
|
if !errors.Is(err, tt.wantErr) {
|
||||||
|
t.Fatalf("Validate() error = %v, want %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestValidatePruneGracePeriod checks that a negative prune_grace_period
|
||||||
|
// is refused rather than acting like 0s, which deletes unreferenced blobs
|
||||||
|
// 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()
|
||||||
|
|
||||||
|
const withoutGracePeriod = "snapshots:\n" +
|
||||||
|
" test:\n" +
|
||||||
|
" paths: [/tmp/vaultik-test-source]\n" +
|
||||||
|
"storage_url: file:///tmp/vaultik-test-storage\n"
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
setting string
|
||||||
|
wantErr error
|
||||||
|
}{
|
||||||
|
{"prune_grace_period: 0s", nil},
|
||||||
|
{"prune_grace_period: 90m", nil},
|
||||||
|
{"prune_grace_period: 36h", nil},
|
||||||
|
{"prune_grace_period: -1h", errNegativePruneGracePeriod},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.setting, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cfg, err := Parse([]byte(withoutGracePeriod + tt.setting + "\n"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Parse() unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = cfg.Validate()
|
||||||
|
if !errors.Is(err, tt.wantErr) {
|
||||||
|
t.Fatalf("Validate() error = %v, want %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoadPruneGracePeriod checks that a config file without
|
||||||
|
// prune_grace_period loads with the documented 24h. The value is written out
|
||||||
|
// here rather than taken from defaultPruneGracePeriod, so that a change of the
|
||||||
|
// default fails this test.
|
||||||
|
func TestLoadPruneGracePeriod(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const withoutGracePeriod = "snapshots:\n" +
|
||||||
|
" test:\n" +
|
||||||
|
" paths: [/tmp/vaultik-test-source]\n" +
|
||||||
|
"storage_url: file:///tmp/vaultik-test-storage\n"
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), "config.yml")
|
||||||
|
|
||||||
|
err := os.WriteFile(path, []byte(withoutGracePeriod), 0o600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load() unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.PruneGracePeriod != 24*time.Hour {
|
||||||
|
t.Errorf("prune_grace_period = %s, want 24h", cfg.PruneGracePeriod)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestLoadRejectsRelativeFileURL checks that a file:// storage_url with a
|
// TestLoadRejectsRelativeFileURL checks that a file:// storage_url with a
|
||||||
// relative path fails at load. Accepted, it would resolve against the
|
// relative path fails at load. Accepted, it would resolve against the
|
||||||
// working directory, so a backup run from another directory would write
|
// working directory, so a backup run from another directory would write
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkFilledKeys is checkKeys for data, the config after smartconfig has
|
||||||
|
// filled in its ${...} references. A key that a reference brings in is not
|
||||||
|
// in the file, and the copy that Encode makes has no line numbers.
|
||||||
|
func checkFilledKeys(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)
|
||||||
|
}
|
||||||
|
|
||||||
|
return checkKeys(&filled, reflect.TypeFor[Config](), "",
|
||||||
|
map[*yaml.Node]bool{}, map[nodeAndType]bool{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -267,6 +267,45 @@ func (r *FileRepository) ListUnderPath(
|
|||||||
return files, rows.Err()
|
return files, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListUnderPathInSnapshot returns the files that ListUnderPath returns
|
||||||
|
// for path and that the snapshot lists, in no particular order.
|
||||||
|
func (r *FileRepository) ListUnderPathInSnapshot(
|
||||||
|
ctx context.Context, path, snapshotID string,
|
||||||
|
) ([]*File, error) {
|
||||||
|
path = strings.TrimRight(path, "/")
|
||||||
|
dirPrefix := path + "/"
|
||||||
|
|
||||||
|
query := `
|
||||||
|
SELECT f.id, f.path, f.source_path, f.mtime, f.mtime_nsec, f.size,
|
||||||
|
f.mode, f.uid, f.gid, f.link_target
|
||||||
|
FROM files f
|
||||||
|
JOIN snapshot_files sf ON sf.file_id = f.id
|
||||||
|
WHERE sf.snapshot_id = ?
|
||||||
|
AND (f.path = ? OR substr(f.path, 1, length(?)) = ?)
|
||||||
|
`
|
||||||
|
|
||||||
|
rows, err := r.db.conn.QueryContext(ctx, query,
|
||||||
|
snapshotID, path, dirPrefix, dirPrefix)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("querying files: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
var files []*File
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
file, err := r.scanFileRows(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("scanning file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
files = append(files, file)
|
||||||
|
}
|
||||||
|
|
||||||
|
return files, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// ListIDsWithChunksNotInUploadedBlobs returns the IDs of the files whose
|
// ListIDsWithChunksNotInUploadedBlobs returns the IDs of the files whose
|
||||||
// path starts with prefix and that list at least one chunk held by no
|
// path starts with prefix and that list at least one chunk held by no
|
||||||
// blob whose upload has completed (uploaded_ts set). A new snapshot
|
// blob whose upload has completed (uploaded_ts set). A new snapshot
|
||||||
|
|||||||
@@ -212,6 +212,65 @@ func TestFileRepositoryListUnderPath(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListUnderPathInSnapshot leaves out files outside path and files the
|
||||||
|
// snapshot does not list, though another snapshot lists them.
|
||||||
|
func TestFileRepositoryListUnderPathInSnapshot(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db, cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
files := database.NewFileRepository(db)
|
||||||
|
snapshots := database.NewSnapshotRepository(db)
|
||||||
|
|
||||||
|
listed := map[string][]string{
|
||||||
|
"one": {"/src/a.txt", "/src2/c.txt"},
|
||||||
|
"two": {"/src/b.txt"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for snapshotID, paths := range listed {
|
||||||
|
err := snapshots.Create(ctx, nil, &database.Snapshot{
|
||||||
|
ID: types.SnapshotID(snapshotID),
|
||||||
|
Hostname: "host",
|
||||||
|
StartedAt: time.Now(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create snapshot %s: %v", snapshotID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, path := range paths {
|
||||||
|
err := files.Create(ctx, nil, &database.File{
|
||||||
|
Path: types.FilePath(path),
|
||||||
|
MTime: time.Now().Truncate(time.Second),
|
||||||
|
Mode: 0644,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create %s: %v", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = snapshots.AddFile(ctx, nil, snapshotID, path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to add %s to %s: %v", path, snapshotID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
found, err := files.ListUnderPathInSnapshot(ctx, "/src", "one")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to list files: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := make([]string, 0, len(found))
|
||||||
|
for _, f := range found {
|
||||||
|
got = append(got, f.Path.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
if !slices.Equal(got, []string{"/src/a.txt"}) {
|
||||||
|
t.Errorf("got %q, want only /src/a.txt", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFileRepositorySymlink(t *testing.T) {
|
func TestFileRepositorySymlink(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -123,6 +123,16 @@ CREATE TABLE IF NOT EXISTS snapshot_blobs (
|
|||||||
-- Index for efficient blob lookups (used in orphan detection)
|
-- Index for efficient blob lookups (used in orphan detection)
|
||||||
CREATE INDEX IF NOT EXISTS idx_snapshot_blobs_blob_id ON snapshot_blobs(blob_id);
|
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
|
-- Uploads table: tracks blob upload metrics
|
||||||
CREATE TABLE IF NOT EXISTS uploads (
|
CREATE TABLE IF NOT EXISTS uploads (
|
||||||
blob_hash TEXT PRIMARY KEY,
|
blob_hash TEXT PRIMARY KEY,
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// SnapshotRepository provides access to the snapshots table and its
|
// 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 {
|
type SnapshotRepository struct {
|
||||||
db *DB
|
db *DB
|
||||||
}
|
}
|
||||||
@@ -500,27 +501,57 @@ func (r *SnapshotRepository) GetBlobHashes(
|
|||||||
ORDER BY sb.blob_hash
|
ORDER BY sb.blob_hash
|
||||||
`
|
`
|
||||||
|
|
||||||
rows, err := r.db.conn.QueryContext(ctx, query, snapshotID)
|
blobs, err := r.queryStrings(ctx, query, snapshotID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("querying blob hashes: %w", err)
|
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 err error
|
||||||
var blobHash string
|
if tx != nil {
|
||||||
|
_, err = tx.ExecContext(ctx, query, snapshotID, dir)
|
||||||
err := rows.Scan(&blobHash)
|
} else {
|
||||||
if err != nil {
|
_, err = r.db.ExecWithLog(ctx, query, snapshotID, dir)
|
||||||
return nil, fmt.Errorf("scanning blob hash: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
blobs = append(blobs, blobHash)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
// GetSnapshotTotalCompressedSize returns the total compressed size of all
|
||||||
@@ -709,6 +740,34 @@ func (r *SnapshotRepository) DeleteSnapshotUploads(
|
|||||||
return nil
|
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
|
// extendedCompressionRatio computes the compression ratio for a snapshot
|
||||||
// from its stored blob_size and the given uncompressed size. Returns 1.0
|
// from its stored blob_size and the given uncompressed size. Returns 1.0
|
||||||
// when the uncompressed size is zero.
|
// when the uncompressed size is zero.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -422,3 +423,52 @@ func TestSnapshotRepositoryDuplicate(t *testing.T) {
|
|||||||
t.Error("expected error for duplicate snapshot")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/aws/aws-sdk-go-v2/aws"
|
"github.com/aws/aws-sdk-go-v2/aws"
|
||||||
"github.com/aws/aws-sdk-go-v2/config"
|
"github.com/aws/aws-sdk-go-v2/config"
|
||||||
@@ -302,7 +303,10 @@ func IsNotFound(err error) bool {
|
|||||||
type ObjectInfo struct {
|
type ObjectInfo struct {
|
||||||
Key string
|
Key string
|
||||||
Size int64
|
Size int64
|
||||||
Err error
|
// ModTime is the object's LastModified time. Only ListObjectsStream
|
||||||
|
// sets it.
|
||||||
|
ModTime time.Time
|
||||||
|
Err error
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListObjectsStream lists objects with the given prefix and returns a channel.
|
// ListObjectsStream lists objects with the given prefix and returns a channel.
|
||||||
@@ -343,8 +347,9 @@ func (c *Client) ListObjectsStream(
|
|||||||
}
|
}
|
||||||
|
|
||||||
ch <- ObjectInfo{
|
ch <- ObjectInfo{
|
||||||
Key: key,
|
Key: key,
|
||||||
Size: *obj.Size,
|
Size: *obj.Size,
|
||||||
|
ModTime: aws.ToTime(obj.LastModified),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
}
|
||||||
@@ -578,3 +578,54 @@ func TestExcludePatterns_DoubleStarFileAtEveryDepth(t *testing.T) {
|
|||||||
require.Equal(t, 1, result.FilesScanned,
|
require.Equal(t, 1, result.FilesScanned,
|
||||||
"**/ file pattern should match at the top level and nested")
|
"**/ file pattern should match at the top level and nested")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExcludePatterns_AnchoredDoubleStarAtEveryDepth(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// As in gitignore, /**/ also matches directly under the source dir
|
||||||
|
fs := setupAnchoredTestFS(t)
|
||||||
|
|
||||||
|
scanner, repos, cleanup := createTestScanner(t, fs, []string{"/**/projectname"})
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
createSnapshotRecord(ctx, t, repos, "test-snapshot")
|
||||||
|
|
||||||
|
result, err := scanner.Scan(ctx, "/backup", "test-snapshot")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// /**/projectname should exclude BOTH:
|
||||||
|
// - /backup/projectname/file.txt
|
||||||
|
// - /backup/otherproject/projectname/file.txt
|
||||||
|
// Total files: 4, excluded: 2
|
||||||
|
require.Equal(t, 2, result.FilesScanned,
|
||||||
|
"/**/ pattern should match at the top level and nested")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExcludePatterns_DoubleStarInMiddleMatchesZeroDirectories(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// As in gitignore, a/**/b matches a/b as well as a/c/b. The last part
|
||||||
|
// is a glob: with a plain name the glob library already matched a/b.
|
||||||
|
fs := &afero.MemMapFs{}
|
||||||
|
files := []string{"/backup/a/x.log", "/backup/a/c/y.log", "/backup/a/c/kept.txt"}
|
||||||
|
|
||||||
|
for _, path := range files {
|
||||||
|
require.NoError(t, fs.MkdirAll(filepath.Dir(path), 0755))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, path, []byte(path), 0644))
|
||||||
|
}
|
||||||
|
|
||||||
|
scanner, repos, cleanup := createTestScanner(t, fs, []string{"a/**/*.log"})
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
createSnapshotRecord(ctx, t, repos, "test-snapshot")
|
||||||
|
|
||||||
|
result, err := scanner.Scan(ctx, "/backup", "test-snapshot")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// a/**/*.log should exclude /backup/a/x.log and /backup/a/c/y.log
|
||||||
|
// Total files: 3, excluded: 2
|
||||||
|
require.Equal(t, 1, result.FilesScanned,
|
||||||
|
"a/**/*.log should match a/x.log as well as a/c/y.log")
|
||||||
|
}
|
||||||
+21
-11
@@ -1,6 +1,8 @@
|
|||||||
package snapshot
|
package snapshot
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
"sneak.berlin/go/vaultik/internal/config"
|
"sneak.berlin/go/vaultik/internal/config"
|
||||||
@@ -16,6 +18,12 @@ type ScannerParams struct {
|
|||||||
Fs afero.Fs
|
Fs afero.Fs
|
||||||
Exclude []string // Exclude patterns (combined global + snapshot-specific)
|
Exclude []string // Exclude patterns (combined global + snapshot-specific)
|
||||||
SkipErrors bool // Skip file read errors (log loudly but continue)
|
SkipErrors bool // Skip file read errors (log loudly but continue)
|
||||||
|
// PreviousSnapshotID is the most recent completed snapshot of the
|
||||||
|
// same name, "" for none.
|
||||||
|
PreviousSnapshotID string
|
||||||
|
// 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.
|
// Module exports backup functionality as an fx module.
|
||||||
@@ -44,17 +52,19 @@ func provideScannerFactory(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return NewScanner(ScannerConfig{
|
return NewScanner(ScannerConfig{
|
||||||
FS: params.Fs,
|
FS: params.Fs,
|
||||||
ChunkSize: cfg.ChunkSize.Int64(),
|
ChunkSize: cfg.ChunkSize.Int64(),
|
||||||
Repositories: repos,
|
Repositories: repos,
|
||||||
Storage: storer,
|
Storage: storer,
|
||||||
MaxBlobSize: cfg.BlobSizeLimit.Int64(),
|
MaxBlobSize: cfg.BlobSizeLimit.Int64(),
|
||||||
CompressionLevel: cfg.CompressionLevel,
|
CompressionLevel: cfg.CompressionLevel,
|
||||||
AgeRecipients: cfg.AgeRecipients,
|
AgeRecipients: cfg.AgeRecipients,
|
||||||
EnableProgress: params.EnableProgress,
|
EnableProgress: params.EnableProgress,
|
||||||
UI: params.UI,
|
UI: params.UI,
|
||||||
Exclude: excludes,
|
Exclude: excludes,
|
||||||
SkipErrors: params.SkipErrors,
|
SkipErrors: params.SkipErrors,
|
||||||
|
PreviousSnapshotID: params.PreviousSnapshotID,
|
||||||
|
DeviceNumber: params.DeviceNumber,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -78,8 +78,7 @@ func TestProgressPercentWithSecondPathSmaller(t *testing.T) {
|
|||||||
|
|
||||||
stats := progress.GetStats()
|
stats := progress.GetStats()
|
||||||
|
|
||||||
// Directories count toward the total size but produce no chunks, so
|
// With both paths counted, the percentage must not exceed 100%.
|
||||||
// the percentage ends just under 100%.
|
|
||||||
percent := float64(stats.BytesProcessed.Load()) /
|
percent := float64(stats.BytesProcessed.Load()) /
|
||||||
float64(stats.TotalSize.Load()) * 100
|
float64(stats.TotalSize.Load()) * 100
|
||||||
if percent > 100 {
|
if percent > 100 {
|
||||||
|
|||||||
+443
-133
@@ -58,6 +58,10 @@ type Scanner struct {
|
|||||||
compressionLevel int
|
compressionLevel int
|
||||||
ageRecipient string
|
ageRecipient string
|
||||||
snapshotID string // Current snapshot being processed
|
snapshotID string // Current snapshot being processed
|
||||||
|
// previousSnapshotID is the most recent completed snapshot of the
|
||||||
|
// same name, "" for none. Only a file it lists can be counted as
|
||||||
|
// deleted.
|
||||||
|
previousSnapshotID string
|
||||||
// currentSourcePath is the source directory being scanned, stored with
|
// currentSourcePath is the source directory being scanned, stored with
|
||||||
// each file record.
|
// each file record.
|
||||||
currentSourcePath string
|
currentSourcePath string
|
||||||
@@ -69,6 +73,9 @@ type Scanner struct {
|
|||||||
skipErrors bool
|
skipErrors bool
|
||||||
// ui is the user-facing output; never nil (defaults to a discarding writer).
|
// ui is the user-facing output; never nil (defaults to a discarding writer).
|
||||||
ui *ui.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
|
// In-memory cache of known chunk hashes for fast existence checks
|
||||||
knownChunks map[string]struct{}
|
knownChunks map[string]struct{}
|
||||||
@@ -129,15 +136,24 @@ type ScannerConfig struct {
|
|||||||
// SkipErrors skips files that cannot be opened or read (log loudly but
|
// SkipErrors skips files that cannot be opened or read (log loudly but
|
||||||
// continue); packer, database, encryption, and upload errors still abort.
|
// continue); packer, database, encryption, and upload errors still abort.
|
||||||
SkipErrors bool
|
SkipErrors bool
|
||||||
|
// PreviousSnapshotID is the most recent completed snapshot of the
|
||||||
|
// same name, "" for none.
|
||||||
|
PreviousSnapshotID string
|
||||||
|
// 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
|
// ScanResult contains the results of a scan operation. Files and bytes
|
||||||
// are counted per file: BytesScanned is the size of the new and changed
|
// are counted per file: BytesScanned is the size of the new and changed
|
||||||
// files, BytesSkipped that of the unchanged ones. FilesFailed counts the
|
// files, BytesSkipped that of the unchanged ones. FilesFailed counts the
|
||||||
// new and changed files that phase 2 could not store; FilesScanned
|
// new and changed files that phase 2 could not store, each entry removed
|
||||||
// includes them and BytesScanned does not. ChunksCreated counts the
|
// after the walk listed its directory and, under --skip-errors, each
|
||||||
// chunks that were not already stored, and NewChunkSize is their total
|
// entry the walk could not read, such as a directory it could not list;
|
||||||
// size.
|
// FilesScanned includes them and BytesScanned does not. ChunksCreated
|
||||||
|
// counts the chunks that were not already stored, and NewChunkSize is
|
||||||
|
// their total size.
|
||||||
type ScanResult struct {
|
type ScanResult struct {
|
||||||
FilesScanned int
|
FilesScanned int
|
||||||
FilesSkipped int
|
FilesSkipped int
|
||||||
@@ -154,6 +170,17 @@ type ScanResult struct {
|
|||||||
UploadDuration time.Duration
|
UploadDuration time.Duration
|
||||||
StartTime time.Time
|
StartTime time.Time
|
||||||
EndTime time.Time
|
EndTime time.Time
|
||||||
|
|
||||||
|
// FilesUnreadable is the part of FilesFailed that --skip-errors
|
||||||
|
// skipped because it could not be read or listed; the rest are files
|
||||||
|
// removed after the walk listed them.
|
||||||
|
FilesUnreadable int
|
||||||
|
|
||||||
|
// 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
|
// NewScanner creates a new scanner instance
|
||||||
@@ -194,6 +221,11 @@ func NewScanner(cfg ScannerConfig) *Scanner {
|
|||||||
uiw = ui.NewWithColor(io.Discard, false)
|
uiw = ui.NewWithColor(io.Discard, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deviceNumber := cfg.DeviceNumber
|
||||||
|
if deviceNumber == nil {
|
||||||
|
deviceNumber = deviceNumberFromStat
|
||||||
|
}
|
||||||
|
|
||||||
return &Scanner{
|
return &Scanner{
|
||||||
fs: cfg.FS,
|
fs: cfg.FS,
|
||||||
chunker: chunker.NewChunker(cfg.ChunkSize),
|
chunker: chunker.NewChunker(cfg.ChunkSize),
|
||||||
@@ -207,7 +239,9 @@ func NewScanner(cfg ScannerConfig) *Scanner {
|
|||||||
compiledExclude: compiledExclude,
|
compiledExclude: compiledExclude,
|
||||||
progress: progress,
|
progress: progress,
|
||||||
skipErrors: cfg.SkipErrors,
|
skipErrors: cfg.SkipErrors,
|
||||||
|
previousSnapshotID: cfg.PreviousSnapshotID,
|
||||||
ui: uiw,
|
ui: uiw,
|
||||||
|
deviceNumber: deviceNumber,
|
||||||
pendingChunkHashes: make(map[string]struct{}),
|
pendingChunkHashes: make(map[string]struct{}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -258,8 +292,9 @@ func (s *Scanner) Scan(
|
|||||||
|
|
||||||
filesToProcess := scanResult.FilesToProcess
|
filesToProcess := scanResult.FilesToProcess
|
||||||
|
|
||||||
// Phase 1b: Detect deleted files by comparing DB against scanned files
|
// Phase 1b: Detect deleted files by comparing the previous snapshot
|
||||||
err = s.detectDeletedFilesFromMap(ctx, knownFiles, existingFiles, result)
|
// of this name against scanned files
|
||||||
|
err = s.detectDeletedFiles(ctx, path, existingFiles, result)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("detecting deleted files: %w", err)
|
return nil, fmt.Errorf("detecting deleted files: %w", err)
|
||||||
}
|
}
|
||||||
@@ -275,6 +310,11 @@ func (s *Scanner) Scan(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err = s.recordDirectories(ctx, scanResult)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
// Summarize scan phase results and update progress
|
// Summarize scan phase results and update progress
|
||||||
s.summarizeScanPhase(result, filesToProcess)
|
s.summarizeScanPhase(result, filesToProcess)
|
||||||
|
|
||||||
@@ -439,9 +479,8 @@ func (s *Scanner) summarizeScanPhase(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// loadKnownFiles loads the known files at and beneath path from the
|
// loadKnownFiles loads the known files at and beneath path from the
|
||||||
// database into a map for fast lookup. Every loaded file the scan does
|
// database into a map for fast lookup. This avoids per-file database
|
||||||
// not find is counted as deleted. This avoids per-file database queries
|
// queries during the scan phase.
|
||||||
// during the scan phase.
|
|
||||||
func (s *Scanner) loadKnownFiles(
|
func (s *Scanner) loadKnownFiles(
|
||||||
ctx context.Context, path string,
|
ctx context.Context, path string,
|
||||||
) (map[string]*database.File, error) {
|
) (map[string]*database.File, error) {
|
||||||
@@ -847,14 +886,20 @@ func (s *Scanner) batchInsertFileData(
|
|||||||
// ScanPhaseResult contains the results of the scan phase
|
// ScanPhaseResult contains the results of the scan phase
|
||||||
type ScanPhaseResult struct {
|
type ScanPhaseResult struct {
|
||||||
FilesToProcess []*FileToProcess
|
FilesToProcess []*FileToProcess
|
||||||
UnchangedFileIDs []types.FileID // IDs of unchanged files to associate with snapshot
|
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
|
// scanPhase performs the initial directory scan to identify files to process
|
||||||
// It uses the pre-loaded knownFiles map for fast change detection without DB
|
// It uses the pre-loaded knownFiles map for fast change detection without DB
|
||||||
// queries. It also populates existingFiles map for deletion detection.
|
// queries. It also populates existingFiles map for deletion detection.
|
||||||
// Returns files needing processing and IDs of unchanged files for snapshot
|
// Returns files needing processing, IDs of unchanged files for snapshot
|
||||||
// association.
|
// association, and the directories found.
|
||||||
func (s *Scanner) scanPhase(
|
func (s *Scanner) scanPhase(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
path string,
|
path string,
|
||||||
@@ -862,10 +907,12 @@ func (s *Scanner) scanPhase(
|
|||||||
existingFiles map[string]struct{},
|
existingFiles map[string]struct{},
|
||||||
knownFiles map[string]*database.File,
|
knownFiles map[string]*database.File,
|
||||||
) (*ScanPhaseResult, error) {
|
) (*ScanPhaseResult, error) {
|
||||||
// Use known file count as estimate for progress (accurate for subsequent backups)
|
// Use known file count as estimate for progress (accurate for
|
||||||
estimatedTotal := int64(len(knownFiles))
|
// subsequent backups). The walk does not count directories, so the
|
||||||
|
// estimate leaves them out.
|
||||||
|
estimatedTotal := countNonDirectories(knownFiles)
|
||||||
|
|
||||||
collector := &scanCollector{}
|
collector := &scanCollector{dirsWithEntries: make(map[string]struct{})}
|
||||||
|
|
||||||
// Set up periodic status output
|
// Set up periodic status output
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
@@ -874,8 +921,28 @@ func (s *Scanner) scanPhase(
|
|||||||
log.Debug("Starting directory walk", "path", path)
|
log.Debug("Starting directory walk", "path", path)
|
||||||
|
|
||||||
walkFn := func(filePath string, info os.FileInfo, err error) error {
|
walkFn := func(filePath string, info os.FileInfo, err error) error {
|
||||||
|
// Check exclude patterns first, so an excluded entry that is gone
|
||||||
|
// or cannot be looked up is neither counted nor reported. For
|
||||||
|
// directories, skip the entire subtree. info is nil when the
|
||||||
|
// walk could not look the entry up.
|
||||||
|
if s.shouldExclude(filePath, path) {
|
||||||
|
if info != nil && info.IsDir() {
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// The source path is in no directory listing, so its absence is
|
||||||
|
// an error rather than a removal.
|
||||||
|
if errors.Is(err, os.ErrNotExist) && filePath != path {
|
||||||
|
countRemovedDuringWalk(filePath, existingFiles, result)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return s.handleWalkError(filePath, err)
|
return s.handleWalkError(filePath, err, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check context cancellation
|
// Check context cancellation
|
||||||
@@ -885,18 +952,16 @@ func (s *Scanner) scanPhase(
|
|||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check exclude patterns - for directories, skip the entire subtree
|
// The scanned directory itself is not an entry found in it.
|
||||||
if s.shouldExclude(filePath, path) {
|
if filePath != path || !info.IsDir() {
|
||||||
if info.IsDir() {
|
result.EntriesFound++
|
||||||
return filepath.SkipDir
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s.recordIfMountPoint(filePath, path, collector)
|
||||||
|
|
||||||
// Handle symlinks and directories
|
// Handle symlinks and directories
|
||||||
handled, err := s.recordSpecialEntry(
|
handled, err := s.recordSpecialEntry(
|
||||||
filePath, info, existingFiles, collector, result)
|
filePath, info, existingFiles, knownFiles, collector, result)
|
||||||
if handled {
|
if handled {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -906,17 +971,8 @@ func (s *Scanner) scanPhase(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track this file as existing (for deletion detection)
|
filesScanned, changedCount := s.recordRegularFile(
|
||||||
existingFiles[filePath] = struct{}{}
|
filePath, info, existingFiles, knownFiles, collector, result)
|
||||||
|
|
||||||
// Check file against in-memory map (no DB query!)
|
|
||||||
file, needsProcessing := s.checkFileInMemory(filePath, info, knownFiles)
|
|
||||||
|
|
||||||
filesScanned, changedCount := collector.recordRegularFile(
|
|
||||||
filePath, info, file, needsProcessing)
|
|
||||||
|
|
||||||
// Update result stats
|
|
||||||
s.updateScanEntryStats(result, needsProcessing, info)
|
|
||||||
|
|
||||||
// Output periodic status
|
// Output periodic status
|
||||||
if time.Since(lastStatusTime) >= scanStatusInterval {
|
if time.Since(lastStatusTime) >= scanStatusInterval {
|
||||||
@@ -936,27 +992,46 @@ func (s *Scanner) scanPhase(
|
|||||||
return &ScanPhaseResult{
|
return &ScanPhaseResult{
|
||||||
FilesToProcess: collector.filesToProcess,
|
FilesToProcess: collector.filesToProcess,
|
||||||
UnchangedFileIDs: collector.unchangedFileIDs,
|
UnchangedFileIDs: collector.unchangedFileIDs,
|
||||||
|
Directories: collector.directories,
|
||||||
|
MountPoints: collector.mountPoints,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// countNonDirectories returns how many of files are not directories.
|
||||||
|
func countNonDirectories(files map[string]*database.File) int64 {
|
||||||
|
var count int64
|
||||||
|
|
||||||
|
for _, f := range files {
|
||||||
|
if f.Mode&uint32(os.ModeDir) == 0 {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
// recordSpecialEntry records symlinks and directories (which have no
|
// recordSpecialEntry records symlinks and directories (which have no
|
||||||
// data to chunk) and reports whether it handled the entry. For a symlink
|
// data to chunk) and reports whether it handled the entry. A symlink is
|
||||||
// whose target cannot be read it returns handleWalkError's result.
|
// counted and checked for changes as a regular file is; a directory is
|
||||||
|
// set aside to be recorded and is not counted. For a symlink whose
|
||||||
|
// target cannot be read it returns handleWalkError's result.
|
||||||
func (s *Scanner) recordSpecialEntry(
|
func (s *Scanner) recordSpecialEntry(
|
||||||
filePath string, info os.FileInfo,
|
filePath string, info os.FileInfo,
|
||||||
existingFiles map[string]struct{},
|
existingFiles map[string]struct{},
|
||||||
|
knownFiles map[string]*database.File,
|
||||||
collector *scanCollector, result *ScanResult,
|
collector *scanCollector, result *ScanResult,
|
||||||
) (bool, error) {
|
) (bool, error) {
|
||||||
// Handle symlinks
|
// Handle symlinks
|
||||||
if info.Mode()&os.ModeSymlink != 0 {
|
if info.Mode()&os.ModeSymlink != 0 {
|
||||||
file, err := s.buildSymlinkEntry(filePath, info)
|
file, err := s.buildSymlinkEntry(filePath, info)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return true, s.handleWalkError(filePath, err)
|
return true, s.handleWalkError(filePath, err, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
existingFiles[filePath] = struct{}{}
|
existingFiles[filePath] = struct{}{}
|
||||||
collector.addToProcess(filePath, info, file)
|
needsProcessing := s.checkFileInMemory(file, knownFiles)
|
||||||
s.updateScanEntryStats(result, true, info)
|
collector.recordFile(filePath, info, file, needsProcessing)
|
||||||
|
s.updateScanEntryStats(result, needsProcessing, info)
|
||||||
|
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
@@ -966,7 +1041,7 @@ func (s *Scanner) recordSpecialEntry(
|
|||||||
if info.IsDir() {
|
if info.IsDir() {
|
||||||
file := s.buildDirectoryEntry(filePath, info)
|
file := s.buildDirectoryEntry(filePath, info)
|
||||||
existingFiles[filePath] = struct{}{}
|
existingFiles[filePath] = struct{}{}
|
||||||
collector.addToProcess(filePath, info, file)
|
collector.addDirectory(filePath, info, file)
|
||||||
|
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
@@ -974,15 +1049,62 @@ func (s *Scanner) recordSpecialEntry(
|
|||||||
return false, nil
|
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,
|
||||||
|
// with or without --skip-errors.
|
||||||
|
func countRemovedDuringWalk(
|
||||||
|
filePath string, existingFiles map[string]struct{}, result *ScanResult,
|
||||||
|
) {
|
||||||
|
log.Warn("File was deleted during the directory walk, skipping",
|
||||||
|
"path", filePath)
|
||||||
|
|
||||||
|
// The walk found it, so it is not also counted as deleted.
|
||||||
|
existingFiles[filePath] = struct{}{}
|
||||||
|
result.FilesScanned++
|
||||||
|
result.FilesFailed++
|
||||||
|
}
|
||||||
|
|
||||||
// handleWalkError deals with a filesystem error surfaced by the walk:
|
// handleWalkError deals with a filesystem error surfaced by the walk:
|
||||||
// skipped (with a UI notice) under --skip-errors, fatal otherwise.
|
// under --skip-errors the entry is skipped with a UI notice and counted
|
||||||
func (s *Scanner) handleWalkError(filePath string, err error) error {
|
// as examined, failed and unreadable, otherwise the error is fatal.
|
||||||
|
func (s *Scanner) handleWalkError(
|
||||||
|
filePath string, err error, result *ScanResult,
|
||||||
|
) error {
|
||||||
if s.skipErrors {
|
if s.skipErrors {
|
||||||
log.Error("Failed to access file (skipping due to --skip-errors)",
|
log.Error("Failed to access file (skipping due to --skip-errors)",
|
||||||
"path", filePath, "error", err)
|
"path", filePath, "error", err)
|
||||||
s.ui.Errorf("Failed to access %s: %v. Skipping (--skip-errors).",
|
s.ui.Errorf("Failed to access %s: %v. Skipping (--skip-errors).",
|
||||||
s.ui.Path(filePath), err)
|
s.ui.Path(filePath), err)
|
||||||
|
|
||||||
|
result.FilesScanned++
|
||||||
|
result.FilesFailed++
|
||||||
|
result.FilesUnreadable++
|
||||||
|
|
||||||
return nil // Continue scanning
|
return nil // Continue scanning
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -996,28 +1118,52 @@ type scanCollector struct {
|
|||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
filesToProcess []*FileToProcess
|
filesToProcess []*FileToProcess
|
||||||
unchangedFileIDs []types.FileID // Just IDs - no new records needed
|
unchangedFileIDs []types.FileID // Just IDs - no new records needed
|
||||||
|
directories []*FileToProcess
|
||||||
filesScanned int64
|
filesScanned int64
|
||||||
|
// dirsWithEntries holds each directory the walk has found an entry in.
|
||||||
|
dirsWithEntries map[string]struct{}
|
||||||
|
mountPoints []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// addToProcess queues a file entry for processing.
|
// firstEntryIn notes that the walk found an entry in directory dir and
|
||||||
func (c *scanCollector) addToProcess(
|
// 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
|
||||||
|
// as a file.
|
||||||
|
func (c *scanCollector) addDirectory(
|
||||||
path string, info os.FileInfo, file *database.File,
|
path string, info os.FileInfo, file *database.File,
|
||||||
) {
|
) {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
c.filesToProcess = append(c.filesToProcess, &FileToProcess{
|
c.directories = append(c.directories, &FileToProcess{
|
||||||
Path: path,
|
Path: path,
|
||||||
FileInfo: info,
|
FileInfo: info,
|
||||||
File: file,
|
File: file,
|
||||||
})
|
})
|
||||||
c.filesScanned++
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// recordRegularFile records a regular file as changed (to process) or
|
// recordFile records a regular file or symlink as changed (to process)
|
||||||
// unchanged (snapshot association only) and returns the running scanned
|
// or unchanged (snapshot association only) and returns the running
|
||||||
// and changed counts.
|
// scanned and changed counts.
|
||||||
func (c *scanCollector) recordRegularFile(
|
func (c *scanCollector) recordFile(
|
||||||
path string, info os.FileInfo, file *database.File, needsProcessing bool,
|
path string, info os.FileInfo, file *database.File, needsProcessing bool,
|
||||||
) (int64, int) {
|
) (int64, int) {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
@@ -1120,7 +1266,28 @@ func (s *Scanner) printScanProgressLine(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildSymlinkEntry creates a File record for a symlink.
|
// buildRegularFileEntry creates a File record for a regular file, with
|
||||||
|
// no ID: checkFileInMemory sets it.
|
||||||
|
func (s *Scanner) buildRegularFileEntry(path string, info os.FileInfo) *database.File {
|
||||||
|
var uid, gid uint32
|
||||||
|
if stat, ok := info.Sys().(*syscall.Stat_t); ok {
|
||||||
|
uid = stat.Uid
|
||||||
|
gid = stat.Gid
|
||||||
|
}
|
||||||
|
|
||||||
|
return &database.File{
|
||||||
|
Path: types.FilePath(path),
|
||||||
|
SourcePath: types.SourcePath(s.currentSourcePath),
|
||||||
|
MTime: info.ModTime(),
|
||||||
|
Size: info.Size(),
|
||||||
|
Mode: uint32(info.Mode()),
|
||||||
|
UID: uid,
|
||||||
|
GID: gid,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildSymlinkEntry creates a File record for a symlink, with no ID:
|
||||||
|
// checkFileInMemory sets it.
|
||||||
func (s *Scanner) buildSymlinkEntry(
|
func (s *Scanner) buildSymlinkEntry(
|
||||||
path string, info os.FileInfo,
|
path string, info os.FileInfo,
|
||||||
) (*database.File, error) {
|
) (*database.File, error) {
|
||||||
@@ -1136,7 +1303,6 @@ func (s *Scanner) buildSymlinkEntry(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &database.File{
|
return &database.File{
|
||||||
ID: types.NewFileID(),
|
|
||||||
Path: types.FilePath(path),
|
Path: types.FilePath(path),
|
||||||
SourcePath: types.SourcePath(s.currentSourcePath),
|
SourcePath: types.SourcePath(s.currentSourcePath),
|
||||||
MTime: info.ModTime(),
|
MTime: info.ModTime(),
|
||||||
@@ -1168,6 +1334,102 @@ func (s *Scanner) buildDirectoryEntry(path string, info os.FileInfo) *database.F
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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, scanned *ScanPhaseResult,
|
||||||
|
) error {
|
||||||
|
for _, dir := range scanned.Directories {
|
||||||
|
err := s.recordNonRegularFile(ctx, dir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("recording directory %s: %w", dir.Path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
// recordNonRegularFile writes a symlink or directory entry to the database
|
||||||
// and associates it with the current snapshot. No chunking is performed.
|
// and associates it with the current snapshot. No chunking is performed.
|
||||||
func (s *Scanner) recordNonRegularFile(ctx context.Context, ftp *FileToProcess) error {
|
func (s *Scanner) recordNonRegularFile(ctx context.Context, ftp *FileToProcess) error {
|
||||||
@@ -1181,62 +1443,36 @@ func (s *Scanner) recordNonRegularFile(ctx context.Context, ftp *FileToProcess)
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkFileInMemory checks if a file needs processing using the in-memory map
|
// checkFileInMemory reports whether a regular file or symlink needs
|
||||||
|
// processing, and gives file the ID of its known record, or a new ID for
|
||||||
|
// a new path.
|
||||||
// No database access is performed - this is purely CPU/memory work
|
// No database access is performed - this is purely CPU/memory work
|
||||||
func (s *Scanner) checkFileInMemory(
|
func (s *Scanner) checkFileInMemory(
|
||||||
path string, info os.FileInfo, knownFiles map[string]*database.File,
|
file *database.File, knownFiles map[string]*database.File,
|
||||||
) (*database.File, bool) {
|
) bool {
|
||||||
var uid, gid uint32
|
existingFile, exists := knownFiles[file.Path.String()]
|
||||||
if stat, ok := info.Sys().(*syscall.Stat_t); ok {
|
|
||||||
uid = stat.Uid
|
|
||||||
gid = stat.Gid
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check against in-memory map first to get existing ID if available
|
|
||||||
existingFile, exists := knownFiles[path]
|
|
||||||
|
|
||||||
// Create file record with ID set upfront
|
|
||||||
// For new files, generate UUID immediately so it's available for chunk associations
|
|
||||||
// For existing files, reuse the existing ID
|
|
||||||
var fileID types.FileID
|
|
||||||
if exists {
|
|
||||||
fileID = existingFile.ID
|
|
||||||
} else {
|
|
||||||
fileID = types.NewFileID()
|
|
||||||
}
|
|
||||||
|
|
||||||
file := &database.File{
|
|
||||||
ID: fileID,
|
|
||||||
Path: types.FilePath(path),
|
|
||||||
SourcePath: types.SourcePath(s.currentSourcePath),
|
|
||||||
MTime: info.ModTime(),
|
|
||||||
Size: info.Size(),
|
|
||||||
Mode: uint32(info.Mode()),
|
|
||||||
UID: uid,
|
|
||||||
GID: gid,
|
|
||||||
}
|
|
||||||
|
|
||||||
// New file - needs processing
|
// New file - needs processing
|
||||||
if !exists {
|
if !exists {
|
||||||
return file, true
|
file.ID = types.NewFileID()
|
||||||
|
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
file.ID = existingFile.ID
|
||||||
|
|
||||||
// No uploaded blob holds one of its chunks (see loadFilesToRechunk)
|
// No uploaded blob holds one of its chunks (see loadFilesToRechunk)
|
||||||
if _, rechunk := s.filesToRechunk[fileID]; rechunk {
|
if _, rechunk := s.filesToRechunk[file.ID]; rechunk {
|
||||||
return file, true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if file has changed
|
// Check if file has changed
|
||||||
if existingFile.Size != file.Size ||
|
return existingFile.Size != file.Size ||
|
||||||
!existingFile.MTime.Equal(file.MTime) ||
|
!existingFile.MTime.Equal(file.MTime) ||
|
||||||
existingFile.Mode != file.Mode ||
|
existingFile.Mode != file.Mode ||
|
||||||
existingFile.UID != file.UID ||
|
existingFile.UID != file.UID ||
|
||||||
existingFile.GID != file.GID {
|
existingFile.GID != file.GID ||
|
||||||
return file, true
|
existingFile.LinkTarget != file.LinkTarget
|
||||||
}
|
|
||||||
|
|
||||||
// File unchanged
|
|
||||||
return file, false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// batchAddFilesToSnapshot adds existing file IDs to the snapshot association table
|
// batchAddFilesToSnapshot adds existing file IDs to the snapshot association table
|
||||||
@@ -1363,17 +1599,16 @@ func (s *Scanner) processPhase(
|
|||||||
return s.finalizeProcessPhase(ctx, result)
|
return s.finalizeProcessPhase(ctx, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// processFileWithErrorHandling records a directory or symlink, or wraps
|
// processFileWithErrorHandling records a symlink, or wraps
|
||||||
// processFileStreaming for a regular file with error recovery for deleted
|
// processFileStreaming for a regular file with error recovery for deleted
|
||||||
// files and skip-errors mode. Returns (skipped, error).
|
// files and skip-errors mode. Returns (skipped, error).
|
||||||
func (s *Scanner) processFileWithErrorHandling(
|
func (s *Scanner) processFileWithErrorHandling(
|
||||||
ctx context.Context, fileToProcess *FileToProcess, result *ScanResult,
|
ctx context.Context, fileToProcess *FileToProcess, result *ScanResult,
|
||||||
) (bool, error) {
|
) (bool, error) {
|
||||||
// A directory or symlink has no data to open or read; it is only
|
// A symlink has no data to open or read; it is only recorded in the
|
||||||
// recorded in the local index. An error recording it stops the run
|
// local index. An error recording it stops the run even under
|
||||||
// even under --skip-errors, or the snapshot would complete without it.
|
// --skip-errors, or the snapshot would complete without it.
|
||||||
mode := os.FileMode(fileToProcess.File.Mode)
|
if os.FileMode(fileToProcess.File.Mode)&os.ModeSymlink != 0 {
|
||||||
if mode&os.ModeSymlink != 0 || mode.IsDir() {
|
|
||||||
err := s.recordNonRegularFile(ctx, fileToProcess)
|
err := s.recordNonRegularFile(ctx, fileToProcess)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("processing file %s: %w", fileToProcess.Path, err)
|
return false, fmt.Errorf("processing file %s: %w", fileToProcess.Path, err)
|
||||||
@@ -1414,6 +1649,7 @@ func (s *Scanner) processFileWithErrorHandling(
|
|||||||
s.ui.Path(fileToProcess.Path), err)
|
s.ui.Path(fileToProcess.Path), err)
|
||||||
|
|
||||||
countFailedFile(fileToProcess, result)
|
countFailedFile(fileToProcess, result)
|
||||||
|
result.FilesUnreadable++
|
||||||
|
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
@@ -1954,21 +2190,29 @@ func (s *Scanner) queueFileForBatchInsert(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// detectDeletedFilesFromMap finds files that existed in previous snapshots
|
// detectDeletedFiles counts as deleted each file at or beneath path that
|
||||||
// but no longer exist. Uses pre-loaded maps to avoid any filesystem or
|
// the previous snapshot of this name lists, the walk did not find, and
|
||||||
// database access.
|
// is gone from disk. A file that is excluded, or inside an excluded
|
||||||
func (s *Scanner) detectDeletedFilesFromMap(
|
// directory, is not counted: the walk skips it whether or not it is
|
||||||
|
// still on disk.
|
||||||
|
func (s *Scanner) detectDeletedFiles(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
knownFiles map[string]*database.File,
|
path string,
|
||||||
existingFiles map[string]struct{},
|
existingFiles map[string]struct{},
|
||||||
result *ScanResult,
|
result *ScanResult,
|
||||||
) error {
|
) error {
|
||||||
if len(knownFiles) == 0 {
|
if s.previousSnapshotID == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check each known file against the enumerated set (no filesystem access needed)
|
previousFiles, err := s.repos.Files.ListUnderPathInSnapshot(
|
||||||
for path, file := range knownFiles {
|
ctx, path, s.previousSnapshotID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("listing files of snapshot %s: %w",
|
||||||
|
s.previousSnapshotID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, file := range previousFiles {
|
||||||
// Check context cancellation periodically
|
// Check context cancellation periodically
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
@@ -1976,13 +2220,22 @@ func (s *Scanner) detectDeletedFilesFromMap(
|
|||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the file exists in our enumerated set
|
filePath := file.Path.String()
|
||||||
if _, exists := existingFiles[path]; !exists {
|
|
||||||
// File has been deleted
|
_, exists := existingFiles[filePath]
|
||||||
result.FilesDeleted++
|
if exists || s.excludedOrInExcludedDir(filePath, path) {
|
||||||
result.BytesDeleted += file.Size
|
continue
|
||||||
log.Debug("Detected deleted file", "path", path, "size", file.Size)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Under --skip-errors the walk also misses what it cannot reach,
|
||||||
|
// such as the files in a directory it cannot open.
|
||||||
|
if !s.goneFromDisk(filePath) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
result.FilesDeleted++
|
||||||
|
result.BytesDeleted += file.Size
|
||||||
|
log.Debug("Detected deleted file", "path", filePath, "size", file.Size)
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.FilesDeleted > 0 {
|
if result.FilesDeleted > 0 {
|
||||||
@@ -1993,6 +2246,36 @@ func (s *Scanner) detectDeletedFilesFromMap(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// excludedOrInExcludedDir reports whether filePath, which is at or
|
||||||
|
// beneath rootPath, or any directory between rootPath and it, matches an
|
||||||
|
// exclude pattern. A pattern such as */.local/share/Trash matches the
|
||||||
|
// directory but not the files inside it, which the walk skips too.
|
||||||
|
func (s *Scanner) excludedOrInExcludedDir(filePath, rootPath string) bool {
|
||||||
|
for p := filePath; len(p) > len(rootPath); p = filepath.Dir(p) {
|
||||||
|
if s.shouldExclude(p, rootPath) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// goneFromDisk reports whether looking filePath up on disk finds that
|
||||||
|
// it does not exist. Like the walk, it does not follow a symlink, so a
|
||||||
|
// symlink whose target is gone is still on disk. A file whose directory
|
||||||
|
// was replaced by a regular file is gone too, though the lookup reports
|
||||||
|
// "not a directory".
|
||||||
|
func (s *Scanner) goneFromDisk(filePath string) bool {
|
||||||
|
var err error
|
||||||
|
if lstater, ok := s.fs.(afero.Lstater); ok {
|
||||||
|
_, _, err = lstater.LstatIfPossible(filePath)
|
||||||
|
} else {
|
||||||
|
_, err = s.fs.Stat(filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.Is(err, os.ErrNotExist) || errors.Is(err, syscall.ENOTDIR)
|
||||||
|
}
|
||||||
|
|
||||||
// wrapPermissionError augments permission errors with platform-specific
|
// wrapPermissionError augments permission errors with platform-specific
|
||||||
// remediation instructions. On macOS, TCC-protected directories (Calendars,
|
// remediation instructions. On macOS, TCC-protected directories (Calendars,
|
||||||
// Reminders, Photos, etc.) return EPERM unless the running application has
|
// Reminders, Photos, etc.) return EPERM unless the running application has
|
||||||
@@ -2035,33 +2318,60 @@ func compileExcludePatterns(patterns []string) []compiledPattern {
|
|||||||
pattern = strings.TrimSuffix(pattern, "/")
|
pattern = strings.TrimSuffix(pattern, "/")
|
||||||
|
|
||||||
// A leading "**/" demands a "/" before the name and so misses an
|
// A leading "**/" demands a "/" before the name and so misses an
|
||||||
// entry directly under the source path. An unanchored pattern
|
// entry directly under the source path. It means "at every depth",
|
||||||
// already matches at every depth, so drop the prefix: as in
|
// which is what an unanchored pattern already does, so drop the
|
||||||
// gitignore, "**/x" then also matches a top-level "x".
|
// prefix and match unanchored: as in gitignore, "**/x" and
|
||||||
if !anchored {
|
// "/**/x" then also match a top-level "x".
|
||||||
pattern = strings.TrimPrefix(pattern, "**/")
|
if rest, found := strings.CutPrefix(pattern, "**/"); found {
|
||||||
|
pattern = rest
|
||||||
|
anchored = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compile the glob pattern
|
// Likewise "/**/" inside a pattern demands two slashes, so
|
||||||
// For patterns without path separators, we need to match them as components
|
// "a/**/*.log" misses "a/x.log". As in gitignore it stands for
|
||||||
// e.g., ".git" should match ".git" anywhere in the path
|
// zero or more directories, so also compile the pattern with it
|
||||||
g, err := glob.Compile(pattern, '/')
|
// made a single "/". The glob library's {a,b} is no help: with a
|
||||||
if err != nil {
|
// "**" inside it in mid-pattern, it misses paths it should match.
|
||||||
log.Warn("Invalid exclude pattern, skipping", "pattern", p, "error", err)
|
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)
|
||||||
|
|
||||||
continue
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
compiled = append(compiled, compiledPattern{
|
||||||
|
pattern: g,
|
||||||
|
anchored: anchored,
|
||||||
|
original: p,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
compiled = append(compiled, compiledPattern{
|
|
||||||
pattern: g,
|
|
||||||
anchored: anchored,
|
|
||||||
original: p,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return compiled
|
return compiled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// doubleStarVariants returns pattern once for every way of keeping each
|
||||||
|
// "/**/" in it or making it a single "/".
|
||||||
|
func doubleStarVariants(pattern string) []string {
|
||||||
|
const doubleStar = "/**/"
|
||||||
|
|
||||||
|
before, after, found := strings.Cut(pattern, doubleStar)
|
||||||
|
if !found {
|
||||||
|
return []string{pattern}
|
||||||
|
}
|
||||||
|
|
||||||
|
var variants []string
|
||||||
|
|
||||||
|
for _, rest := range doubleStarVariants(after) {
|
||||||
|
variants = append(variants, before+doubleStar+rest, before+"/"+rest)
|
||||||
|
}
|
||||||
|
|
||||||
|
return variants
|
||||||
|
}
|
||||||
|
|
||||||
// shouldExclude checks if a path should be excluded based on exclude patterns
|
// shouldExclude checks if a path should be excluded based on exclude patterns
|
||||||
// filePath is the full path to the file
|
// filePath is the full path to the file
|
||||||
// rootPath is the root of the backup source directory
|
// rootPath is the root of the backup source directory
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
//nolint:testpackage // exercises the unexported countNonDirectories helper
|
||||||
|
package snapshot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/vaultik/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The walk counts regular files and symlinks, not directories, so the
|
||||||
|
// estimate of its count made from the local index's records leaves the
|
||||||
|
// directories out. See https://git.eeqj.de/sneak/vaultik/issues/305.
|
||||||
|
func TestCountNonDirectories(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
known := map[string]*database.File{
|
||||||
|
"/src": {Mode: uint32(os.ModeDir | 0o755)},
|
||||||
|
"/src/sub": {Mode: uint32(os.ModeDir | 0o755)},
|
||||||
|
"/src/a.txt": {Mode: 0o644},
|
||||||
|
"/src/link": {Mode: uint32(os.ModeSymlink | 0o777), LinkTarget: "a.txt"},
|
||||||
|
}
|
||||||
|
|
||||||
|
got := countNonDirectories(known)
|
||||||
|
if got != 2 {
|
||||||
|
t.Errorf("countNonDirectories = %d, want 2", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -271,16 +271,18 @@ func (sm *SnapshotManager) CompleteSnapshot(
|
|||||||
return sm.MarkSnapshotComplete(ctx, snapshotID)
|
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:
|
// This method executes the complete snapshot metadata export process:
|
||||||
// 1. Creates a temporary directory for working files
|
// 1. Creates a temporary directory for working files
|
||||||
// 2. Copies the main database to preserve its state
|
// 2. Copies the main database to preserve its state
|
||||||
// 3. Cleans the copy to contain only current snapshot data
|
// 3. Cleans the copy to contain only current snapshot data
|
||||||
// 4. Dumps the cleaned database to SQL
|
// 4. Vacuums the copy to remove the deleted data
|
||||||
// 5. Compresses the SQL dump with zstd
|
// 5. Compresses the copy with zstd
|
||||||
// 6. Encrypts the compressed data with age (always; recipients are required)
|
// 6. Encrypts the compressed copy with age (always; recipients are required)
|
||||||
// 7. Uploads to S3 at: snapshots/{snapshot-id}.sql.zst[.age]
|
// 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
|
// The only caller (finalizeSnapshotMetadata) does not close the main database
|
||||||
// before calling this method: the index is copied at dbPath while it is still
|
// 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:
|
// The cleanup is performed in a specific order to maintain referential integrity:
|
||||||
// 1. Delete other snapshots
|
// 1. Delete other snapshots
|
||||||
// 2. Delete orphaned snapshot associations (snapshot_files, snapshot_blobs)
|
// 2. Delete orphaned snapshot associations (snapshot_files, snapshot_blobs,
|
||||||
// for deleted snapshots
|
// snapshot_mount_points) for deleted snapshots
|
||||||
// 3. Delete orphaned files (not in the current snapshot)
|
// 3. Delete orphaned files (not in the current snapshot)
|
||||||
// 4. Delete orphaned chunk-to-file mappings (references to deleted files)
|
// 4. Delete orphaned chunk-to-file mappings (references to deleted files)
|
||||||
// 5. Delete orphaned blobs (not in the current snapshot)
|
// 5. Delete orphaned blobs (not in the current snapshot)
|
||||||
@@ -948,8 +950,8 @@ func (sm *SnapshotManager) deleteOtherSnapshots(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// deleteOrphanedSnapshotAssociations deletes snapshot_files and
|
// deleteOrphanedSnapshotAssociations deletes snapshot_files,
|
||||||
// snapshot_blobs for deleted snapshots
|
// snapshot_blobs and snapshot_mount_points for deleted snapshots
|
||||||
func (sm *SnapshotManager) deleteOrphanedSnapshotAssociations(
|
func (sm *SnapshotManager) deleteOrphanedSnapshotAssociations(
|
||||||
ctx context.Context, tx *sql.Tx, currentSnapshotID string,
|
ctx context.Context, tx *sql.Tx, currentSnapshotID string,
|
||||||
) error {
|
) error {
|
||||||
@@ -983,6 +985,19 @@ func (sm *SnapshotManager) deleteOrphanedSnapshotAssociations(
|
|||||||
log.Debug("[Temp DB Cleanup] Deleted snapshot_blobs associations",
|
log.Debug("[Temp DB Cleanup] Deleted snapshot_blobs associations",
|
||||||
"count", rowsAffected)
|
"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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,12 +7,14 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"io"
|
"io"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"sneak.berlin/go/vaultik/internal/config"
|
"sneak.berlin/go/vaultik/internal/config"
|
||||||
"sneak.berlin/go/vaultik/internal/database"
|
"sneak.berlin/go/vaultik/internal/database"
|
||||||
"sneak.berlin/go/vaultik/internal/log"
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
|
"sneak.berlin/go/vaultik/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
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,
|
// Two creates of one snapshot name back to back start within one second,
|
||||||
// the resolution of the timestamp in a snapshot ID. See
|
// the resolution of the timestamp in a snapshot ID. See
|
||||||
// https://git.eeqj.de/sneak/vaultik/issues/270.
|
// https://git.eeqj.de/sneak/vaultik/issues/270.
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"reflect"
|
"reflect"
|
||||||
"sort"
|
"sort"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/vaultik/internal/storage"
|
"sneak.berlin/go/vaultik/internal/storage"
|
||||||
)
|
)
|
||||||
@@ -19,15 +20,17 @@ import (
|
|||||||
// constructor, so the contract is defined once.
|
// constructor, so the contract is defined once.
|
||||||
//
|
//
|
||||||
// It exercises the public Storer interface: round-trip, stat, list with
|
// It exercises the public Storer interface: round-trip, stat, list with
|
||||||
// prefix filtering, overwrite, delete, delete-of-missing, and not-found on
|
// prefix filtering, the modification time ListStream reports, overwrite,
|
||||||
// Get and Stat. Each section takes its own fresh backend instance, so the
|
// delete, delete-of-missing, and not-found on Get and Stat. Each section
|
||||||
// order of sections never matters and no section sees another's objects.
|
// takes its own fresh backend instance, so the order of sections never
|
||||||
|
// matters and no section sees another's objects.
|
||||||
func runStorerConformance(t *testing.T, newStorer func(*testing.T) storage.Storer) {
|
func runStorerConformance(t *testing.T, newStorer func(*testing.T) storage.Storer) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
conformanceRoundTrip(t, newStorer(t))
|
conformanceRoundTrip(t, newStorer(t))
|
||||||
conformanceOverwrite(t, newStorer(t))
|
conformanceOverwrite(t, newStorer(t))
|
||||||
conformanceList(t, newStorer(t))
|
conformanceList(t, newStorer(t))
|
||||||
|
conformanceListStreamModTime(t, newStorer(t))
|
||||||
conformanceDelete(t, newStorer(t))
|
conformanceDelete(t, newStorer(t))
|
||||||
conformanceNotFound(t, newStorer(t))
|
conformanceNotFound(t, newStorer(t))
|
||||||
}
|
}
|
||||||
@@ -118,6 +121,35 @@ func conformanceList(t *testing.T, s storage.Storer) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// conformanceListStreamModTime checks that ListStream gives an object
|
||||||
|
// just written a modification time of about now, which prune compares
|
||||||
|
// with its grace period.
|
||||||
|
func conformanceListStreamModTime(t *testing.T, s storage.Storer) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
err := s.Put(ctx, "blobs/dd/written-now", bytes.NewReader([]byte("data")))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Put: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var listed []storage.ObjectInfo
|
||||||
|
|
||||||
|
for info := range s.ListStream(ctx, "blobs/") {
|
||||||
|
listed = append(listed, info)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(listed) != 1 || listed[0].Err != nil {
|
||||||
|
t.Fatalf("ListStream(\"blobs/\") = %v, want the one object", listed)
|
||||||
|
}
|
||||||
|
|
||||||
|
if time.Since(listed[0].ModTime).Abs() > time.Minute {
|
||||||
|
t.Errorf("ListStream ModTime = %v, want within a minute of now",
|
||||||
|
listed[0].ModTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// conformanceDelete checks that Delete removes an object and that deleting
|
// conformanceDelete checks that Delete removes an object and that deleting
|
||||||
// a missing key is not an error.
|
// a missing key is not an error.
|
||||||
func conformanceDelete(t *testing.T, s storage.Storer) {
|
func conformanceDelete(t *testing.T, s storage.Storer) {
|
||||||
|
|||||||
@@ -2,15 +2,17 @@
|
|||||||
// faults on demand, so tests can reproduce the failure modes a real
|
// faults on demand, so tests can reproduce the failure modes a real
|
||||||
// backend exhibits: an upload that fails partway, a backend that reports
|
// backend exhibits: an upload that fails partway, a backend that reports
|
||||||
// success while storing nothing, reads that return corrupt or truncated
|
// success while storing nothing, reads that return corrupt or truncated
|
||||||
// bytes, and a delete the backend refuses. It is the seam called for by
|
// bytes, and a delete or a stat the backend refuses. It is the seam
|
||||||
// the fault-injection tests (sneak/vaultik issue 72) and is meant to be
|
// called for by the fault-injection tests (sneak/vaultik issue 72) and
|
||||||
// reused by future tests rather than re-implemented per case.
|
// 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
|
// change that: OnPut decides the fate of each write, OnGet decides how
|
||||||
// each read's bytes are returned, and OnDelete decides whether each
|
// each read's bytes are returned, OnDelete decides whether each delete
|
||||||
// delete fails. All are keyed by the object key, so a test can fault
|
// fails, and OnStat whether each stat fails. All are keyed by the object
|
||||||
// only blobs, only metadata, or a single object.
|
// key, so a test can fault only blobs, only metadata, or a single
|
||||||
|
// object.
|
||||||
package faultstore
|
package faultstore
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -30,6 +32,11 @@ var ErrInjectedUpload = errors.New("faultstore: injected upload failure")
|
|||||||
// fail.
|
// fail.
|
||||||
var ErrInjectedDelete = errors.New("faultstore: injected delete failure")
|
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.
|
// PutAction is the disposition OnPut assigns to a write.
|
||||||
type PutAction int
|
type PutAction int
|
||||||
|
|
||||||
@@ -76,6 +83,10 @@ type Storer struct {
|
|||||||
// object key. Returning true fails the delete and leaves the object
|
// object key. Returning true fails the delete and leaves the object
|
||||||
// in place.
|
// in place.
|
||||||
OnDelete func(key string) bool
|
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.
|
// New wraps inner. inner must be non-nil.
|
||||||
@@ -159,8 +170,12 @@ func damage(fault GetFault, data []byte) []byte {
|
|||||||
return out
|
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) {
|
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)
|
return f.inner.Stat(ctx, key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+86
-19
@@ -21,9 +21,13 @@ type FileStorer struct {
|
|||||||
// NewFileStorer creates a new filesystem storage backend.
|
// NewFileStorer creates a new filesystem storage backend.
|
||||||
//
|
//
|
||||||
// Construction is intentionally cheap and does not touch the filesystem.
|
// Construction is intentionally cheap and does not touch the filesystem.
|
||||||
// The basePath is recorded; the directory is created lazily on first
|
// The backend never creates basePath: the user does, so that when
|
||||||
// write. Write operations (Put/PutWithProgress) call MkdirAll for the
|
// basePath is a directory on an unplugged volume, a write fails instead
|
||||||
// per-blob parent directory, which also covers basePath on first use.
|
// of landing on the disk under the mount point. This does not hold when
|
||||||
|
// basePath is the mount point itself, which stays behind when its volume
|
||||||
|
// is unplugged.
|
||||||
|
// Put and PutWithProgress fail on a missing basePath and create the
|
||||||
|
// directories below it as needed.
|
||||||
// Get and Stat report a key under a missing basePath as ErrNotFound.
|
// Get and Stat report a key under a missing basePath as ErrNotFound.
|
||||||
// List and ListStream fail on a missing basePath, because listing it as
|
// List and ListStream fail on a missing basePath, because listing it as
|
||||||
// an empty store would make `prune` drop every local snapshot record.
|
// an empty store would make `prune` drop every local snapshot record.
|
||||||
@@ -120,9 +124,10 @@ func (f *FileStorer) Delete(_ context.Context, key string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// List returns all keys with the given prefix. It fails when the
|
// List returns all keys with the given prefix, reading through symlinked
|
||||||
// destination directory is missing; a missing prefix under it is an
|
// directories. It fails when the destination directory is missing, or
|
||||||
// empty listing.
|
// when the prefix or a directory above it is a symlink whose target is
|
||||||
|
// missing; a prefix that is truly missing is an empty listing.
|
||||||
func (f *FileStorer) List(ctx context.Context, prefix string) ([]string, error) {
|
func (f *FileStorer) List(ctx context.Context, prefix string) ([]string, error) {
|
||||||
var keys []string
|
var keys []string
|
||||||
|
|
||||||
@@ -133,8 +138,7 @@ func (f *FileStorer) List(ctx context.Context, prefix string) ([]string, error)
|
|||||||
|
|
||||||
basePath := f.fullPath(prefix)
|
basePath := f.fullPath(prefix)
|
||||||
|
|
||||||
// Check if the prefix exists
|
exists, err := f.prefixExists(prefix)
|
||||||
exists, err := afero.Exists(f.fs, basePath)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("checking path: %w", err)
|
return nil, fmt.Errorf("checking path: %w", err)
|
||||||
}
|
}
|
||||||
@@ -143,7 +147,7 @@ func (f *FileStorer) List(ctx context.Context, prefix string) ([]string, error)
|
|||||||
return keys, nil // Empty list for non-existent prefix
|
return keys, nil // Empty list for non-existent prefix
|
||||||
}
|
}
|
||||||
|
|
||||||
err = afero.Walk(f.fs, basePath, func(path string, info os.FileInfo, err error) error {
|
err = f.walk(basePath, func(path string, info os.FileInfo, err error) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -176,7 +180,9 @@ func (f *FileStorer) List(ctx context.Context, prefix string) ([]string, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ListStream returns a channel of ObjectInfo for large result sets. Like
|
// ListStream returns a channel of ObjectInfo for large result sets. Like
|
||||||
// List, it sends an error when the destination directory is missing.
|
// List, it reads through symlinked directories and sends an error when
|
||||||
|
// the destination directory is missing, or when the prefix or a directory
|
||||||
|
// above it is a symlink whose target is missing.
|
||||||
func (f *FileStorer) ListStream(ctx context.Context, prefix string) <-chan ObjectInfo {
|
func (f *FileStorer) ListStream(ctx context.Context, prefix string) <-chan ObjectInfo {
|
||||||
ch := make(chan ObjectInfo)
|
ch := make(chan ObjectInfo)
|
||||||
|
|
||||||
@@ -192,8 +198,7 @@ func (f *FileStorer) ListStream(ctx context.Context, prefix string) <-chan Objec
|
|||||||
|
|
||||||
basePath := f.fullPath(prefix)
|
basePath := f.fullPath(prefix)
|
||||||
|
|
||||||
// Check if the prefix exists
|
exists, err := f.prefixExists(prefix)
|
||||||
exists, err := afero.Exists(f.fs, basePath)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ch <- ObjectInfo{Err: fmt.Errorf("checking path: %w", err)}
|
ch <- ObjectInfo{Err: fmt.Errorf("checking path: %w", err)}
|
||||||
|
|
||||||
@@ -204,7 +209,7 @@ func (f *FileStorer) ListStream(ctx context.Context, prefix string) <-chan Objec
|
|||||||
return // Empty channel for non-existent prefix
|
return // Empty channel for non-existent prefix
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = afero.Walk(f.fs, basePath, func(path string, info os.FileInfo, err error) error {
|
_ = f.walk(basePath, func(path string, info os.FileInfo, err error) error {
|
||||||
// Check context cancellation
|
// Check context cancellation
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
@@ -230,8 +235,9 @@ func (f *FileStorer) ListStream(ctx context.Context, prefix string) <-chan Objec
|
|||||||
// Normalize path separators
|
// Normalize path separators
|
||||||
relPath = strings.ReplaceAll(relPath, string(filepath.Separator), "/")
|
relPath = strings.ReplaceAll(relPath, string(filepath.Separator), "/")
|
||||||
ch <- ObjectInfo{
|
ch <- ObjectInfo{
|
||||||
Key: relPath,
|
Key: relPath,
|
||||||
Size: info.Size(),
|
Size: info.Size(),
|
||||||
|
ModTime: info.ModTime(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,11 +249,12 @@ func (f *FileStorer) ListStream(ctx context.Context, prefix string) <-chan Objec
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DeletePartialUploads removes every file under prefix whose name ends in
|
// DeletePartialUploads removes every file under prefix whose name ends in
|
||||||
// tempSuffix. A missing prefix has none to remove.
|
// tempSuffix. Like List, it reads through symlinked directories and fails
|
||||||
|
// on one whose target is missing. A missing prefix has none to remove.
|
||||||
func (f *FileStorer) DeletePartialUploads(ctx context.Context, prefix string) error {
|
func (f *FileStorer) DeletePartialUploads(ctx context.Context, prefix string) error {
|
||||||
basePath := f.fullPath(prefix)
|
basePath := f.fullPath(prefix)
|
||||||
|
|
||||||
exists, err := afero.Exists(f.fs, basePath)
|
exists, err := f.prefixExists(prefix)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("checking path: %w", err)
|
return fmt.Errorf("checking path: %w", err)
|
||||||
}
|
}
|
||||||
@@ -256,7 +263,7 @@ func (f *FileStorer) DeletePartialUploads(ctx context.Context, prefix string) er
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
err = afero.Walk(f.fs, basePath, func(path string, info os.FileInfo, err error) error {
|
err = f.walk(basePath, func(path string, info os.FileInfo, err error) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -297,7 +304,19 @@ func (f *FileStorer) writeAtomic(
|
|||||||
path := f.fullPath(key)
|
path := f.fullPath(key)
|
||||||
dir := filepath.Dir(path)
|
dir := filepath.Dir(path)
|
||||||
|
|
||||||
err := f.fs.MkdirAll(dir, storageDirPerm)
|
// Checked on every write, so a destination that disappears part-way
|
||||||
|
// through a backup fails it rather than being recreated by MkdirAll.
|
||||||
|
_, err := f.fs.Stat(f.basePath)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("destination directory does not exist; "+
|
||||||
|
"create it, or mount the volume it is on: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("checking destination directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = f.fs.MkdirAll(dir, storageDirPerm)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("creating directories: %w", err)
|
return fmt.Errorf("creating directories: %w", err)
|
||||||
}
|
}
|
||||||
@@ -352,6 +371,54 @@ func (f *FileStorer) writeAtomic(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// walk is afero.Walk, except that it reads through a symlinked directory
|
||||||
|
// the way Get and Stat do, where afero.Walk reports the link as one file.
|
||||||
|
// afero.Walk uses Lstat, which does not follow a link, whenever the
|
||||||
|
// filesystem has one; the struct below carries only the afero.Fs methods,
|
||||||
|
// so the walk uses Stat instead. A link back up the tree is followed until
|
||||||
|
// the operating system refuses the path for too many levels of links, and
|
||||||
|
// the walk then fails.
|
||||||
|
func (f *FileStorer) walk(root string, fn filepath.WalkFunc) error {
|
||||||
|
return afero.Walk(struct{ afero.Fs }{f.fs}, root, fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// prefixExists reports whether prefix exists under the destination
|
||||||
|
// directory. It checks each directory on the way down to prefix in turn,
|
||||||
|
// and the first one Stat cannot find decides: if it is not there at all,
|
||||||
|
// prefix is missing; if it is a symlink whose target is missing, such as
|
||||||
|
// a link to a volume that is not mounted, prefixExists fails, since
|
||||||
|
// listing it as empty would let prune delete every blob.
|
||||||
|
func (f *FileStorer) prefixExists(prefix string) (bool, error) {
|
||||||
|
lstater, canLstat := f.fs.(afero.Lstater)
|
||||||
|
path := f.basePath
|
||||||
|
|
||||||
|
for name := range strings.SplitSeq(prefix, "/") {
|
||||||
|
path = filepath.Join(path, name)
|
||||||
|
|
||||||
|
_, err := f.fs.Stat(path)
|
||||||
|
if err == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !os.IsNotExist(err) {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lstat does not follow a symlink at path, so it finds the link
|
||||||
|
// that Stat could not follow.
|
||||||
|
if canLstat {
|
||||||
|
_, _, lstatErr := lstater.LstatIfPossible(path)
|
||||||
|
if lstatErr == nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
// fullPath returns the full filesystem path for a key.
|
// fullPath returns the full filesystem path for a key.
|
||||||
func (f *FileStorer) fullPath(key string) string {
|
func (f *FileStorer) fullPath(key string) string {
|
||||||
return filepath.Join(f.basePath, key)
|
return filepath.Join(f.basePath, key)
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"sneak.berlin/go/vaultik/internal/storage"
|
"sneak.berlin/go/vaultik/internal/storage"
|
||||||
@@ -60,3 +63,272 @@ func TestFileStorerListMissingDestination(t *testing.T) {
|
|||||||
t.Errorf("ListStream error = %v, want a not-exist error", streamErr)
|
t.Errorf("ListStream error = %v, want a not-exist error", streamErr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The two directories at the top of a destination, either of which the
|
||||||
|
// tests below replace with a symlink.
|
||||||
|
const (
|
||||||
|
metadataDir = "metadata"
|
||||||
|
blobsDir = "blobs"
|
||||||
|
)
|
||||||
|
|
||||||
|
// moveBehindSymlink moves base/dir to a directory elsewhere and puts a
|
||||||
|
// symlink to it in its place.
|
||||||
|
func moveBehindSymlink(t *testing.T, base, dir string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
linkPath := filepath.Join(base, dir)
|
||||||
|
movedPath := filepath.Join(t.TempDir(), dir)
|
||||||
|
|
||||||
|
err := os.Rename(linkPath, movedPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("moving %s away: %v", dir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = os.Symlink(movedPath, linkPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("linking %s: %v", dir, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFileStorerListsThroughSymlinkedDirectory checks that List and
|
||||||
|
// ListStream read through a symlinked metadata/ or blobs/ directory, as Get
|
||||||
|
// and Stat do, instead of listing the link as one key. Listing the whole
|
||||||
|
// destination meets the link part-way through the walk; listing its prefix
|
||||||
|
// starts the walk at the link. A prefix missing behind the link lists as
|
||||||
|
// empty.
|
||||||
|
func TestFileStorerListsThroughSymlinkedDirectory(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, dir := range []string{metadataDir, blobsDir} {
|
||||||
|
t.Run(dir, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
base := t.TempDir()
|
||||||
|
key := dir + "/aa/bb/object"
|
||||||
|
|
||||||
|
s, err := storage.NewFileStorer(base)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewFileStorer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = s.Put(context.Background(), key, strings.NewReader("data"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Put: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
moveBehindSymlink(t, base, dir)
|
||||||
|
|
||||||
|
want := []string{key}
|
||||||
|
|
||||||
|
for _, prefix := range []string{"", dir + "/"} {
|
||||||
|
got := listSorted(t, s, prefix)
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Errorf("List(%q) = %v, want %v", prefix, got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
got = listStreamKeys(t, s, prefix)
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Errorf("ListStream(%q) = %v, want %v", prefix, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
absent := dir + "/absent/"
|
||||||
|
|
||||||
|
if got := listSorted(t, s, absent); len(got) != 0 {
|
||||||
|
t.Errorf("List(%q) = %v, want empty", absent, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := listStreamKeys(t, s, absent); len(got) != 0 {
|
||||||
|
t.Errorf("ListStream(%q) = %v, want empty", absent, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFileStorerListFailsOnSymlinkWithMissingTarget checks that List and
|
||||||
|
// ListStream fail on a symlinked metadata/ or blobs/ directory whose target
|
||||||
|
// is missing, such as a link to a volume that is not mounted, as they do on
|
||||||
|
// a missing destination directory, instead of listing it as empty. That
|
||||||
|
// holds for a prefix below the link too.
|
||||||
|
func TestFileStorerListFailsOnSymlinkWithMissingTarget(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
for _, dir := range []string{metadataDir, blobsDir} {
|
||||||
|
t.Run(dir, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
base := t.TempDir()
|
||||||
|
|
||||||
|
s, err := storage.NewFileStorer(base)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewFileStorer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
unmounted := filepath.Join(t.TempDir(), "unmounted")
|
||||||
|
|
||||||
|
err = os.Symlink(unmounted, filepath.Join(base, dir))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Symlink: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, prefix := range []string{"", dir + "/", dir + "/aa/"} {
|
||||||
|
keys, err := s.List(ctx, prefix)
|
||||||
|
if !errors.Is(err, fs.ErrNotExist) {
|
||||||
|
t.Errorf("List(%q) = %v, %v; want a not-exist error",
|
||||||
|
prefix, keys, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var streamErr error
|
||||||
|
|
||||||
|
for object := range s.ListStream(ctx, prefix) {
|
||||||
|
if object.Err != nil {
|
||||||
|
streamErr = object.Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !errors.Is(streamErr, fs.ErrNotExist) {
|
||||||
|
t.Errorf("ListStream(%q) error = %v, want a not-exist error",
|
||||||
|
prefix, streamErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFileStorerListFailsOnSymlinkLoop checks that a symlink pointing back
|
||||||
|
// at the destination makes List and ListStream fail instead of walking it
|
||||||
|
// forever.
|
||||||
|
func TestFileStorerListFailsOnSymlinkLoop(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
base := t.TempDir()
|
||||||
|
|
||||||
|
s, err := storage.NewFileStorer(base)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewFileStorer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = s.Put(ctx, testBlobKey, strings.NewReader("data"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Put: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = os.Symlink(base, filepath.Join(base, "blobs", "loop"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Symlink: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
keys, err := s.List(ctx, "blobs/")
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("List = %d keys and no error, want an error", len(keys))
|
||||||
|
}
|
||||||
|
|
||||||
|
var streamErr error
|
||||||
|
|
||||||
|
for object := range s.ListStream(ctx, "blobs/") {
|
||||||
|
if object.Err != nil {
|
||||||
|
streamErr = object.Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if streamErr == nil {
|
||||||
|
t.Error("ListStream sent no error, want one")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFileStorerDeletePartialUploadsThroughSymlinkedDirectory checks that
|
||||||
|
// DeletePartialUploads, which remote nuke uses, removes a leftover temp
|
||||||
|
// file behind a symlinked metadata/ or blobs/ directory and keeps the
|
||||||
|
// object at the real key.
|
||||||
|
func TestFileStorerDeletePartialUploadsThroughSymlinkedDirectory(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
for _, dir := range []string{metadataDir, blobsDir} {
|
||||||
|
t.Run(dir, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
base := t.TempDir()
|
||||||
|
key := dir + "/aa/bb/object"
|
||||||
|
|
||||||
|
s, err := storage.NewFileStorer(base)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewFileStorer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = s.Put(ctx, key, strings.NewReader("data"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Put: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
leftover := filepath.Join(base, key+"-123456.partial")
|
||||||
|
|
||||||
|
err = os.WriteFile(leftover, []byte("half"), 0o600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("writing leftover temp file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
moveBehindSymlink(t, base, dir)
|
||||||
|
|
||||||
|
err = s.DeletePartialUploads(ctx, dir+"/")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DeletePartialUploads: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// leftover runs through the link.
|
||||||
|
_, err = os.Stat(leftover)
|
||||||
|
if !os.IsNotExist(err) {
|
||||||
|
t.Errorf("leftover temp file was not removed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.Stat(ctx, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Stat of the real key: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFileStorerPutMissingDestination checks that a write fails when the
|
||||||
|
// destination directory has gone, even after earlier writes to it
|
||||||
|
// succeeded, and that it does not create the directory again.
|
||||||
|
func TestFileStorerPutMissingDestination(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
base := filepath.Join(t.TempDir(), "usbstick")
|
||||||
|
|
||||||
|
err := os.Mkdir(base, 0o750)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Mkdir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s, err := storage.NewFileStorer(base)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewFileStorer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = s.Put(ctx, "blobs/aa/bb/first", strings.NewReader("first"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first Put: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = os.Rename(base, base+"-unplugged")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Rename: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = s.Put(ctx, "blobs/aa/bb/second", strings.NewReader("second"))
|
||||||
|
if !errors.Is(err, fs.ErrNotExist) {
|
||||||
|
t.Errorf("Put = %v, want a not-exist error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = os.Stat(base)
|
||||||
|
if !errors.Is(err, fs.ErrNotExist) {
|
||||||
|
t.Errorf("Put created the destination directory again (Stat = %v)", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,10 +7,12 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/rclone/rclone/fs"
|
"github.com/rclone/rclone/fs"
|
||||||
|
"github.com/rclone/rclone/fs/config"
|
||||||
"github.com/rclone/rclone/fs/config/configfile"
|
"github.com/rclone/rclone/fs/config/configfile"
|
||||||
"github.com/rclone/rclone/fs/operations"
|
"github.com/rclone/rclone/fs/operations"
|
||||||
|
|
||||||
@@ -33,6 +35,15 @@ type RcloneStorer struct {
|
|||||||
// The remote parameter is the rclone remote name (as configured via `rclone config`).
|
// The remote parameter is the rclone remote name (as configured via `rclone config`).
|
||||||
// The path parameter is the path within the remote.
|
// The path parameter is the path within the remote.
|
||||||
func NewRcloneStorer(ctx context.Context, remote, path string) (*RcloneStorer, error) {
|
func NewRcloneStorer(ctx context.Context, remote, path string) (*RcloneStorer, error) {
|
||||||
|
// rclone reads RCLONE_CONFIG only as the value of its --config flag,
|
||||||
|
// which vaultik does not have, so it is applied here as that flag would.
|
||||||
|
if configPath, ok := os.LookupEnv("RCLONE_CONFIG"); ok {
|
||||||
|
err := config.SetConfigPath(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("using RCLONE_CONFIG %q: %w", configPath, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Install the default config file handler
|
// Install the default config file handler
|
||||||
configfile.Install()
|
configfile.Install()
|
||||||
|
|
||||||
@@ -42,6 +53,14 @@ func NewRcloneStorer(ctx context.Context, remote, path string) (*RcloneStorer, e
|
|||||||
rclonePath += path
|
rclonePath += path
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// On S3, Swift and some other remotes rclone keeps a time of its own in
|
||||||
|
// each object's metadata, which costs one request per object to read.
|
||||||
|
// rclone's use_server_modtime setting makes ModTime the time the
|
||||||
|
// listing gives instead: when the object was stored, which is what
|
||||||
|
// prune compares with prune_grace_period.
|
||||||
|
ctx, ci := fs.AddConfig(ctx)
|
||||||
|
ci.UseServerModTime = true
|
||||||
|
|
||||||
// Create the rclone filesystem
|
// Create the rclone filesystem
|
||||||
fsys, err := fs.NewFs(ctx, rclonePath)
|
fsys, err := fs.NewFs(ctx, rclonePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -200,8 +219,9 @@ func (r *RcloneStorer) ListStream(
|
|||||||
|
|
||||||
if prefix == "" || strings.HasPrefix(key, prefix) {
|
if prefix == "" || strings.HasPrefix(key, prefix) {
|
||||||
ch <- ObjectInfo{
|
ch <- ObjectInfo{
|
||||||
Key: key,
|
Key: key,
|
||||||
Size: obj.Size(),
|
Size: obj.Size(),
|
||||||
|
ModTime: obj.ModTime(ctx),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,12 +5,19 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/johannesboyne/gofakes3"
|
||||||
|
"github.com/johannesboyne/gofakes3/backend/s3mem"
|
||||||
"github.com/rclone/rclone/fs"
|
"github.com/rclone/rclone/fs"
|
||||||
|
"github.com/rclone/rclone/fs/config"
|
||||||
"github.com/rclone/rclone/fs/config/configmap"
|
"github.com/rclone/rclone/fs/config/configmap"
|
||||||
"sneak.berlin/go/vaultik/internal/storage"
|
"sneak.berlin/go/vaultik/internal/storage"
|
||||||
)
|
)
|
||||||
@@ -390,3 +397,110 @@ func TestNewRcloneStorerUnknownRemote(t *testing.T) {
|
|||||||
t.Errorf("NewRcloneStorer error = %v, want ErrRemoteNotFound", err)
|
t.Errorf("NewRcloneStorer error = %v, want ErrRemoteNotFound", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestNewRcloneStorerReadsRcloneConfigEnv checks that a remote defined only
|
||||||
|
// in the file RCLONE_CONFIG names is found and written to, as rclone itself
|
||||||
|
// would.
|
||||||
|
func TestNewRcloneStorerReadsRcloneConfigEnv(t *testing.T) {
|
||||||
|
// rclone keeps the config file path in a process-global variable, which
|
||||||
|
// NewRcloneStorer leaves pointing at this test's file.
|
||||||
|
previous := config.GetConfigPath()
|
||||||
|
|
||||||
|
t.Cleanup(func() { _ = config.SetConfigPath(previous) })
|
||||||
|
|
||||||
|
confFile := filepath.Join(t.TempDir(), "rclone.conf")
|
||||||
|
|
||||||
|
err := os.WriteFile(confFile, []byte("[loc]\ntype = local\n"), 0o600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("writing rclone config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv("RCLONE_CONFIG", confFile)
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
s, err := storage.NewRcloneStorer(ctx, "loc", dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewRcloneStorer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = s.Put(ctx, testBlobKey, strings.NewReader("blob-bytes"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Put: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = os.Stat(filepath.Join(dir, testBlobKey))
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("object is not in the remote's directory: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRcloneListStreamOnS3MakesNoRequestPerObject checks that listing an
|
||||||
|
// rclone S3 remote takes each object's time from the listing itself. By
|
||||||
|
// default rclone reads the time it keeps in each object's metadata, one
|
||||||
|
// request per object, and every command that lists the destination store
|
||||||
|
// would slow down as it grows.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // NewRcloneStorer installs the process-global rclone config
|
||||||
|
func TestRcloneListStreamOnS3MakesNoRequestPerObject(t *testing.T) {
|
||||||
|
backend := s3mem.New()
|
||||||
|
|
||||||
|
err := backend.CreateBucket(s3TestBucket)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create bucket: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = "blob-bytes"
|
||||||
|
|
||||||
|
_, err = backend.PutObject(s3TestBucket, testBlobKey, nil,
|
||||||
|
strings.NewReader(body), int64(len(body)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seed object: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var headRequests atomic.Int64
|
||||||
|
|
||||||
|
fakeS3 := gofakes3.New(backend).Server()
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(
|
||||||
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == http.MethodHead {
|
||||||
|
headRequests.Add(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fakeS3.ServeHTTP(w, r)
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
|
remote := ":s3,provider=Other" +
|
||||||
|
",access_key_id=" + s3TestAccessKeyID +
|
||||||
|
",secret_access_key=" + s3TestSecretAccessKey +
|
||||||
|
",endpoint='" + srv.URL + "'"
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
s, err := storage.NewRcloneStorer(ctx, remote, s3TestBucket)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewRcloneStorer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
headRequests.Store(0)
|
||||||
|
|
||||||
|
var listed []storage.ObjectInfo
|
||||||
|
|
||||||
|
for info := range s.ListStream(ctx, "blobs/") {
|
||||||
|
listed = append(listed, info)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(listed) != 1 || listed[0].Err != nil {
|
||||||
|
t.Fatalf("ListStream(\"blobs/\") = %v, want the one object", listed)
|
||||||
|
}
|
||||||
|
|
||||||
|
if time.Since(listed[0].ModTime).Abs() > time.Minute {
|
||||||
|
t.Errorf("ListStream ModTime = %v, want within a minute of now",
|
||||||
|
listed[0].ModTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
if n := headRequests.Load(); n != 0 {
|
||||||
|
t.Errorf("ListStream made %d HEAD request(s), want none", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -89,9 +89,10 @@ func (s *S3Storer) ListStream(ctx context.Context, prefix string) <-chan ObjectI
|
|||||||
|
|
||||||
for info := range s.client.ListObjectsStream(ctx, prefix, false) {
|
for info := range s.client.ListObjectsStream(ctx, prefix, false) {
|
||||||
ch <- ObjectInfo{
|
ch <- ObjectInfo{
|
||||||
Key: info.Key,
|
Key: info.Key,
|
||||||
Size: info.Size,
|
Size: info.Size,
|
||||||
Err: info.Err,
|
ModTime: info.ModTime,
|
||||||
|
Err: info.Err,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|||||||
@@ -100,8 +100,9 @@ func TestS3StorerMissingKeyMapsToErrNotFound(t *testing.T) {
|
|||||||
// TestS3ListingCutShortIsAnError checks that List and ListStream fail on a
|
// TestS3ListingCutShortIsAnError checks that List and ListStream fail on a
|
||||||
// listing page marked truncated that carries no continuation token, as 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,
|
// 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
|
// it would hide every later key: prune would treat the blobs of the
|
||||||
// snapshots listed there as unreferenced.
|
// 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) {
|
func TestS3ListingCutShortIsAnError(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrNotFound is returned when an object does not exist.
|
// ErrNotFound is returned when an object does not exist.
|
||||||
@@ -27,7 +28,10 @@ type ProgressCallback func(bytesTransferred int64) error
|
|||||||
type ObjectInfo struct {
|
type ObjectInfo struct {
|
||||||
Key string // Object key/path
|
Key string // Object key/path
|
||||||
Size int64 // Size in bytes
|
Size int64 // Size in bytes
|
||||||
Err error // Error for streaming results (nil on success)
|
// ModTime is when the object was written, as the store reports it.
|
||||||
|
// Only ListStream sets it; Stat leaves it zero.
|
||||||
|
ModTime time.Time
|
||||||
|
Err error // Error for streaming results (nil on success)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Info provides human-readable storage configuration.
|
// Info provides human-readable storage configuration.
|
||||||
@@ -41,7 +45,8 @@ type Info struct {
|
|||||||
// directory for filesystem).
|
// directory for filesystem).
|
||||||
type Storer interface {
|
type Storer interface {
|
||||||
// Put stores data at the specified key.
|
// Put stores data at the specified key.
|
||||||
// Parent directories are created automatically for filesystem backends.
|
// The filesystem backend fails when its destination directory does
|
||||||
|
// not exist, and creates the directories below it as needed.
|
||||||
Put(ctx context.Context, key string, data io.Reader) error
|
Put(ctx context.Context, key string, data io.Reader) error
|
||||||
|
|
||||||
// PutWithProgress stores data with progress reporting.
|
// PutWithProgress stores data with progress reporting.
|
||||||
@@ -66,7 +71,8 @@ type Storer interface {
|
|||||||
// For large result sets, prefer ListStream.
|
// For large result sets, prefer ListStream.
|
||||||
List(ctx context.Context, prefix string) ([]string, error)
|
List(ctx context.Context, prefix string) ([]string, error)
|
||||||
|
|
||||||
// ListStream returns a channel of ObjectInfo for large result sets.
|
// ListStream returns a channel of ObjectInfo, with each object's key,
|
||||||
|
// size and modification time, for large result sets.
|
||||||
// The channel is closed when listing completes.
|
// The channel is closed when listing completes.
|
||||||
// If an error occurs during listing, the final item will have Err set.
|
// If an error occurs during listing, the final item will have Err set.
|
||||||
ListStream(ctx context.Context, prefix string) <-chan ObjectInfo
|
ListStream(ctx context.Context, prefix string) <-chan ObjectInfo
|
||||||
|
|||||||
+47
-6
@@ -30,6 +30,12 @@ var (
|
|||||||
"storage URL must not carry credentials; " +
|
"storage URL must not carry credentials; " +
|
||||||
"set s3.access_key_id and s3.secret_access_key in the config instead")
|
"set s3.access_key_id and s3.secret_access_key in the config instead")
|
||||||
ErrURLUnknownParam = errors.New("unknown query parameter in storage URL")
|
ErrURLUnknownParam = errors.New("unknown query parameter in storage URL")
|
||||||
|
ErrURLBadQuery = errors.New(
|
||||||
|
"storage URL query cannot be decoded; " +
|
||||||
|
"check it for a ; or a % not followed by two hex digits")
|
||||||
|
ErrRcloneRemoteParams = errors.New(
|
||||||
|
"rclone URL must not carry parameters in the remote name; " +
|
||||||
|
"set them on the remote with rclone config instead")
|
||||||
)
|
)
|
||||||
|
|
||||||
// URL represents a parsed storage URL.
|
// URL represents a parsed storage URL.
|
||||||
@@ -90,8 +96,9 @@ func parseFileURL(rawPath string) (*URL, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// parseS3URL parses an s3://bucket/prefix URL. It rejects credentials in
|
// parseS3URL parses an s3://bucket/prefix URL. It rejects credentials in
|
||||||
// the userinfo and any query parameter other than endpoint, region and
|
// the userinfo or in the endpoint parameter's userinfo, a query that
|
||||||
// ssl, so a credential-bearing URL is never stored or echoed.
|
// cannot be decoded, and any query parameter other than endpoint, region
|
||||||
|
// and ssl, so a credential-bearing URL is never stored or echoed.
|
||||||
func parseS3URL(rawURL string) (*URL, error) {
|
func parseS3URL(rawURL string) (*URL, error) {
|
||||||
u, err := url.Parse(rawURL)
|
u, err := url.Parse(rawURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -107,13 +114,26 @@ func parseS3URL(rawURL string) (*URL, error) {
|
|||||||
return nil, ErrMissingBucket
|
return nil, ErrMissingBucket
|
||||||
}
|
}
|
||||||
|
|
||||||
query := u.Query()
|
// u.Query() would silently drop a part it cannot decode, endpoint
|
||||||
|
// included. The decode error is not wrapped: it can quote the value.
|
||||||
|
query, err := url.ParseQuery(u.RawQuery)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrURLBadQuery
|
||||||
|
}
|
||||||
|
|
||||||
err = rejectUnknownParams(query, "endpoint", "region", "ssl")
|
err = rejectUnknownParams(query, "endpoint", "region", "ssl")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A host name never contains "@", so one in an endpoint, with or
|
||||||
|
// without a scheme, can only introduce userinfo.
|
||||||
|
for _, endpoint := range query["endpoint"] {
|
||||||
|
if strings.Contains(endpoint, "@") {
|
||||||
|
return nil, ErrURLCredentials
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return &URL{
|
return &URL{
|
||||||
Scheme: schemeS3,
|
Scheme: schemeS3,
|
||||||
Bucket: bucket,
|
Bucket: bucket,
|
||||||
@@ -125,9 +145,23 @@ func parseS3URL(rawURL string) (*URL, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// parseRcloneURL parses an rclone://remote/path URL. rclone:// takes no
|
// parseRcloneURL parses an rclone://remote/path URL. rclone:// takes no
|
||||||
// query parameters, so credentials in the userinfo and any parameter at
|
// query parameters, so credentials in the userinfo, parameters in the
|
||||||
|
// remote name, a query that cannot be decoded and any query parameter at
|
||||||
// all are rejected rather than silently ignored.
|
// all are rejected rather than silently ignored.
|
||||||
func parseRcloneURL(rawURL string) (*URL, error) {
|
func parseRcloneURL(rawURL string) (*URL, error) {
|
||||||
|
// The remote name runs up to the first "/", "?" or "#". It never
|
||||||
|
// contains a comma; one starts rclone's remote,param=value form,
|
||||||
|
// whose values can be credentials. This is checked before url.Parse,
|
||||||
|
// whose error for such a name can print part of it.
|
||||||
|
remote := strings.TrimPrefix(rawURL, "rclone://")
|
||||||
|
if end := strings.IndexAny(remote, "/?#"); end >= 0 {
|
||||||
|
remote = remote[:end]
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(remote, ",") {
|
||||||
|
return nil, ErrRcloneRemoteParams
|
||||||
|
}
|
||||||
|
|
||||||
u, err := url.Parse(rawURL)
|
u, err := url.Parse(rawURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, wrapParseError(err)
|
return nil, wrapParseError(err)
|
||||||
@@ -137,12 +171,19 @@ func parseRcloneURL(rawURL string) (*URL, error) {
|
|||||||
return nil, ErrURLCredentials
|
return nil, ErrURLCredentials
|
||||||
}
|
}
|
||||||
|
|
||||||
remote := u.Host
|
remote = u.Host
|
||||||
if remote == "" {
|
if remote == "" {
|
||||||
return nil, ErrMissingRemote
|
return nil, ErrMissingRemote
|
||||||
}
|
}
|
||||||
|
|
||||||
err = rejectUnknownParams(u.Query())
|
// As in parseS3URL: u.Query() would drop a part it cannot decode, and
|
||||||
|
// the decode error can quote the value.
|
||||||
|
query, err := url.ParseQuery(u.RawQuery)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrURLBadQuery
|
||||||
|
}
|
||||||
|
|
||||||
|
err = rejectUnknownParams(query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -150,6 +150,19 @@ func TestParseStorageURLRejectsCredentials(t *testing.T) {
|
|||||||
wantErr: storage.ErrURLCredentials,
|
wantErr: storage.ErrURLCredentials,
|
||||||
secrets: []string{key, secret, "mybucket"},
|
secrets: []string{key, secret, "mybucket"},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "s3 userinfo in endpoint",
|
||||||
|
raw: "s3://mybucket?endpoint=https://" +
|
||||||
|
key + ":" + secret + "@minio.example.com",
|
||||||
|
wantErr: storage.ErrURLCredentials,
|
||||||
|
secrets: []string{key, secret},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "s3 userinfo in endpoint without scheme",
|
||||||
|
raw: "s3://mybucket?endpoint=" + key + ":" + secret + "@minio.example.com",
|
||||||
|
wantErr: storage.ErrURLCredentials,
|
||||||
|
secrets: []string{key, secret},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "s3 unknown query param",
|
name: "s3 unknown query param",
|
||||||
raw: "s3://mybucket?access_key=" + key + "&secret=" + secret,
|
raw: "s3://mybucket?access_key=" + key + "&secret=" + secret,
|
||||||
@@ -203,6 +216,112 @@ func TestParseStorageURLRejectsCredentials(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestParseStorageURLRejectsRcloneRemoteParams checks that an rclone
|
||||||
|
// remote name carrying parameters (rclone's "remote,param=value" form,
|
||||||
|
// which can hold a credential) is rejected without echoing the value,
|
||||||
|
// including the URLs that url.Parse itself refuses.
|
||||||
|
func TestParseStorageURLRejectsRcloneRemoteParams(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
raw string
|
||||||
|
secrets []string // must not appear in the error message
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "configured remote",
|
||||||
|
raw: "rclone://gdrive,client_secret=topsecret/backups",
|
||||||
|
secrets: []string{"topsecret"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "remote defined inline",
|
||||||
|
raw: "rclone://:s3,access_key_id=KEY,secret_access_key=SECRET/bucket",
|
||||||
|
secrets: []string{"KEY", "SECRET"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "colon in a parameter value",
|
||||||
|
raw: "rclone://gdrive,client_secret=ab:cd/backups",
|
||||||
|
secrets: []string{"cd"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, err := storage.ParseStorageURL(tc.raw)
|
||||||
|
if !errors.Is(err, storage.ErrRcloneRemoteParams) {
|
||||||
|
t.Fatalf("ParseStorageURL(%q) error = %v, want %v",
|
||||||
|
tc.raw, err, storage.ErrRcloneRemoteParams)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, secret := range tc.secrets {
|
||||||
|
if strings.Contains(err.Error(), secret) {
|
||||||
|
t.Errorf("error message leaked %q: %v", secret, err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseStorageURLRejectsUndecodableQuery checks that an s3 or rclone
|
||||||
|
// URL whose query cannot be decoded is rejected, not read with that part
|
||||||
|
// of the query left out, and that the error does not echo it.
|
||||||
|
func TestParseStorageURLRejectsUndecodableQuery(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const (
|
||||||
|
key = "AKIAKEY"
|
||||||
|
secret = "topsecret"
|
||||||
|
)
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
raw string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "semicolon",
|
||||||
|
raw: "s3://mybucket/prefix?endpoint=https://" + key + ":" + secret +
|
||||||
|
"@minio.example.com:9000;region=us-east-1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bad percent escape",
|
||||||
|
raw: "s3://mybucket/prefix?endpoint=https://" + key + ":" + secret +
|
||||||
|
"%zz@minio.example.com:9000",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "rclone semicolon",
|
||||||
|
raw: "rclone://gdrive/backups?token=" + secret + ";x=1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "rclone bad percent escape",
|
||||||
|
raw: "rclone://gdrive/backups?token=" + secret + "%zz",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, err := storage.ParseStorageURL(tc.raw)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("ParseStorageURL(%q) returned no error", tc.raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !errors.Is(err, storage.ErrURLBadQuery) {
|
||||||
|
t.Errorf("ParseStorageURL(%q) error = %v, want %v",
|
||||||
|
tc.raw, err, storage.ErrURLBadQuery)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, s := range []string{key, secret, "minio.example.com"} {
|
||||||
|
if strings.Contains(err.Error(), s) {
|
||||||
|
t.Errorf("error message leaked %q: %v", s, err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestParseStorageURLParseFailureHidesURL checks that when url.Parse
|
// TestParseStorageURLParseFailureHidesURL checks that when url.Parse
|
||||||
// itself fails, the wrapped error carries only the inner cause, not the
|
// itself fails, the wrapped error carries only the inner cause, not the
|
||||||
// *url.Error whose text embeds the raw (possibly credential-bearing) URL.
|
// *url.Error whose text embeds the raw (possibly credential-bearing) URL.
|
||||||
|
|||||||
@@ -104,7 +104,8 @@ func (v *Vaultik) FetchAndDecryptBlob(
|
|||||||
|
|
||||||
// FetchBlob downloads a blob and returns a reader for the encrypted data.
|
// FetchBlob downloads a blob and returns a reader for the encrypted data.
|
||||||
// The reader is closed as soon as ctx is cancelled, which ends a read
|
// The reader is closed as soon as ctx is cancelled, which ends a read
|
||||||
// blocked inside it.
|
// blocked inside it. A storage error is returned as is: restore and deep
|
||||||
|
// verify name the blob in their own errors.
|
||||||
func (v *Vaultik) FetchBlob(
|
func (v *Vaultik) FetchBlob(
|
||||||
ctx context.Context, blobHash string,
|
ctx context.Context, blobHash string,
|
||||||
) (io.ReadCloser, error) {
|
) (io.ReadCloser, error) {
|
||||||
@@ -119,7 +120,7 @@ func (v *Vaultik) FetchBlob(
|
|||||||
|
|
||||||
rc, err := v.Storage.Get(ctx, blobPath)
|
rc, err := v.Storage.Get(ctx, blobPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("downloading blob %s: %w", shortHash(blobHash), err)
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return newCloseOnCancelReader(ctx, rc), nil
|
return newCloseOnCancelReader(ctx, rc), nil
|
||||||
|
|||||||
@@ -39,9 +39,9 @@ type blobDiskCacheEntry struct {
|
|||||||
// internally and will fall back to LRU eviction if curBytes exceeds
|
// internally and will fall back to LRU eviction if curBytes exceeds
|
||||||
// maxBytes. Restore passes math.MaxInt64 as maxBytes and drives
|
// maxBytes. Restore passes math.MaxInt64 as maxBytes and drives
|
||||||
// eviction itself via Delete() through restoreSweeper, which deletes
|
// eviction itself via Delete() through restoreSweeper, which deletes
|
||||||
// each blob the moment every file that references its chunks has been
|
// each blob once no file still to be restored in this run needs it.
|
||||||
// written. LRU never fires under that configuration; it is kept as a
|
// LRU never fires under that configuration; it is kept as a safety net
|
||||||
// safety net for callers that don't manage eviction themselves.
|
// for callers that don't manage eviction themselves.
|
||||||
//
|
//
|
||||||
// Get/ReadAt/peak-Len counters are debugging instrumentation used by
|
// Get/ReadAt/peak-Len counters are debugging instrumentation used by
|
||||||
// tests to assert that the restore code path uses ReadAt rather than
|
// tests to assert that the restore code path uses ReadAt rather than
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ func TestBackupAfterRemovingNewestSnapshotRestoresChangedFile(t *testing.T) {
|
|||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
dataDir := filepath.Join(tempDir, "src")
|
dataDir := filepath.Join(tempDir, "src")
|
||||||
storeDir := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
restoreDir := filepath.Join(tempDir, "restored")
|
restoreDir := filepath.Join(tempDir, "restored")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
changedPath := filepath.Join(dataDir, "appended.bin")
|
changedPath := filepath.Join(dataDir, "appended.bin")
|
||||||
@@ -184,7 +184,7 @@ func TestBackupAfterInterruptedRunRestoresChangedFile(t *testing.T) {
|
|||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
dataDir := filepath.Join(tempDir, "src")
|
dataDir := filepath.Join(tempDir, "src")
|
||||||
storeDir := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
restoreDir := filepath.Join(tempDir, "restored")
|
restoreDir := filepath.Join(tempDir, "restored")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
changedPath := filepath.Join(dataDir, "appended.bin")
|
changedPath := filepath.Join(dataDir, "appended.bin")
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -34,7 +35,7 @@ func TestDeepVerifyAcceptsHealthyAndRejectsCorruptBlob(t *testing.T) {
|
|||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
dataDir := filepath.Join(tempDir, "source")
|
dataDir := filepath.Join(tempDir, "source")
|
||||||
storeDir := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
chunkSize := int64(64 * 1024)
|
chunkSize := int64(64 * 1024)
|
||||||
@@ -93,7 +94,7 @@ func TestDeepVerifyJSONCountsBlobs(t *testing.T) {
|
|||||||
|
|
||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
storeDir := filepath.Join(t.TempDir(), "remote")
|
storeDir := t.TempDir()
|
||||||
|
|
||||||
cfg, storer, snapshotID, keys := backUpSeveralBlobs(ctx, t, storeDir)
|
cfg, storer, snapshotID, keys := backUpSeveralBlobs(ctx, t, storeDir)
|
||||||
|
|
||||||
@@ -142,14 +143,15 @@ func TestDeepVerifyJSONCountsBlobs(t *testing.T) {
|
|||||||
|
|
||||||
// TestDeepVerifyJSONCountsWrongSizeBlob checks that under --deep --json a
|
// TestDeepVerifyJSONCountsWrongSizeBlob checks that under --deep --json a
|
||||||
// blob of the wrong size is counted in mismatched and fails the run, and
|
// blob of the wrong size is counted in mismatched and fails the run, and
|
||||||
// that the check goes on past it to count a missing blob after it.
|
// that the check goes on past it to count a missing blob after it. The
|
||||||
|
// store is on disk, so the message must not call it S3.
|
||||||
func TestDeepVerifyJSONCountsWrongSizeBlob(t *testing.T) {
|
func TestDeepVerifyJSONCountsWrongSizeBlob(t *testing.T) {
|
||||||
log.Initialize(log.Config{})
|
log.Initialize(log.Config{})
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
storeDir := filepath.Join(t.TempDir(), "remote")
|
storeDir := t.TempDir()
|
||||||
|
|
||||||
cfg, storer, snapshotID, keys := backUpSeveralBlobs(ctx, t, storeDir)
|
cfg, storer, snapshotID, keys := backUpSeveralBlobs(ctx, t, storeDir)
|
||||||
|
|
||||||
@@ -168,17 +170,20 @@ func TestDeepVerifyJSONCountsWrongSizeBlob(t *testing.T) {
|
|||||||
require.Equal(t, 1, got.Mismatched)
|
require.Equal(t, 1, got.Mismatched)
|
||||||
require.Equal(t, 1, got.Missing)
|
require.Equal(t, 1, got.Missing)
|
||||||
require.Zero(t, got.Verified)
|
require.Zero(t, got.Verified)
|
||||||
|
require.Contains(t, got.ErrorMessage, "with the wrong size")
|
||||||
|
require.NotContains(t, got.ErrorMessage, "S3")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestDeepVerifyStopsOnBlobThatCannotBeChecked checks that a blob that is
|
// TestDeepVerifyStopsOnBlobThatCannotBeChecked checks that a blob that is
|
||||||
// present in storage but cannot be checked is not counted as missing:
|
// present in storage but cannot be checked is not counted as missing:
|
||||||
// deep verify stops and fails with the storage error.
|
// deep verify stops and fails, saying it could not check the blob, with
|
||||||
|
// the storage error.
|
||||||
func TestDeepVerifyStopsOnBlobThatCannotBeChecked(t *testing.T) {
|
func TestDeepVerifyStopsOnBlobThatCannotBeChecked(t *testing.T) {
|
||||||
log.Initialize(log.Config{})
|
log.Initialize(log.Config{})
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
storeDir := filepath.Join(t.TempDir(), "remote")
|
storeDir := t.TempDir()
|
||||||
|
|
||||||
cfg, storer, snapshotID, keys := backUpSeveralBlobs(ctx, t, storeDir)
|
cfg, storer, snapshotID, keys := backUpSeveralBlobs(ctx, t, storeDir)
|
||||||
failing := statFailingStorer{Storer: storer, failKeys: []string{keys[0]}}
|
failing := statFailingStorer{Storer: storer, failKeys: []string{keys[0]}}
|
||||||
@@ -187,6 +192,8 @@ func TestDeepVerifyStopsOnBlobThatCannotBeChecked(t *testing.T) {
|
|||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
require.Equal(t, "failed", got.Status)
|
require.Equal(t, "failed", got.Status)
|
||||||
require.Zero(t, got.Missing)
|
require.Zero(t, got.Missing)
|
||||||
|
require.Contains(t, got.ErrorMessage,
|
||||||
|
"could not check blob "+path.Base(keys[0]))
|
||||||
require.Contains(t, got.ErrorMessage, errStatDenied.Error())
|
require.Contains(t, got.ErrorMessage, errStatDenied.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package vaultik
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"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 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
|
||||||
|
// host, path or time.
|
||||||
|
const destinationMarkerText = "This is a vaultik backup destination store.\n"
|
||||||
|
|
||||||
|
// errDestinationNotPresent is returned when the local index holds a
|
||||||
|
// snapshot stored on the backup destination store and the store 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 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 {
|
||||||
|
return fmt.Errorf("listing local snapshots: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config is nil only in tests, which then record no binding (see
|
||||||
|
// EnsureStorageBinding).
|
||||||
|
if v.Config != nil {
|
||||||
|
stored, configured, err := v.storageBinding()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if stored != "" && stored != configured {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = v.Storage.Stat(v.ctx, destinationMarkerKey)
|
||||||
|
if errors.Is(err, storage.ErrNotFound) {
|
||||||
|
return fmt.Errorf("%w: %s has no %s, but the local index holds "+
|
||||||
|
"snapshots stored there; mount the volume if it is removable, "+
|
||||||
|
"or if the destination was emptied on purpose, run "+
|
||||||
|
"'vaultik database delete' and back up again",
|
||||||
|
errDestinationNotPresent, v.Storage.Info().Location,
|
||||||
|
destinationMarkerKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Info, not a warning: --cron must stay silent on a successful
|
||||||
|
// backup from a host whose credentials allow only PUT.
|
||||||
|
if err != nil {
|
||||||
|
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.
|
||||||
|
func (v *Vaultik) markDestination() error {
|
||||||
|
err := v.checkDestinationMarker()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = v.Storage.Put(v.ctx, destinationMarkerKey,
|
||||||
|
strings.NewReader(destinationMarkerText))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("writing %s to the backup destination store: %w",
|
||||||
|
destinationMarkerKey, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
package vaultik_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/vaultik/internal/database"
|
||||||
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
|
"sneak.berlin/go/vaultik/internal/storage"
|
||||||
|
"sneak.berlin/go/vaultik/internal/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 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.
|
||||||
|
const markerFile = "vaultik-destination.txt"
|
||||||
|
|
||||||
|
// backUpThenUnplugMountPoint backs up to a file:// destination, then
|
||||||
|
// moves the destination directory away and leaves an empty directory in
|
||||||
|
// its place, as unplugging a volume mounted there would. It returns the
|
||||||
|
// path of that empty directory.
|
||||||
|
func backUpThenUnplugMountPoint(
|
||||||
|
ctx context.Context, t *testing.T,
|
||||||
|
) (*vaultik.Vaultik, *database.Repositories, *bytes.Buffer, string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
storeDir := filepath.Join(t.TempDir(), "mnt")
|
||||||
|
v, repos, out := backUpToFileDestination(ctx, t, storeDir)
|
||||||
|
|
||||||
|
require.NoError(t, os.Rename(storeDir, storeDir+"-unplugged"))
|
||||||
|
require.NoError(t, os.Mkdir(storeDir, 0o750))
|
||||||
|
|
||||||
|
return v, repos, out, storeDir
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertUntouched checks that the local index still holds its one
|
||||||
|
// snapshot and that nothing was written to the empty mount point.
|
||||||
|
func assertUntouched(
|
||||||
|
ctx context.Context, t *testing.T,
|
||||||
|
repos *database.Repositories, storeDir string,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
snapshots, err := repos.Snapshots.ListRecent(ctx, listRecentTestLimit)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, snapshots, 1, "no local snapshot record may be deleted")
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(storeDir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, entries, "nothing may be written to the empty mount point")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFirstBackupWritesMarker checks that a backup writes the marker
|
||||||
|
// file at the root of the destination.
|
||||||
|
func TestFirstBackupWritesMarker(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
storeDir := t.TempDir()
|
||||||
|
backUpToFileDestination(context.Background(), t, storeDir)
|
||||||
|
|
||||||
|
assert.FileExists(t, filepath.Join(storeDir, markerFile))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBackupRefusesDestinationWithoutMarker checks that snapshot create
|
||||||
|
// --prune writes nothing and drops no local snapshot record when the
|
||||||
|
// destination has no marker.
|
||||||
|
func TestBackupRefusesDestinationWithoutMarker(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
v, repos, _, storeDir := backUpThenUnplugMountPoint(ctx, t)
|
||||||
|
|
||||||
|
err := v.CreateSnapshot(&vaultik.SnapshotCreateOptions{
|
||||||
|
Cron: true,
|
||||||
|
Prune: true,
|
||||||
|
})
|
||||||
|
require.ErrorContains(t, err, markerFile)
|
||||||
|
|
||||||
|
assertUntouched(ctx, t, repos, storeDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// incomplete snapshots before it scans; checked after that, the local
|
||||||
|
// index would hold none and nothing would be checked.
|
||||||
|
func TestBackupChecksMarkerBeforeDroppingIncompleteSnapshots(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
storeDir := t.TempDir()
|
||||||
|
v, repos := newFileDestinationVaultik(ctx, t, storeDir)
|
||||||
|
|
||||||
|
require.NoError(t, repos.Snapshots.Create(ctx, nil, &database.Snapshot{
|
||||||
|
ID: "host_first_2026-01-02T03:04:05Z",
|
||||||
|
Hostname: "host",
|
||||||
|
StartedAt: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC),
|
||||||
|
}))
|
||||||
|
|
||||||
|
err := backUp(v, "first")
|
||||||
|
require.ErrorContains(t, err, markerFile)
|
||||||
|
|
||||||
|
assertUntouched(ctx, t, repos, storeDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPruneRefusesDestinationWithoutMarker checks that prune fails and
|
||||||
|
// deletes no local snapshot record when the destination has no marker.
|
||||||
|
func TestPruneRefusesDestinationWithoutMarker(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
v, repos, _, storeDir := backUpThenUnplugMountPoint(ctx, t)
|
||||||
|
|
||||||
|
err := v.Prune(&vaultik.PruneOptions{Force: true})
|
||||||
|
require.ErrorContains(t, err, markerFile)
|
||||||
|
|
||||||
|
assertUntouched(ctx, t, repos, storeDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPurgeRefusesDestinationWithoutMarker checks that snapshot purge
|
||||||
|
// fails and deletes no local snapshot record when the destination has no
|
||||||
|
// marker.
|
||||||
|
func TestPurgeRefusesDestinationWithoutMarker(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
v, repos, _, storeDir := backUpThenUnplugMountPoint(ctx, t)
|
||||||
|
|
||||||
|
err := v.PurgeSnapshotsWithOptions(&vaultik.SnapshotPurgeOptions{
|
||||||
|
KeepLatest: true,
|
||||||
|
Force: true,
|
||||||
|
})
|
||||||
|
require.ErrorContains(t, err, markerFile)
|
||||||
|
|
||||||
|
assertUntouched(ctx, t, repos, storeDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNukeRemoteRefusesDestinationWithoutMarker checks that remote nuke
|
||||||
|
// fails, deletes no local snapshot record, and neither reports the
|
||||||
|
// destination empty nor advises running nuke again when the destination
|
||||||
|
// has no marker.
|
||||||
|
func TestNukeRemoteRefusesDestinationWithoutMarker(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
v, repos, out, storeDir := backUpThenUnplugMountPoint(ctx, t)
|
||||||
|
|
||||||
|
err := v.NukeRemote(true)
|
||||||
|
require.ErrorContains(t, err, markerFile)
|
||||||
|
assert.NotContains(t, out.String(), "now empty")
|
||||||
|
assert.NotContains(t, out.String(), "vaultik remote nuke --force")
|
||||||
|
|
||||||
|
assertUntouched(ctx, t, repos, storeDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRemoveSnapshotWarnsWhenDestinationHasNoMarker checks that snapshot
|
||||||
|
// remove warns that the metadata could not be removed from a destination
|
||||||
|
// without a marker, instead of reporting that it was.
|
||||||
|
func TestRemoveSnapshotWarnsWhenDestinationHasNoMarker(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
v, repos, out, _ := backUpThenUnplugMountPoint(ctx, t)
|
||||||
|
|
||||||
|
result, err := v.RemoveSnapshot(localSnapshotID(ctx, t, repos, "first"),
|
||||||
|
&vaultik.RemoveOptions{Force: true})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.False(t, result.RemoteRemoved)
|
||||||
|
assert.Contains(t, out.String(),
|
||||||
|
"Could not remove snapshot metadata from remote")
|
||||||
|
assert.Contains(t, out.String(), markerFile)
|
||||||
|
assert.NotContains(t, out.String(),
|
||||||
|
"Removed snapshot metadata from remote storage")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRemoveSnapshotOnlyOnDestinationFailsWithoutMarker checks that
|
||||||
|
// snapshot remove, for a snapshot that is only on the destination, fails
|
||||||
|
// saying the destination is not present when it has no marker, instead
|
||||||
|
// of saying the snapshot does not exist.
|
||||||
|
func TestRemoveSnapshotOnlyOnDestinationFailsWithoutMarker(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
storeDir := filepath.Join(t.TempDir(), "mnt")
|
||||||
|
v, repos, _ := backUpToFileDestination(ctx, t, storeDir)
|
||||||
|
require.NoError(t, backUp(v, "second"))
|
||||||
|
|
||||||
|
id := localSnapshotID(ctx, t, repos, "first")
|
||||||
|
_, err := v.RemoveSnapshot(id,
|
||||||
|
&vaultik.RemoveOptions{Force: true, LocalOnly: true})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Unplug the volume: an empty directory stays at its mount point.
|
||||||
|
require.NoError(t, os.Rename(storeDir, storeDir+"-unplugged"))
|
||||||
|
require.NoError(t, os.Mkdir(storeDir, 0o750))
|
||||||
|
|
||||||
|
_, err = v.RemoveSnapshot(id, &vaultik.RemoveOptions{Force: true})
|
||||||
|
require.ErrorContains(t, err, markerFile)
|
||||||
|
require.ErrorContains(t, err, "mount the volume")
|
||||||
|
|
||||||
|
assertUntouched(ctx, t, repos, storeDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestListSnapshotsWarnsWhenDestinationHasNoMarker checks that snapshot
|
||||||
|
// list warns and shows the local index alone when the destination has no
|
||||||
|
// marker, without reporting the local snapshot as missing from it.
|
||||||
|
func TestListSnapshotsWarnsWhenDestinationHasNoMarker(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
v, repos, out, _ := backUpThenUnplugMountPoint(ctx, t)
|
||||||
|
id := localSnapshotID(ctx, t, repos, "first")
|
||||||
|
|
||||||
|
require.NoError(t, v.ListSnapshots(false))
|
||||||
|
|
||||||
|
assert.Contains(t, out.String(), "Could not list backup destination store")
|
||||||
|
assert.Contains(t, out.String(), markerFile)
|
||||||
|
assert.Contains(t, out.String(), id)
|
||||||
|
assert.NotContains(t, out.String(), "not found in backup destination store")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestListSnapshotsAfterStorageURLChangeToEmptyDirectory checks that
|
||||||
|
// snapshot list, after storage_url is changed to an empty directory,
|
||||||
|
// warns only that the local index is bound to a different destination:
|
||||||
|
// the local snapshots are stored at the old one, so the new one's lack
|
||||||
|
// of a marker says nothing. With --json the local row is not on the new
|
||||||
|
// destination, so remote_present is false, not unknown.
|
||||||
|
func TestListSnapshotsAfterStorageURLChangeToEmptyDirectory(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
v, repos, out := backUpToFileDestination(ctx, t, t.TempDir())
|
||||||
|
id := localSnapshotID(ctx, t, repos, "first")
|
||||||
|
|
||||||
|
newDir := t.TempDir()
|
||||||
|
store, err := storage.NewFileStorer(newDir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
v.Storage = store
|
||||||
|
v.Config.StorageURL = "file://" + newDir
|
||||||
|
|
||||||
|
require.NoError(t, v.ListSnapshots(false))
|
||||||
|
|
||||||
|
assert.Contains(t, out.String(), id)
|
||||||
|
assert.Contains(t, out.String(),
|
||||||
|
"local index is bound to a different backup destination")
|
||||||
|
assert.NotContains(t, out.String(), "Could not list backup destination store")
|
||||||
|
assert.NotContains(t, out.String(), markerFile)
|
||||||
|
|
||||||
|
out.Reset()
|
||||||
|
require.NoError(t, v.ListSnapshots(true))
|
||||||
|
|
||||||
|
rows := decodeListJSON(t, out.String())
|
||||||
|
require.Len(t, rows, 1)
|
||||||
|
require.NotNil(t, rows[0].RemotePresent)
|
||||||
|
assert.False(t, *rows[0].RemotePresent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBackupAfterNukeRemoteSucceeds checks that remote nuke leaves the
|
||||||
|
// destination ready for a new first backup, which writes the marker
|
||||||
|
// again.
|
||||||
|
func TestBackupAfterNukeRemoteSucceeds(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
storeDir := t.TempDir()
|
||||||
|
v, _, _ := backUpToFileDestination(context.Background(), t, storeDir)
|
||||||
|
|
||||||
|
require.NoError(t, v.NukeRemote(true))
|
||||||
|
assert.NoFileExists(t, filepath.Join(storeDir, markerFile))
|
||||||
|
|
||||||
|
require.NoError(t, backUp(v, "second"))
|
||||||
|
assert.FileExists(t, filepath.Join(storeDir, markerFile))
|
||||||
|
}
|
||||||
@@ -192,7 +192,7 @@ func assertRestoreRejectsDamagedBlob(
|
|||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
dataDir := filepath.Join(tempDir, "src")
|
dataDir := filepath.Join(tempDir, "src")
|
||||||
storeDir := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
restoreDir := filepath.Join(tempDir, "restored")
|
restoreDir := filepath.Join(tempDir, "restored")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
@@ -238,7 +238,7 @@ func TestDeepVerifyCatchesLyingBackend(t *testing.T) {
|
|||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
dataDir := filepath.Join(tempDir, "src")
|
dataDir := filepath.Join(tempDir, "src")
|
||||||
storeDir := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -290,7 +290,7 @@ func TestInterruptedBlobUploadRecordsNoUploadedBlob(t *testing.T) {
|
|||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
dataDir := filepath.Join(tempDir, "src")
|
dataDir := filepath.Join(tempDir, "src")
|
||||||
storeDir := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -298,10 +298,6 @@ func TestInterruptedBlobUploadRecordsNoUploadedBlob(t *testing.T) {
|
|||||||
|
|
||||||
writeFaultSourceTree(t, fs, dataDir)
|
writeFaultSourceTree(t, fs, dataDir)
|
||||||
|
|
||||||
// No upload succeeds, so nothing creates the destination directory.
|
|
||||||
// It must exist for the listing below to show that no blob survived.
|
|
||||||
require.NoError(t, os.Mkdir(storeDir, 0o750))
|
|
||||||
|
|
||||||
inner, err := storage.NewFileStorer(storeDir)
|
inner, err := storage.NewFileStorer(storeDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@@ -365,7 +361,7 @@ func TestBackupRetryAfterInterruptedUploadIsRestorable(t *testing.T) {
|
|||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
dataDir := filepath.Join(tempDir, "src")
|
dataDir := filepath.Join(tempDir, "src")
|
||||||
storeDir := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
restoreDir := filepath.Join(tempDir, "restored")
|
restoreDir := filepath.Join(tempDir, "restored")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
@@ -429,7 +425,7 @@ func TestBackupSurvivesMetadataExportInterruption(t *testing.T) {
|
|||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
dataDir := filepath.Join(tempDir, "src")
|
dataDir := filepath.Join(tempDir, "src")
|
||||||
storeDir := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -445,6 +441,10 @@ func TestBackupSurvivesMetadataExportInterruption(t *testing.T) {
|
|||||||
|
|
||||||
repos := database.NewRepositories(db)
|
repos := database.NewRepositories(db)
|
||||||
|
|
||||||
|
// snapshot create writes the marker; this backup runs its steps one
|
||||||
|
// by one, so the marker is written here.
|
||||||
|
require.NoError(t, inner.Put(ctx, markerFile, strings.NewReader("")))
|
||||||
|
|
||||||
// Back up and complete with a working backend.
|
// Back up and complete with a working backend.
|
||||||
sm := newFaultSnapshotManager(fs, inner, cfg, repos)
|
sm := newFaultSnapshotManager(fs, inner, cfg, repos)
|
||||||
scanner := newFaultScanner(fs, inner, cfg, repos)
|
scanner := newFaultScanner(fs, inner, cfg, repos)
|
||||||
@@ -493,8 +493,14 @@ func TestBackupSurvivesMetadataExportInterruption(t *testing.T) {
|
|||||||
"shallow verify must report the missing manifest")
|
"shallow verify must report the missing manifest")
|
||||||
|
|
||||||
// snapshot list must not crash on the partial snapshot.
|
// snapshot list must not crash on the partial snapshot.
|
||||||
|
var listOut strings.Builder
|
||||||
|
|
||||||
|
reader.UI = ui.NewWithColor(&listOut, false)
|
||||||
require.NoError(t, reader.ListSnapshots(false),
|
require.NoError(t, reader.ListSnapshots(false),
|
||||||
"snapshot list must tolerate a partially-exported snapshot")
|
"snapshot list must tolerate a partially-exported snapshot")
|
||||||
|
assert.NotContains(t, listOut.String(),
|
||||||
|
"Could not list backup destination store",
|
||||||
|
"snapshot list must read the partial snapshot from the destination")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scenario 2, repair: the process dies during the metadata export of a
|
// Scenario 2, repair: the process dies during the metadata export of a
|
||||||
@@ -511,7 +517,7 @@ func TestBackupCompletesOnlyAfterMetadataExport(t *testing.T) {
|
|||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
dataDir := filepath.Join(tempDir, "src")
|
dataDir := filepath.Join(tempDir, "src")
|
||||||
storeDir := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
restoreDir := filepath.Join(tempDir, "restored")
|
restoreDir := filepath.Join(tempDir, "restored")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
@@ -661,17 +667,19 @@ func faultScannerFactory(
|
|||||||
) snapshot.ScannerFactory {
|
) snapshot.ScannerFactory {
|
||||||
return func(params snapshot.ScannerParams) *snapshot.Scanner {
|
return func(params snapshot.ScannerParams) *snapshot.Scanner {
|
||||||
return snapshot.NewScanner(snapshot.ScannerConfig{
|
return snapshot.NewScanner(snapshot.ScannerConfig{
|
||||||
FS: params.Fs,
|
FS: params.Fs,
|
||||||
Storage: storer,
|
Storage: storer,
|
||||||
ChunkSize: faultChunkSize,
|
ChunkSize: faultChunkSize,
|
||||||
MaxBlobSize: faultMaxBlobSize,
|
MaxBlobSize: faultMaxBlobSize,
|
||||||
CompressionLevel: cfg.CompressionLevel,
|
CompressionLevel: cfg.CompressionLevel,
|
||||||
AgeRecipients: cfg.AgeRecipients,
|
AgeRecipients: cfg.AgeRecipients,
|
||||||
Repositories: repos,
|
Repositories: repos,
|
||||||
EnableProgress: params.EnableProgress,
|
EnableProgress: params.EnableProgress,
|
||||||
UI: params.UI,
|
UI: params.UI,
|
||||||
Exclude: params.Exclude,
|
Exclude: params.Exclude,
|
||||||
SkipErrors: params.SkipErrors,
|
SkipErrors: params.SkipErrors,
|
||||||
|
PreviousSnapshotID: params.PreviousSnapshotID,
|
||||||
|
DeviceNumber: params.DeviceNumber,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -686,7 +694,7 @@ func TestRestoreReportsDiskFull(t *testing.T) {
|
|||||||
osFS := afero.NewOsFs()
|
osFS := afero.NewOsFs()
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
dataDir := filepath.Join(tempDir, "src")
|
dataDir := filepath.Join(tempDir, "src")
|
||||||
storeDir := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
restoreDir := filepath.Join(tempDir, "restored")
|
restoreDir := filepath.Join(tempDir, "restored")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
|
|||||||
@@ -246,7 +246,7 @@ func (v *Vaultik) RemoteInfo(jsonOutput bool) error {
|
|||||||
v.stdoutf("Type: %s\n", storageInfo.Type)
|
v.stdoutf("Type: %s\n", storageInfo.Type)
|
||||||
v.stdoutf("Location: %s\n", storageInfo.Location)
|
v.stdoutf("Location: %s\n", storageInfo.Location)
|
||||||
v.printlnStdout()
|
v.printlnStdout()
|
||||||
v.stdoutf("Scanning snapshot metadata...\n")
|
v.UI.Beginf("Scanning snapshot metadata...")
|
||||||
}
|
}
|
||||||
|
|
||||||
snapshotMetadata, snapshotIDs, skippedManifestCount, err := v.collectSnapshotMetadata()
|
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(
|
referencedBlobs, unreadableManifests := v.collectReferencedBlobsFromManifests(
|
||||||
@@ -447,7 +447,7 @@ func (v *Vaultik) scanRemoteBlobStorage(
|
|||||||
result *RemoteInfoResult, referencedBlobs map[string]int64, showText bool,
|
result *RemoteInfoResult, referencedBlobs map[string]int64, showText bool,
|
||||||
) error {
|
) error {
|
||||||
if showText {
|
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
|
// Use prune's listing, so a file under blobs/ that prune would never
|
||||||
@@ -457,9 +457,9 @@ func (v *Vaultik) scanRemoteBlobStorage(
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, size := range allBlobs {
|
for _, blob := range allBlobs {
|
||||||
result.TotalBlobCount++
|
result.TotalBlobCount++
|
||||||
result.TotalBlobSize += size
|
result.TotalBlobSize += blob.Size
|
||||||
}
|
}
|
||||||
|
|
||||||
// A blob named only by a manifest that could not be read, or by one
|
// A blob named only by a manifest that could not be read, or by one
|
||||||
@@ -474,10 +474,10 @@ func (v *Vaultik) scanRemoteBlobStorage(
|
|||||||
orphanedSize int64
|
orphanedSize int64
|
||||||
)
|
)
|
||||||
|
|
||||||
for hash, size := range allBlobs {
|
for hash, blob := range allBlobs {
|
||||||
if _, referenced := referencedBlobs[hash]; !referenced {
|
if _, referenced := referencedBlobs[hash]; !referenced {
|
||||||
orphanedCount++
|
orphanedCount++
|
||||||
orphanedSize += size
|
orphanedSize += blob.Size
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -771,7 +771,7 @@ func TestEndToEndFileStorage(t *testing.T) {
|
|||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
dataDir := filepath.Join(tempDir, "source")
|
dataDir := filepath.Join(tempDir, "source")
|
||||||
storeDir := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
restoreDir := filepath.Join(tempDir, "restored")
|
restoreDir := filepath.Join(tempDir, "restored")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
@@ -964,7 +964,7 @@ func TestDedupOnlySnapshotRestores(t *testing.T) {
|
|||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
dataDir := filepath.Join(tempDir, "source")
|
dataDir := filepath.Join(tempDir, "source")
|
||||||
storeDir := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
restoreDir := filepath.Join(tempDir, "restored")
|
restoreDir := filepath.Join(tempDir, "restored")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
@@ -24,21 +25,28 @@ import (
|
|||||||
// that is not plugged in, cannot be listed. It is not an empty store, so
|
// that is not plugged in, cannot be listed. It is not an empty store, so
|
||||||
// no command may conclude from it that the local snapshots are gone.
|
// no command may conclude from it that the local snapshots are gone.
|
||||||
|
|
||||||
// backUpToFileDestination backs up the snapshot named "first" to a
|
// newFileDestinationVaultik writes the fault source tree and returns a
|
||||||
// file:// destination at storeDir, which need not exist yet. Everything
|
// Vaultik that backs it up to a file:// destination at storeDir.
|
||||||
// the returned Vaultik prints after the backup goes to the returned
|
func newFileDestinationVaultik(
|
||||||
// buffer.
|
|
||||||
func backUpToFileDestination(
|
|
||||||
ctx context.Context, t *testing.T, storeDir string,
|
ctx context.Context, t *testing.T, storeDir string,
|
||||||
) (*vaultik.Vaultik, *database.Repositories, *bytes.Buffer) {
|
) (*vaultik.Vaultik, *database.Repositories) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
osFs := afero.NewOsFs()
|
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
dataDir := filepath.Join(tempDir, "src")
|
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)
|
store, err := storage.NewFileStorer(storeDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -49,9 +57,85 @@ func backUpToFileDestination(
|
|||||||
|
|
||||||
repos := database.NewRepositories(db)
|
repos := database.NewRepositories(db)
|
||||||
cfg := changedFileConfig(dataDir, dbPath)
|
cfg := changedFileConfig(dataDir, dbPath)
|
||||||
v := newBackupVaultik(ctx, cfg, store, repos, db, osFs)
|
|
||||||
|
|
||||||
|
return newBackupVaultik(ctx, cfg, store, repos, db, 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, 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 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()
|
||||||
|
|
||||||
|
firstBackup.once.Do(func() { makeFirstBackup(ctx, t) })
|
||||||
|
require.True(t, firstBackup.ready, "another test failed to make firstBackup")
|
||||||
|
|
||||||
|
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{}
|
out := &bytes.Buffer{}
|
||||||
v.Stdout = out
|
v.Stdout = out
|
||||||
@@ -75,21 +159,28 @@ func backUpThenUnplug(
|
|||||||
return v, repos, out
|
return v, repos, out
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestFirstBackupCreatesDestinationDirectory checks that a first backup
|
// TestBackupFailsWhenDestinationDirectoryMissing checks that a backup to
|
||||||
// to a destination directory that does not exist yet creates it, and
|
// a file:// destination whose directory does not exist fails before it
|
||||||
// that the destination can be listed afterwards.
|
// records a snapshot, and does not create the directory.
|
||||||
func TestFirstBackupCreatesDestinationDirectory(t *testing.T) {
|
func TestBackupFailsWhenDestinationDirectoryMissing(t *testing.T) {
|
||||||
log.Initialize(log.Config{})
|
log.Initialize(log.Config{})
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
storeDir := filepath.Join(t.TempDir(), "volume", "backup")
|
volume := filepath.Join(t.TempDir(), "volume")
|
||||||
v, _, out := backUpToFileDestination(ctx, t, storeDir)
|
v, repos := newFileDestinationVaultik(ctx, t, filepath.Join(volume, "backup"))
|
||||||
|
|
||||||
require.NoError(t, v.ListSnapshots(false))
|
err := backUp(v, "first")
|
||||||
|
require.ErrorIs(t, err, fs.ErrNotExist)
|
||||||
|
require.ErrorContains(t, err, "create it, or mount the volume it is on")
|
||||||
|
|
||||||
assert.NotContains(t, out.String(), "Could not list backup destination store")
|
_, err = os.Stat(volume)
|
||||||
assert.NotContains(t, out.String(), "not found in backup destination store")
|
require.ErrorIs(t, err, fs.ErrNotExist,
|
||||||
|
"the backup must not create the destination directory")
|
||||||
|
|
||||||
|
snapshots, err := repos.Snapshots.ListRecent(ctx, listRecentTestLimit)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, snapshots)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestListSnapshotsWarnsWhenDestinationMissing checks that snapshot list
|
// TestListSnapshotsWarnsWhenDestinationMissing checks that snapshot list
|
||||||
@@ -169,3 +260,41 @@ func TestPurgeSaysListingFailedOnceWhenDestinationMissing(t *testing.T) {
|
|||||||
assert.Equal(t, 1, strings.Count(err.Error(), "listing remote snapshots"),
|
assert.Equal(t, 1, strings.Count(err.Error(), "listing remote snapshots"),
|
||||||
err.Error())
|
err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestNukeRemoteKeepsLocalIndexWhenDestinationMissing checks that remote
|
||||||
|
// nuke fails on a destination it cannot list, leaves the local index
|
||||||
|
// unchanged, and neither reports the destination empty nor advises
|
||||||
|
// running nuke again.
|
||||||
|
func TestNukeRemoteKeepsLocalIndexWhenDestinationMissing(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
v, repos, out := backUpThenUnplug(ctx, t)
|
||||||
|
|
||||||
|
filesBefore, err := repos.Files.ListAll(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEmpty(t, filesBefore)
|
||||||
|
|
||||||
|
blobsBefore, err := repos.Blobs.GetAll(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEmpty(t, blobsBefore)
|
||||||
|
|
||||||
|
err = v.NukeRemote(true)
|
||||||
|
require.ErrorIs(t, err, fs.ErrNotExist)
|
||||||
|
require.ErrorContains(t, err, "listing remote snapshots")
|
||||||
|
assert.NotContains(t, out.String(), "now empty")
|
||||||
|
assert.NotContains(t, out.String(), "vaultik remote nuke --force")
|
||||||
|
|
||||||
|
snapshots, err := repos.Snapshots.ListRecent(ctx, listRecentTestLimit)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, snapshots, 1, "nuke must delete no local snapshot record")
|
||||||
|
|
||||||
|
files, err := repos.Files.ListAll(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, files, len(filesBefore), "nuke must delete no local file record")
|
||||||
|
|
||||||
|
blobs, err := repos.Blobs.GetAll(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, blobs, len(blobsBefore), "nuke must delete no local blob record")
|
||||||
|
}
|
||||||
@@ -39,7 +39,7 @@ func TestNewChunkSizeOfChangedFileStaysFixed(t *testing.T) {
|
|||||||
|
|
||||||
appendRandomBytes(t, fs, files, changedPath, appendedFileSize)
|
appendRandomBytes(t, fs, files, changedPath, appendedFileSize)
|
||||||
|
|
||||||
store, err := storage.NewFileStorer(filepath.Join(tempDir, "remote"))
|
store, err := storage.NewFileStorer(t.TempDir())
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
db, err := database.New(ctx, dbPath)
|
db, err := database.New(ctx, dbPath)
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ import (
|
|||||||
// TestNukeRemoteLeavesNoFiles checks that remote nuke leaves no file
|
// TestNukeRemoteLeavesNoFiles checks that remote nuke leaves no file
|
||||||
// under a file:// destination, including the `.partial` files uploads
|
// under a file:// destination, including the `.partial` files uploads
|
||||||
// killed part-way leave next to a snapshot's metadata and next to where a
|
// killed part-way leave next to a snapshot's metadata and next to where a
|
||||||
// blob would have been.
|
// blob would have been, a snapshot's database whose manifest was never
|
||||||
|
// uploaded, and a file under blobs/ whose name is not a blob hash.
|
||||||
func TestNukeRemoteLeavesNoFiles(t *testing.T) {
|
func TestNukeRemoteLeavesNoFiles(t *testing.T) {
|
||||||
log.Initialize(log.Config{})
|
log.Initialize(log.Config{})
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -38,6 +39,9 @@ func TestNukeRemoteLeavesNoFiles(t *testing.T) {
|
|||||||
"db.zst.age-123456.partial"),
|
"db.zst.age-123456.partial"),
|
||||||
filepath.Join(storeDir, "blobs", hash[:2], hash[2:4],
|
filepath.Join(storeDir, "blobs", hash[:2], hash[2:4],
|
||||||
hash+"-123456.partial"),
|
hash+"-123456.partial"),
|
||||||
|
filepath.Join(storeDir, "metadata",
|
||||||
|
snapshot.RemoteSnapshotKey("no-manifest"), "db.zst.age"),
|
||||||
|
filepath.Join(storeDir, "blobs", "0b", "01", ".DS_Store"),
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, leftover := range leftovers {
|
for _, leftover := range leftovers {
|
||||||
@@ -72,7 +76,7 @@ func TestNukeRemoteLeavesNoFiles(t *testing.T) {
|
|||||||
// TestNukeRemoteFailsWhenADeleteFails checks that remote nuke returns an
|
// TestNukeRemoteFailsWhenADeleteFails checks that remote nuke returns an
|
||||||
// error when a snapshot's metadata or a blob cannot be deleted, does not
|
// 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
|
// 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) {
|
func TestNukeRemoteFailsWhenADeleteFails(t *testing.T) {
|
||||||
log.Initialize(log.Config{})
|
log.Initialize(log.Config{})
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -100,6 +104,60 @@ func TestNukeRemoteFailsWhenADeleteFails(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestNukeRemoteKeepsLocalSnapshotWhoseMetadataIsNotDeleted checks that
|
||||||
|
// remote nuke keeps a snapshot in the local index while its metadata
|
||||||
|
// cannot be deleted from the destination store: dropped from the local
|
||||||
|
// index, it would be left there as a remote-only snapshot without its
|
||||||
|
// name.
|
||||||
|
func TestNukeRemoteKeepsLocalSnapshotWhoseMetadataIsNotDeleted(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
storeDir := filepath.Join(t.TempDir(), "store")
|
||||||
|
v, repos, _ := backUpToFileDestination(ctx, t, storeDir)
|
||||||
|
|
||||||
|
store := faultstore.New(v.Storage)
|
||||||
|
store.OnDelete = func(key string) bool {
|
||||||
|
return strings.HasPrefix(key, "metadata/")
|
||||||
|
}
|
||||||
|
v.Storage = store
|
||||||
|
|
||||||
|
require.Error(t, v.NukeRemote(true))
|
||||||
|
|
||||||
|
snapshots, err := repos.Snapshots.ListRecent(ctx, listRecentTestLimit)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, snapshots, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNukeRemoteNamesWhatIsLeft checks that remote nuke, when an object
|
||||||
|
// it does not recognise cannot be deleted, does not report the store
|
||||||
|
// empty but names that object and fails.
|
||||||
|
func TestNukeRemoteNamesWhatIsLeft(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
storeDir := filepath.Join(t.TempDir(), "store")
|
||||||
|
v, _, out := backUpToFileDestination(ctx, t, storeDir)
|
||||||
|
|
||||||
|
const leftKey = "blobs/0b/01/.DS_Store"
|
||||||
|
|
||||||
|
leftPath := filepath.Join(storeDir, filepath.FromSlash(leftKey))
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Dir(leftPath), 0o750))
|
||||||
|
require.NoError(t, os.WriteFile(leftPath, []byte("finder"), 0o600))
|
||||||
|
|
||||||
|
store := faultstore.New(v.Storage)
|
||||||
|
store.OnDelete = func(key string) bool { return key == leftKey }
|
||||||
|
v.Storage = store
|
||||||
|
|
||||||
|
require.Error(t, v.NukeRemote(true))
|
||||||
|
|
||||||
|
assert.Contains(t, out.String(), leftKey)
|
||||||
|
assert.NotContains(t, out.String(), "now empty")
|
||||||
|
assert.Contains(t, out.String(), "vaultik remote nuke --force")
|
||||||
|
}
|
||||||
|
|
||||||
// TestNukeRemoteRefusesAnotherDestinationWithoutRetryAdvice checks that
|
// TestNukeRemoteRefusesAnotherDestinationWithoutRetryAdvice checks that
|
||||||
// remote nuke, when the local index belongs to a different destination
|
// remote nuke, when the local index belongs to a different destination
|
||||||
// than the configured one, fails without advising to run nuke again:
|
// than the configured one, fails without advising to run nuke again:
|
||||||
@@ -122,8 +180,7 @@ func TestNukeRemoteRefusesAnotherDestinationWithoutRetryAdvice(t *testing.T) {
|
|||||||
|
|
||||||
// TestNukeRemoteFailsWhenALocalSnapshotCannotBeRemoved checks that a
|
// TestNukeRemoteFailsWhenALocalSnapshotCannotBeRemoved checks that a
|
||||||
// snapshot remote nuke cannot remove from the local index fails the
|
// snapshot remote nuke cannot remove from the local index fails the
|
||||||
// run: nuke then leaves that snapshot's metadata on the destination
|
// run, and that nuke then does not report the destination store empty.
|
||||||
// store.
|
|
||||||
func TestNukeRemoteFailsWhenALocalSnapshotCannotBeRemoved(t *testing.T) {
|
func TestNukeRemoteFailsWhenALocalSnapshotCannotBeRemoved(t *testing.T) {
|
||||||
log.Initialize(log.Config{})
|
log.Initialize(log.Config{})
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|||||||
+381
-91
@@ -4,9 +4,13 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/vaultik/internal/log"
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
|
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||||
|
"sneak.berlin/go/vaultik/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PruneOptions contains options for the prune command
|
// PruneOptions contains options for the prune command
|
||||||
@@ -22,9 +26,20 @@ var errNukeRequiresForce = errors.New(
|
|||||||
// errBlobDeleteFailed reports unreferenced blobs prune could not delete.
|
// errBlobDeleteFailed reports unreferenced blobs prune could not delete.
|
||||||
var errBlobDeleteFailed = errors.New("blob(s) could not be deleted")
|
var errBlobDeleteFailed = errors.New("blob(s) could not be deleted")
|
||||||
|
|
||||||
|
// 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(
|
||||||
|
"object(s) still on the backup destination store")
|
||||||
|
|
||||||
// nukeCommandHint is the command suggested when a remote nuke could not
|
// nukeCommandHint is the command suggested when a remote nuke could not
|
||||||
// delete everything: running it again deletes what is left, which
|
// delete everything: running it again deletes what is left, which
|
||||||
// `vaultik prune` does not do for snapshot metadata.
|
// `vaultik prune` does not do for snapshot metadata that has its
|
||||||
|
// manifest.
|
||||||
const nukeCommandHint = "vaultik remote nuke --force"
|
const nukeCommandHint = "vaultik remote nuke --force"
|
||||||
|
|
||||||
// metadataDirName is the top-level remote directory holding snapshot
|
// metadataDirName is the top-level remote directory holding snapshot
|
||||||
@@ -35,10 +50,12 @@ const metadataDirName = "metadata"
|
|||||||
// (blobs/<aa>/<bb>/<hash>).
|
// (blobs/<aa>/<bb>/<hash>).
|
||||||
const blobKeyParts = 4
|
const blobKeyParts = 4
|
||||||
|
|
||||||
// NukeRemote deletes every snapshot's metadata and every blob from remote
|
// NukeRemote deletes every object under metadata/ and blobs/ in remote
|
||||||
// storage, along with any object an upload cut off part-way left under a
|
// storage, whatever its name, along with any object an upload cut off
|
||||||
// temporary `.partial` name. After this returns successfully the bucket
|
// part-way left under a temporary `.partial` name, and last the
|
||||||
// prefix is empty and the next backup starts from scratch.
|
// destination marker. It returns successfully only when a listing
|
||||||
|
// afterwards finds nothing left under metadata/ and blobs/, and the next
|
||||||
|
// backup then starts from scratch.
|
||||||
//
|
//
|
||||||
// Refuses to run unless force is true. The caller is responsible for
|
// Refuses to run unless force is true. The caller is responsible for
|
||||||
// confirming with the user.
|
// confirming with the user.
|
||||||
@@ -48,12 +65,19 @@ func (v *Vaultik) NukeRemote(force bool) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Checked before anything is deleted: running nuke again would fail
|
// Checked before anything is deleted: running nuke again would fail
|
||||||
// the same way, so the advice below must not follow this error.
|
// the same way, so the advice below must not follow these errors.
|
||||||
|
// The listing also fails on a destination that answers that it has
|
||||||
|
// no marker while the local index holds snapshots.
|
||||||
err := v.EnsureStorageBinding()
|
err := v.EnsureStorageBinding()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_, err = v.listAllRemoteSnapshotKeys()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
err = v.emptyDestinationStore()
|
err = v.emptyDestinationStore()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
v.UI.Warningf("Run '%s' again to delete what is left on the "+
|
v.UI.Warningf("Run '%s' again to delete what is left on the "+
|
||||||
@@ -68,7 +92,8 @@ func (v *Vaultik) NukeRemote(force bool) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// emptyDestinationStore does the deleting for NukeRemote and stops at
|
// emptyDestinationStore does the deleting for NukeRemote and stops at
|
||||||
// the first step that fails.
|
// the first step that fails. It also fails when objects are still
|
||||||
|
// listed at the end, naming each one.
|
||||||
func (v *Vaultik) emptyDestinationStore() error {
|
func (v *Vaultik) emptyDestinationStore() error {
|
||||||
v.UI.Beginf("Removing all snapshot metadata from backup destination store.")
|
v.UI.Beginf("Removing all snapshot metadata from backup destination store.")
|
||||||
|
|
||||||
@@ -77,25 +102,69 @@ func (v *Vaultik) emptyDestinationStore() error {
|
|||||||
return fmt.Errorf("removing all snapshots: %w", err)
|
return fmt.Errorf("removing all snapshots: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
v.UI.Beginf("Removing any blobs still present in backup destination store.")
|
v.UI.Beginf("Removing everything still present in backup destination store.")
|
||||||
|
|
||||||
err = v.PruneBlobs(&PruneOptions{Force: true})
|
var left []string
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("pruning blobs: %w", err)
|
for _, prefix := range []string{"metadata/", "blobs/"} {
|
||||||
|
keys, err := v.deleteEverythingUnder(prefix)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
left = append(left, keys...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The file and rclone listings skip `.partial` objects, so the two
|
for _, key := range left {
|
||||||
// steps above never delete them.
|
v.UI.Warningf("Still on the backup destination store: %s", key)
|
||||||
for _, prefix := range []string{"metadata/", "blobs/"} {
|
}
|
||||||
err = v.Storage.DeletePartialUploads(v.ctx, prefix)
|
|
||||||
if err != nil {
|
if len(left) > 0 {
|
||||||
return fmt.Errorf("deleting partial uploads: %w", err)
|
return fmt.Errorf("%d %w", len(left), errObjectsLeft)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Last, once the local snapshot records are gone: the next backup
|
||||||
|
// then has none and writes the marker again.
|
||||||
|
err = v.Storage.Delete(v.ctx, destinationMarkerKey)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("deleting %s: %w", destinationMarkerKey, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// deleteEverythingUnder deletes every object under prefix, whatever its
|
||||||
|
// name, and returns the keys a listing afterwards still finds. A delete
|
||||||
|
// that fails is logged and the others still run; that object then
|
||||||
|
// shows up in the listing afterwards.
|
||||||
|
func (v *Vaultik) deleteEverythingUnder(prefix string) ([]string, error) {
|
||||||
|
keys, err := v.Storage.List(v.ctx, prefix)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("listing %s: %w", prefix, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, key := range keys {
|
||||||
|
err = v.Storage.Delete(v.ctx, key)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Failed to delete object", "key", key, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The file and rclone listings skip `.partial` objects, so the
|
||||||
|
// deletes above never reach them.
|
||||||
|
err = v.Storage.DeletePartialUploads(v.ctx, prefix)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("deleting partial uploads: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
left, err := v.Storage.List(v.ctx, prefix)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("listing %s: %w", prefix, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return left, nil
|
||||||
|
}
|
||||||
|
|
||||||
// PruneBlobsResult contains the result of a blob prune operation
|
// PruneBlobsResult contains the result of a blob prune operation
|
||||||
//
|
//
|
||||||
//nolint:tagliatelle // snake_case is the established JSON output format
|
//nolint:tagliatelle // snake_case is the established JSON output format
|
||||||
@@ -104,14 +173,24 @@ type PruneBlobsResult struct {
|
|||||||
BlobsDeleted int `json:"blobs_deleted"`
|
BlobsDeleted int `json:"blobs_deleted"`
|
||||||
BlobsFailed int `json:"blobs_failed,omitempty"`
|
BlobsFailed int `json:"blobs_failed,omitempty"`
|
||||||
BytesFreed int64 `json:"bytes_freed"`
|
BytesFreed int64 `json:"bytes_freed"`
|
||||||
|
// BlobsTooNew counts the unreferenced blobs kept because they are
|
||||||
|
// 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
|
// Prune removes orphaned data from the local index database AND, from the
|
||||||
// unreferenced blobs from the backup destination store. This is the
|
// backup destination store, unreferenced blobs and the directories under
|
||||||
// single user-facing prune entry point — the split between local and
|
// metadata/ with no manifest. This is the single user-facing prune entry
|
||||||
// remote cleanup is an implementation detail. Calling code should
|
// point — the split between local and remote cleanup is an
|
||||||
// prefer this method over PruneDatabase or PruneBlobs individually
|
// implementation detail. Calling code should prefer this method over
|
||||||
// unless it specifically wants one half.
|
// PruneDatabase or PruneBlobs individually unless it specifically wants
|
||||||
|
// one half.
|
||||||
func (v *Vaultik) Prune(opts *PruneOptions) error {
|
func (v *Vaultik) Prune(opts *PruneOptions) error {
|
||||||
err := v.EnsureStorageBinding()
|
err := v.EnsureStorageBinding()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -135,11 +214,17 @@ func (v *Vaultik) Prune(opts *PruneOptions) error {
|
|||||||
return v.PruneBlobs(opts)
|
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 {
|
func (v *Vaultik) PruneBlobs(opts *PruneOptions) error {
|
||||||
log.Info("Starting prune operation")
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -149,50 +234,44 @@ func (v *Vaultik) PruneBlobs(opts *PruneOptions) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
unreferencedBlobs, totalSize := v.findUnreferencedBlobs(allBlobs, allBlobsReferenced)
|
unreferencedBlobs, totalSize, tooNew := v.findUnreferencedBlobs(
|
||||||
|
allBlobs, allBlobsReferenced)
|
||||||
|
|
||||||
result := &PruneBlobsResult{BlobsFound: len(unreferencedBlobs)}
|
dirsWithoutManifest, err := v.findMetadataDirsWithoutManifest(metadataObjects)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
if len(unreferencedBlobs) == 0 {
|
result := &PruneBlobsResult{
|
||||||
|
BlobsFound: len(unreferencedBlobs),
|
||||||
|
BlobsTooNew: tooNew,
|
||||||
|
}
|
||||||
|
|
||||||
|
v.reportBlobsTooNew(tooNew, opts)
|
||||||
|
|
||||||
|
if len(unreferencedBlobs) == 0 && len(dirsWithoutManifest) == 0 {
|
||||||
log.Info("No unreferenced blobs found")
|
log.Info("No unreferenced blobs found")
|
||||||
|
|
||||||
if opts.JSON {
|
if opts.JSON {
|
||||||
return v.outputPruneBlobsJSON(result)
|
return v.outputPruneBlobsJSON(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
v.printlnStdout("No unreferenced blobs to remove.")
|
v.UI.Infof("No unreferenced blobs to remove.")
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Info("Found unreferenced blobs",
|
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.JSON {
|
if !v.confirmPrune(opts, len(unreferencedBlobs), totalSize,
|
||||||
v.stdoutf("Found %d unreferenced blob(s) totaling %s\n",
|
dirsWithoutManifest) {
|
||||||
len(unreferencedBlobs), ubytes(totalSize))
|
return nil
|
||||||
}
|
|
||||||
|
|
||||||
if !opts.Force && !opts.JSON {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
v.deleteUnreferencedBlobs(unreferencedBlobs, allBlobs, result)
|
v.deleteUnreferencedBlobs(unreferencedBlobs, allBlobs, result)
|
||||||
|
v.deleteMetadataDirs(dirsWithoutManifest, result)
|
||||||
|
|
||||||
if opts.JSON {
|
if opts.JSON {
|
||||||
err = v.outputPruneBlobsJSON(result)
|
err = v.outputPruneBlobsJSON(result)
|
||||||
@@ -200,33 +279,142 @@ func (v *Vaultik) PruneBlobs(opts *PruneOptions) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
v.stdoutf("\nDeleted %d blob(s) totaling %s\n",
|
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))
|
result.BlobsDeleted, ubytes(result.BytesFreed))
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.BlobsFailed > 0 {
|
if dirCount > 0 {
|
||||||
return fmt.Errorf("%d %w", result.BlobsFailed, errBlobDeleteFailed)
|
v.UI.Infof("Deleted %d metadata director(ies) with no manifest.",
|
||||||
|
result.MetadataDirsDeleted)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// collectReferencedBlobs downloads all manifests and returns the set of
|
// listMetadataObjects lists every object under metadata/ on the
|
||||||
// referenced blob hashes.
|
// 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
|
// Every manifest must be read successfully. A manifest that cannot be
|
||||||
// downloaded or decoded means its snapshot's blobs are unknown, so
|
// downloaded or decoded means its snapshot's blobs are unknown, so
|
||||||
// treating them as unreferenced would let prune delete data a snapshot
|
// treating them as unreferenced would let prune delete data a snapshot
|
||||||
// still needs. Rather than risk that silent loss, any failure returns an
|
// still needs. Rather than risk that silent loss, any failure returns an
|
||||||
// error naming the remote key and prune deletes nothing.
|
// error naming the remote key and prune deletes nothing.
|
||||||
func (v *Vaultik) collectReferencedBlobs() (map[string]bool, error) {
|
func (v *Vaultik) collectReferencedBlobs(
|
||||||
log.Info("Listing remote snapshots")
|
metadataObjects []storage.ObjectInfo,
|
||||||
// IDs returned by listUniqueSnapshotIDs are remote keys (hashed
|
) (map[string]bool, error) {
|
||||||
|
// IDs returned by uniqueSnapshotIDs are remote keys (hashed
|
||||||
// subdirectories under metadata/), not human snapshot IDs.
|
// subdirectories under metadata/), not human snapshot IDs.
|
||||||
remoteKeys, err := v.listUniqueSnapshotIDs()
|
remoteKeys := uniqueSnapshotIDs(metadataObjects)
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("listing snapshot keys: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Info("Found manifests in remote storage", "count", len(remoteKeys))
|
log.Info("Found manifests in remote storage", "count", len(remoteKeys))
|
||||||
|
|
||||||
@@ -251,18 +439,14 @@ func (v *Vaultik) collectReferencedBlobs() (map[string]bool, error) {
|
|||||||
return allBlobsReferenced, nil
|
return allBlobsReferenced, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// listUniqueSnapshotIDs returns deduplicated snapshot IDs from remote metadata
|
// uniqueSnapshotIDs returns deduplicated snapshot IDs from the listed
|
||||||
func (v *Vaultik) listUniqueSnapshotIDs() ([]string, error) {
|
// metadata objects.
|
||||||
objectCh := v.Storage.ListStream(v.ctx, "metadata/")
|
func uniqueSnapshotIDs(metadataObjects []storage.ObjectInfo) []string {
|
||||||
seen := make(map[string]bool)
|
seen := make(map[string]bool)
|
||||||
|
|
||||||
var snapshotIDs []string
|
var snapshotIDs []string
|
||||||
|
|
||||||
for object := range objectCh {
|
for _, object := range metadataObjects {
|
||||||
if object.Err != nil {
|
|
||||||
return nil, fmt.Errorf("listing metadata objects: %w", object.Err)
|
|
||||||
}
|
|
||||||
|
|
||||||
parts := strings.Split(object.Key, "/")
|
parts := strings.Split(object.Key, "/")
|
||||||
if len(parts) >= minSnapshotIDParts &&
|
if len(parts) >= minSnapshotIDParts &&
|
||||||
parts[0] == metadataDirName && parts[1] != "" {
|
parts[0] == metadataDirName && parts[1] != "" {
|
||||||
@@ -277,14 +461,15 @@ func (v *Vaultik) listUniqueSnapshotIDs() ([]string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return snapshotIDs, nil
|
return snapshotIDs
|
||||||
}
|
}
|
||||||
|
|
||||||
// listAllRemoteBlobs returns a map of all blob hashes to their sizes in remote storage
|
// listAllRemoteBlobs returns a map of all blob hashes in remote storage
|
||||||
func (v *Vaultik) listAllRemoteBlobs() (map[string]int64, error) {
|
// to their listing entries.
|
||||||
|
func (v *Vaultik) listAllRemoteBlobs() (map[string]storage.ObjectInfo, error) {
|
||||||
log.Info("Listing all blobs in storage")
|
log.Info("Listing all blobs in storage")
|
||||||
|
|
||||||
allBlobs := make(map[string]int64)
|
allBlobs := make(map[string]storage.ObjectInfo)
|
||||||
blobObjectCh := v.Storage.ListStream(v.ctx, "blobs/")
|
blobObjectCh := v.Storage.ListStream(v.ctx, "blobs/")
|
||||||
|
|
||||||
for object := range blobObjectCh {
|
for object := range blobObjectCh {
|
||||||
@@ -308,7 +493,7 @@ func (v *Vaultik) listAllRemoteBlobs() (map[string]int64, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
allBlobs[parts[3]] = object.Size
|
allBlobs[parts[3]] = object
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Info("Found blobs in storage", "count", len(allBlobs))
|
log.Info("Found blobs in storage", "count", len(allBlobs))
|
||||||
@@ -316,30 +501,135 @@ func (v *Vaultik) listAllRemoteBlobs() (map[string]int64, error) {
|
|||||||
return allBlobs, nil
|
return allBlobs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// findUnreferencedBlobs returns blob hashes not referenced by any
|
// findUnreferencedBlobs returns the hashes of the blobs no manifest
|
||||||
// manifest and their total size.
|
// references that are older than the prune grace period, and their total
|
||||||
|
// size. It also returns how many unreferenced blobs it left out for
|
||||||
|
// being newer than that, or of unknown age because the listing gave no
|
||||||
|
// time for them.
|
||||||
func (v *Vaultik) findUnreferencedBlobs(
|
func (v *Vaultik) findUnreferencedBlobs(
|
||||||
allBlobs map[string]int64, referenced map[string]bool,
|
allBlobs map[string]storage.ObjectInfo, referenced map[string]bool,
|
||||||
) ([]string, int64) {
|
) ([]string, int64, int) {
|
||||||
var (
|
var (
|
||||||
unreferenced []string
|
unreferenced []string
|
||||||
totalSize int64
|
totalSize int64
|
||||||
|
tooNew int
|
||||||
)
|
)
|
||||||
|
|
||||||
for hash, size := range allBlobs {
|
cutoff := time.Now().UTC().Add(-v.Config.PruneGracePeriod)
|
||||||
if !referenced[hash] {
|
|
||||||
unreferenced = append(unreferenced, hash)
|
for hash, blob := range allBlobs {
|
||||||
totalSize += size
|
if referenced[hash] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if blob.ModTime.IsZero() || blob.ModTime.After(cutoff) {
|
||||||
|
tooNew++
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
unreferenced = append(unreferenced, hash)
|
||||||
|
totalSize += blob.Size
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return unreferenced, totalSize
|
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.
|
||||||
|
func (v *Vaultik) reportBlobsTooNew(tooNew int, opts *PruneOptions) {
|
||||||
|
if tooNew == 0 || opts.JSON {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
v.UI.Infof("Kept %d unreferenced blob(s) newer than "+
|
||||||
|
"prune_grace_period (%s), or of unknown age: a backup still "+
|
||||||
|
"running may need them.",
|
||||||
|
tooNew, v.Config.PruneGracePeriod)
|
||||||
}
|
}
|
||||||
|
|
||||||
// deleteUnreferencedBlobs deletes the given blobs from storage and
|
// deleteUnreferencedBlobs deletes the given blobs from storage and
|
||||||
// populates the result.
|
// populates the result.
|
||||||
func (v *Vaultik) deleteUnreferencedBlobs(
|
func (v *Vaultik) deleteUnreferencedBlobs(
|
||||||
unreferencedBlobs []string, allBlobs map[string]int64, result *PruneBlobsResult,
|
unreferencedBlobs []string, allBlobs map[string]storage.ObjectInfo,
|
||||||
|
result *PruneBlobsResult,
|
||||||
) {
|
) {
|
||||||
log.Info("Deleting unreferenced blobs")
|
log.Info("Deleting unreferenced blobs")
|
||||||
|
|
||||||
@@ -354,7 +644,7 @@ func (v *Vaultik) deleteUnreferencedBlobs(
|
|||||||
}
|
}
|
||||||
|
|
||||||
result.BlobsDeleted++
|
result.BlobsDeleted++
|
||||||
result.BytesFreed += allBlobs[hash]
|
result.BytesFreed += allBlobs[hash].Size
|
||||||
|
|
||||||
if (i+1)%progressLogEvery == 0 || i == len(unreferencedBlobs)-1 {
|
if (i+1)%progressLogEvery == 0 || i == len(unreferencedBlobs)-1 {
|
||||||
log.Info("Deletion progress",
|
log.Info("Deletion progress",
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
package vaultik //nolint:testpackage // exercises unexported count helpers
|
package vaultik //nolint:testpackage // exercises unexported count helpers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"sneak.berlin/go/vaultik/internal/database"
|
"sneak.berlin/go/vaultik/internal/database"
|
||||||
"sneak.berlin/go/vaultik/internal/log"
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
|
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||||
|
"sneak.berlin/go/vaultik/internal/ui"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestTableCountForReportSurfacesReadFailure is the regression guard for
|
// TestTableCountForReportSurfacesReadFailure is the regression guard for
|
||||||
@@ -76,3 +80,59 @@ func TestCountDiffUnknownWhenEitherSideUnknown(t *testing.T) {
|
|||||||
assert.Nil(t, countDiff(&before, nil), "unknown after yields unknown delta")
|
assert.Nil(t, countDiff(&before, nil), "unknown after yields unknown delta")
|
||||||
assert.Nil(t, countDiff(nil, nil))
|
assert.Nil(t, countDiff(nil, nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestPruneDatabaseSummaryLabelsTotals removes the only incomplete
|
||||||
|
// snapshot from an index that also holds a complete one. The figure in
|
||||||
|
// brackets is every snapshot left in the local index, so the line must
|
||||||
|
// say so rather than read as incomplete snapshots that remain.
|
||||||
|
func TestPruneDatabaseSummaryLabelsTotals(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
db, err := database.New(ctx, ":memory:")
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
|
||||||
|
repos := database.NewRepositories(db)
|
||||||
|
|
||||||
|
startedAt := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
||||||
|
completedAt := startedAt.Add(time.Minute)
|
||||||
|
|
||||||
|
require.NoError(t, repos.Snapshots.Create(ctx, nil, &database.Snapshot{
|
||||||
|
ID: "host_complete_2026-01-02T03:04:05Z",
|
||||||
|
Hostname: "host",
|
||||||
|
StartedAt: startedAt,
|
||||||
|
CompletedAt: &completedAt,
|
||||||
|
}))
|
||||||
|
require.NoError(t, repos.Snapshots.Create(ctx, nil, &database.Snapshot{
|
||||||
|
ID: "host_incomplete_2026-01-02T03:04:05Z",
|
||||||
|
Hostname: "host",
|
||||||
|
StartedAt: startedAt,
|
||||||
|
}))
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
|
||||||
|
v := &Vaultik{
|
||||||
|
DB: db,
|
||||||
|
Repositories: repos,
|
||||||
|
SnapshotManager: snapshot.NewSnapshotManager(
|
||||||
|
snapshot.SnapshotManagerParams{Repos: repos}),
|
||||||
|
UI: ui.NewWithColor(&out, false),
|
||||||
|
}
|
||||||
|
v.SetContext(ctx)
|
||||||
|
|
||||||
|
result, err := v.PruneDatabase()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, int64(1), result.SnapshotsDeleted)
|
||||||
|
|
||||||
|
assert.Contains(t, out.String(), "Incomplete snapshots: 1 removed "+
|
||||||
|
"(total snapshots left in the local index: 1).")
|
||||||
|
assert.Contains(t, out.String(), "Orphaned files: 0 removed "+
|
||||||
|
"(total files left in the local index: 0).")
|
||||||
|
assert.Contains(t, out.String(), "Orphaned chunks: 0 removed "+
|
||||||
|
"(total chunks left in the local index: 0).")
|
||||||
|
assert.Contains(t, out.String(), "Orphaned blobs: 0 removed "+
|
||||||
|
"(total blobs left in the local index: 0).")
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
package vaultik_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/vaultik/internal/config"
|
||||||
|
"sneak.berlin/go/vaultik/internal/database"
|
||||||
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
|
"sneak.berlin/go/vaultik/internal/storage"
|
||||||
|
"sneak.berlin/go/vaultik/internal/storage/faultstore"
|
||||||
|
"sneak.berlin/go/vaultik/internal/ui"
|
||||||
|
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestPruneDuringBackupKeepsItsBlobs runs a prune from another host
|
||||||
|
// sharing the destination store at the moment a backup has uploaded its
|
||||||
|
// 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()
|
||||||
|
|
||||||
|
fs := afero.NewOsFs()
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
dataDir := filepath.Join(tempDir, "src")
|
||||||
|
storeDir := t.TempDir()
|
||||||
|
restoreDir := filepath.Join(tempDir, "restored")
|
||||||
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
cfg := faultTestConfig()
|
||||||
|
testFiles := writeFaultSourceTree(t, fs, dataDir)
|
||||||
|
|
||||||
|
inner, err := storage.NewFileStorer(storeDir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
otherHostCfg := faultTestConfig()
|
||||||
|
otherHostCfg.PruneGracePeriod = time.Hour
|
||||||
|
otherHost := newReaderVaultik(ctx, otherHostCfg, inner, nil, fs)
|
||||||
|
|
||||||
|
var (
|
||||||
|
pruned bool
|
||||||
|
pruneErr error
|
||||||
|
)
|
||||||
|
|
||||||
|
store := faultstore.New(inner)
|
||||||
|
store.OnPut = func(key string) faultstore.PutAction {
|
||||||
|
if strings.HasSuffix(key, "/manifest.json.zst") {
|
||||||
|
pruned = true
|
||||||
|
pruneErr = otherHost.PruneBlobs(&vaultik.PruneOptions{Force: true})
|
||||||
|
}
|
||||||
|
|
||||||
|
return faultstore.PutNormal
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := database.New(ctx, dbPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
repos := database.NewRepositories(db)
|
||||||
|
|
||||||
|
id := fullFaultBackup(ctx, t, fs, store, cfg, repos, dataDir, dbPath, "shared")
|
||||||
|
require.NoError(t, db.Close())
|
||||||
|
|
||||||
|
require.True(t, pruned, "the prune must run before the manifest upload")
|
||||||
|
require.NoError(t, pruneErr)
|
||||||
|
|
||||||
|
v := newReaderVaultik(ctx, cfg, inner, nil, fs)
|
||||||
|
require.NoError(t, v.Restore(&vaultik.RestoreOptions{
|
||||||
|
SnapshotID: id,
|
||||||
|
TargetDir: restoreDir,
|
||||||
|
Verify: true,
|
||||||
|
}), "a prune during the backup must not delete the backup's blobs "+
|
||||||
|
"or its db.zst.age")
|
||||||
|
|
||||||
|
assertRestoredTree(t, fs, restoreDir, testFiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPruneKeepsUnreferencedBlobWithNoTime checks that a blob no
|
||||||
|
// manifest lists, whose listing gives no modification time (as an S3
|
||||||
|
// listing entry without LastModified does), is kept and counted with
|
||||||
|
// the blobs too new to delete: its age is unknown.
|
||||||
|
func TestPruneKeepsUnreferencedBlobWithNoTime(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newListEnv(t)
|
||||||
|
|
||||||
|
// Written into the mock store's map directly rather than through
|
||||||
|
// Put, so its listing gives no time.
|
||||||
|
blobKey := "blobs/" + testBlobHashA[:2] + "/" + testBlobHashA[2:4] +
|
||||||
|
"/" + testBlobHashA
|
||||||
|
env.store.data[blobKey] = []byte("blob")
|
||||||
|
|
||||||
|
require.NoError(t, env.v.PruneBlobs(&vaultik.PruneOptions{JSON: true}))
|
||||||
|
|
||||||
|
assert.True(t, env.store.hasKey(blobKey),
|
||||||
|
"a blob of unknown age must be kept")
|
||||||
|
|
||||||
|
var result vaultik.PruneBlobsResult
|
||||||
|
|
||||||
|
require.NoError(t, json.Unmarshal(env.stdout.Bytes(), &result))
|
||||||
|
assert.Equal(t, 0, result.BlobsDeleted)
|
||||||
|
assert.Equal(t, 1, result.BlobsTooNew)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPruneDeletesUnreferencedBlobOlderThanGracePeriod checks that the
|
||||||
|
// grace period only delays deletion: a blob no manifest lists is deleted
|
||||||
|
// once it is older than the grace period, and kept and counted until
|
||||||
|
// then.
|
||||||
|
func TestPruneDeletesUnreferencedBlobOlderThanGracePeriod(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
storeDir := t.TempDir()
|
||||||
|
|
||||||
|
store, err := storage.NewFileStorer(storeDir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
oldKey := "blobs/" + testBlobHashA[:2] + "/" + testBlobHashA[2:4] +
|
||||||
|
"/" + testBlobHashA
|
||||||
|
newKey := "blobs/" + testBlobHashB[:2] + "/" + testBlobHashB[2:4] +
|
||||||
|
"/" + testBlobHashB
|
||||||
|
|
||||||
|
for _, key := range []string{oldKey, newKey} {
|
||||||
|
require.NoError(t, store.Put(ctx, key, strings.NewReader("blob")))
|
||||||
|
}
|
||||||
|
|
||||||
|
gracePeriod := time.Hour
|
||||||
|
beforeGracePeriod := time.Now().Add(-gracePeriod - time.Minute)
|
||||||
|
require.NoError(t, os.Chtimes(filepath.Join(storeDir, oldKey),
|
||||||
|
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, oldKey)
|
||||||
|
require.ErrorIs(t, err, storage.ErrNotFound,
|
||||||
|
"a blob older than the grace period must be deleted")
|
||||||
|
|
||||||
|
_, err = store.Stat(ctx, newKey)
|
||||||
|
require.NoError(t, err, "a blob newer than the grace period must be kept")
|
||||||
|
|
||||||
|
var result vaultik.PruneBlobsResult
|
||||||
|
|
||||||
|
require.NoError(t, json.Unmarshal(stdout.Bytes(), &result))
|
||||||
|
assert.Equal(t, 1, result.BlobsDeleted)
|
||||||
|
assert.Equal(t, 1, result.BlobsTooNew)
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
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.NoError(t, env.v.PruneBlobs(&vaultik.PruneOptions{}))
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"sneak.berlin/go/vaultik/internal/log"
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
"sneak.berlin/go/vaultik/internal/snapshot"
|
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||||
"sneak.berlin/go/vaultik/internal/types"
|
"sneak.berlin/go/vaultik/internal/types"
|
||||||
|
"sneak.berlin/go/vaultik/internal/ui"
|
||||||
"sneak.berlin/go/vaultik/internal/vaultik"
|
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -34,6 +35,9 @@ func setupConsistencyTest(
|
|||||||
repos := database.NewRepositories(db)
|
repos := database.NewRepositories(db)
|
||||||
mockStorage := NewMockStorer()
|
mockStorage := NewMockStorer()
|
||||||
|
|
||||||
|
// A backup wrote to this destination, so it carries the marker.
|
||||||
|
require.NoError(t, mockStorage.Put(ctx, markerFile, strings.NewReader("")))
|
||||||
|
|
||||||
for _, id := range snapshotIDs {
|
for _, id := range snapshotIDs {
|
||||||
parts := strings.Split(id, "_")
|
parts := strings.Split(id, "_")
|
||||||
startedAt, err := time.Parse(time.RFC3339, parts[len(parts)-1])
|
startedAt, err := time.Parse(time.RFC3339, parts[len(parts)-1])
|
||||||
@@ -58,13 +62,16 @@ func setupConsistencyTest(
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
stdout := &bytes.Buffer{}
|
||||||
|
|
||||||
v := &vaultik.Vaultik{
|
v := &vaultik.Vaultik{
|
||||||
Storage: mockStorage,
|
Storage: mockStorage,
|
||||||
Repositories: repos,
|
Repositories: repos,
|
||||||
DB: db,
|
DB: db,
|
||||||
Stdout: &bytes.Buffer{},
|
Stdout: stdout,
|
||||||
Stderr: &bytes.Buffer{},
|
Stderr: &bytes.Buffer{},
|
||||||
Stdin: &bytes.Buffer{},
|
Stdin: &bytes.Buffer{},
|
||||||
|
UI: ui.NewWithColor(stdout, false),
|
||||||
}
|
}
|
||||||
v.SetContext(ctx)
|
v.SetContext(ctx)
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"sneak.berlin/go/vaultik/internal/log"
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
"sneak.berlin/go/vaultik/internal/snapshot"
|
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||||
"sneak.berlin/go/vaultik/internal/types"
|
"sneak.berlin/go/vaultik/internal/types"
|
||||||
|
"sneak.berlin/go/vaultik/internal/ui"
|
||||||
"sneak.berlin/go/vaultik/internal/vaultik"
|
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -44,6 +45,9 @@ func setupPurgeTest(
|
|||||||
repos := database.NewRepositories(db)
|
repos := database.NewRepositories(db)
|
||||||
mockStorage := NewMockStorer()
|
mockStorage := NewMockStorer()
|
||||||
|
|
||||||
|
// A backup wrote to this destination, so it carries the marker.
|
||||||
|
require.NoError(t, mockStorage.Put(ctx, markerFile, strings.NewReader("")))
|
||||||
|
|
||||||
// Insert each snapshot into the DB and create remote metadata stubs.
|
// Insert each snapshot into the DB and create remote metadata stubs.
|
||||||
// Use timestamps parsed from snapshot IDs for realistic ordering.
|
// Use timestamps parsed from snapshot IDs for realistic ordering.
|
||||||
for _, id := range snapshotIDs {
|
for _, id := range snapshotIDs {
|
||||||
@@ -86,6 +90,7 @@ func setupPurgeTest(
|
|||||||
Stdout: stdout,
|
Stdout: stdout,
|
||||||
Stderr: stderr,
|
Stderr: stderr,
|
||||||
Stdin: stdin,
|
Stdin: stdin,
|
||||||
|
UI: ui.NewWithColor(stdout, false),
|
||||||
}
|
}
|
||||||
v.SetContext(ctx)
|
v.SetContext(ctx)
|
||||||
|
|
||||||
@@ -279,7 +284,8 @@ func TestPurgeOlderThan_TooLargeDeletesNothing(t *testing.T) {
|
|||||||
OlderThan: "1000y",
|
OlderThan: "1000y",
|
||||||
Force: true,
|
Force: true,
|
||||||
})
|
})
|
||||||
require.Error(t, err)
|
require.EqualError(t, err,
|
||||||
|
`invalid duration: "1000y" is longer than about 292 years`)
|
||||||
|
|
||||||
err = v.PurgeSnapshotsWithOptions(&vaultik.SnapshotPurgeOptions{
|
err = v.PurgeSnapshotsWithOptions(&vaultik.SnapshotPurgeOptions{
|
||||||
OlderThan: "292y",
|
OlderThan: "292y",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"sneak.berlin/go/vaultik/internal/log"
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
"sneak.berlin/go/vaultik/internal/snapshot"
|
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||||
|
"sneak.berlin/go/vaultik/internal/ui"
|
||||||
)
|
)
|
||||||
|
|
||||||
// testBlobHashB is a blob that the manifest written by addRemote does
|
// 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, "unreadable_manifests")
|
||||||
assert.NotContains(t, doc, "skipped_manifest_count")
|
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")
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package vaultik_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
|
"sneak.berlin/go/vaultik/internal/ui"
|
||||||
|
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestPurgeSnapshots_QuietSuppressesReport checks that `snapshot purge
|
||||||
|
// --keep-latest --force` under --quiet deletes the older snapshot and
|
||||||
|
// prints nothing.
|
||||||
|
func TestPurgeSnapshots_QuietSuppressesReport(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
v := setupPurgeTest(t, snapHostname, []string{snapHomeT0, snapHomeT1})
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
|
||||||
|
v.Stdout = &out
|
||||||
|
v.UI = ui.NewWithColor(&out, false)
|
||||||
|
v.UI.SetQuiet(true)
|
||||||
|
|
||||||
|
require.NoError(t, v.PurgeSnapshotsWithOptions(&vaultik.SnapshotPurgeOptions{
|
||||||
|
KeepLatest: true,
|
||||||
|
Force: true,
|
||||||
|
}))
|
||||||
|
|
||||||
|
assert.Equal(t, []string{snapHomeT1}, listRemainingSnapshots(t, v))
|
||||||
|
assert.Empty(t, out.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPurgeSnapshots_QuietPromptListsSnapshots checks that `snapshot
|
||||||
|
// purge --keep-latest` without --force still lists the snapshots its
|
||||||
|
// prompt asks to delete under --quiet.
|
||||||
|
func TestPurgeSnapshots_QuietPromptListsSnapshots(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
v := setupPurgeTest(t, snapHostname, []string{snapHomeT0, snapHomeT1})
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
|
||||||
|
v.Stdout = &out
|
||||||
|
v.UI = ui.NewWithColor(&out, false)
|
||||||
|
v.UI.SetQuiet(true)
|
||||||
|
|
||||||
|
// Stdin is empty, so the prompt reads no answer and cancels.
|
||||||
|
require.NoError(t, v.PurgeSnapshotsWithOptions(&vaultik.SnapshotPurgeOptions{
|
||||||
|
KeepLatest: true,
|
||||||
|
}))
|
||||||
|
|
||||||
|
assert.Contains(t, out.String(), snapHomeT0)
|
||||||
|
assert.Contains(t, out.String(), "Delete 1 snapshot(s)? [y/N]")
|
||||||
|
assert.Len(t, listRemainingSnapshots(t, v), 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPruneBlobs_QuietPromptShowsBlobTotal checks that `prune` without
|
||||||
|
// --force still states the count and total size of the blobs its prompt
|
||||||
|
// asks to delete under --quiet.
|
||||||
|
func TestPruneBlobs_QuietPromptShowsBlobTotal(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newListEnv(t)
|
||||||
|
env.v.UI.SetQuiet(true)
|
||||||
|
|
||||||
|
// The store holds no manifest, so nothing references this blob.
|
||||||
|
addBlob(t, env.store.testStorer, testBlobHashA)
|
||||||
|
|
||||||
|
// Stdin is empty, so the prompt reads no answer and cancels.
|
||||||
|
require.NoError(t, env.v.PruneBlobs(&vaultik.PruneOptions{}))
|
||||||
|
|
||||||
|
assert.Contains(t, env.stdout.String(),
|
||||||
|
"Found 1 unreferenced blob(s) totaling")
|
||||||
|
assert.Contains(t, env.stdout.String(),
|
||||||
|
"Delete 1 unreferenced blob(s)? [y/N]")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRemoveSnapshot_QuietSuppressesReport checks that `snapshot remove`
|
||||||
|
// under --quiet prints nothing with --dry-run or with --force, and that
|
||||||
|
// the --json document still prints.
|
||||||
|
func TestRemoveSnapshot_QuietSuppressesReport(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
newQuietRemover := func() (*vaultik.TestVaultik, *testStorer) {
|
||||||
|
store := newTestStorer()
|
||||||
|
addManifest(t, store, "snapshot-001", nil)
|
||||||
|
|
||||||
|
// A backup wrote to this destination, so it carries the marker.
|
||||||
|
require.NoError(t, store.Put(context.Background(), markerFile,
|
||||||
|
bytes.NewReader(nil)))
|
||||||
|
|
||||||
|
tv := vaultik.NewForTesting(store)
|
||||||
|
tv.Repositories = newLocalIndex(t, "snapshot-001")
|
||||||
|
tv.UI.SetQuiet(true)
|
||||||
|
|
||||||
|
return tv, store
|
||||||
|
}
|
||||||
|
|
||||||
|
tv, store := newQuietRemover()
|
||||||
|
|
||||||
|
_, err := tv.RemoveSnapshot("snapshot-001",
|
||||||
|
&vaultik.RemoveOptions{DryRun: true})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, tv.Stdout.String(),
|
||||||
|
"--dry-run must print nothing under --quiet")
|
||||||
|
|
||||||
|
_, err = tv.RemoveSnapshot("snapshot-001",
|
||||||
|
&vaultik.RemoveOptions{Force: true})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, store.hasKey(remoteKeyPath("snapshot-001")))
|
||||||
|
assert.Empty(t, tv.Stdout.String(),
|
||||||
|
"--force must print nothing under --quiet")
|
||||||
|
|
||||||
|
tv, _ = newQuietRemover()
|
||||||
|
|
||||||
|
_, err = tv.RemoveSnapshot("snapshot-001",
|
||||||
|
&vaultik.RemoveOptions{JSON: true})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var result vaultik.RemoveResult
|
||||||
|
|
||||||
|
require.NoError(t, json.Unmarshal(tv.Stdout.Bytes(), &result),
|
||||||
|
"the --json document must still print under --quiet")
|
||||||
|
assert.True(t, result.RemoteRemoved)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNukeRemote_QuietSuppressesReport checks that `remote nuke --force`
|
||||||
|
// under --quiet prints nothing while it removes a snapshot's metadata
|
||||||
|
// and its blobs.
|
||||||
|
func TestNukeRemote_QuietSuppressesReport(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
storeDir := filepath.Join(t.TempDir(), "store")
|
||||||
|
v, _, out := backUpToFileDestination(ctx, t, storeDir)
|
||||||
|
v.UI.SetQuiet(true)
|
||||||
|
|
||||||
|
require.NoError(t, v.NukeRemote(true))
|
||||||
|
assert.Empty(t, out.String())
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
"sneak.berlin/go/vaultik/internal/log"
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
"sneak.berlin/go/vaultik/internal/snapshot"
|
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||||
"sneak.berlin/go/vaultik/internal/storage"
|
"sneak.berlin/go/vaultik/internal/storage"
|
||||||
|
"sneak.berlin/go/vaultik/internal/storage/faultstore"
|
||||||
"sneak.berlin/go/vaultik/internal/types"
|
"sneak.berlin/go/vaultik/internal/types"
|
||||||
"sneak.berlin/go/vaultik/internal/vaultik"
|
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||||
)
|
)
|
||||||
@@ -25,11 +26,14 @@ import (
|
|||||||
type testStorer struct {
|
type testStorer struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
data map[string][]byte
|
data map[string][]byte
|
||||||
|
// putTimes holds when Put stored each object; ListStream reports it.
|
||||||
|
putTimes map[string]time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func newTestStorer() *testStorer {
|
func newTestStorer() *testStorer {
|
||||||
return &testStorer{
|
return &testStorer{
|
||||||
data: make(map[string][]byte),
|
data: make(map[string][]byte),
|
||||||
|
putTimes: make(map[string]time.Time),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,6 +47,7 @@ func (s *testStorer) Put(_ context.Context, key string, reader io.Reader) error
|
|||||||
}
|
}
|
||||||
|
|
||||||
s.data[key] = data
|
s.data[key] = data
|
||||||
|
s.putTimes[key] = time.Now().UTC()
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -86,6 +91,7 @@ func (s *testStorer) Delete(_ context.Context, key string) error {
|
|||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
delete(s.data, key)
|
delete(s.data, key)
|
||||||
|
delete(s.putTimes, key)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -119,8 +125,9 @@ func (s *testStorer) ListStream(
|
|||||||
for key, data := range s.data {
|
for key, data := range s.data {
|
||||||
if prefix == "" || strings.HasPrefix(key, prefix) {
|
if prefix == "" || strings.HasPrefix(key, prefix) {
|
||||||
ch <- storage.ObjectInfo{
|
ch <- storage.ObjectInfo{
|
||||||
Key: key,
|
Key: key,
|
||||||
Size: int64(len(data)),
|
Size: int64(len(data)),
|
||||||
|
ModTime: s.putTimes[key],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -303,6 +310,10 @@ func TestRemoveSnapshot_DefaultRemovesMetadataNotBlobs(t *testing.T) {
|
|||||||
addBlob(t, store, blobUnique)
|
addBlob(t, store, blobUnique)
|
||||||
addBlob(t, store, blobShared)
|
addBlob(t, store, blobShared)
|
||||||
|
|
||||||
|
// A backup wrote to this destination, so it carries the marker.
|
||||||
|
require.NoError(t, store.Put(context.Background(), markerFile,
|
||||||
|
bytes.NewReader(nil)))
|
||||||
|
|
||||||
tv := vaultik.NewForTesting(store)
|
tv := vaultik.NewForTesting(store)
|
||||||
tv.Repositories = newLocalIndex(t, "snapshot-001", "snapshot-002")
|
tv.Repositories = newLocalIndex(t, "snapshot-001", "snapshot-002")
|
||||||
|
|
||||||
@@ -350,7 +361,7 @@ func TestRemoveSnapshot_DryRun(t *testing.T) {
|
|||||||
assert.True(t, store.hasKey("blobs/aa/aa/"+blobA))
|
assert.True(t, store.hasKey("blobs/aa/aa/"+blobA))
|
||||||
assert.True(t, store.hasKey(remoteKeyPath("snapshot-001")))
|
assert.True(t, store.hasKey(remoteKeyPath("snapshot-001")))
|
||||||
|
|
||||||
assert.Contains(t, tv.Stdout.String(), "[Dry run - no changes made]")
|
assert.Contains(t, tv.Stdout.String(), "Dry run: no changes made.")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRemoveSnapshot_UnknownIDIsError checks that an ID naming no
|
// TestRemoveSnapshot_UnknownIDIsError checks that an ID naming no
|
||||||
@@ -372,6 +383,10 @@ func TestRemoveSnapshot_UnknownIDIsError(t *testing.T) {
|
|||||||
store := newTestStorer()
|
store := newTestStorer()
|
||||||
addManifest(t, store, "snapshot-001", nil)
|
addManifest(t, store, "snapshot-001", nil)
|
||||||
|
|
||||||
|
// A backup wrote to this destination, so it carries the marker.
|
||||||
|
require.NoError(t, store.Put(context.Background(), markerFile,
|
||||||
|
bytes.NewReader(nil)))
|
||||||
|
|
||||||
tv := vaultik.NewForTesting(store)
|
tv := vaultik.NewForTesting(store)
|
||||||
tv.Repositories = newLocalIndex(t, "snapshot-001")
|
tv.Repositories = newLocalIndex(t, "snapshot-001")
|
||||||
|
|
||||||
@@ -392,7 +407,12 @@ func TestRemoveSnapshot_NoMetadataOnDestination(t *testing.T) {
|
|||||||
log.Initialize(log.Config{})
|
log.Initialize(log.Config{})
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
tv := vaultik.NewForTesting(newTestStorer())
|
// A backup wrote to this destination, so it carries the marker.
|
||||||
|
store := newTestStorer()
|
||||||
|
require.NoError(t, store.Put(context.Background(), markerFile,
|
||||||
|
bytes.NewReader(nil)))
|
||||||
|
|
||||||
|
tv := vaultik.NewForTesting(store)
|
||||||
tv.Repositories = newLocalIndex(t, "snapshot-001")
|
tv.Repositories = newLocalIndex(t, "snapshot-001")
|
||||||
|
|
||||||
result, err := tv.RemoveSnapshot("snapshot-001",
|
result, err := tv.RemoveSnapshot("snapshot-001",
|
||||||
@@ -431,6 +451,40 @@ func TestRemoveSnapshot_NotInLocalIndex(t *testing.T) {
|
|||||||
assert.Contains(t, out, "Removed snapshot metadata from remote storage")
|
assert.Contains(t, out, "Removed snapshot metadata from remote storage")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestRemoveSnapshot_NotInLocalIndexDeleteFails checks that removing a
|
||||||
|
// snapshot only the destination store holds fails when its metadata
|
||||||
|
// cannot be deleted there, under --force and under --json: nothing was
|
||||||
|
// removed, so the command must not succeed.
|
||||||
|
func TestRemoveSnapshot_NotInLocalIndexDeleteFails(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for name, opts := range map[string]vaultik.RemoveOptions{
|
||||||
|
"force": {Force: true},
|
||||||
|
"json": {JSON: true},
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
store := newTestStorer()
|
||||||
|
addManifest(t, store, "snapshot-001", nil)
|
||||||
|
|
||||||
|
faulty := faultstore.New(store)
|
||||||
|
faulty.OnDelete = func(string) bool { return true }
|
||||||
|
|
||||||
|
tv := vaultik.NewForTesting(faulty)
|
||||||
|
tv.Repositories = newLocalIndex(t)
|
||||||
|
|
||||||
|
result, err := tv.RemoveSnapshot("snapshot-001", &opts)
|
||||||
|
|
||||||
|
require.ErrorIs(t, err, faultstore.ErrInjectedDelete)
|
||||||
|
assert.False(t, result.RemoteRemoved)
|
||||||
|
assert.True(t, store.hasKey(remoteKeyPath("snapshot-001")))
|
||||||
|
assert.Empty(t, tv.Stdout.String())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRemoveAllSnapshots_RequiresForce(t *testing.T) {
|
func TestRemoveAllSnapshots_RequiresForce(t *testing.T) {
|
||||||
log.Initialize(log.Config{})
|
log.Initialize(log.Config{})
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -504,7 +558,7 @@ func TestRemoveAllSnapshots_DryRun(t *testing.T) {
|
|||||||
|
|
||||||
assert.Equal(t, initialCount, store.keyCount())
|
assert.Equal(t, initialCount, store.keyCount())
|
||||||
|
|
||||||
assert.Contains(t, tv.Stdout.String(), "[Dry run - no changes made]")
|
assert.Contains(t, tv.Stdout.String(), "Dry run: no changes made.")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRemoveAllSnapshots_NoSnapshots(t *testing.T) {
|
func TestRemoveAllSnapshots_NoSnapshots(t *testing.T) {
|
||||||
|
|||||||
+105
-39
@@ -12,6 +12,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"filippo.io/age"
|
"filippo.io/age"
|
||||||
@@ -27,6 +28,7 @@ import (
|
|||||||
// Sentinel errors for restore failures.
|
// Sentinel errors for restore failures.
|
||||||
var (
|
var (
|
||||||
errFilesFailedRestore = errors.New("file(s) failed to restore")
|
errFilesFailedRestore = errors.New("file(s) failed to restore")
|
||||||
|
errPathsNotInSnapshot = errors.New("requested path(s) not in the snapshot")
|
||||||
errFilesFailedVerify = errors.New("files failed verification")
|
errFilesFailedVerify = errors.New("files failed verification")
|
||||||
errDecryptionKeyRequired = errors.New(
|
errDecryptionKeyRequired = errors.New(
|
||||||
"decryption key required for restore\n\n" +
|
"decryption key required for restore\n\n" +
|
||||||
@@ -150,16 +152,20 @@ func (v *Vaultik) Restore(opts *RestoreOptions) error {
|
|||||||
repos := database.NewRepositories(tempDB)
|
repos := database.NewRepositories(tempDB)
|
||||||
|
|
||||||
// Step 2: Get list of files to restore
|
// Step 2: Get list of files to restore
|
||||||
files, err := v.getFilesToRestore(v.ctx, repos, opts.Paths)
|
files, unmatched, err := v.getFilesToRestore(v.ctx, repos, opts.Paths)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("getting files to restore: %w", err)
|
return fmt.Errorf("getting files to restore: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Named before anything is restored, so the names are shown even
|
||||||
|
// when the restore stops on an error.
|
||||||
|
unmatchedErr := v.reportUnmatchedPaths(unmatched)
|
||||||
|
|
||||||
if len(files) == 0 {
|
if len(files) == 0 {
|
||||||
log.Warn("No files found to restore")
|
log.Warn("No files found to restore")
|
||||||
v.UI.Warningf("No files found to restore.")
|
v.UI.Warningf("No files found to restore.")
|
||||||
|
|
||||||
return nil
|
return unmatchedErr
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Info("Found files to restore", "count", len(files))
|
log.Info("Found files to restore", "count", len(files))
|
||||||
@@ -185,6 +191,20 @@ func (v *Vaultik) Restore(opts *RestoreOptions) error {
|
|||||||
|
|
||||||
result.Duration = time.Since(startTime)
|
result.Duration = time.Since(startTime)
|
||||||
|
|
||||||
|
return v.finishRestore(repos, files, unmatchedErr, opts, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// finishRestore prints the restore summary and the post-restore
|
||||||
|
// warnings, runs optional verification, and converts any failed-file
|
||||||
|
// count into an error. Otherwise it returns unmatchedErr, the error for
|
||||||
|
// the requested paths that matched nothing (nil when there are none).
|
||||||
|
func (v *Vaultik) finishRestore(
|
||||||
|
repos *database.Repositories,
|
||||||
|
files []*database.File,
|
||||||
|
unmatchedErr error,
|
||||||
|
opts *RestoreOptions,
|
||||||
|
result *RestoreResult,
|
||||||
|
) error {
|
||||||
log.Info("Restore complete",
|
log.Info("Restore complete",
|
||||||
"files_restored", result.FilesRestored,
|
"files_restored", result.FilesRestored,
|
||||||
"bytes_restored", ubytes(result.BytesRestored),
|
"bytes_restored", ubytes(result.BytesRestored),
|
||||||
@@ -199,21 +219,8 @@ func (v *Vaultik) Restore(opts *RestoreOptions) error {
|
|||||||
v.UI.Duration(result.Duration),
|
v.UI.Duration(result.Duration),
|
||||||
)
|
)
|
||||||
|
|
||||||
return v.finishRestore(repos, files, opts, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
// finishRestore emits the post-restore warnings, runs optional
|
|
||||||
// verification, and converts any failed-file count into an error.
|
|
||||||
func (v *Vaultik) finishRestore(
|
|
||||||
repos *database.Repositories,
|
|
||||||
files []*database.File,
|
|
||||||
opts *RestoreOptions,
|
|
||||||
result *RestoreResult,
|
|
||||||
) error {
|
|
||||||
if os.Geteuid() != 0 {
|
if os.Geteuid() != 0 {
|
||||||
v.UI.Warningf("Restore did not preserve file ownership: chown(2) " +
|
v.warnIfOwnershipNotPreserved(files, opts.TargetDir)
|
||||||
"requires root. Re-run as root (e.g. with sudo) if you need " +
|
|
||||||
"original UID/GID preserved.")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.FilesFailed > 0 {
|
if result.FilesFailed > 0 {
|
||||||
@@ -236,7 +243,60 @@ func (v *Vaultik) finishRestore(
|
|||||||
return fmt.Errorf("%d %w", result.FilesFailed, errFilesFailedRestore)
|
return fmt.Errorf("%d %w", result.FilesFailed, errFilesFailedRestore)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return unmatchedErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// warnIfOwnershipNotPreserved warns once if some entry restored under
|
||||||
|
// targetDir has another owner or group on disk than the snapshot
|
||||||
|
// records. It is meant for a restore by a normal user, which changes no
|
||||||
|
// owner or group: a new entry is owned by that user, and its group is
|
||||||
|
// that user's, or the group of the directory it is created in on macOS
|
||||||
|
// and in a setgid directory on Linux. An entry that is not on disk, or
|
||||||
|
// whose filesystem reports no owner, is skipped.
|
||||||
|
func (v *Vaultik) warnIfOwnershipNotPreserved(
|
||||||
|
files []*database.File, targetDir string,
|
||||||
|
) {
|
||||||
|
for _, file := range files {
|
||||||
|
targetPath, err := containedRestorePath(
|
||||||
|
v.Fs, targetDir, file.Path.String())
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := lstatIfPossible(v.Fs, targetPath)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
owner, ok := info.Sys().(*syscall.Stat_t)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if owner.Uid != file.UID || owner.Gid != file.GID {
|
||||||
|
v.UI.Warningf("Restore did not preserve file ownership: chown(2) " +
|
||||||
|
"requires root. Re-run as root (e.g. with sudo) if you need " +
|
||||||
|
"original UID/GID preserved.")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// reportUnmatchedPaths warns, by name, about each requested path that
|
||||||
|
// matched nothing in the snapshot, and returns an error counting them,
|
||||||
|
// or nil when there are none.
|
||||||
|
func (v *Vaultik) reportUnmatchedPaths(unmatched []string) error {
|
||||||
|
if len(unmatched) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, path := range unmatched {
|
||||||
|
log.Warn("Requested path matches nothing in the snapshot", "path", path)
|
||||||
|
v.UI.Warningf("%s matches nothing in the snapshot.", v.UI.Path(path))
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("%d %w", len(unmatched), errPathsNotInSnapshot)
|
||||||
}
|
}
|
||||||
|
|
||||||
// restoreIdentities parses the configured age secret key once into every
|
// restoreIdentities parses the configured age secret key once into every
|
||||||
@@ -299,12 +359,6 @@ func (v *Vaultik) restoreAllFiles(
|
|||||||
_ = blobCache.Close()
|
_ = blobCache.Close()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Per-restore sweep state: every blob_size_limit/100 bytes written,
|
|
||||||
// scan the cache and delete any blob whose remaining file references
|
|
||||||
// are all already restored.
|
|
||||||
sweeper := newRestoreSweeper(v.ctx, repos, blobCache,
|
|
||||||
v.Config.BlobSizeLimit.Int64()/sweepIntervalDivisor)
|
|
||||||
|
|
||||||
blobByHash, blobIDToHash, err := v.buildBlobIndexes(repos)
|
blobByHash, blobIDToHash, err := v.buildBlobIndexes(repos)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -316,6 +370,12 @@ func (v *Vaultik) restoreAllFiles(
|
|||||||
return nil, fmt.Errorf("building restore plan: %w", err)
|
return nil, fmt.Errorf("building restore plan: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-restore sweep state: every blob_size_limit/100 bytes written,
|
||||||
|
// scan the cache and delete any blob that no pending file in the
|
||||||
|
// plan references.
|
||||||
|
sweeper := newRestoreSweeper(plan, blobCache,
|
||||||
|
v.Config.BlobSizeLimit.Int64()/sweepIntervalDivisor)
|
||||||
|
|
||||||
// Ranging over files rather than the map reports them in a stable order.
|
// Ranging over files rather than the map reports them in a stable order.
|
||||||
for _, file := range files {
|
for _, file := range files {
|
||||||
if planErr, ok := unplanned[file.ID]; ok {
|
if planErr, ok := unplanned[file.ID]; ok {
|
||||||
@@ -403,10 +463,8 @@ func (v *Vaultik) runRestoreLoop(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Record the file as restored so the sweeper can free blobs
|
// Drop the file from the plan's indexes so future picks ignore
|
||||||
// once all referencing files are done, and drop it from the
|
// it and the sweeper can free blobs no pending file needs.
|
||||||
// plan's indexes so future picks ignore it.
|
|
||||||
session.sweeper.fileRestored(fileID.String())
|
|
||||||
plan.finishFile(fileID)
|
plan.finishFile(fileID)
|
||||||
|
|
||||||
processed++
|
processed++
|
||||||
@@ -840,27 +898,34 @@ func (v *Vaultik) materializeSnapshotDB(
|
|||||||
return db, tempDir, nil
|
return db, tempDir, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getFilesToRestore returns the list of files to restore based on path filters
|
// getFilesToRestore returns the list of files to restore based on path
|
||||||
|
// filters, and the filters, as given, that matched no file.
|
||||||
func (v *Vaultik) getFilesToRestore(
|
func (v *Vaultik) getFilesToRestore(
|
||||||
ctx context.Context, repos *database.Repositories, pathFilters []string,
|
ctx context.Context, repos *database.Repositories, pathFilters []string,
|
||||||
) ([]*database.File, error) {
|
) ([]*database.File, []string, error) {
|
||||||
// If no filters, get all files
|
// If no filters, get all files
|
||||||
if len(pathFilters) == 0 {
|
if len(pathFilters) == 0 {
|
||||||
return repos.Files.ListAll(ctx)
|
files, err := repos.Files.ListAll(ctx)
|
||||||
|
|
||||||
|
return files, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get files matching the path filters
|
// Get files matching the path filters
|
||||||
var result []*database.File
|
var (
|
||||||
|
result []*database.File
|
||||||
|
unmatched []string
|
||||||
|
)
|
||||||
|
|
||||||
seen := make(map[string]bool)
|
seen := make(map[string]bool)
|
||||||
|
|
||||||
for _, filter := range pathFilters {
|
for _, filter := range pathFilters {
|
||||||
// Normalize the filter path
|
files, err := repos.Files.ListUnderPath(ctx, filepath.Clean(filter))
|
||||||
filter = filepath.Clean(filter)
|
|
||||||
|
|
||||||
files, err := repos.Files.ListUnderPath(ctx, filter)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("listing files under %s: %w", filter, err)
|
return nil, nil, fmt.Errorf("listing files under %s: %w", filter, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(files) == 0 {
|
||||||
|
unmatched = append(unmatched, filter)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, file := range files {
|
for _, file := range files {
|
||||||
@@ -871,7 +936,7 @@ func (v *Vaultik) getFilesToRestore(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result, nil
|
return result, unmatched, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildChunkToBlobMap creates a mapping from chunk hash to blob information
|
// buildChunkToBlobMap creates a mapping from chunk hash to blob information
|
||||||
@@ -936,8 +1001,9 @@ type restoreSession struct {
|
|||||||
// root can chown a file to an arbitrary UID/GID — non-root chown
|
// root can chown a file to an arbitrary UID/GID — non-root chown
|
||||||
// always fails with EPERM. Attempting it anyway produces N
|
// always fails with EPERM. Attempting it anyway produces N
|
||||||
// guaranteed-failed syscalls + N noisy debug lines, so we skip
|
// guaranteed-failed syscalls + N noisy debug lines, so we skip
|
||||||
// the call entirely as non-root and emit one warning at the end
|
// the call entirely as non-root; warnIfOwnershipNotPreserved warns
|
||||||
// of the restore explaining that ownership was not preserved.
|
// once at the end of the restore if that left any owner or group
|
||||||
|
// other than recorded.
|
||||||
runningAsRoot bool
|
runningAsRoot bool
|
||||||
// directories holds every restored directory, for
|
// directories holds every restored directory, for
|
||||||
// applyDirectoryMetadata to finish after the restore loop.
|
// applyDirectoryMetadata to finish after the restore loop.
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ func TestRestoreOnAnotherMachine(t *testing.T) {
|
|||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
dataDir := filepath.Join(tempDir, "source")
|
dataDir := filepath.Join(tempDir, "source")
|
||||||
storeDir := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
restoreDir := filepath.Join(tempDir, "restored")
|
restoreDir := filepath.Join(tempDir, "restored")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ func TestRestoreCleansTempDirOnInterrupt(t *testing.T) {
|
|||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
|
|
||||||
dataDir := filepath.Join(root, "source")
|
dataDir := filepath.Join(root, "source")
|
||||||
storeDir := filepath.Join(root, "remote")
|
storeDir := t.TempDir()
|
||||||
restoreDir := filepath.Join(root, "restored")
|
restoreDir := filepath.Join(root, "restored")
|
||||||
dbPath := filepath.Join(root, "index.sqlite")
|
dbPath := filepath.Join(root, "index.sqlite")
|
||||||
|
|
||||||
@@ -74,7 +74,8 @@ func TestRestoreCleansTempDirOnInterrupt(t *testing.T) {
|
|||||||
buildLocalityFixture(t, fs, dataDir)
|
buildLocalityFixture(t, fs, dataDir)
|
||||||
|
|
||||||
cfg, storer, snapshotID := setupLocalityBackup(
|
cfg, storer, snapshotID := setupLocalityBackup(
|
||||||
context.Background(), t, fs, dataDir, storeDir, dbPath)
|
context.Background(), t, fs, dataDir, storeDir, dbPath,
|
||||||
|
localitySourceBytes)
|
||||||
|
|
||||||
// Point the "" temp paths (the blob cache directory and the
|
// Point the "" temp paths (the blob cache directory and the
|
||||||
// snapshot-database directory) at a private directory so the test can
|
// snapshot-database directory) at a private directory so the test can
|
||||||
|
|||||||
@@ -46,11 +46,11 @@ import (
|
|||||||
// slice out a few KB — currently the dominant cost in restore.
|
// slice out a few KB — currently the dominant cost in restore.
|
||||||
//
|
//
|
||||||
// The test deliberately constructs an adversarial scenario: three
|
// The test deliberately constructs an adversarial scenario: three
|
||||||
// blobs A/B/C of ~6 MB each, nine files distributed across them, and
|
// blobs A/B/C of five 64 KiB chunks each, nine files distributed across
|
||||||
// path-ordered names that interleave the blobs (a1, b1, c1, a2, b2,
|
// them, and path-ordered names that interleave the blobs (a1, b1, c1,
|
||||||
// c2, …) so naive path-order processing would touch every blob before
|
// a2, b2, c2, …) so naive path-order processing would touch every blob
|
||||||
// finishing any of them.
|
// before finishing any of them.
|
||||||
// localitySource is one 1 MiB fixture file used by the locality test.
|
// localitySource is one 64 KiB fixture file used by the locality test.
|
||||||
type localitySource struct {
|
type localitySource struct {
|
||||||
path string
|
path string
|
||||||
data []byte
|
data []byte
|
||||||
@@ -63,8 +63,12 @@ type localityCopy struct {
|
|||||||
data []byte
|
data []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// localitySourceBytes is the size of each source file of the locality
|
||||||
|
// fixture.
|
||||||
|
const localitySourceBytes = 64 * 1024
|
||||||
|
|
||||||
// buildLocalityFixture writes the adversarial source layout described
|
// buildLocalityFixture writes the adversarial source layout described
|
||||||
// in TestRestoreLocalityAndReadAt: 15 sources of 1 MiB each (which the
|
// in TestRestoreLocalityAndReadAt: 15 sources of 64 KiB each (which the
|
||||||
// backup packs into 3 blobs of 5 chunks) plus 9 interleaved-name copies
|
// backup packs into 3 blobs of 5 chunks) plus 9 interleaved-name copies
|
||||||
// (3 per blob group).
|
// (3 per blob group).
|
||||||
func buildLocalityFixture(
|
func buildLocalityFixture(
|
||||||
@@ -73,7 +77,6 @@ func buildLocalityFixture(
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
const (
|
const (
|
||||||
srcBytes = 1024 * 1024
|
|
||||||
srcCount = 15
|
srcCount = 15
|
||||||
blobsCount = 3
|
blobsCount = 3
|
||||||
perBlob = srcCount / blobsCount
|
perBlob = srcCount / blobsCount
|
||||||
@@ -83,7 +86,7 @@ func buildLocalityFixture(
|
|||||||
for i := range srcCount {
|
for i := range srcCount {
|
||||||
s := &localitySource{
|
s := &localitySource{
|
||||||
path: fmt.Sprintf("src-%03d.bin", i+1),
|
path: fmt.Sprintf("src-%03d.bin", i+1),
|
||||||
data: randomBytes(t, srcBytes),
|
data: randomBytes(t, localitySourceBytes),
|
||||||
}
|
}
|
||||||
sources[i] = s
|
sources[i] = s
|
||||||
require.NoError(t, afero.WriteFile(
|
require.NoError(t, afero.WriteFile(
|
||||||
@@ -115,19 +118,20 @@ func buildLocalityFixture(
|
|||||||
|
|
||||||
// setupLocalityBackup runs the backup half of the locality test: a
|
// setupLocalityBackup runs the backup half of the locality test: a
|
||||||
// snapshot of dataDir into a file storer with sizes tuned so 15 one-chunk
|
// snapshot of dataDir into a file storer with sizes tuned so 15 one-chunk
|
||||||
// files pack into 3 blobs. The index database is closed before returning
|
// files of srcBytes pack into 3 blobs. The index database is closed
|
||||||
// so the restore half runs from remote bytes only.
|
// before returning so the restore half runs from remote bytes only.
|
||||||
func setupLocalityBackup(
|
func setupLocalityBackup(
|
||||||
ctx context.Context, t *testing.T, fs afero.Fs,
|
ctx context.Context, t *testing.T, fs afero.Fs,
|
||||||
dataDir, storeDir, dbPath string,
|
dataDir, storeDir, dbPath string, srcBytes int64,
|
||||||
) (*config.Config, *storage.FileStorer, string) {
|
) (*config.Config, *storage.FileStorer, string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
// chunkSize avg = 4 MiB makes minSize = 1 MiB, so a 1 MiB file
|
// An average chunk size of four times srcBytes makes the minimum
|
||||||
// becomes one chunk. maxBlobSize = 5 MiB packs exactly 5 chunks
|
// chunk srcBytes, so each source file becomes one chunk. A blob limit
|
||||||
// per blob, yielding 3 blobs from 15 source files.
|
// of five times srcBytes packs exactly 5 chunks per blob, yielding 3
|
||||||
chunkSize := int64(4 * 1024 * 1024)
|
// blobs from 15 source files.
|
||||||
maxBlobSize := int64(5 * 1024 * 1024)
|
chunkSize := 4 * srcBytes
|
||||||
|
maxBlobSize := 5 * srcBytes
|
||||||
|
|
||||||
storer, err := storage.NewFileStorer(storeDir)
|
storer, err := storage.NewFileStorer(storeDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -197,7 +201,7 @@ func TestRestoreLocalityAndReadAt(t *testing.T) {
|
|||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
dataDir := filepath.Join(tempDir, "source")
|
dataDir := filepath.Join(tempDir, "source")
|
||||||
storeDir := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
restoreDir := filepath.Join(tempDir, "restored")
|
restoreDir := filepath.Join(tempDir, "restored")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
@@ -208,7 +212,7 @@ func TestRestoreLocalityAndReadAt(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
cfg, storer, snapshotID := setupLocalityBackup(
|
cfg, storer, snapshotID := setupLocalityBackup(
|
||||||
ctx, t, fs, dataDir, storeDir, dbPath)
|
ctx, t, fs, dataDir, storeDir, dbPath, localitySourceBytes)
|
||||||
|
|
||||||
// Wrap the storer so we can count downloads per blob key.
|
// Wrap the storer so we can count downloads per blob key.
|
||||||
counter := newCountingStorer(storer)
|
counter := newCountingStorer(storer)
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package vaultik //nolint:testpackage // calls the unexported ownership check
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/vaultik/internal/database"
|
||||||
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
|
"sneak.berlin/go/vaultik/internal/ui"
|
||||||
|
)
|
||||||
|
|
||||||
|
// These tests cover https://git.eeqj.de/sneak/vaultik/issues/316: a
|
||||||
|
// restore by a normal user warns that ownership was not preserved only
|
||||||
|
// when some restored entry has another owner or group on disk than the
|
||||||
|
// snapshot records.
|
||||||
|
|
||||||
|
const ownershipWarning = "did not preserve file ownership"
|
||||||
|
|
||||||
|
// TestOwnershipWarningSilentWhenEntriesMatch checks that a restore whose
|
||||||
|
// entries all end up with their recorded owner and group prints no
|
||||||
|
// warning.
|
||||||
|
func TestOwnershipWarningSilentWhenEntriesMatch(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
targetDir := t.TempDir()
|
||||||
|
owner := ownerOf(t, targetDir)
|
||||||
|
|
||||||
|
out := restoreAsNormalUser(t, targetDir, []*database.File{
|
||||||
|
{Path: "/a", Mode: plainFileMode, UID: owner.Uid, GID: owner.Gid},
|
||||||
|
{Path: "/b", Mode: plainFileMode, UID: owner.Uid, GID: owner.Gid},
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Empty(t, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOwnershipWarningWhenOwnerDiffers checks that the warning is printed
|
||||||
|
// when an entry recorded with another owner is restored owned by the
|
||||||
|
// restoring user, even though its group matches the recorded one.
|
||||||
|
func TestOwnershipWarningWhenOwnerDiffers(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
targetDir := t.TempDir()
|
||||||
|
owner := ownerOf(t, targetDir)
|
||||||
|
|
||||||
|
out := restoreAsNormalUser(t, targetDir, []*database.File{
|
||||||
|
{Path: "/a", Mode: plainFileMode, UID: otherOwnerID, GID: owner.Gid},
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Equal(t, 1, strings.Count(out, ownershipWarning), out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOwnershipWarningWhenDirectoryGivesItsGroup checks that the warning
|
||||||
|
// is printed, once, when entries recorded with the restoring user's
|
||||||
|
// owner and group take another group from the setgid directory they are
|
||||||
|
// restored into, as every new entry takes its directory's group on
|
||||||
|
// macOS.
|
||||||
|
func TestOwnershipWarningWhenDirectoryGivesItsGroup(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
if os.Geteuid() != 0 {
|
||||||
|
t.Skip("giving a directory to another group needs root")
|
||||||
|
}
|
||||||
|
|
||||||
|
targetDir := t.TempDir()
|
||||||
|
user := ownerOf(t, targetDir)
|
||||||
|
|
||||||
|
require.NoError(t, os.Chown(targetDir, -1, int(otherOwnerID)))
|
||||||
|
require.NoError(t, os.Chmod(targetDir, os.ModeSetgid|writableTestMode))
|
||||||
|
|
||||||
|
out := restoreAsNormalUser(t, targetDir, []*database.File{
|
||||||
|
{Path: "/a", Mode: plainFileMode, UID: user.Uid, GID: user.Gid},
|
||||||
|
{Path: "/b", Mode: plainFileMode, UID: user.Uid, GID: user.Gid},
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Equal(t, 1, strings.Count(out, ownershipWarning), out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ownerOf returns the owner and group path has on disk.
|
||||||
|
func ownerOf(t *testing.T, path string) *syscall.Stat_t {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
info, err := os.Lstat(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
owner, ok := info.Sys().(*syscall.Stat_t)
|
||||||
|
require.True(t, ok)
|
||||||
|
|
||||||
|
return owner
|
||||||
|
}
|
||||||
|
|
||||||
|
// restoreAsNormalUser restores rows, all empty regular files, into
|
||||||
|
// targetDir without changing any owner or group, as restore does for a
|
||||||
|
// normal user, and returns what warnIfOwnershipNotPreserved prints.
|
||||||
|
func restoreAsNormalUser(
|
||||||
|
t *testing.T, targetDir string, rows []*database.File,
|
||||||
|
) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
rows, repos := makeFiles(ctx, t, rows)
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
|
||||||
|
v := newContainmentVaultik(ctx, afero.NewOsFs())
|
||||||
|
v.UI = ui.NewWithColor(&out, false)
|
||||||
|
|
||||||
|
session := &restoreSession{
|
||||||
|
v: v,
|
||||||
|
ctx: ctx,
|
||||||
|
repos: repos,
|
||||||
|
opts: &RestoreOptions{TargetDir: targetDir},
|
||||||
|
result: &RestoreResult{},
|
||||||
|
}
|
||||||
|
for _, row := range rows {
|
||||||
|
require.NoError(t, session.restoreFile(row))
|
||||||
|
}
|
||||||
|
|
||||||
|
v.warnIfOwnershipNotPreserved(rows, targetDir)
|
||||||
|
|
||||||
|
return out.String()
|
||||||
|
}
|
||||||
@@ -107,7 +107,7 @@ func backupOneFile(
|
|||||||
require.NoError(t, afero.WriteFile(fs, srcPath, content, mode))
|
require.NoError(t, afero.WriteFile(fs, srcPath, content, mode))
|
||||||
require.NoError(t, fs.Chmod(srcPath, mode))
|
require.NoError(t, fs.Chmod(srcPath, mode))
|
||||||
|
|
||||||
storeDir := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
storer, err := storage.NewFileStorer(storeDir)
|
storer, err := storage.NewFileStorer(storeDir)
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ var (
|
|||||||
// with no further downloads.
|
// with no further downloads.
|
||||||
// - blobFiles: for each blob, the set of pending files referencing
|
// - blobFiles: for each blob, the set of pending files referencing
|
||||||
// it. Used to short-circuit "when this blob lands, which files
|
// it. Used to short-circuit "when this blob lands, which files
|
||||||
// become ready" without a global scan.
|
// become ready" without a global scan, and by the sweeper to tell
|
||||||
|
// whether a cached blob is still needed.
|
||||||
type restorePlan struct {
|
type restorePlan struct {
|
||||||
fileBlobs map[types.FileID]map[string]struct{}
|
fileBlobs map[types.FileID]map[string]struct{}
|
||||||
blobFiles map[string]map[types.FileID]struct{}
|
blobFiles map[string]map[types.FileID]struct{}
|
||||||
@@ -237,6 +238,12 @@ func (p *restorePlan) filesReferencingBlob(blobHash string) []types.FileID {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pendingFileUsesBlob reports whether any pending file references the
|
||||||
|
// named blob, whether or not the blob is cached.
|
||||||
|
func (p *restorePlan) pendingFileUsesBlob(blobHash string) bool {
|
||||||
|
return len(p.blobFiles[blobHash]) > 0
|
||||||
|
}
|
||||||
|
|
||||||
// hasPending reports whether any unfinished files remain.
|
// hasPending reports whether any unfinished files remain.
|
||||||
func (p *restorePlan) hasPending() bool {
|
func (p *restorePlan) hasPending() bool {
|
||||||
return len(p.fileBlobs) > 0
|
return len(p.fileBlobs) > 0
|
||||||
|
|||||||
@@ -63,15 +63,6 @@ func TestRunRestoreLoopFailsOnAbandonedFiles(t *testing.T) {
|
|||||||
|
|
||||||
t.Cleanup(func() { _ = cache.Close() })
|
t.Cleanup(func() { _ = cache.Close() })
|
||||||
|
|
||||||
v := &Vaultik{ctx: ctx}
|
|
||||||
session := &restoreSession{
|
|
||||||
v: v,
|
|
||||||
ctx: ctx,
|
|
||||||
repos: repos,
|
|
||||||
sweeper: newRestoreSweeper(ctx, repos, cache, 1),
|
|
||||||
result: &RestoreResult{},
|
|
||||||
}
|
|
||||||
|
|
||||||
// A file that is still pending but whose uncached-blob set is empty
|
// A file that is still pending but whose uncached-blob set is empty
|
||||||
// and which was never queued as ready: the loop can neither restore
|
// and which was never queued as ready: the loop can neither restore
|
||||||
// nor download it. This is the abandonment the guard must catch.
|
// nor download it. This is the abandonment the guard must catch.
|
||||||
@@ -83,6 +74,15 @@ func TestRunRestoreLoopFailsOnAbandonedFiles(t *testing.T) {
|
|||||||
cached: map[string]struct{}{},
|
cached: map[string]struct{}{},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
v := &Vaultik{ctx: ctx}
|
||||||
|
session := &restoreSession{
|
||||||
|
v: v,
|
||||||
|
ctx: ctx,
|
||||||
|
repos: repos,
|
||||||
|
sweeper: newRestoreSweeper(plan, cache, 1),
|
||||||
|
result: &RestoreResult{},
|
||||||
|
}
|
||||||
|
|
||||||
err = v.runRestoreLoop(session, plan, map[types.FileID]*database.File{}, 0)
|
err = v.runRestoreLoop(session, plan, map[types.FileID]*database.File{}, 0)
|
||||||
require.ErrorIs(t, err, errRestoreIncomplete)
|
require.ErrorIs(t, err, errRestoreIncomplete)
|
||||||
}
|
}
|
||||||
@@ -231,7 +231,8 @@ func (f trailingByteFile) Close() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestRestoreMissingBlobAbortsWithoutSkipErrors checks that a deleted blob
|
// TestRestoreMissingBlobAbortsWithoutSkipErrors checks that a deleted blob
|
||||||
// still ends the restore with an error when SkipErrors is not set.
|
// still ends the restore with an error when SkipErrors is not set, and
|
||||||
|
// that the error names the blob once.
|
||||||
func TestRestoreMissingBlobAbortsWithoutSkipErrors(t *testing.T) {
|
func TestRestoreMissingBlobAbortsWithoutSkipErrors(t *testing.T) {
|
||||||
log.Initialize(log.Config{})
|
log.Initialize(log.Config{})
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -247,6 +248,8 @@ func TestRestoreMissingBlobAbortsWithoutSkipErrors(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
require.ErrorIs(t, err, storage.ErrNotFound)
|
require.ErrorIs(t, err, storage.ErrNotFound)
|
||||||
|
assert.Equal(t, 1, strings.Count(err.Error(), "downloading blob"),
|
||||||
|
err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRestoreChunkInNoBlobAbortsWithoutSkipErrors checks that a chunk held
|
// TestRestoreChunkInNoBlobAbortsWithoutSkipErrors checks that a chunk held
|
||||||
@@ -345,7 +348,7 @@ func backupSingleChunkFiles(
|
|||||||
require.NoError(t, afero.WriteFile(fs, path, files[path], 0o644))
|
require.NoError(t, afero.WriteFile(fs, path, files[path], 0o644))
|
||||||
}
|
}
|
||||||
|
|
||||||
storer, err := storage.NewFileStorer(filepath.Join(tempDir, "remote"))
|
storer, err := storage.NewFileStorer(t.TempDir())
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
db, err := database.New(ctx, dbPath)
|
db, err := database.New(ctx, dbPath)
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ func TestRestoreAndDeepVerifyRejectSwappedDatabase(t *testing.T) {
|
|||||||
|
|
||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
storeDir := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
|
|
||||||
chunkSize := int64(64 * 1024)
|
chunkSize := int64(64 * 1024)
|
||||||
|
|
||||||
@@ -100,7 +100,7 @@ func TestDeepVerifyRejectsSwappedDatabaseWithEmptyManifest(t *testing.T) {
|
|||||||
|
|
||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
storeDir := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
|
|
||||||
chunkSize := int64(64 * 1024)
|
chunkSize := int64(64 * 1024)
|
||||||
|
|
||||||
|
|||||||
@@ -1,46 +1,30 @@
|
|||||||
package vaultik
|
package vaultik
|
||||||
|
|
||||||
import (
|
// restoreSweeper frees cached blobs once no file still to be restored in
|
||||||
"context"
|
// this run needs them. It works as follows:
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"sneak.berlin/go/vaultik/internal/database"
|
|
||||||
"sneak.berlin/go/vaultik/internal/log"
|
|
||||||
)
|
|
||||||
|
|
||||||
// restoreSweeper frees cached blobs once all files that reference any of
|
|
||||||
// their chunks have been restored. It works as follows:
|
|
||||||
//
|
//
|
||||||
// 1. Callers add a file's ID to an in-memory restored set via
|
// 1. After each chunk is restored, chunkRestored accumulates a running
|
||||||
// fileRestored once the file is fully written to disk.
|
|
||||||
// 2. After each chunk is restored, chunkRestored accumulates a running
|
|
||||||
// byte count.
|
// byte count.
|
||||||
// 3. When the accumulator crosses a threshold (one hundredth of the
|
// 2. When the accumulator crosses a threshold (one hundredth of the
|
||||||
// configured blob size — so a sweep runs about a hundred times per
|
// configured blob size — so a sweep runs about a hundred times per
|
||||||
// blob's worth of restored bytes), the sweeper iterates every key in
|
// blob's worth of restored bytes), the sweeper iterates every key in
|
||||||
// the cache. For each cached blob it asks the DB which files
|
// the cache and deletes each blob that the restore plan no longer
|
||||||
// reference any chunk in that blob, then compares that list against
|
// needs.
|
||||||
// the in-memory restored set. If any referencing file is missing
|
|
||||||
// from the set the blob is kept; otherwise the cache entry is
|
|
||||||
// deleted.
|
|
||||||
//
|
//
|
||||||
// All DB reads happen against the snapshot's temporary metadata DB,
|
// The plan drops a file once it is restored or has failed, and holds
|
||||||
// which is local, indexed, and not under contention — the queries are
|
// only the files this run restores, so files outside the requested paths
|
||||||
// cheap and run at most once per blob per sweep interval.
|
// never keep a blob.
|
||||||
type restoreSweeper struct {
|
type restoreSweeper struct {
|
||||||
ctx context.Context //nolint:containedctx // ctx bound at construction by design
|
plan *restorePlan
|
||||||
repos *database.Repositories
|
|
||||||
cache *blobDiskCache
|
cache *blobDiskCache
|
||||||
threshold int64
|
threshold int64
|
||||||
bytesAccum int64
|
bytesAccum int64
|
||||||
restored map[string]struct{}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// newRestoreSweeper returns a sweeper that triggers eviction every
|
// newRestoreSweeper returns a sweeper that triggers eviction every
|
||||||
// `threshold` bytes restored. Callers should pass blob_size_limit/100.
|
// `threshold` bytes restored. Callers should pass blob_size_limit/100.
|
||||||
func newRestoreSweeper(
|
func newRestoreSweeper(
|
||||||
ctx context.Context,
|
plan *restorePlan,
|
||||||
repos *database.Repositories,
|
|
||||||
cache *blobDiskCache,
|
cache *blobDiskCache,
|
||||||
threshold int64,
|
threshold int64,
|
||||||
) *restoreSweeper {
|
) *restoreSweeper {
|
||||||
@@ -49,21 +33,12 @@ func newRestoreSweeper(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &restoreSweeper{
|
return &restoreSweeper{
|
||||||
ctx: ctx,
|
plan: plan,
|
||||||
repos: repos,
|
|
||||||
cache: cache,
|
cache: cache,
|
||||||
threshold: threshold,
|
threshold: threshold,
|
||||||
restored: make(map[string]struct{}),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// fileRestored records a file as fully restored. After this call, any
|
|
||||||
// blob whose only remaining references come from files in the restored
|
|
||||||
// set may be evicted on the next sweep.
|
|
||||||
func (s *restoreSweeper) fileRestored(fileID string) {
|
|
||||||
s.restored[fileID] = struct{}{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// chunkRestored accounts n bytes against the sweep threshold and runs a
|
// chunkRestored accounts n bytes against the sweep threshold and runs a
|
||||||
// sweep if the threshold has been crossed since the last sweep.
|
// sweep if the threshold has been crossed since the last sweep.
|
||||||
func (s *restoreSweeper) chunkRestored(n int64) {
|
func (s *restoreSweeper) chunkRestored(n int64) {
|
||||||
@@ -76,60 +51,11 @@ func (s *restoreSweeper) chunkRestored(n int64) {
|
|||||||
s.sweep()
|
s.sweep()
|
||||||
}
|
}
|
||||||
|
|
||||||
// sweep deletes any cached blob whose chunks are no longer referenced
|
// sweep deletes every cached blob that no pending file references.
|
||||||
// by an unrestored file. Per-blob DB failures are logged and the blob
|
|
||||||
// is kept — we'd rather hold a blob longer than risk a re-download.
|
|
||||||
func (s *restoreSweeper) sweep() {
|
func (s *restoreSweeper) sweep() {
|
||||||
for _, blobHash := range s.cache.Keys() {
|
for _, blobHash := range s.cache.Keys() {
|
||||||
needed, err := s.blobStillNeeded(blobHash)
|
if !s.plan.pendingFileUsesBlob(blobHash) {
|
||||||
if err != nil {
|
|
||||||
log.Debug("sweeper referencing-files query failed",
|
|
||||||
"blob_hash", blobHash[:16], "error", err)
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if !needed {
|
|
||||||
s.cache.Delete(blobHash)
|
s.cache.Delete(blobHash)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// blobStillNeeded returns true if any file that references a chunk in
|
|
||||||
// this blob has not yet been restored. On any error the function
|
|
||||||
// returns true — keeping the blob is always the safe answer because we
|
|
||||||
// can't prove we're done with it.
|
|
||||||
func (s *restoreSweeper) blobStillNeeded(blobHash string) (bool, error) {
|
|
||||||
rows, err := s.repos.DB().Conn().QueryContext(s.ctx, `
|
|
||||||
SELECT DISTINCT fc.file_id
|
|
||||||
FROM file_chunks fc
|
|
||||||
JOIN blob_chunks bc ON bc.chunk_hash = fc.chunk_hash
|
|
||||||
JOIN blobs b ON b.id = bc.blob_id
|
|
||||||
WHERE b.blob_hash = ?
|
|
||||||
`, blobHash)
|
|
||||||
if err != nil {
|
|
||||||
return true, fmt.Errorf("querying referencing files: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() { _ = rows.Close() }()
|
|
||||||
|
|
||||||
for rows.Next() {
|
|
||||||
var fileID string
|
|
||||||
|
|
||||||
err := rows.Scan(&fileID)
|
|
||||||
if err != nil {
|
|
||||||
return true, fmt.Errorf("scanning file_id: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, ok := s.restored[fileID]; !ok {
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err = rows.Err()
|
|
||||||
if err != nil {
|
|
||||||
return true, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
@@ -27,10 +27,10 @@ import (
|
|||||||
// TestRestoreSweeperEvictsBlobs exercises the reference-counted blob
|
// TestRestoreSweeperEvictsBlobs exercises the reference-counted blob
|
||||||
// disk cache eviction during restore.
|
// disk cache eviction during restore.
|
||||||
//
|
//
|
||||||
// The scenario: 30 unique 1 MB random files plus 10 duplicates of those
|
// The scenario: 30 unique 128 KiB random files plus 10 duplicates of
|
||||||
// (40 files total, 30 MB of unique content) get backed up with a 10 MB
|
// those (40 files total, 3.75 MiB of unique content) get backed up with
|
||||||
// blob_size_limit. After backup the snapshot's encrypted blobs are
|
// a 1 MiB blob_size_limit. After backup the snapshot's encrypted blobs
|
||||||
// restored through Vaultik.Restore, and per-key Get counts on the
|
// are restored through Vaultik.Restore, and per-key Get counts on the
|
||||||
// storage layer are recorded. Each blob in the snapshot MUST be
|
// storage layer are recorded. Each blob in the snapshot MUST be
|
||||||
// downloaded exactly once — re-downloads would mean the sweeper either
|
// downloaded exactly once — re-downloads would mean the sweeper either
|
||||||
// evicted a blob that was still needed (LRU regression) or that the
|
// 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
|
// original AND every duplicate referencing its chunks have been
|
||||||
// restored.
|
// restored.
|
||||||
// buildSweeperFixture writes the source layout for the sweeper test: 30
|
// 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
|
// PRNG seed is fixed so failures are reproducible; the entropy is what
|
||||||
// matters here — the FastCDC chunker needs realistic-looking data to
|
// matters here — the FastCDC chunker needs realistic-looking data to
|
||||||
// pick chunk boundaries naturally. Returns the expected content by
|
// pick chunk boundaries naturally. Returns the expected content by
|
||||||
@@ -51,7 +51,7 @@ func buildSweeperFixture(
|
|||||||
) map[string][]byte {
|
) map[string][]byte {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
const fileSize = 1 * 1024 * 1024
|
const fileSize = 128 * 1024
|
||||||
|
|
||||||
rng := rand.New(rand.NewSource(42)) //nolint:gosec // G404: fixture data only
|
rng := rand.New(rand.NewSource(42)) //nolint:gosec // G404: fixture data only
|
||||||
|
|
||||||
@@ -159,7 +159,7 @@ func runSweeperBackup(
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, uniqueFiles+duplicateFiles, scanResult.FilesScanned)
|
require.Equal(t, uniqueFiles+duplicateFiles, scanResult.FilesScanned)
|
||||||
require.Greater(t, scanResult.BlobsCreated, 1,
|
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.CompleteSnapshot(ctx, snapshotID))
|
||||||
require.NoError(t, sm.ExportSnapshotMetadata(ctx, dbPath, snapshotID))
|
require.NoError(t, sm.ExportSnapshotMetadata(ctx, dbPath, snapshotID))
|
||||||
@@ -186,7 +186,7 @@ func TestRestoreSweeperEvictsBlobs(t *testing.T) {
|
|||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
dataDir := filepath.Join(tempDir, "source")
|
dataDir := filepath.Join(tempDir, "source")
|
||||||
storeDir := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
restoreDir := filepath.Join(tempDir, "restored")
|
restoreDir := filepath.Join(tempDir, "restored")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
@@ -199,8 +199,8 @@ func TestRestoreSweeperEvictsBlobs(t *testing.T) {
|
|||||||
|
|
||||||
expected := buildSweeperFixture(t, fs, dataDir, uniqueFiles, duplicateFiles)
|
expected := buildSweeperFixture(t, fs, dataDir, uniqueFiles, duplicateFiles)
|
||||||
|
|
||||||
chunkSize := int64(64 * 1024)
|
chunkSize := int64(16 * 1024)
|
||||||
maxBlobSize := int64(10 * 1024 * 1024)
|
maxBlobSize := int64(1024 * 1024)
|
||||||
|
|
||||||
storer, err := storage.NewFileStorer(storeDir)
|
storer, err := storage.NewFileStorer(storeDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
package vaultik //nolint:testpackage // reads the restore blob cache's peak size
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/vaultik/internal/config"
|
||||||
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
|
"sneak.berlin/go/vaultik/internal/storage"
|
||||||
|
"sneak.berlin/go/vaultik/internal/ui"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestRestoreFreesBlobsNoPendingFileNeeds asserts that the restore cache
|
||||||
|
// frees a blob once no file still to be restored in this run needs it.
|
||||||
|
// Every original file has a copy with the same content, so every blob
|
||||||
|
// holds chunks of copies: a restore that leaves the copies out of its
|
||||||
|
// paths, or fails on them, must not keep the blobs for them.
|
||||||
|
func TestRestoreFreesBlobsNoPendingFileNeeds(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Small files keep the backup and both restores quick.
|
||||||
|
const fileBytes = 16 * 1024
|
||||||
|
|
||||||
|
fs := afero.NewOsFs()
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
|
dataDir := filepath.Join(tempDir, "source")
|
||||||
|
originalsDir := filepath.Join(dataDir, "originals")
|
||||||
|
copiesDir := filepath.Join(dataDir, "copies")
|
||||||
|
storeDir := t.TempDir()
|
||||||
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
|
originals := writeFilesWithCopies(t, fs, originalsDir, copiesDir, fileBytes)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
cfg, storer, snapshotID := setupLocalityBackup(
|
||||||
|
ctx, t, fs, dataDir, storeDir, dbPath, fileBytes)
|
||||||
|
|
||||||
|
t.Run("restore by path", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
restoreDir := filepath.Join(tempDir, "by-path")
|
||||||
|
|
||||||
|
peak, err := restorePeakCachedBlobs(ctx, t, fs, cfg, storer,
|
||||||
|
&RestoreOptions{
|
||||||
|
SnapshotID: snapshotID,
|
||||||
|
TargetDir: restoreDir,
|
||||||
|
Paths: []string{originalsDir},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
requireRestoredFiles(t, fs, restoreDir, originals)
|
||||||
|
assert.LessOrEqualf(t, peak, 1,
|
||||||
|
"peak cached blobs was %d; blobs were kept for unselected files",
|
||||||
|
peak)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("restore with failed files", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
restoreDir := filepath.Join(tempDir, "with-failures")
|
||||||
|
|
||||||
|
// A regular file where the copies directory belongs makes every
|
||||||
|
// copy fail to restore.
|
||||||
|
blocker := filepath.Join(restoreDir, copiesDir)
|
||||||
|
require.NoError(t, fs.MkdirAll(filepath.Dir(blocker), 0o755))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, blocker, nil, 0o644))
|
||||||
|
|
||||||
|
peak, err := restorePeakCachedBlobs(ctx, t, fs, cfg, storer,
|
||||||
|
&RestoreOptions{
|
||||||
|
SnapshotID: snapshotID,
|
||||||
|
TargetDir: restoreDir,
|
||||||
|
SkipErrors: true,
|
||||||
|
})
|
||||||
|
require.ErrorIs(t, err, errFilesFailedRestore)
|
||||||
|
|
||||||
|
requireRestoredFiles(t, fs, restoreDir, originals)
|
||||||
|
assert.LessOrEqualf(t, peak, 1,
|
||||||
|
"peak cached blobs was %d; blobs were kept for failed files", peak)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeFilesWithCopies writes 15 random files of fileBytes into
|
||||||
|
// originalsDir and a copy of each into copiesDir. With
|
||||||
|
// setupLocalityBackup given the same size, each file is one chunk and the
|
||||||
|
// 15 chunks fill 3 blobs. Returns the originals' content by path.
|
||||||
|
func writeFilesWithCopies(
|
||||||
|
t *testing.T, fs afero.Fs, originalsDir, copiesDir string, fileBytes int,
|
||||||
|
) map[string][]byte {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
const fileCount = 15
|
||||||
|
|
||||||
|
require.NoError(t, fs.MkdirAll(originalsDir, 0o755))
|
||||||
|
require.NoError(t, fs.MkdirAll(copiesDir, 0o755))
|
||||||
|
|
||||||
|
originals := make(map[string][]byte, fileCount)
|
||||||
|
|
||||||
|
for i := range fileCount {
|
||||||
|
name := fmt.Sprintf("file-%02d.bin", i)
|
||||||
|
data := randomBytes(t, fileBytes)
|
||||||
|
original := filepath.Join(originalsDir, name)
|
||||||
|
originals[original] = data
|
||||||
|
|
||||||
|
require.NoError(t, afero.WriteFile(fs, original, data, 0o644))
|
||||||
|
require.NoError(t, afero.WriteFile(
|
||||||
|
fs, filepath.Join(copiesDir, name), data, 0o644))
|
||||||
|
}
|
||||||
|
|
||||||
|
return originals
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireRestoredFiles requires every file in want to have been restored
|
||||||
|
// under restoreDir with its content intact.
|
||||||
|
func requireRestoredFiles(
|
||||||
|
t *testing.T, fs afero.Fs, restoreDir string, want map[string][]byte,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
for path, data := range want {
|
||||||
|
got, err := afero.ReadFile(fs, filepath.Join(restoreDir, path))
|
||||||
|
require.NoErrorf(t, err, "file missing after restore: %s", path)
|
||||||
|
require.Equalf(t, data, got, "byte mismatch for %s", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// restorePeakCachedBlobs runs a restore and returns the most blobs its
|
||||||
|
// cache held at once, together with the restore's error.
|
||||||
|
func restorePeakCachedBlobs(
|
||||||
|
ctx context.Context, t *testing.T, fs afero.Fs,
|
||||||
|
cfg *config.Config, storer storage.Storer, opts *RestoreOptions,
|
||||||
|
) (int, error) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var cache *blobDiskCache
|
||||||
|
|
||||||
|
v := &Vaultik{
|
||||||
|
Config: cfg,
|
||||||
|
Storage: storer,
|
||||||
|
Fs: fs,
|
||||||
|
Stdout: io.Discard,
|
||||||
|
Stderr: io.Discard,
|
||||||
|
UI: ui.NewWithColor(io.Discard, false),
|
||||||
|
restoreCacheObserver: func(c *blobDiskCache) {
|
||||||
|
cache = c
|
||||||
|
},
|
||||||
|
}
|
||||||
|
v.SetContext(ctx)
|
||||||
|
|
||||||
|
err := v.Restore(opts)
|
||||||
|
|
||||||
|
require.NotNil(t, cache, "restoreCacheObserver must fire during restore")
|
||||||
|
|
||||||
|
return cache.PeakLen(), err
|
||||||
|
}
|
||||||
@@ -33,7 +33,7 @@ func TestRestoreRejectsTruncatedMetadataDB(t *testing.T) {
|
|||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
dataDir := filepath.Join(tempDir, "source")
|
dataDir := filepath.Join(tempDir, "source")
|
||||||
storeDir := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
restoreDir := filepath.Join(tempDir, "restored")
|
restoreDir := filepath.Join(tempDir, "restored")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
package vaultik_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/vaultik/internal/config"
|
||||||
|
"sneak.berlin/go/vaultik/internal/database"
|
||||||
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
|
"sneak.berlin/go/vaultik/internal/storage"
|
||||||
|
"sneak.berlin/go/vaultik/internal/storage/faultstore"
|
||||||
|
"sneak.berlin/go/vaultik/internal/ui"
|
||||||
|
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||||
|
)
|
||||||
|
|
||||||
|
// These tests cover https://git.eeqj.de/sneak/vaultik/issues/309: a path
|
||||||
|
// given to restore that matches nothing in the snapshot is named in a
|
||||||
|
// warning and makes the restore fail, after the paths that do match are
|
||||||
|
// restored.
|
||||||
|
|
||||||
|
func TestRestoreNamesUnmatchedPathAndRestoresTheMatchingOne(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
fs := afero.NewOsFs()
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
dataDir := filepath.Join(tempDir, "src")
|
||||||
|
storeDir := t.TempDir()
|
||||||
|
restoreDir := filepath.Join(tempDir, "restored")
|
||||||
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
cfg := faultTestConfig()
|
||||||
|
files := writeFaultSourceTree(t, fs, dataDir)
|
||||||
|
|
||||||
|
store, err := storage.NewFileStorer(storeDir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
db, err := database.New(ctx, dbPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
id := fullFaultBackup(ctx, t, fs, store, cfg,
|
||||||
|
database.NewRepositories(db), dataDir, dbPath, "unmatched")
|
||||||
|
require.NoError(t, db.Close())
|
||||||
|
|
||||||
|
matched := filepath.Join(dataDir, "small.txt")
|
||||||
|
unmatched := filepath.Join(dataDir, "typo.txt")
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
|
||||||
|
v := newReaderVaultik(ctx, cfg, store, nil, fs)
|
||||||
|
v.UI = ui.NewWithColor(&out, false)
|
||||||
|
|
||||||
|
err = v.Restore(&vaultik.RestoreOptions{
|
||||||
|
SnapshotID: id,
|
||||||
|
TargetDir: restoreDir,
|
||||||
|
Paths: []string{matched, unmatched},
|
||||||
|
})
|
||||||
|
|
||||||
|
require.Error(t, err, "a path that matches nothing must fail the restore")
|
||||||
|
assert.Contains(t, out.String(), unmatched,
|
||||||
|
"the path that matches nothing must be named")
|
||||||
|
assert.NotContains(t, out.String(), matched,
|
||||||
|
"the path that matches must not be reported")
|
||||||
|
|
||||||
|
got, err := afero.ReadFile(fs, filepath.Join(restoreDir, matched))
|
||||||
|
require.NoError(t, err, "the path that matches must still be restored")
|
||||||
|
assert.Equal(t, files[matched], got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// When no requested path matches, nothing is restored and each path is
|
||||||
|
// named. One path is relative. The other goes through the symlink that
|
||||||
|
// is the snapshot's configured path, while the entries are stored under
|
||||||
|
// the directory the symlink points to.
|
||||||
|
func TestRestoreNamesEveryPathWhenNothingMatches(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const snapshotName = "linked"
|
||||||
|
|
||||||
|
fs := afero.NewOsFs()
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
dataDir := filepath.Join(tempDir, "src")
|
||||||
|
linkDir := filepath.Join(tempDir, "srclink")
|
||||||
|
storeDir := t.TempDir()
|
||||||
|
restoreDir := filepath.Join(tempDir, "restored")
|
||||||
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
writeFaultSourceTree(t, fs, dataDir)
|
||||||
|
require.NoError(t, os.Symlink(dataDir, linkDir))
|
||||||
|
|
||||||
|
cfg := faultTestConfig()
|
||||||
|
cfg.IndexPath = dbPath
|
||||||
|
cfg.ChunkSize = config.Size(faultChunkSize)
|
||||||
|
cfg.Snapshots = map[string]config.SnapshotConfig{
|
||||||
|
snapshotName: {Paths: []string{linkDir}},
|
||||||
|
}
|
||||||
|
|
||||||
|
store, err := storage.NewFileStorer(storeDir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
db, err := database.New(ctx, dbPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
repos := database.NewRepositories(db)
|
||||||
|
v := newBackupVaultik(ctx, cfg, store, repos, db, fs)
|
||||||
|
|
||||||
|
require.NoError(t, backUp(v, snapshotName))
|
||||||
|
|
||||||
|
id := localSnapshotID(ctx, t, repos, snapshotName)
|
||||||
|
require.NoError(t, db.Close())
|
||||||
|
|
||||||
|
throughLink := filepath.Join(linkDir, "sub")
|
||||||
|
relative := strings.TrimPrefix(filepath.Join(dataDir, "small.txt"), "/")
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
|
||||||
|
reader := newReaderVaultik(ctx, cfg, store, nil, fs)
|
||||||
|
reader.UI = ui.NewWithColor(&out, false)
|
||||||
|
|
||||||
|
err = reader.Restore(&vaultik.RestoreOptions{
|
||||||
|
SnapshotID: id,
|
||||||
|
TargetDir: restoreDir,
|
||||||
|
Paths: []string{throughLink, relative},
|
||||||
|
})
|
||||||
|
|
||||||
|
require.Error(t, err, "a restore that matches nothing must fail")
|
||||||
|
assert.Contains(t, out.String(), throughLink,
|
||||||
|
"the path through the symlink must be named")
|
||||||
|
assert.Contains(t, out.String(), relative,
|
||||||
|
"the relative path must be named")
|
||||||
|
|
||||||
|
_, err = fs.Stat(restoreDir)
|
||||||
|
assert.True(t, os.IsNotExist(err), "nothing must be restored")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Without --skip-errors a damaged blob stops the restore part way. The
|
||||||
|
// path that matches nothing must still be named.
|
||||||
|
func TestRestoreNamesUnmatchedPathWhenADamagedBlobStopsIt(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
fs := afero.NewOsFs()
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
dataDir := filepath.Join(tempDir, "src")
|
||||||
|
storeDir := t.TempDir()
|
||||||
|
restoreDir := filepath.Join(tempDir, "restored")
|
||||||
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
cfg := faultTestConfig()
|
||||||
|
|
||||||
|
writeFaultSourceTree(t, fs, dataDir)
|
||||||
|
|
||||||
|
inner, err := storage.NewFileStorer(storeDir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
db, err := database.New(ctx, dbPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
id := fullFaultBackup(ctx, t, fs, inner, cfg,
|
||||||
|
database.NewRepositories(db), dataDir, dbPath, "damaged")
|
||||||
|
require.NoError(t, db.Close())
|
||||||
|
|
||||||
|
damaged := faultstore.New(inner)
|
||||||
|
damaged.OnGet = func(key string) faultstore.GetFault {
|
||||||
|
if strings.HasPrefix(key, "blobs/") {
|
||||||
|
return faultstore.GetCorrupt
|
||||||
|
}
|
||||||
|
|
||||||
|
return faultstore.GetNormal
|
||||||
|
}
|
||||||
|
|
||||||
|
matched := filepath.Join(dataDir, "small.txt")
|
||||||
|
unmatched := filepath.Join(dataDir, "typo.txt")
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
|
||||||
|
v := newReaderVaultik(ctx, cfg, damaged, nil, fs)
|
||||||
|
v.UI = ui.NewWithColor(&out, false)
|
||||||
|
|
||||||
|
err = v.Restore(&vaultik.RestoreOptions{
|
||||||
|
SnapshotID: id,
|
||||||
|
TargetDir: restoreDir,
|
||||||
|
Paths: []string{matched, unmatched},
|
||||||
|
})
|
||||||
|
|
||||||
|
require.Error(t, err, "a damaged blob must stop the restore")
|
||||||
|
assert.Contains(t, err.Error(), "blob",
|
||||||
|
"the restore must stop on the damaged blob")
|
||||||
|
assert.Contains(t, out.String(), unmatched,
|
||||||
|
"the path that matches nothing must be named")
|
||||||
|
}
|
||||||
@@ -28,7 +28,7 @@ func TestVerifyPassesWhenBackupReusesStoredBlob(t *testing.T) {
|
|||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
dataDir := filepath.Join(tempDir, "source")
|
dataDir := filepath.Join(tempDir, "source")
|
||||||
storeDir := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
secondDBPath := filepath.Join(tempDir, "second.sqlite")
|
secondDBPath := filepath.Join(tempDir, "second.sqlite")
|
||||||
|
|
||||||
chunkSize := int64(64 * 1024)
|
chunkSize := int64(64 * 1024)
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ func TestBackupOfSameSecondRewriteRestoresNewContent(t *testing.T) {
|
|||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
dataDir := filepath.Join(tempDir, "src")
|
dataDir := filepath.Join(tempDir, "src")
|
||||||
storeDir := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
restoreDir := filepath.Join(tempDir, "restored")
|
restoreDir := filepath.Join(tempDir, "restored")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
rewrittenPath := filepath.Join(dataDir, "small.txt")
|
rewrittenPath := filepath.Join(dataDir, "small.txt")
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import (
|
|||||||
// then grows one stored blob so its size no longer matches the manifest.
|
// then grows one stored blob so its size no longer matches the manifest.
|
||||||
// Shallow verify must then fail, count the grown blob as a size mismatch,
|
// Shallow verify must then fail, count the grown blob as a size mismatch,
|
||||||
// and drop it from the verified count rather than continuing to report it
|
// and drop it from the verified count rather than continuing to report it
|
||||||
// as checked.
|
// as checked, and its error must not repeat the cli layer's prefix.
|
||||||
func TestShallowVerifyDetectsWrongBlobSize(t *testing.T) {
|
func TestShallowVerifyDetectsWrongBlobSize(t *testing.T) {
|
||||||
log.Initialize(log.Config{})
|
log.Initialize(log.Config{})
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -36,7 +36,7 @@ func TestShallowVerifyDetectsWrongBlobSize(t *testing.T) {
|
|||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
dataDir := filepath.Join(tempDir, "source")
|
dataDir := filepath.Join(tempDir, "source")
|
||||||
storeDir := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
chunkSize := int64(32 * 1024)
|
chunkSize := int64(32 * 1024)
|
||||||
@@ -96,6 +96,12 @@ func TestShallowVerifyDetectsWrongBlobSize(t *testing.T) {
|
|||||||
require.Equal(t, 1, bad.Mismatched)
|
require.Equal(t, 1, bad.Mismatched)
|
||||||
require.Equal(t, healthy.BlobCount-1, bad.Verified,
|
require.Equal(t, healthy.BlobCount-1, bad.Verified,
|
||||||
"the wrong-sized blob must not be counted as verified")
|
"the wrong-sized blob must not be counted as verified")
|
||||||
|
|
||||||
|
// The cli layer prints this error after its own "Verification failed:".
|
||||||
|
err = newVerifier(io.Discard).VerifySnapshotWithOptions(
|
||||||
|
snapshotID, &vaultik.VerifyOptions{})
|
||||||
|
require.EqualError(t, err,
|
||||||
|
"problems found in storage: 1 blobs have the wrong size")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestShallowVerifyDetectsMissingDatabase backs up a real snapshot,
|
// TestShallowVerifyDetectsMissingDatabase backs up a real snapshot,
|
||||||
@@ -110,7 +116,7 @@ func TestShallowVerifyDetectsMissingDatabase(t *testing.T) {
|
|||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
dataDir := filepath.Join(tempDir, "source")
|
dataDir := filepath.Join(tempDir, "source")
|
||||||
storeDir := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
chunkSize := int64(32 * 1024)
|
chunkSize := int64(32 * 1024)
|
||||||
@@ -193,7 +199,7 @@ func TestShallowVerifyReportsStatErrorsApartFromMissing(t *testing.T) {
|
|||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
dataDir := filepath.Join(tempDir, "source")
|
dataDir := filepath.Join(tempDir, "source")
|
||||||
storeDir := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
chunkSize := int64(32 * 1024)
|
chunkSize := int64(32 * 1024)
|
||||||
@@ -282,7 +288,7 @@ func TestShallowVerifyQuietFailsOnStatErrorAlone(t *testing.T) {
|
|||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
dataDir := filepath.Join(tempDir, "source")
|
dataDir := filepath.Join(tempDir, "source")
|
||||||
storeDir := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
chunkSize := int64(32 * 1024)
|
chunkSize := int64(32 * 1024)
|
||||||
|
|||||||
+436
-125
@@ -7,6 +7,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -23,6 +24,7 @@ var (
|
|||||||
errSnapshotNotInConfig = errors.New("snapshot not found in config")
|
errSnapshotNotInConfig = errors.New("snapshot not found in config")
|
||||||
errNoSnapshotsInConfig = errors.New("no snapshots configured")
|
errNoSnapshotsInConfig = errors.New("no snapshots configured")
|
||||||
errSnapshotVerifyFailed = errors.New("verification failed")
|
errSnapshotVerifyFailed = errors.New("verification failed")
|
||||||
|
errProblemsInStorage = errors.New("problems found in storage")
|
||||||
errRemoveAllNeedsForce = errors.New("--all requires --force")
|
errRemoveAllNeedsForce = errors.New("--all requires --force")
|
||||||
errSnapshotNotFound = errors.New("snapshot not found")
|
errSnapshotNotFound = errors.New("snapshot not found")
|
||||||
errSnapshotRemoveFailed = errors.New("snapshot(s) could not be removed")
|
errSnapshotRemoveFailed = errors.New("snapshot(s) could not be removed")
|
||||||
@@ -38,6 +40,30 @@ var (
|
|||||||
// caller can report it apart from a failed backup.
|
// caller can report it apart from a failed backup.
|
||||||
var ErrPostBackupPruneFailed = errors.New("post-backup prune failed")
|
var ErrPostBackupPruneFailed = errors.New("post-backup prune failed")
|
||||||
|
|
||||||
|
// ErrFilesUnreadable is returned when --skip-errors skipped files or
|
||||||
|
// directories it could not read or list. The snapshot is complete and
|
||||||
|
// kept, so the caller can report it apart from a failed backup.
|
||||||
|
var ErrFilesUnreadable = errors.New("file(s) could not be read")
|
||||||
|
|
||||||
|
// 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
|
// listRecentLimit caps how many snapshot rows are fetched from the
|
||||||
// local index for listing and purge operations.
|
// local index for listing and purge operations.
|
||||||
const listRecentLimit = 10000
|
const listRecentLimit = 10000
|
||||||
@@ -94,6 +120,13 @@ func (v *Vaultik) CreateSnapshot(opts *SnapshotCreateOptions) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Before PruneDatabase: it drops incomplete snapshots, which could
|
||||||
|
// leave the local index without one and so skip the marker check.
|
||||||
|
err = v.markDestination()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// Clean up incomplete snapshots FIRST, before any scanning.
|
// Clean up incomplete snapshots FIRST, before any scanning.
|
||||||
// This is critical for data safety; PruneDatabase below does it.
|
// This is critical for data safety; PruneDatabase below does it.
|
||||||
hostname := v.Config.Hostname
|
hostname := v.Config.Hostname
|
||||||
@@ -116,30 +149,15 @@ func (v *Vaultik) CreateSnapshot(opts *SnapshotCreateOptions) error {
|
|||||||
return fmt.Errorf("prune database: %w", err)
|
return fmt.Errorf("prune database: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine which snapshots to process
|
snapshotNames, err := v.snapshotNamesToCreate(opts.Snapshots)
|
||||||
snapshotNames := opts.Snapshots
|
if err != nil {
|
||||||
if len(snapshotNames) == 0 {
|
return err
|
||||||
snapshotNames = v.Config.SnapshotNames()
|
|
||||||
} else {
|
|
||||||
// Validate requested snapshot names exist
|
|
||||||
for _, name := range snapshotNames {
|
|
||||||
if _, ok := v.Config.Snapshots[name]; !ok {
|
|
||||||
return fmt.Errorf("%w: %q", errSnapshotNotInConfig, name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(snapshotNames) == 0 {
|
filesUnreadable, emptyPaths, unmountedPaths, err := v.createNamedSnapshots(
|
||||||
return errNoSnapshotsInConfig
|
opts, hostname, snapshotNames)
|
||||||
}
|
if err != nil {
|
||||||
|
return err
|
||||||
// Process each named snapshot
|
|
||||||
for snapIdx, snapName := range snapshotNames {
|
|
||||||
err = v.createNamedSnapshot(
|
|
||||||
opts, hostname, snapName, snapIdx+1, len(snapshotNames))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Print overall summary if multiple snapshots
|
// Print overall summary if multiple snapshots
|
||||||
@@ -148,13 +166,109 @@ func (v *Vaultik) CreateSnapshot(opts *SnapshotCreateOptions) error {
|
|||||||
len(snapshotNames), v.UI.Duration(time.Since(overallStartTime)))
|
len(snapshotNames), v.UI.Duration(time.Since(overallStartTime)))
|
||||||
}
|
}
|
||||||
|
|
||||||
if opts.Prune {
|
switch {
|
||||||
|
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)
|
err = v.runPostBackupPrune(snapshotNames, opts.KeepNewerThan)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("%w: %w", ErrPostBackupPruneFailed, err)
|
return fmt.Errorf("%w: %w", ErrPostBackupPruneFailed, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
// closing line. A file removed during the run is counted as failed in
|
||||||
|
// the summary but is not an error, with or without --skip-errors.
|
||||||
|
func (v *Vaultik) finishCreateSnapshot(filesUnreadable int) error {
|
||||||
|
if filesUnreadable > 0 {
|
||||||
|
return fmt.Errorf("%d %w", filesUnreadable, ErrFilesUnreadable)
|
||||||
|
}
|
||||||
|
|
||||||
// Terminus must obey the --cron invariant: silent on total
|
// Terminus must obey the --cron invariant: silent on total
|
||||||
// success only. UI.Complete is dropped in cron/quiet mode (that's
|
// success only. UI.Complete is dropped in cron/quiet mode (that's
|
||||||
// the success path), but if any warnings fired during the run we
|
// the success path), but if any warnings fired during the run we
|
||||||
@@ -170,10 +284,33 @@ func (v *Vaultik) CreateSnapshot(opts *SnapshotCreateOptions) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// snapshotNamesToCreate returns the requested snapshot names, each checked
|
||||||
|
// against the config, or every configured name when none was requested.
|
||||||
|
func (v *Vaultik) snapshotNamesToCreate(requested []string) ([]string, error) {
|
||||||
|
snapshotNames := requested
|
||||||
|
if len(snapshotNames) == 0 {
|
||||||
|
snapshotNames = v.Config.SnapshotNames()
|
||||||
|
} else {
|
||||||
|
// Validate requested snapshot names exist
|
||||||
|
for _, name := range snapshotNames {
|
||||||
|
if _, ok := v.Config.Snapshots[name]; !ok {
|
||||||
|
return nil, fmt.Errorf("%w: %q", errSnapshotNotInConfig, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(snapshotNames) == 0 {
|
||||||
|
return nil, errNoSnapshotsInConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
return snapshotNames, nil
|
||||||
|
}
|
||||||
|
|
||||||
// runPostBackupPrune drops older snapshots of the given names and removes
|
// runPostBackupPrune drops older snapshots of the given names and removes
|
||||||
// orphan blobs from remote storage. If keepNewerThan is set (e.g. "4w"),
|
// from remote storage the orphan blobs and the directories under
|
||||||
// snapshots newer than that duration are kept. Otherwise only the latest
|
// metadata/ with no manifest, as PruneBlobs does. If keepNewerThan is set
|
||||||
// snapshot of each name is kept.
|
// (e.g. "4w"), snapshots newer than that duration are kept. Otherwise only
|
||||||
|
// the latest snapshot of each name is kept.
|
||||||
func (v *Vaultik) runPostBackupPrune(
|
func (v *Vaultik) runPostBackupPrune(
|
||||||
snapshotNames []string, keepNewerThan string,
|
snapshotNames []string, keepNewerThan string,
|
||||||
) error {
|
) error {
|
||||||
@@ -200,7 +337,7 @@ func (v *Vaultik) runPostBackupPrune(
|
|||||||
|
|
||||||
err = v.PruneBlobs(&PruneOptions{Force: true})
|
err = v.PruneBlobs(&PruneOptions{Force: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("pruning orphaned blobs: %w", err)
|
return fmt.Errorf("pruning the destination store: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -222,16 +359,28 @@ type snapshotStats struct {
|
|||||||
totalBlobsUploaded int
|
totalBlobsUploaded int
|
||||||
uploadDuration time.Duration
|
uploadDuration time.Duration
|
||||||
|
|
||||||
|
// The part of totalFilesFailed that --skip-errors skipped because it
|
||||||
|
// could not be read or listed.
|
||||||
|
totalFilesUnreadable int
|
||||||
|
|
||||||
|
// The 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
|
// The sizes of all blobs the snapshot references, set by
|
||||||
// finalizeSnapshotMetadata once snapshot_blobs is populated.
|
// finalizeSnapshotMetadata once snapshot_blobs is populated.
|
||||||
blobSize int64
|
blobSize int64
|
||||||
blobUncompressedSize int64
|
blobUncompressedSize int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// createNamedSnapshot creates a single named snapshot
|
// createNamedSnapshot creates a single named snapshot and returns the
|
||||||
|
// statistics of its scan.
|
||||||
func (v *Vaultik) createNamedSnapshot(
|
func (v *Vaultik) createNamedSnapshot(
|
||||||
opts *SnapshotCreateOptions, hostname, snapName string, idx, total int,
|
opts *SnapshotCreateOptions, hostname, snapName string, idx, total int,
|
||||||
) error {
|
) (*snapshotStats, error) {
|
||||||
snapshotStartTime := time.Now()
|
snapshotStartTime := time.Now()
|
||||||
|
|
||||||
if total > 1 {
|
if total > 1 {
|
||||||
@@ -240,21 +389,27 @@ func (v *Vaultik) createNamedSnapshot(
|
|||||||
|
|
||||||
resolvedDirs, err := v.resolveSnapshotPaths(snapName)
|
resolvedDirs, err := v.resolveSnapshotPaths(snapName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
previousSnapshotID, err := v.previousSnapshotID(snapName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
scanner := v.ScannerFactory(snapshot.ScannerParams{
|
scanner := v.ScannerFactory(snapshot.ScannerParams{
|
||||||
EnableProgress: !opts.Cron,
|
EnableProgress: !opts.Cron,
|
||||||
UI: v.UI,
|
UI: v.UI,
|
||||||
Fs: v.Fs,
|
Fs: v.Fs,
|
||||||
Exclude: v.Config.GetExcludes(snapName),
|
Exclude: v.Config.GetExcludes(snapName),
|
||||||
SkipErrors: opts.SkipErrors,
|
SkipErrors: opts.SkipErrors,
|
||||||
|
PreviousSnapshotID: previousSnapshotID,
|
||||||
})
|
})
|
||||||
|
|
||||||
snapshotID, err := v.SnapshotManager.CreateSnapshotWithName(
|
snapshotID, err := v.SnapshotManager.CreateSnapshotWithName(
|
||||||
v.ctx, hostname, snapName, v.Globals.Version, v.Globals.Commit)
|
v.ctx, hostname, snapName, v.Globals.Version, v.Globals.Commit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("creating snapshot: %w", err)
|
return nil, fmt.Errorf("creating snapshot: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Info("Beginning snapshot", "snapshot_id", snapshotID, "name", snapName)
|
log.Info("Beginning snapshot", "snapshot_id", snapshotID, "name", snapName)
|
||||||
@@ -262,12 +417,12 @@ func (v *Vaultik) createNamedSnapshot(
|
|||||||
|
|
||||||
stats, err := v.scanAllDirectories(scanner, resolvedDirs, snapshotID)
|
stats, err := v.scanAllDirectories(scanner, resolvedDirs, snapshotID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = v.finalizeSnapshotMetadata(snapshotID, stats)
|
err = v.finalizeSnapshotMetadata(snapshotID, stats)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Info("Snapshot complete",
|
log.Info("Snapshot complete",
|
||||||
@@ -280,11 +435,78 @@ func (v *Vaultik) createNamedSnapshot(
|
|||||||
|
|
||||||
v.printSnapshotSummary(snapshotID, snapshotStartTime, stats)
|
v.printSnapshotSummary(snapshotID, snapshotStartTime, stats)
|
||||||
|
|
||||||
return 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
|
||||||
|
// named snapName in the local index, or "" when there is none.
|
||||||
|
func (v *Vaultik) previousSnapshotID(snapName string) (string, error) {
|
||||||
|
snapshots, err := v.Repositories.Snapshots.ListRecent(v.ctx, listRecentLimit)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("listing snapshots: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, s := range snapshots {
|
||||||
|
if s.CompletedAt != nil &&
|
||||||
|
parseSnapshotName(s.ID.String(), s.Hostname.String()) == snapName {
|
||||||
|
return s.ID.String(), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveSnapshotPaths resolves source directories to absolute paths
|
// resolveSnapshotPaths resolves source directories to absolute paths
|
||||||
// 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) {
|
func (v *Vaultik) resolveSnapshotPaths(snapName string) ([]string, error) {
|
||||||
snapConfig := v.Config.Snapshots[snapName]
|
snapConfig := v.Config.Snapshots[snapName]
|
||||||
resolvedDirs := make([]string, 0, len(snapConfig.Paths))
|
resolvedDirs := make([]string, 0, len(snapConfig.Paths))
|
||||||
@@ -297,12 +519,18 @@ func (v *Vaultik) resolveSnapshotPaths(snapName string) ([]string, error) {
|
|||||||
|
|
||||||
resolvedPath, err := filepath.EvalSymlinks(absPath)
|
resolvedPath, err := filepath.EvalSymlinks(absPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
// A path that does not exist is left for the scan to report.
|
||||||
resolvedPath = absPath
|
// One that exists is a symlink whose target is gone, as one
|
||||||
} else {
|
// 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(
|
return nil, fmt.Errorf(
|
||||||
"failed to resolve symlinks for %s: %w", absPath, err)
|
"failed to resolve symlinks for %s: %w", absPath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resolvedPath = absPath
|
||||||
}
|
}
|
||||||
|
|
||||||
resolvedDirs = append(resolvedDirs, resolvedPath)
|
resolvedDirs = append(resolvedDirs, resolvedPath)
|
||||||
@@ -348,6 +576,7 @@ func (v *Vaultik) scanAllDirectories(
|
|||||||
stats.totalBlobs += result.BlobsCreated
|
stats.totalBlobs += result.BlobsCreated
|
||||||
stats.totalFilesSkipped += result.FilesSkipped
|
stats.totalFilesSkipped += result.FilesSkipped
|
||||||
stats.totalFilesFailed += result.FilesFailed
|
stats.totalFilesFailed += result.FilesFailed
|
||||||
|
stats.totalFilesUnreadable += result.FilesUnreadable
|
||||||
stats.totalBytesSkipped += result.BytesSkipped
|
stats.totalBytesSkipped += result.BytesSkipped
|
||||||
stats.totalFilesDeleted += result.FilesDeleted
|
stats.totalFilesDeleted += result.FilesDeleted
|
||||||
stats.totalBytesDeleted += result.BytesDeleted
|
stats.totalBytesDeleted += result.BytesDeleted
|
||||||
@@ -355,6 +584,10 @@ func (v *Vaultik) scanAllDirectories(
|
|||||||
stats.totalBytesUploaded += result.BytesUploaded
|
stats.totalBytesUploaded += result.BytesUploaded
|
||||||
stats.uploadDuration += result.UploadDuration
|
stats.uploadDuration += result.UploadDuration
|
||||||
|
|
||||||
|
if result.EntriesFound == 0 {
|
||||||
|
stats.emptyPaths = append(stats.emptyPaths, dir)
|
||||||
|
}
|
||||||
|
|
||||||
log.Info("Directory scan complete",
|
log.Info("Directory scan complete",
|
||||||
"path", dir,
|
"path", dir,
|
||||||
"files", result.FilesScanned,
|
"files", result.FilesScanned,
|
||||||
@@ -574,7 +807,7 @@ func (v *Vaultik) PurgeSnapshotsWithOptions(opts *SnapshotPurgeOptions) error {
|
|||||||
|
|
||||||
if len(toDelete) == 0 {
|
if len(toDelete) == 0 {
|
||||||
if !opts.Quiet {
|
if !opts.Quiet {
|
||||||
v.printlnStdout("No snapshots to delete")
|
v.UI.Infof("No snapshots to delete.")
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -612,7 +845,7 @@ func selectSnapshotsToPurge(
|
|||||||
case opts.OlderThan != "":
|
case opts.OlderThan != "":
|
||||||
duration, err := parseDuration(opts.OlderThan)
|
duration, err := parseDuration(opts.OlderThan)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("invalid duration: %w", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
cutoff := time.Now().UTC().Add(-duration)
|
cutoff := time.Now().UTC().Add(-duration)
|
||||||
@@ -631,19 +864,19 @@ func selectSnapshotsToPurge(
|
|||||||
func (v *Vaultik) confirmAndExecutePurge(
|
func (v *Vaultik) confirmAndExecutePurge(
|
||||||
toDelete []SnapshotInfo, force, quiet bool,
|
toDelete []SnapshotInfo, force, quiet bool,
|
||||||
) error {
|
) error {
|
||||||
if !quiet {
|
// Confirm unless --force is used. The list, the prompt and its answer
|
||||||
|
// bypass the UI: they are an interactive exchange the operator must
|
||||||
|
// see, so --quiet does not silence them.
|
||||||
|
if !force {
|
||||||
v.stdoutf("The following snapshots will be deleted:\n\n")
|
v.stdoutf("The following snapshots will be deleted:\n\n")
|
||||||
|
|
||||||
for _, snap := range toDelete {
|
for _, snap := range toDelete {
|
||||||
v.stdoutf(" %s (%s, %s)\n",
|
v.stdoutf(" %s (%s, %s)\n",
|
||||||
snap.ID,
|
snap.ID,
|
||||||
snap.Timestamp.Format("2006-01-02 15:04:05"),
|
snap.Timestamp.Format(time.DateTime),
|
||||||
ubytes(snap.CompressedSize))
|
ubytes(snap.CompressedSize))
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Confirm unless --force is used
|
|
||||||
if !force {
|
|
||||||
v.stdoutf("\nDelete %d snapshot(s)? [y/N] ", len(toDelete))
|
v.stdoutf("\nDelete %d snapshot(s)? [y/N] ", len(toDelete))
|
||||||
|
|
||||||
var confirm string
|
var confirm string
|
||||||
@@ -661,7 +894,16 @@ func (v *Vaultik) confirmAndExecutePurge(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
} else if !quiet {
|
} else if !quiet {
|
||||||
v.stdoutf("\nDeleting %d snapshot(s) (--force specified)\n", len(toDelete))
|
v.UI.Infof("The following snapshots will be deleted:")
|
||||||
|
|
||||||
|
for _, snap := range toDelete {
|
||||||
|
v.UI.Detailf("%s (%s, %s)",
|
||||||
|
v.UI.Snapshot(snap.ID.String()),
|
||||||
|
snap.Timestamp.Format(time.DateTime),
|
||||||
|
ubytes(snap.CompressedSize))
|
||||||
|
}
|
||||||
|
|
||||||
|
v.UI.Infof("Deleting %d snapshot(s) (--force specified).", len(toDelete))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete snapshots (both local and remote)
|
// Delete snapshots (both local and remote)
|
||||||
@@ -669,10 +911,11 @@ func (v *Vaultik) confirmAndExecutePurge(
|
|||||||
snapshotID := snap.ID.String()
|
snapshotID := snap.ID.String()
|
||||||
log.Info("Deleting snapshot", "id", snapshotID)
|
log.Info("Deleting snapshot", "id", snapshotID)
|
||||||
|
|
||||||
|
// Stop before the remote delete, so a snapshot still in the local
|
||||||
|
// index keeps its metadata on the destination store.
|
||||||
err := v.deleteSnapshotFromLocalDB(snapshotID)
|
err := v.deleteSnapshotFromLocalDB(snapshotID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("Failed to delete from local database",
|
return fmt.Errorf("deleting snapshot %s from local index: %w", snapshotID, err)
|
||||||
"snapshot_id", snapshotID, "error", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = v.deleteRemoteSnapshotByKey(snapshot.RemoteSnapshotKey(snapshotID))
|
_, err = v.deleteRemoteSnapshotByKey(snapshot.RemoteSnapshotKey(snapshotID))
|
||||||
@@ -693,9 +936,9 @@ func (v *Vaultik) confirmAndExecutePurge(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !quiet {
|
if !quiet {
|
||||||
v.stdoutf("Deleted %d snapshot(s)\n", len(toDelete))
|
v.UI.Infof("Deleted %d snapshot(s).", len(toDelete))
|
||||||
v.printlnStdout(
|
v.UI.Noticef("Run '%s' to clean up unreferenced remote blobs.",
|
||||||
"\nNote: Run 'vaultik prune' to clean up unreferenced remote blobs.")
|
pruneCommandHint)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -776,7 +1019,7 @@ func (v *Vaultik) VerifySnapshotWithOptions(
|
|||||||
v.printlnStdout()
|
v.printlnStdout()
|
||||||
|
|
||||||
// Check each blob is present with the size the manifest records.
|
// 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)
|
v.checkSnapshotDatabase(remoteKey, result)
|
||||||
@@ -931,8 +1174,10 @@ func (v *Vaultik) formatVerifyResult(
|
|||||||
v.printVerifySummary(result, failure)
|
v.printVerifySummary(result, failure)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Not errSnapshotVerifyFailed: the cli layer prints this after its own
|
||||||
|
// "Verification failed:".
|
||||||
if failure != "" {
|
if failure != "" {
|
||||||
return fmt.Errorf("%w: %s", errSnapshotVerifyFailed, failure)
|
return fmt.Errorf("%w: %s", errProblemsInStorage, failure)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -1068,7 +1313,7 @@ func (v *Vaultik) CleanupLocalSnapshots(opts *PruneOptions) error {
|
|||||||
log.Info("Removing stale local snapshot record", "snapshot_id", id)
|
log.Info("Removing stale local snapshot record", "snapshot_id", id)
|
||||||
|
|
||||||
if !opts.JSON {
|
if !opts.JSON {
|
||||||
v.stdoutf("Removing stale local record: %s\n", id)
|
v.UI.Infof("Removing stale local record: %s.", v.UI.Snapshot(id))
|
||||||
}
|
}
|
||||||
|
|
||||||
err = v.deleteSnapshotFromLocalDB(id)
|
err = v.deleteSnapshotFromLocalDB(id)
|
||||||
@@ -1091,9 +1336,9 @@ func (v *Vaultik) CleanupLocalSnapshots(opts *PruneOptions) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if removed == 0 {
|
if removed == 0 {
|
||||||
v.printlnStdout("No stale local snapshots found.")
|
v.UI.Infof("No stale local snapshots found.")
|
||||||
} else {
|
} else {
|
||||||
v.stdoutf("Removed %d stale local snapshot record(s).\n", removed)
|
v.UI.Infof("Removed %d stale local snapshot record(s).", removed)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -1210,7 +1455,7 @@ const pruneCommandHint = "vaultik prune"
|
|||||||
// snapshotRemoveCommandHint is the command suggested, with the
|
// snapshotRemoveCommandHint is the command suggested, with the
|
||||||
// snapshot's ID, when a remove could not reach the destination store:
|
// snapshot's ID, when a remove could not reach the destination store:
|
||||||
// running it again removes the snapshot's metadata there, which
|
// 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"
|
const snapshotRemoveCommandHint = "vaultik snapshot remove"
|
||||||
|
|
||||||
// RemoveSnapshot removes a snapshot from the local index database and,
|
// RemoveSnapshot removes a snapshot from the local index database and,
|
||||||
@@ -1218,9 +1463,11 @@ const snapshotRemoveCommandHint = "vaultik snapshot remove"
|
|||||||
// destination store. Blobs are NOT touched: removing a snapshot's
|
// destination store. Blobs are NOT touched: removing a snapshot's
|
||||||
// blobs requires reading every remaining remote manifest (the remote
|
// blobs requires reading every remaining remote manifest (the remote
|
||||||
// may hold snapshots this host doesn't know about), which is what
|
// may hold snapshots this host doesn't know about), which is what
|
||||||
// `vaultik prune` is for — the command prints the prune invocation to
|
// `vaultik prune` is for — when metadata was removed from the
|
||||||
// run as a follow-up. When the remote is unreachable the command still
|
// destination store, the text output names the prune invocation to run
|
||||||
// completes the local-DB removal and warns.
|
// as a follow-up. When removing the metadata fails, a snapshot in the
|
||||||
|
// local index is still removed from it, with a warning; a snapshot
|
||||||
|
// only on the destination store is an error.
|
||||||
func (v *Vaultik) RemoveSnapshot(
|
func (v *Vaultik) RemoveSnapshot(
|
||||||
snapshotID string, opts *RemoveOptions,
|
snapshotID string, opts *RemoveOptions,
|
||||||
) (*RemoveResult, error) {
|
) (*RemoveResult, error) {
|
||||||
@@ -1242,10 +1489,18 @@ func (v *Vaultik) RemoveSnapshot(
|
|||||||
return v.removeSnapshotDryRun(result, snapshotID, opts)
|
return v.removeSnapshotDryRun(result, snapshotID, opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !opts.Force && !opts.JSON && !v.confirmRemoveSnapshot(snapshotID, opts) {
|
if !v.confirmRemoveSnapshot(snapshotID, opts) {
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Checked before the local record is deleted: it may be the last
|
||||||
|
// snapshot in the local index, and the check applies only while the
|
||||||
|
// local index holds one.
|
||||||
|
var markerErr error
|
||||||
|
if !opts.LocalOnly {
|
||||||
|
markerErr = v.checkDestinationMarker()
|
||||||
|
}
|
||||||
|
|
||||||
if inLocalIndex {
|
if inLocalIndex {
|
||||||
log.Info("Removing snapshot from local database", "snapshot_id", snapshotID)
|
log.Info("Removing snapshot from local database", "snapshot_id", snapshotID)
|
||||||
|
|
||||||
@@ -1255,8 +1510,10 @@ func (v *Vaultik) RemoveSnapshot(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !opts.LocalOnly {
|
result.RemoteRemoved, err = v.removeSnapshotRemote(
|
||||||
result.RemoteRemoved = v.removeSnapshotRemote(snapshotID, opts)
|
snapshotID, inLocalIndex, markerErr, opts)
|
||||||
|
if err != nil {
|
||||||
|
return result, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if v.SnapshotManager != nil {
|
if v.SnapshotManager != nil {
|
||||||
@@ -1272,14 +1529,15 @@ func (v *Vaultik) RemoveSnapshot(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if inLocalIndex {
|
if inLocalIndex {
|
||||||
v.stdoutf("Removed snapshot '%s' from local database\n", snapshotID)
|
v.UI.Infof("Removed snapshot '%s' from local database.",
|
||||||
|
v.UI.Snapshot(snapshotID))
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.RemoteRemoved {
|
if result.RemoteRemoved {
|
||||||
v.printlnStdout("Removed snapshot metadata from remote storage")
|
v.UI.Infof("Removed snapshot metadata from remote storage.")
|
||||||
v.stdoutf("\nNote: The removed snapshot's blobs remain on the remote. "+
|
v.UI.Noticef("The removed snapshot's blobs remain on the remote. "+
|
||||||
"Run '%s' to delete any blobs no longer referenced by any "+
|
"Run '%s' to delete any blobs no longer referenced by any "+
|
||||||
"remaining remote snapshot.\n", pruneCommandHint)
|
"remaining remote snapshot.", pruneCommandHint)
|
||||||
}
|
}
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
@@ -1290,7 +1548,9 @@ func (v *Vaultik) RemoveSnapshot(
|
|||||||
// remove: no row and, unless LocalOnly is set, no metadata on the
|
// remove: no row and, unless LocalOnly is set, no metadata on the
|
||||||
// destination store either. The destination store is consulted only
|
// destination store either. The destination store is consulted only
|
||||||
// when the local index has no row, so a snapshot in the local index can
|
// when the local index has no row, so a snapshot in the local index can
|
||||||
// still be removed from it while the destination store is unreachable.
|
// still be removed from it while the destination store is unreachable,
|
||||||
|
// and only after checkDestinationMarker passes, so an unplugged volume's
|
||||||
|
// empty mount point is not taken for a store without the snapshot.
|
||||||
func (v *Vaultik) findSnapshotToRemove(
|
func (v *Vaultik) findSnapshotToRemove(
|
||||||
snapshotID string, opts *RemoveOptions,
|
snapshotID string, opts *RemoveOptions,
|
||||||
) (bool, error) {
|
) (bool, error) {
|
||||||
@@ -1310,6 +1570,11 @@ func (v *Vaultik) findSnapshotToRemove(
|
|||||||
errSnapshotNotFound, snapshotID)
|
errSnapshotNotFound, snapshotID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err := v.checkDestinationMarker()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
objects, err := v.remoteSnapshotObjects(snapshot.RemoteSnapshotKey(snapshotID))
|
objects, err := v.remoteSnapshotObjects(snapshot.RemoteSnapshotKey(snapshotID))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("looking up snapshot on destination store: %w", err)
|
return false, fmt.Errorf("looking up snapshot on destination store: %w", err)
|
||||||
@@ -1335,22 +1600,28 @@ func (v *Vaultik) removeSnapshotDryRun(
|
|||||||
return result, v.outputRemoveJSON(result)
|
return result, v.outputRemoveJSON(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
v.stdoutf("Would remove snapshot: %s\n", snapshotID)
|
v.UI.Infof("Would remove snapshot: %s.", v.UI.Snapshot(snapshotID))
|
||||||
|
|
||||||
if !opts.LocalOnly {
|
if !opts.LocalOnly {
|
||||||
v.printlnStdout("Would also remove snapshot metadata from " +
|
v.UI.Infof("Would also remove snapshot metadata from " +
|
||||||
"remote storage (blobs untouched)")
|
"remote storage (blobs untouched).")
|
||||||
}
|
}
|
||||||
|
|
||||||
v.printlnStdout("[Dry run - no changes made]")
|
v.UI.Noticef("Dry run: no changes made.")
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// confirmRemoveSnapshot prompts for confirmation before removing a
|
// confirmRemoveSnapshot prompts for confirmation before removing a
|
||||||
// snapshot. Returns false (treating EOF/read errors as "no") unless the
|
// snapshot, unless --force or --json is set. Returns false (treating
|
||||||
// user answers "y".
|
// EOF/read errors as "no") unless the user answers "y". The prompt and
|
||||||
|
// its answer bypass the UI: they are an interactive exchange the
|
||||||
|
// operator must see, so --quiet does not silence them.
|
||||||
func (v *Vaultik) confirmRemoveSnapshot(snapshotID string, opts *RemoveOptions) bool {
|
func (v *Vaultik) confirmRemoveSnapshot(snapshotID string, opts *RemoveOptions) bool {
|
||||||
|
if opts.Force || opts.JSON {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
if opts.LocalOnly {
|
if opts.LocalOnly {
|
||||||
v.stdoutf("Remove snapshot '%s' from local database "+
|
v.stdoutf("Remove snapshot '%s' from local database "+
|
||||||
"(remote untouched)? [y/N] ", snapshotID)
|
"(remote untouched)? [y/N] ", snapshotID)
|
||||||
@@ -1378,19 +1649,41 @@ func (v *Vaultik) confirmRemoveSnapshot(snapshotID string, opts *RemoveOptions)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// removeSnapshotRemote strips the snapshot's metadata from the
|
// removeSnapshotRemote strips the snapshot's metadata from the
|
||||||
// destination store, warning and proceeding on failure: the local-DB
|
// destination store. It returns true only when the delete finished
|
||||||
// removal has already happened, so the user is told the remote half
|
// without an error and removed at least one object; under LocalOnly it
|
||||||
// didn't finish and to run `vaultik snapshot remove` for the snapshot
|
// does nothing. markerErr is the result of checkDestinationMarker; when
|
||||||
// again once the destination store is reachable (`vaultik prune` never
|
// it is set, nothing is deleted and it is the failure. When the snapshot
|
||||||
// removes snapshot metadata). Returns true when the remote removal
|
// is only on the destination store, a failure is returned as an error,
|
||||||
// succeeded and deleted something.
|
// because that removal is all the command does. When the snapshot was in
|
||||||
func (v *Vaultik) removeSnapshotRemote(snapshotID string, opts *RemoveOptions) bool {
|
// the local index, its removal there has already happened, so a failure
|
||||||
|
// is only a warning telling the user to run `vaultik snapshot remove`
|
||||||
|
// for the snapshot again once the destination store is reachable
|
||||||
|
// (`vaultik prune` never removes snapshot metadata that has its
|
||||||
|
// manifest).
|
||||||
|
func (v *Vaultik) removeSnapshotRemote(
|
||||||
|
snapshotID string, inLocalIndex bool, markerErr error,
|
||||||
|
opts *RemoveOptions,
|
||||||
|
) (bool, error) {
|
||||||
|
if opts.LocalOnly {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
log.Info("Removing snapshot metadata from remote storage",
|
log.Info("Removing snapshot metadata from remote storage",
|
||||||
"snapshot_id", snapshotID)
|
"snapshot_id", snapshotID)
|
||||||
|
|
||||||
remoteKey := snapshot.RemoteSnapshotKey(snapshotID)
|
var deleted int
|
||||||
|
|
||||||
|
err := markerErr
|
||||||
|
if err == nil {
|
||||||
|
deleted, err = v.deleteRemoteSnapshotByKey(
|
||||||
|
snapshot.RemoteSnapshotKey(snapshotID))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil && !inLocalIndex {
|
||||||
|
return false, fmt.Errorf(
|
||||||
|
"removing snapshot metadata from destination store: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
deleted, err := v.deleteRemoteSnapshotByKey(remoteKey)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warn("Could not remove snapshot metadata from remote storage; "+
|
log.Warn("Could not remove snapshot metadata from remote storage; "+
|
||||||
"run '"+snapshotRemoveCommandHint+"' with the snapshot's ID "+
|
"run '"+snapshotRemoveCommandHint+"' with the snapshot's ID "+
|
||||||
@@ -1405,17 +1698,19 @@ func (v *Vaultik) removeSnapshotRemote(snapshotID string, opts *RemoveOptions) b
|
|||||||
err, snapshotRemoveCommandHint, snapshotID)
|
err, snapshotRemoveCommandHint, snapshotID)
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return deleted > 0
|
return deleted > 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveAllSnapshots removes every snapshot known to the local
|
// RemoveAllSnapshots removes every snapshot known to the local
|
||||||
// database from the local index, and (with --remote) every snapshot
|
// database from the local index, and (with --remote) every snapshot
|
||||||
// metadata directory in remote storage. Both sides are processed so a
|
// metadata directory in remote storage. Both sides are processed so a
|
||||||
// "remove --all" leaves nothing behind, even when the local DB and
|
// "remove --all" leaves nothing behind, even when the local DB and
|
||||||
// remote storage have diverged.
|
// remote storage have diverged. Unless LocalOnly is set, it fails
|
||||||
|
// 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) {
|
func (v *Vaultik) RemoveAllSnapshots(opts *RemoveOptions) (*RemoveResult, error) {
|
||||||
err := v.EnsureStorageBinding()
|
err := v.EnsureStorageBinding()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1428,11 +1723,15 @@ func (v *Vaultik) RemoveAllSnapshots(opts *RemoveOptions) (*RemoveResult, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// remoteKeys is the set of metadata/<key>/ subdirectories on the
|
// remoteKeys is the set of metadata/<key>/ subdirectories on the
|
||||||
// destination store; failures are downgraded to a warning so a
|
// destination store. Only for a local-only remove is a failure
|
||||||
// permission-denied or unreachable remote can't block a local-only
|
// downgraded to a warning, so a permission-denied or unreachable
|
||||||
// remove.
|
// remote can't block it.
|
||||||
remoteKeys, remoteErr := v.listAllRemoteSnapshotKeys()
|
remoteKeys, remoteErr := v.listAllRemoteSnapshotKeys()
|
||||||
if remoteErr != nil {
|
if remoteErr != nil {
|
||||||
|
if !opts.LocalOnly {
|
||||||
|
return nil, remoteErr
|
||||||
|
}
|
||||||
|
|
||||||
log.Warn("Could not list remote snapshots", "error", remoteErr)
|
log.Warn("Could not list remote snapshots", "error", remoteErr)
|
||||||
v.UI.Warningf("Could not list remote snapshots: %v.", remoteErr)
|
v.UI.Warningf("Could not list remote snapshots: %v.", remoteErr)
|
||||||
}
|
}
|
||||||
@@ -1455,7 +1754,7 @@ func (v *Vaultik) RemoveAllSnapshots(opts *RemoveOptions) (*RemoveResult, error)
|
|||||||
|
|
||||||
if len(localSnaps) == 0 && len(orphanRemoteKeys) == 0 {
|
if len(localSnaps) == 0 && len(orphanRemoteKeys) == 0 {
|
||||||
if !opts.JSON {
|
if !opts.JSON {
|
||||||
v.printlnStdout("No snapshots found")
|
v.UI.Infof("No snapshots found.")
|
||||||
}
|
}
|
||||||
|
|
||||||
return &RemoveResult{}, nil
|
return &RemoveResult{}, nil
|
||||||
@@ -1494,8 +1793,7 @@ func (v *Vaultik) localSnapshotIDs() ([]string, error) {
|
|||||||
// listAllRemoteSnapshotKeys collects the hashed remote keys
|
// listAllRemoteSnapshotKeys collects the hashed remote keys
|
||||||
// (subdirectories under metadata/) currently present in the
|
// (subdirectories under metadata/) currently present in the
|
||||||
// destination store. Returns (nil, err) when the store cannot be
|
// destination store. Returns (nil, err) when the store cannot be
|
||||||
// listed; callers must treat that as "no remote info available," not
|
// listed, or is not present by checkDestinationMarker.
|
||||||
// fatal.
|
|
||||||
func (v *Vaultik) listAllRemoteSnapshotKeys() ([]string, error) {
|
func (v *Vaultik) listAllRemoteSnapshotKeys() ([]string, error) {
|
||||||
log.Info("Listing all remote snapshots")
|
log.Info("Listing all remote snapshots")
|
||||||
|
|
||||||
@@ -1544,6 +1842,11 @@ func (v *Vaultik) listAllRemoteSnapshotKeys() ([]string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err := v.checkDestinationMarker()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
return keys, nil
|
return keys, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1560,27 +1863,27 @@ func (v *Vaultik) handleRemoveAllDryRun(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !opts.JSON {
|
if !opts.JSON {
|
||||||
v.stdoutf("Would remove %d local snapshot(s):\n", len(localSnaps))
|
v.UI.Infof("Would remove %d local snapshot(s):", len(localSnaps))
|
||||||
|
|
||||||
for _, id := range localSnaps {
|
for _, id := range localSnaps {
|
||||||
v.stdoutf(" %s\n", id)
|
v.UI.Detailf("%s", v.UI.Snapshot(id))
|
||||||
}
|
}
|
||||||
|
|
||||||
if !opts.LocalOnly {
|
if !opts.LocalOnly {
|
||||||
if len(orphanRemoteKeys) > 0 {
|
if len(orphanRemoteKeys) > 0 {
|
||||||
v.stdoutf("Would also remove %d orphan remote snapshot key(s):\n",
|
v.UI.Infof("Would also remove %d orphan remote snapshot key(s):",
|
||||||
len(orphanRemoteKeys))
|
len(orphanRemoteKeys))
|
||||||
|
|
||||||
for _, key := range orphanRemoteKeys {
|
for _, key := range orphanRemoteKeys {
|
||||||
v.stdoutf(" %s\n", key)
|
v.UI.Detailf("%s", key)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
v.printlnStdout("Would also remove snapshot metadata from " +
|
v.UI.Infof("Would also remove snapshot metadata from " +
|
||||||
"remote storage (blobs untouched)")
|
"remote storage (blobs untouched).")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
v.printlnStdout("[Dry run - no changes made]")
|
v.UI.Noticef("Dry run: no changes made.")
|
||||||
}
|
}
|
||||||
|
|
||||||
if opts.JSON {
|
if opts.JSON {
|
||||||
@@ -1629,10 +1932,10 @@ func (v *Vaultik) executeRemoveAll(
|
|||||||
return result, v.outputRemoveJSON(result)
|
return result, v.outputRemoveJSON(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
v.stdoutf("Removed %d snapshot(s)\n", len(result.SnapshotsRemoved))
|
v.UI.Infof("Removed %d snapshot(s).", len(result.SnapshotsRemoved))
|
||||||
|
|
||||||
if !opts.LocalOnly && result.RemoteRemoved {
|
if !opts.LocalOnly && result.RemoteRemoved {
|
||||||
v.printlnStdout("Removed snapshot metadata from remote storage")
|
v.UI.Infof("Removed snapshot metadata from remote storage.")
|
||||||
}
|
}
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
@@ -1674,10 +1977,12 @@ func (v *Vaultik) deleteIncompleteSnapshot(snapshotIDStr string, id any) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// removeAllLocalSnapshots removes each known local snapshot (and, unless
|
// removeAllLocalSnapshots removes each known local snapshot's remote
|
||||||
// --local-only, its remote metadata), returning the number of snapshots
|
// metadata, unless --local-only, and then its local record, returning
|
||||||
// it could not remove. A snapshot left in the local index keeps its
|
// the number of snapshots it could not remove. The local record goes
|
||||||
// remote metadata too, since it is not an orphan remote key.
|
// only once the metadata is deleted: dropped first, it would leave that
|
||||||
|
// metadata on the destination store as a remote-only snapshot whose name
|
||||||
|
// is lost.
|
||||||
func (v *Vaultik) removeAllLocalSnapshots(
|
func (v *Vaultik) removeAllLocalSnapshots(
|
||||||
localSnaps []string, opts *RemoveOptions, result *RemoveResult,
|
localSnaps []string, opts *RemoveOptions, result *RemoveResult,
|
||||||
) int {
|
) int {
|
||||||
@@ -1686,6 +1991,19 @@ func (v *Vaultik) removeAllLocalSnapshots(
|
|||||||
for _, snapshotID := range localSnaps {
|
for _, snapshotID := range localSnaps {
|
||||||
log.Info("Removing snapshot", "snapshot_id", snapshotID)
|
log.Info("Removing snapshot", "snapshot_id", snapshotID)
|
||||||
|
|
||||||
|
if !opts.LocalOnly {
|
||||||
|
_, err := v.deleteRemoteSnapshotByKey(
|
||||||
|
snapshot.RemoteSnapshotKey(snapshotID))
|
||||||
|
if err != nil {
|
||||||
|
log.Warn("Failed to remove snapshot metadata from remote",
|
||||||
|
"snapshot_id", snapshotID, "error", err)
|
||||||
|
|
||||||
|
failures++
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
err := v.deleteSnapshotFromLocalDB(snapshotID)
|
err := v.deleteSnapshotFromLocalDB(snapshotID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("Failed to remove from local database",
|
log.Error("Failed to remove from local database",
|
||||||
@@ -1696,17 +2014,6 @@ func (v *Vaultik) removeAllLocalSnapshots(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if !opts.LocalOnly {
|
|
||||||
_, err = v.deleteRemoteSnapshotByKey(
|
|
||||||
snapshot.RemoteSnapshotKey(snapshotID))
|
|
||||||
if err != nil {
|
|
||||||
log.Warn("Failed to remove snapshot metadata from remote",
|
|
||||||
"snapshot_id", snapshotID, "error", err)
|
|
||||||
|
|
||||||
failures++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result.SnapshotsRemoved = append(result.SnapshotsRemoved, snapshotID)
|
result.SnapshotsRemoved = append(result.SnapshotsRemoved, snapshotID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1889,13 +2196,17 @@ func (v *Vaultik) PruneDatabase() (*PruneResult, error) {
|
|||||||
snapshotsRemain := countDiff(snapshotCountBefore, &result.SnapshotsDeleted)
|
snapshotsRemain := countDiff(snapshotCountBefore, &result.SnapshotsDeleted)
|
||||||
|
|
||||||
v.UI.Completef("Pruned local index database.")
|
v.UI.Completef("Pruned local index database.")
|
||||||
v.UI.Detailf("Incomplete snapshots: %s removed (%s remain).",
|
v.UI.Detailf("Incomplete snapshots: %s removed "+
|
||||||
|
"(total snapshots left in the local index: %s).",
|
||||||
countText(&result.SnapshotsDeleted), countText(snapshotsRemain))
|
countText(&result.SnapshotsDeleted), countText(snapshotsRemain))
|
||||||
v.UI.Detailf("Orphaned files: %s removed (%s remain).",
|
v.UI.Detailf("Orphaned files: %s removed "+
|
||||||
|
"(total files left in the local index: %s).",
|
||||||
countText(result.FilesDeleted), countText(fileCountAfter))
|
countText(result.FilesDeleted), countText(fileCountAfter))
|
||||||
v.UI.Detailf("Orphaned chunks: %s removed (%s remain).",
|
v.UI.Detailf("Orphaned chunks: %s removed "+
|
||||||
|
"(total chunks left in the local index: %s).",
|
||||||
countText(result.ChunksDeleted), countText(chunkCountAfter))
|
countText(result.ChunksDeleted), countText(chunkCountAfter))
|
||||||
v.UI.Detailf("Orphaned blobs: %s removed (%s remain).",
|
v.UI.Detailf("Orphaned blobs: %s removed "+
|
||||||
|
"(total blobs left in the local index: %s).",
|
||||||
countText(result.BlobsDeleted), countText(blobCountAfter))
|
countText(result.BlobsDeleted), countText(blobCountAfter))
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
package vaultik_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/vaultik/internal/config"
|
||||||
|
"sneak.berlin/go/vaultik/internal/database"
|
||||||
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
|
"sneak.berlin/go/vaultik/internal/storage"
|
||||||
|
"sneak.berlin/go/vaultik/internal/ui"
|
||||||
|
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||||
|
)
|
||||||
|
|
||||||
|
// These tests cover https://git.eeqj.de/sneak/vaultik/issues/304: a
|
||||||
|
// backup counts a file as deleted only when the previous snapshot of
|
||||||
|
// the same name had it and it is gone, and never counts an excluded
|
||||||
|
// file.
|
||||||
|
|
||||||
|
// deletedCountEnv is a backup setup over the source directory src whose
|
||||||
|
// user-facing output is kept in out. Each test configures its snapshots
|
||||||
|
// with setSnapshot.
|
||||||
|
type deletedCountEnv struct {
|
||||||
|
v *vaultik.Vaultik
|
||||||
|
out *bytes.Buffer
|
||||||
|
src string
|
||||||
|
}
|
||||||
|
|
||||||
|
// newDeletedCountEnv writes files, keyed by path relative to src.
|
||||||
|
func newDeletedCountEnv(t *testing.T, files map[string][]byte) *deletedCountEnv {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
fs := afero.NewOsFs()
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
srcDir := filepath.Join(tempDir, "src")
|
||||||
|
|
||||||
|
for rel, content := range files {
|
||||||
|
path := filepath.Join(srcDir, rel)
|
||||||
|
require.NoError(t, fs.MkdirAll(filepath.Dir(path), 0o755))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, path, content, 0o644))
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := faultTestConfig()
|
||||||
|
cfg.IndexPath = filepath.Join(tempDir, "index.sqlite")
|
||||||
|
cfg.Snapshots = map[string]config.SnapshotConfig{}
|
||||||
|
|
||||||
|
store, err := storage.NewFileStorer(t.TempDir())
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
db, err := database.New(ctx, cfg.IndexPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
|
||||||
|
repos := database.NewRepositories(db)
|
||||||
|
out := &bytes.Buffer{}
|
||||||
|
v := newBackupVaultik(ctx, cfg, store, repos, db, fs)
|
||||||
|
v.UI = ui.NewWithColor(out, false)
|
||||||
|
|
||||||
|
return &deletedCountEnv{v: v, out: out, src: srcDir}
|
||||||
|
}
|
||||||
|
|
||||||
|
// setSnapshot configures the snapshot name to back up the path rel
|
||||||
|
// beneath src, with the given excludes.
|
||||||
|
func (e *deletedCountEnv) setSnapshot(name, rel string, exclude ...string) {
|
||||||
|
e.v.Config.Snapshots[name] = config.SnapshotConfig{
|
||||||
|
Paths: []string{filepath.Join(e.src, rel)},
|
||||||
|
Exclude: exclude,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// backUp runs a backup of the named snapshot and returns its output.
|
||||||
|
func (e *deletedCountEnv) backUp(t *testing.T, name string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
e.out.Reset()
|
||||||
|
require.NoError(t, e.v.CreateSnapshot(&vaultik.SnapshotCreateOptions{
|
||||||
|
Snapshots: []string{name},
|
||||||
|
}))
|
||||||
|
|
||||||
|
return e.out.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// A file this snapshot excludes is not counted as deleted: neither one
|
||||||
|
// that another snapshot backs up, nor one that the previous snapshot of
|
||||||
|
// this name had before the exclude was added.
|
||||||
|
func TestBackupDoesNotCountExcludedFileAsDeleted(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newDeletedCountEnv(t, map[string][]byte{
|
||||||
|
"top.txt": []byte("top"),
|
||||||
|
"cache/tmp.bin": []byte("cache"),
|
||||||
|
"photos/a.jpg": []byte("photo a"),
|
||||||
|
"photos/b.jpg": []byte("photo b"),
|
||||||
|
"photos/c.jpg": []byte("photo c"),
|
||||||
|
})
|
||||||
|
env.setSnapshot("photos", "photos")
|
||||||
|
env.setSnapshot("home", "", "/photos")
|
||||||
|
|
||||||
|
env.backUp(t, "photos")
|
||||||
|
assert.Contains(t, env.backUp(t, "home"), filesLine(2, 2, 0))
|
||||||
|
|
||||||
|
env.setSnapshot("home", "", "/photos", "/cache")
|
||||||
|
assert.Contains(t, env.backUp(t, "home"), filesLine(1, 0, 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
// A file inside a directory that an exclude added after a backup
|
||||||
|
// matches is not counted as deleted, though the pattern matches only the
|
||||||
|
// directory and the directory is gone from disk.
|
||||||
|
func TestBackupDoesNotCountFileInExcludedDirectoryAsDeleted(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newDeletedCountEnv(t, map[string][]byte{
|
||||||
|
"top.txt": []byte("top"),
|
||||||
|
"user/.local/share/Trash/old.txt": []byte("old"),
|
||||||
|
})
|
||||||
|
env.setSnapshot("home", "")
|
||||||
|
|
||||||
|
assert.Contains(t, env.backUp(t, "home"), filesLine(2, 2, 0))
|
||||||
|
|
||||||
|
require.NoError(t, os.RemoveAll(
|
||||||
|
filepath.Join(env.src, "user/.local/share/Trash")))
|
||||||
|
|
||||||
|
env.setSnapshot("home", "", "*/.local/share/Trash")
|
||||||
|
assert.Contains(t, env.backUp(t, "home"), filesLine(1, 0, 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
// A file whose directory was replaced by a regular file of the same name
|
||||||
|
// is counted as deleted.
|
||||||
|
func TestBackupCountsFileInDirectoryReplacedByFileAsDeleted(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newDeletedCountEnv(t, map[string][]byte{
|
||||||
|
"dir/inside.txt": []byte("inside"),
|
||||||
|
})
|
||||||
|
env.setSnapshot("home", "")
|
||||||
|
|
||||||
|
assert.Contains(t, env.backUp(t, "home"), filesLine(1, 1, 0))
|
||||||
|
|
||||||
|
dir := filepath.Join(env.src, "dir")
|
||||||
|
require.NoError(t, os.RemoveAll(dir))
|
||||||
|
require.NoError(t, os.WriteFile(dir, []byte("now a file"), 0o600))
|
||||||
|
|
||||||
|
assert.Contains(t, env.backUp(t, "home"),
|
||||||
|
"Files: 1 examined, 1 backed up, 0 unchanged, 1 deleted.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Under --skip-errors a file inside a directory that the walk cannot
|
||||||
|
// open is not counted as deleted: it is still on disk.
|
||||||
|
func TestBackupDoesNotCountFileInUnopenableDirectoryAsDeleted(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newDeletedCountEnv(t, map[string][]byte{
|
||||||
|
"open.txt": []byte("open"),
|
||||||
|
"locked/inside.txt": []byte("inside"),
|
||||||
|
})
|
||||||
|
env.setSnapshot("work", "")
|
||||||
|
|
||||||
|
assert.Contains(t, env.backUp(t, "work"), filesLine(2, 2, 0))
|
||||||
|
|
||||||
|
// A directory's mode does not lock out root, who runs the tests, so
|
||||||
|
// opening the directory is made to fail instead.
|
||||||
|
env.v.Fs = &openFailFs{
|
||||||
|
path: filepath.Join(env.src, "locked"),
|
||||||
|
err: os.ErrPermission,
|
||||||
|
}
|
||||||
|
|
||||||
|
env.out.Reset()
|
||||||
|
err := env.v.CreateSnapshot(&vaultik.SnapshotCreateOptions{
|
||||||
|
SkipErrors: true,
|
||||||
|
Snapshots: []string{"work"},
|
||||||
|
})
|
||||||
|
require.ErrorIs(t, err, vaultik.ErrFilesUnreadable)
|
||||||
|
assert.Contains(t, env.out.String(),
|
||||||
|
"Files: 2 examined, 0 backed up, 1 unchanged, 1 failed.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A backup counts only files that the previous snapshot of its own name
|
||||||
|
// had, not one that a later snapshot of another name over the same path
|
||||||
|
// had and that is since removed.
|
||||||
|
func TestBackupIgnoresSnapshotOfAnotherName(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newDeletedCountEnv(t, map[string][]byte{
|
||||||
|
"kept.txt": []byte("kept"),
|
||||||
|
})
|
||||||
|
env.setSnapshot("home", "")
|
||||||
|
env.setSnapshot("other", "")
|
||||||
|
|
||||||
|
env.backUp(t, "home")
|
||||||
|
|
||||||
|
// Snapshot start times are kept in whole seconds: wait so that other's
|
||||||
|
// snapshot is strictly the most recent.
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
|
||||||
|
extra := filepath.Join(env.src, "extra.txt")
|
||||||
|
require.NoError(t, os.WriteFile(extra, []byte("extra"), 0o600))
|
||||||
|
env.backUp(t, "other")
|
||||||
|
require.NoError(t, os.Remove(extra))
|
||||||
|
|
||||||
|
assert.Contains(t, env.backUp(t, "home"), filesLine(1, 0, 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
// A file removed from disk is counted as deleted by the next backup of
|
||||||
|
// the snapshot that had it, and not by any later backup of that
|
||||||
|
// snapshot, nor by the first backup of another name over the same path.
|
||||||
|
func TestBackupCountsDeletedFileOnce(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
aContent := bytes.Repeat([]byte("a"), 1000)
|
||||||
|
bContent := bytes.Repeat([]byte("b"), 2000)
|
||||||
|
keptContent := bytes.Repeat([]byte("k"), 500)
|
||||||
|
|
||||||
|
env := newDeletedCountEnv(t, map[string][]byte{
|
||||||
|
"a.txt": aContent,
|
||||||
|
"b.txt": bContent,
|
||||||
|
"kept.txt": keptContent,
|
||||||
|
})
|
||||||
|
env.setSnapshot("home", "")
|
||||||
|
env.setSnapshot("other", "")
|
||||||
|
|
||||||
|
assert.Contains(t, env.backUp(t, "home"), filesLine(3, 3, 0))
|
||||||
|
|
||||||
|
require.NoError(t, os.Remove(filepath.Join(env.src, "a.txt")))
|
||||||
|
require.NoError(t, os.Remove(filepath.Join(env.src, "b.txt")))
|
||||||
|
|
||||||
|
summary := env.backUp(t, "home")
|
||||||
|
assert.Contains(t, summary,
|
||||||
|
"Files: 1 examined, 0 backed up, 1 unchanged, 2 deleted.")
|
||||||
|
assert.Contains(t, summary,
|
||||||
|
fmt.Sprintf("Data: %s total (%s backed up), %s deleted.",
|
||||||
|
env.v.UI.Size(int64(len(keptContent))), env.v.UI.Size(0),
|
||||||
|
env.v.UI.Size(int64(len(aContent)+len(bContent)))))
|
||||||
|
|
||||||
|
assert.Contains(t, env.backUp(t, "home"), filesLine(1, 0, 1))
|
||||||
|
assert.Contains(t, env.backUp(t, "other"), filesLine(1, 0, 1))
|
||||||
|
}
|
||||||
@@ -49,13 +49,19 @@ func TestSnapshotSummaryCountsFileNotStoredAsFailed(t *testing.T) {
|
|||||||
name string
|
name string
|
||||||
openErr error
|
openErr error
|
||||||
skipErrors bool
|
skipErrors bool
|
||||||
|
wantErr error // nil for none
|
||||||
}{
|
}{
|
||||||
// What a normal user gets opening a file with mode 000.
|
// What a normal user gets opening a file with mode 000.
|
||||||
{name: "unopenable under skip-errors",
|
{name: "unopenable under skip-errors",
|
||||||
openErr: os.ErrPermission, skipErrors: true},
|
openErr: os.ErrPermission, skipErrors: true,
|
||||||
|
wantErr: vaultik.ErrFilesUnreadable},
|
||||||
// What opening a file removed after phase 1 gives.
|
// What opening a file removed after phase 1 gives.
|
||||||
{name: "removed between the phases",
|
{name: "removed between the phases",
|
||||||
openErr: os.ErrNotExist, skipErrors: false},
|
openErr: os.ErrNotExist, skipErrors: false},
|
||||||
|
// A removal is not an error, so --skip-errors does not make the
|
||||||
|
// run fail. See https://git.eeqj.de/sneak/vaultik/issues/310.
|
||||||
|
{name: "removed between the phases under skip-errors",
|
||||||
|
openErr: os.ErrNotExist, skipErrors: true},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@@ -87,7 +93,7 @@ func TestSnapshotSummaryCountsFileNotStoredAsFailed(t *testing.T) {
|
|||||||
"src": {Paths: []string{srcDir}},
|
"src": {Paths: []string{srcDir}},
|
||||||
}
|
}
|
||||||
|
|
||||||
store, err := storage.NewFileStorer(filepath.Join(tempDir, "remote"))
|
store, err := storage.NewFileStorer(t.TempDir())
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
db, err := database.New(ctx, cfg.IndexPath)
|
db, err := database.New(ctx, cfg.IndexPath)
|
||||||
@@ -99,10 +105,11 @@ func TestSnapshotSummaryCountsFileNotStoredAsFailed(t *testing.T) {
|
|||||||
v := newBackupVaultik(ctx, cfg, store, repos, db, fs)
|
v := newBackupVaultik(ctx, cfg, store, repos, db, fs)
|
||||||
v.UI = ui.NewWithColor(out, false)
|
v.UI = ui.NewWithColor(out, false)
|
||||||
|
|
||||||
require.NoError(t, v.CreateSnapshot(&vaultik.SnapshotCreateOptions{
|
err = v.CreateSnapshot(&vaultik.SnapshotCreateOptions{
|
||||||
SkipErrors: tt.skipErrors,
|
SkipErrors: tt.skipErrors,
|
||||||
Snapshots: []string{"src"},
|
Snapshots: []string{"src"},
|
||||||
}))
|
})
|
||||||
|
require.ErrorIs(t, err, tt.wantErr)
|
||||||
|
|
||||||
summary := out.String()
|
summary := out.String()
|
||||||
assert.Contains(t, summary,
|
assert.Contains(t, summary,
|
||||||
|
|||||||
@@ -61,7 +61,9 @@ const tabPadding = 3
|
|||||||
// attempt is made to recover or fabricate the human ID.
|
// attempt is made to recover or fabricate the human ID.
|
||||||
//
|
//
|
||||||
// Snapshots in the local index with no counterpart on the destination
|
// Snapshots in the local index with no counterpart on the destination
|
||||||
// store are reported as drift below the table.
|
// store are reported as drift below the table, unless the local index
|
||||||
|
// is bound to a different destination than the configured one: that is
|
||||||
|
// reported instead.
|
||||||
//
|
//
|
||||||
// If remote listing fails (unmounted volume, permission denied,
|
// If remote listing fails (unmounted volume, permission denied,
|
||||||
// network), we degrade to local-only with a warning. List never fails
|
// network), we degrade to local-only with a warning. List never fails
|
||||||
@@ -103,7 +105,26 @@ func (v *Vaultik) ListSnapshots(jsonOutput bool) error {
|
|||||||
return snapshots[i].Timestamp.After(snapshots[j].Timestamp)
|
return snapshots[i].Timestamp.After(snapshots[j].Timestamp)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
stored, configured, err := v.storageBinding()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// A local index bound to another destination describes that one, so
|
||||||
|
// its records are expected to be missing here, and prune refuses to
|
||||||
|
// run. The binding is reported instead of advising prune.
|
||||||
|
boundElsewhere := stored != "" && stored != configured
|
||||||
|
|
||||||
if jsonOutput {
|
if jsonOutput {
|
||||||
|
// stdout holds only the document, so this goes to the logger.
|
||||||
|
if boundElsewhere {
|
||||||
|
log.Warn("Local index is bound to a different backup "+
|
||||||
|
"destination; to proceed, revert storage_url (or the s3.* "+
|
||||||
|
"endpoint, bucket and prefix) to the bound destination, "+
|
||||||
|
"or run 'vaultik database delete'",
|
||||||
|
"bound_to", stored, "configured", configured)
|
||||||
|
}
|
||||||
|
|
||||||
if remoteErr == nil {
|
if remoteErr == nil {
|
||||||
v.reportJSONListingLimits(listing)
|
v.reportJSONListingLimits(listing)
|
||||||
}
|
}
|
||||||
@@ -125,8 +146,13 @@ func (v *Vaultik) ListSnapshots(jsonOutput bool) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if boundElsewhere {
|
||||||
|
v.UI.Warningf("%v\n%s", errStorageBindingMismatch,
|
||||||
|
buildBindingMismatchMessage(stored, configured))
|
||||||
|
}
|
||||||
|
|
||||||
if remoteErr == nil {
|
if remoteErr == nil {
|
||||||
v.reportListDrift(snapshots, listing)
|
v.reportListDrift(snapshots, listing, boundElsewhere)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -432,7 +458,9 @@ func (v *Vaultik) snapshotInfoFromLocal(ls *database.Snapshot) SnapshotInfo {
|
|||||||
// reportListDrift prints the reconciliation notes the merged table
|
// reportListDrift prints the reconciliation notes the merged table
|
||||||
// cannot express on its own: local records with no counterpart on the
|
// cannot express on its own: local records with no counterpart on the
|
||||||
// destination store, plus counts of remote snapshots that were
|
// destination store, plus counts of remote snapshots that were
|
||||||
// unreadable or omitted.
|
// unreadable or omitted. Local records are not reported when
|
||||||
|
// boundElsewhere is set: ListSnapshots has already said why they are
|
||||||
|
// missing.
|
||||||
//
|
//
|
||||||
// This is what remains of the old reportRemoteDrift, and it no longer
|
// This is what remains of the old reportRemoteDrift, and it no longer
|
||||||
// touches the destination store. Its remote-only half collapsed into
|
// touches the destination store. Its remote-only half collapsed into
|
||||||
@@ -441,6 +469,7 @@ func (v *Vaultik) snapshotInfoFromLocal(ls *database.Snapshot) SnapshotInfo {
|
|||||||
// so `snapshot list` lists the destination exactly once per invocation.
|
// so `snapshot list` lists the destination exactly once per invocation.
|
||||||
func (v *Vaultik) reportListDrift(
|
func (v *Vaultik) reportListDrift(
|
||||||
snapshots []SnapshotInfo, listing *remoteSnapshotListing,
|
snapshots []SnapshotInfo, listing *remoteSnapshotListing,
|
||||||
|
boundElsewhere bool,
|
||||||
) {
|
) {
|
||||||
var localOnly []string
|
var localOnly []string
|
||||||
|
|
||||||
@@ -450,7 +479,7 @@ func (v *Vaultik) reportListDrift(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(localOnly) > 0 {
|
if len(localOnly) > 0 && !boundElsewhere {
|
||||||
v.UI.Warningf("%d local snapshot record(s) not found in backup "+
|
v.UI.Warningf("%d local snapshot record(s) not found in backup "+
|
||||||
"destination store:", len(localOnly))
|
"destination store:", len(localOnly))
|
||||||
|
|
||||||
|
|||||||
@@ -122,6 +122,9 @@ func newListEnv(t *testing.T) *listEnv {
|
|||||||
store := newObservingStorer()
|
store := newObservingStorer()
|
||||||
stdout := &bytes.Buffer{}
|
stdout := &bytes.Buffer{}
|
||||||
|
|
||||||
|
// A backup wrote to this destination, so it carries the marker.
|
||||||
|
require.NoError(t, store.Put(ctx, markerFile, strings.NewReader("")))
|
||||||
|
|
||||||
v := &vaultik.Vaultik{
|
v := &vaultik.Vaultik{
|
||||||
Config: &config.Config{
|
Config: &config.Config{
|
||||||
AgeSecretKey: "",
|
AgeSecretKey: "",
|
||||||
@@ -184,6 +187,17 @@ func (e *listEnv) addRemoteRawTimestamp(
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
remoteKey := snapshot.RemoteSnapshotKey(snapshotID)
|
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{
|
manifest := &snapshot.Manifest{
|
||||||
// Note: the hashed key, never the human ID. That is precisely
|
// Note: the hashed key, never the human ID. That is precisely
|
||||||
// why a remote-only snapshot cannot be named.
|
// why a remote-only snapshot cannot be named.
|
||||||
@@ -199,11 +213,17 @@ func (e *listEnv) addRemoteRawTimestamp(
|
|||||||
data, err := snapshot.EncodeManifest(manifest, 3)
|
data, err := snapshot.EncodeManifest(manifest, 3)
|
||||||
require.NoError(t, err)
|
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))
|
"metadata/"+remoteKey+"/manifest.json.zst", bytes.NewReader(data))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
return remoteKey
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fixtures shared across the listing tests.
|
// Fixtures shared across the listing tests.
|
||||||
@@ -421,6 +441,74 @@ func TestListSnapshots_LocalOnlyReportedAsDrift(t *testing.T) {
|
|||||||
assert.NotContains(t, out, "snapshot cleanup")
|
assert.NotContains(t, out, "snapshot cleanup")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestListSnapshots_BoundElsewhereDoesNotAdvisePrune covers a changed
|
||||||
|
// storage_url: the local index is still bound to the old destination,
|
||||||
|
// so its records are missing from the new one. prune refuses to run in
|
||||||
|
// that state, so the listing must give the choices prune gives instead
|
||||||
|
// of advising it.
|
||||||
|
func TestListSnapshots_BoundElsewhereDoesNotAdvisePrune(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const (
|
||||||
|
oldURL = "file:///mnt/backups/old"
|
||||||
|
newURL = "file:///mnt/backups/new"
|
||||||
|
)
|
||||||
|
|
||||||
|
env := newListEnv(t)
|
||||||
|
env.v.Config.StorageURL = newURL
|
||||||
|
require.NoError(t, env.v.Repositories.LocalMeta.Set(context.Background(),
|
||||||
|
database.LocalMetaKeyStorageURL, oldURL))
|
||||||
|
env.addLocal(t, listLocalID, time.Date(2026, 3, 1, 10, 0, 0, 0, time.UTC))
|
||||||
|
|
||||||
|
err := env.v.ListSnapshots(false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
out := env.stdout.String()
|
||||||
|
|
||||||
|
assert.Contains(t, out, listLocalID)
|
||||||
|
assert.Contains(t, out,
|
||||||
|
"local index is bound to a different backup destination")
|
||||||
|
assert.Contains(t, out, oldURL)
|
||||||
|
assert.Contains(t, out, newURL)
|
||||||
|
assert.Contains(t, out, "revert storage_url")
|
||||||
|
assert.Contains(t, out, "vaultik database delete")
|
||||||
|
assert.NotContains(t, out, "vaultik prune")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestListSnapshots_JSONReportsBoundElsewhere is the --json side of the
|
||||||
|
// test above. Every local row comes out with remote_present false, which
|
||||||
|
// a script reads as stale local records unless it is told, on stderr,
|
||||||
|
// that the local index is bound to a different destination.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // captureProcessStderr replaces os.Stderr
|
||||||
|
func TestListSnapshots_JSONReportsBoundElsewhere(t *testing.T) {
|
||||||
|
const (
|
||||||
|
oldURL = "file:///mnt/backups/old"
|
||||||
|
newURL = "file:///mnt/backups/new"
|
||||||
|
)
|
||||||
|
|
||||||
|
env := newListEnv(t)
|
||||||
|
env.v.Config.StorageURL = newURL
|
||||||
|
require.NoError(t, env.v.Repositories.LocalMeta.Set(context.Background(),
|
||||||
|
database.LocalMetaKeyStorageURL, oldURL))
|
||||||
|
env.addLocal(t, listLocalID, time.Date(2026, 3, 1, 10, 0, 0, 0, time.UTC))
|
||||||
|
|
||||||
|
stderr := captureProcessStderr(t, func() {
|
||||||
|
require.NoError(t, env.v.ListSnapshots(true))
|
||||||
|
})
|
||||||
|
|
||||||
|
rows := decodeListJSON(t, env.stdout.String())
|
||||||
|
require.Len(t, rows, 1)
|
||||||
|
assert.Equal(t, listLocalID, rows[0].ID)
|
||||||
|
|
||||||
|
assert.Contains(t, stderr, "bound to a different backup destination")
|
||||||
|
assert.Contains(t, stderr, oldURL)
|
||||||
|
assert.Contains(t, stderr, newURL)
|
||||||
|
assert.Contains(t, stderr, "revert storage_url")
|
||||||
|
assert.Contains(t, stderr, "vaultik database delete")
|
||||||
|
}
|
||||||
|
|
||||||
// TestListSnapshots_UnreachableRemoteDegrades covers the promise in the
|
// TestListSnapshots_UnreachableRemoteDegrades covers the promise in the
|
||||||
// doc comment: an unreachable destination is a warning plus local-only
|
// doc comment: an unreachable destination is a warning plus local-only
|
||||||
// output, never a failure.
|
// output, never a failure.
|
||||||
@@ -720,9 +808,17 @@ func TestListSnapshots_JSONReportsTruncation(t *testing.T) {
|
|||||||
|
|
||||||
timestamp := time.Date(2026, 3, 2, 11, 22, 33, 0, time.UTC)
|
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 {
|
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() {
|
stderr := captureProcessStderr(t, func() {
|
||||||
|
|||||||
Loaded 100 of 114 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user