Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b42c4bb325 |
No files matched your search
@@ -1,2 +0,0 @@
|
|||||||
node_modules/
|
|
||||||
yarn.lock
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"tabWidth": 4,
|
|
||||||
"proseWrap": "always"
|
|
||||||
}
|
|
||||||
@@ -4,122 +4,127 @@ 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 direction I
|
* Come in hot with strong opinions, even if they are contrary to the
|
||||||
am headed.
|
direction I am headed.
|
||||||
|
|
||||||
- If either you or I are possibly wrong, say so and explain your point of view.
|
* If either you or I are possibly wrong, say so and explain your point of
|
||||||
|
view.
|
||||||
|
|
||||||
- Point out great alternatives I haven't thought of, even when I'm not asking
|
* Point out great alternatives I haven't thought of, even when I'm not
|
||||||
for them.
|
asking for them.
|
||||||
|
|
||||||
- Treat me like the world's leading expert in every situation and every
|
* Treat me like the world's leading expert in every situation and every
|
||||||
conversation, and deliver the absolute best recommendations.
|
conversation, and deliver the absolute best recommendations.
|
||||||
|
|
||||||
- I want excellence, so always be on the lookout for divergences from good data
|
* I want excellence, so always be on the lookout for divergences from good
|
||||||
model design or best practices for object oriented development.
|
data model design or best practices for object oriented development.
|
||||||
|
|
||||||
- IMPORTANT: This is production code, not a research or teaching exercise.
|
* IMPORTANT: This is production code, not a research or teaching exercise.
|
||||||
Deliver professional-level results, not prototypes.
|
Deliver professional-level results, not prototypes.
|
||||||
|
|
||||||
- Please read and understand the `README.md` file in the root of the repo for
|
* Please read and understand the `README.md` file in the root of the repo
|
||||||
project-specific contextual information, including development policies,
|
for project-specific contextual information, including development
|
||||||
practices, and current implementation status.
|
policies, practices, and current implementation status.
|
||||||
|
|
||||||
- Be proactive in suggesting improvements or refactorings in places where we
|
* Be proactive in suggesting improvements or refactorings in places where we
|
||||||
diverge from best practices for clean, modular, maintainable code.
|
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 run
|
`golangci-lint run`. Each Makefile target does exactly one thing — to
|
||||||
lint + fmt-check + test together (the standard pre-commit gate), use
|
run lint + fmt-check + test together (the standard pre-commit gate),
|
||||||
`make check`.
|
use `make check`.
|
||||||
|
|
||||||
2. Always write a `Makefile` with the default target being `test`, and with a
|
2. Always write a `Makefile` with the default target being `test`, and with
|
||||||
`fmt` target that formats the code. The `test` target should run all tests in
|
a `fmt` target that formats the code. The `test` target should run all
|
||||||
the project, and the `fmt` target should format the code. `test` should also
|
tests in the project, and the `fmt` target should format the code.
|
||||||
have a prerequisite target `lint` that should run any linters that are
|
`test` should also have a prerequisite target `lint` that should run any
|
||||||
configured for the project.
|
linters that are configured for the project.
|
||||||
|
|
||||||
3. After each completed bugfix or feature, the code must be committed. Do all of
|
3. After each completed bugfix or feature, the code must be committed. Do
|
||||||
the pre-commit checks (test, lint, fmt) before committing, of course.
|
all of the pre-commit checks (test, lint, fmt) before committing, of
|
||||||
|
course.
|
||||||
|
|
||||||
4. When creating a very simple test script for testing out a new feature,
|
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 is a
|
tests, or change the tests specifically to make them pass (unless there
|
||||||
bug in the test). This is cheating, and it is bad. You should only be
|
is a bug in the test). This is cheating, and it is bad. You should only
|
||||||
modifying the test if it is incorrect or if the test is no longer relevant.
|
be modifying the test if it is incorrect or if the test is no longer
|
||||||
In almost all cases, you should be fixing the code that is being tested, or
|
relevant. In almost all cases, you should be fixing the code that is
|
||||||
updating the tests to match a refactored implementation.
|
being tested, or 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 to see
|
store UTC. Set the local timezone to UTC on startup. If the user needs
|
||||||
the time in a different timezone, store the user's timezone in a separate
|
to see the time in a different timezone, store the user's timezone in a
|
||||||
field and convert the UTC time to the user's timezone when displaying it. For
|
separate field and convert the UTC time to the user's timezone when
|
||||||
internal use and internal applications and administrative purposes, always
|
displaying it. For internal use and internal applications and
|
||||||
display UTC.
|
administrative purposes, always 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 feature,
|
correct syntax (ability to compile/import). If you are writing a new
|
||||||
write a test for it. You don't need to target complete coverage, but you
|
feature, write a test for it. You don't need to target complete
|
||||||
should at least test any new functionality you add. If you are fixing a bug,
|
coverage, but you should at least test any new functionality you add. If
|
||||||
write a test first that reproduces the bug, and then fix the bug in the code.
|
you are fixing a bug, write a test first that reproduces the bug, and
|
||||||
|
then fix the bug in the code.
|
||||||
|
|
||||||
8. When implementing new features, be aware of potential side-effects (such as
|
8. When implementing new features, be aware of potential side-effects (such
|
||||||
state files on disk, data in the database, etc.) and ensure that it is
|
as state files on disk, data in the database, etc.) and ensure that it is
|
||||||
possible to mock or stub these side-effects in tests.
|
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 of the
|
anywhere more than once. Always define a constant (usually at the top
|
||||||
file) and give it a descriptive name, then use that constant in the code
|
of the file) and give it a descriptive name, then use that constant in
|
||||||
instead of the bare string or number.
|
the code 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 of the
|
Making the changes and committing them is sufficient. If anything out
|
||||||
ordinary happened, please explain it, but in the normal case where you found
|
of the ordinary happened, please explain it, but in the normal case
|
||||||
and fixed the bug, or implemented the feature, there is no need for the
|
where you found and fixed the bug, or implemented the feature, there is
|
||||||
end-of-change summary.
|
no need for the end-of-change summary.
|
||||||
|
|
||||||
12. Do not create additional files in the root directory of the project without
|
12. Do not create additional files in the root directory of the project
|
||||||
asking permission first. Configuration files, documentation, and build files
|
without asking permission first. Configuration files, documentation, and
|
||||||
are acceptable in the root, but source code and other files should be
|
build files are acceptable in the root, but source code and other files
|
||||||
organized in appropriate subdirectories.
|
should be 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 change
|
nothing is installed anywhere yet, so there is nothing to migrate. To
|
||||||
the schema, edit `internal/database/schema/001.sql` (and any code that
|
change the schema, edit `internal/database/schema/001.sql` (and any
|
||||||
touches the affected tables) directly. After 1.0, each schema change is a
|
code that touches the affected tables) directly. After 1.0, each schema
|
||||||
new numbered file in that directory and a released file is never edited; an
|
change is a new numbered file in that directory and a released file is
|
||||||
existing local database is then migrated when vaultik is updated. Before
|
never edited; an existing local database is then migrated when vaultik
|
||||||
1.0, a local database left on an older schema is deleted and re-created by a
|
is updated. Before 1.0, a local database left on an older schema is
|
||||||
full backup. See [`docs/DATAMODEL.md`](docs/DATAMODEL.md#schema-migrations).
|
deleted and re-created by a full backup. See
|
||||||
|
[`docs/DATAMODEL.md`](docs/DATAMODEL.md#schema-migrations).
|
||||||
|
|
||||||
14. Never use `git add -A`. Stage only the files you intentionally changed.
|
14. Never use `git add -A`. Stage only the files you intentionally
|
||||||
|
changed.
|
||||||
|
|
||||||
15. Commit messages carry no attribution or advertising trailers for the tool
|
15. Commit messages carry no attribution or advertising trailers for the
|
||||||
that helped write the code, or for its vendor: the owner is the sole author
|
tool that helped write the code, or for its vendor: the owner is the
|
||||||
of code written with a tool.
|
sole author 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 never
|
output. Never run `go test`, a single test or a single package, and
|
||||||
grep the output.
|
never 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 backed by
|
18. For estimates: backing up over 2.5Gbit/s ethernet to an S3 server
|
||||||
2000MB/sec SSD takes about 4 seconds per gigabyte.
|
backed by 2000MB/sec SSD takes about 4 seconds per gigabyte.
|
||||||
@@ -1,14 +1,10 @@
|
|||||||
# Vaultik Architecture
|
# Vaultik Architecture
|
||||||
|
|
||||||
This document describes the internal architecture of Vaultik, focusing on the
|
This document describes the internal architecture of Vaultik, focusing on the data model, type instantiation, and the relationships between core modules.
|
||||||
data model, type instantiation, and the relationships between core modules.
|
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
Vaultik is a backup system that uses content-defined chunking for deduplication
|
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).
|
||||||
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
|
||||||
|
|
||||||
@@ -57,88 +53,57 @@ 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
|
It also stores `source_path`, the source directory the scan found it under, made absolute and with symlinks resolved. Restore does not read it.
|
||||||
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
|
Chunk sizes vary between `avgChunkSize/4` and `avgChunkSize*4` (2.5MB-40MB for the 10MB default average).
|
||||||
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
|
- `Hash`: `hex(SHA256(SHA256(uncompressed blob contents)))`, computed before compression and encryption (see [docs/REPOSTRUCTURE.md](docs/REPOSTRUCTURE.md#blobs-directory-blobs))
|
||||||
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
|
3. Concurrently, the same bytes are compressed with zstd, then encrypted with age (recipients configured in config), and written to a temporary file
|
||||||
age (recipients configured in config), and written to a temporary file
|
4. On finalize, the blob's name is the double SHA-256 of the uncompressed contents — `hex(SHA256(SHA256(...)))` — not a hash of the compressed, encrypted bytes
|
||||||
4. On finalize, the blob's name is the double SHA-256 of the uncompressed
|
5. The finished file is uploaded to `blobs/{hash[0:2]}/{hash[2:4]}/{hash}` and then deleted
|
||||||
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
|
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`.
|
||||||
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
|
||||||
@@ -175,27 +140,21 @@ 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
|
- **Contains**: All configuration from YAML file (S3 credentials, encryption keys, paths, etc.)
|
||||||
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,
|
- **Contains**: All repository interfaces (Files, Chunks, Blobs, Snapshots, etc.)
|
||||||
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
|
||||||
@@ -217,13 +176,11 @@ 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
|
||||||
@@ -237,22 +194,20 @@ 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()
|
||||||
@@ -269,22 +224,17 @@ 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
|
||||||
@@ -292,34 +242,27 @@ 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
|
- `ChunkReaderStreaming(reader, callback)` → Stream chunks with callback (preferred)
|
||||||
(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
|
||||||
@@ -329,48 +272,35 @@ 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 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
|
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
|
||||||
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
|
- `PopulateSnapshotBlobs(ctx, snapshotID)` → Record every blob the snapshot references
|
||||||
references
|
|
||||||
- `ExportSnapshotMetadata(ctx, dbPath, snapshotID)` → Export to S3
|
- `ExportSnapshotMetadata(ctx, dbPath, snapshotID)` → Export to S3
|
||||||
- `MarkSnapshotComplete(ctx, snapshotID)` → Record completion, only after a
|
- `MarkSnapshotComplete(ctx, snapshotID)` → Record completion, only after a successful export
|
||||||
successful export
|
- `CompleteSnapshot(ctx, snapshotID)` → Convenience: populate blobs, then mark complete (no export between)
|
||||||
- `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
|
||||||
@@ -429,16 +359,11 @@ CreateSnapshot(opts)
|
|||||||
|
|
||||||
## Deduplication Strategy
|
## Deduplication Strategy
|
||||||
|
|
||||||
1. **File-level**: Files and symlinks unchanged since last backup are skipped
|
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
|
||||||
(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
|
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.
|
||||||
already exists in the database, the chunk data is not re-uploaded.
|
|
||||||
|
|
||||||
3. **Blob-level**: Blobs contain only unique chunks. Duplicate chunks within a
|
3. **Blob-level**: Blobs contain only unique chunks. Duplicate chunks within a blob are skipped.
|
||||||
blob are skipped.
|
|
||||||
|
|
||||||
## Storage Layout in S3
|
## Storage Layout in S3
|
||||||
|
|
||||||
@@ -457,30 +382,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
|
A destination that answers a look-up of `vaultik-destination.txt` with not
|
||||||
is treated as not present while the local index holds snapshots stored there
|
found is treated as not present while the local index holds snapshots stored
|
||||||
(`checkDestinationMarker` in `internal/vaultik/destination_marker.go`):
|
there (`checkDestinationMarker` in `internal/vaultik/destination_marker.go`):
|
||||||
`snapshot create` refuses to write to it, `prune`, `snapshot purge` and
|
`snapshot create` refuses to write to it, `prune`, `snapshot purge` and
|
||||||
`remote nuke` fail, and `snapshot list` and `snapshot remove` behave as for a
|
`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
|
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
|
unplugged volume's empty mount point for a destination whose snapshots are
|
||||||
gone. When the look-up fails for any reason other than not found, for example
|
all gone. When the look-up fails for any reason other than not found, for
|
||||||
when S3 refuses it to credentials that allow only PUT, the check is skipped, so
|
example when S3 refuses it to credentials that allow only PUT, the check is
|
||||||
a host with such credentials does not notice an emptied destination; see
|
skipped, so a host with such credentials does not notice an emptied
|
||||||
[docs/REPOSTRUCTURE.md](REPOSTRUCTURE.md#marker-file-vaultik-destinationtxt).
|
destination; see
|
||||||
|
[docs/REPOSTRUCTURE.md](docs/REPOSTRUCTURE.md#marker-file-vaultik-destinationtxt).
|
||||||
The `file://` backend never creates its destination directory, so a write to a
|
The `file://` backend never creates its destination directory, so a write to a
|
||||||
missing one fails.
|
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](REPOSTRUCTURE.md#remote-key-derivation) for the
|
[docs/REPOSTRUCTURE.md](docs/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
|
- `Database`: Single-writer mode (`MaxOpenConns=1`) ensures SQLite thread safety.
|
||||||
safety.
|
|
||||||
- `Repositories.WithTx()`: Handles transaction lifecycle automatically.
|
- `Repositories.WithTx()`: Handles transaction lifecycle automatically.
|
||||||
File renamed without changes.
+61
-142
@@ -2,9 +2,7 @@
|
|||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
Vaultik uses a local SQLite database to track file metadata, chunk mappings, and
|
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.
|
||||||
blob associations during the backup process. This database serves as an index
|
|
||||||
for incremental backups and enables efficient deduplication.
|
|
||||||
|
|
||||||
**Important Notes:**
|
**Important Notes:**
|
||||||
|
|
||||||
@@ -27,23 +25,19 @@ 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 has
|
database is migrated the first time an updated vaultik opens it. A file that
|
||||||
shipped in a release is never edited.
|
has 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
|
- `mtime` (INTEGER NOT NULL) - Modification time, whole seconds since the Unix epoch
|
||||||
epoch
|
- `mtime_nsec` (INTEGER NOT NULL) - Nanoseconds within that second, 0 to 999999999
|
||||||
- `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
|
||||||
@@ -51,30 +45,23 @@ 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
|
**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.
|
||||||
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)
|
||||||
@@ -83,49 +70,35 @@ 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
|
**Purpose:** Supports efficient queries for chunk usage and deduplication statistics.
|
||||||
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)))`,
|
- `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)
|
||||||
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
|
- `uncompressed_size` (INTEGER NOT NULL DEFAULT 0) - Total size of chunks before compression
|
||||||
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
|
||||||
- `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
|
**Purpose:** Tracks blob lifecycle from creation through upload. The UUID primary key allows immediate association of chunks with blobs.
|
||||||
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)
|
||||||
@@ -135,46 +108,34 @@ 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
|
- `chunk_count` (INTEGER) - Number of chunks this snapshot stored that were not stored before
|
||||||
stored before
|
- `new_chunk_size` (INTEGER) - Total size of those chunks before compression; `snapshot list` shows it as NEW CHUNK SIZE
|
||||||
- `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
|
- `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
|
||||||
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
|
- `blob_uncompressed_size` (INTEGER) - Total uncompressed size of all referenced blobs
|
||||||
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
|
**Purpose:** Provides snapshot metadata and statistics including version tracking for compatibility.
|
||||||
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`)
|
||||||
@@ -182,146 +143,114 @@ 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
|
**Purpose:** Tracks blob dependencies for snapshots and enables manifest generation.
|
||||||
generation.
|
|
||||||
|
|
||||||
### 10. `snapshot_mount_points`
|
### 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.
|
||||||
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:**
|
**Columns:**
|
||||||
|
|
||||||
- `snapshot_id` (TEXT) - Snapshot ID (FK to snapshots.id)
|
- `snapshot_id` (TEXT) - Snapshot ID (FK to snapshots.id)
|
||||||
- `path` (TEXT) - Absolute path of the directory
|
- `path` (TEXT) - Absolute path of the directory
|
||||||
- PRIMARY KEY (`snapshot_id`, `path`)
|
- PRIMARY KEY (`snapshot_id`, `path`)
|
||||||
|
|
||||||
**Purpose:** Lets `snapshot create --prune` skip the prune when a backup does
|
**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.
|
||||||
not find a mount point that an earlier snapshot of the same name found, as when
|
|
||||||
the volume is not mounted.
|
|
||||||
|
|
||||||
### 11. `uploads`
|
### 11. `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
|
- `snapshot_id` (TEXT NOT NULL) - The snapshot that triggered this upload (FK to snapshots.id)
|
||||||
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
|
**Purpose:** Performance monitoring and tracking which blobs were newly created (uploaded) during each snapshot.
|
||||||
(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
|
- `INSERT OR IGNORE INTO snapshot_mount_points` - Record each directory on a different filesystem from its parent that the walk found an entry in
|
||||||
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
|
- `UPDATE blobs SET blob_hash = ?, finished_ts = ?` - Finalize blob after packing
|
||||||
packing
|
|
||||||
|
|
||||||
4. **Upload**
|
4. **Upload**
|
||||||
- `UPDATE blobs SET uploaded_ts = ?, compressed_size = ?` - Mark blob as
|
- `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)
|
||||||
uploaded, with the size of the object in storage (the upload is skipped
|
- `INSERT INTO uploads` - Record upload metrics with snapshot_id
|
||||||
when the blob is already stored)
|
- `INSERT INTO snapshot_blobs` - Associate blob with snapshot
|
||||||
- `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
|
- Re-chunk a file that lists a chunk no uploaded blob holds, even when its metadata is unchanged
|
||||||
metadata is unchanged
|
- Skip unchanged files but still add to `snapshot_files`
|
||||||
- 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
|
- `INSERT INTO snapshot_blobs` - Reference existing blobs for unchanged files
|
||||||
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
|
6. Generate blob manifest and upload as `metadata/{remote-key}/manifest.json.zst`
|
||||||
`metadata/{remote-key}/manifest.json.zst`
|
|
||||||
|
|
||||||
The `{remote-key}` directory name is a one-way hash of the human snapshot ID, so
|
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 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
|
||||||
1. Downloads and decrypts the snapshot's metadata database (`db.zst.age`) from
|
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
|
||||||
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, once older than `prune_grace_period`, and from the local
|
- Delete from S3, once older than `prune_grace_period`, and from the local
|
||||||
database
|
database
|
||||||
2. **Delete Metadata Directories With No Manifest**
|
2. **Delete Metadata Directories With No Manifest**
|
||||||
- Delete each directory under `metadata/` that has no `manifest.json.zst`
|
- Delete each directory under `metadata/` that has no `manifest.json.zst`
|
||||||
and no local snapshot record, once every object in it is older than
|
and no local snapshot record, once every object in it is older than
|
||||||
`prune_grace_period`
|
`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
|
2. Delete each one and all its associations, without checking S3 for its metadata
|
||||||
metadata
|
|
||||||
3. Clean up orphaned files, chunks, and blobs
|
3. Clean up orphaned files, chunks, and blobs
|
||||||
|
|
||||||
## Repository Pattern
|
## Repository Pattern
|
||||||
@@ -338,13 +267,11 @@ 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()`,
|
- Specialized queries for each entity type (e.g., `DeleteOrphaned()`, `GetIncompleteByHostname()`)
|
||||||
`GetIncompleteByHostname()`)
|
|
||||||
|
|
||||||
## Transaction Management
|
## Transaction Management
|
||||||
|
|
||||||
@@ -357,7 +284,6 @@ 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
|
||||||
@@ -365,25 +291,18 @@ 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
|
2. **Prepared Statements**: All queries use prepared statements for performance and security
|
||||||
and security
|
|
||||||
|
|
||||||
3. **Batch Operations**: Where possible, operations are batched within
|
3. **Batch Operations**: Where possible, operations are batched within transactions
|
||||||
transactions
|
|
||||||
|
|
||||||
4. **Write-Ahead Logging**: The local index runs in SQLite WAL mode with a
|
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
|
||||||
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
|
1. **Foreign Keys**: Enforced through CASCADE DELETE and application-level repository methods
|
||||||
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
|
||||||
+54
-224
@@ -1,13 +1,10 @@
|
|||||||
# Vaultik S3 Repository Structure
|
# Vaultik S3 Repository Structure
|
||||||
|
|
||||||
This document describes the structure and organization of data stored in the S3
|
This document describes the structure and organization of data stored in the S3 bucket by Vaultik.
|
||||||
bucket by Vaultik.
|
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
Vaultik stores all backup data in an S3-compatible object store. The repository
|
Vaultik stores all backup data in an S3-compatible object store. The repository consists of two main components, plus a marker file:
|
||||||
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)
|
||||||
|
|
||||||
@@ -26,208 +23,120 @@ consists of two main components, plus a marker file:
|
|||||||
└── manifest.json.zst
|
└── manifest.json.zst
|
||||||
```
|
```
|
||||||
|
|
||||||
The metadata subdirectory is named with the **remote key**, a one-way hash of
|
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 snapshot ID, not with the human-readable snapshot ID itself. See
|
|
||||||
[Remote Key Derivation](#remote-key-derivation).
|
|
||||||
|
|
||||||
## Marker File (`vaultik-destination.txt`)
|
## Marker File (`vaultik-destination.txt`)
|
||||||
|
|
||||||
Every backup writes this file at the root before it uploads anything. It holds
|
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.
|
||||||
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
|
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.
|
||||||
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**:
|
- **Example**: `blobs/ca/fe/cafebabe1234567890abcdef1234567890abcdef1234567890abcdef12345678`
|
||||||
`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
|
||||||
- **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
|
- **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.
|
||||||
`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
|
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)).
|
||||||
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
|
The human-readable snapshot ID is used in CLI arguments, log lines, and the local database. It is not written to the destination store.
|
||||||
local database. It is not written to the destination store.
|
- **Format**: `<hostname>_<snapshot-name>_<RFC3339>` (or `<hostname>_<RFC3339>` if no
|
||||||
|
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,
|
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.
|
||||||
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
|
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`.
|
||||||
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:
|
- Directory: `metadata/17f97bcde958748af076b926af59823943db59e80ce7170b40f124dfa28f64aa/`
|
||||||
`metadata/17f97bcde958748af076b926af59823943db59e80ce7170b40f124dfa28f64aa/`
|
|
||||||
|
|
||||||
A plain listing of the destination store therefore shows only these hashes, not
|
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.
|
||||||
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
|
- **Purpose**: Contains full file metadata, chunk mappings, and all relationships
|
||||||
relationships
|
- **Why encrypted**: Contains sensitive metadata like file paths, permissions, and ownership
|
||||||
- **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": "cafebabe1234567890abcdef1234567890abcdef1234567890abcdef12345678",
|
{ "hash": "deadbeef1234567890abcdef1234567890abcdef1234567890abcdef12345678", "compressed_size": 32768 }
|
||||||
"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
|
4. **Cross-snapshot deduplication analysis** - Finding shared blobs between snapshots
|
||||||
snapshots
|
|
||||||
|
|
||||||
The manifest contains the remote key, the backup timestamp, the blob count and
|
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.
|
||||||
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
|
- **All file metadata** (paths, permissions, timestamps, ownership in db.zst.age)
|
||||||
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:
|
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
|
||||||
- **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
|
- How many blobs each snapshot references, and the total compressed size
|
||||||
- The compressed size of each blob, and which blobs are shared between snapshots
|
- The compressed size of each blob, and which blobs are shared between snapshots (deduplication patterns)
|
||||||
(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.
|
||||||
- **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.
|
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.
|
||||||
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
|
||||||
@@ -236,118 +145,39 @@ An observer cannot determine:
|
|||||||
|
|
||||||
### Accepted Risks
|
### Accepted Risks
|
||||||
|
|
||||||
These are known, deliberate properties of the format and the tooling, recorded
|
These are known, deliberate properties of the format and the tooling, recorded so an operator can weigh them rather than discover them.
|
||||||
so an operator can weigh them rather than discover them.
|
|
||||||
|
|
||||||
1. **No proof of authorship.** Restore and `verify --deep` prove that data
|
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.
|
||||||
decrypts with the age private key and matches its unkeyed content hashes.
|
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.
|
||||||
They do not prove who wrote it: anyone who knows a recipient public key and
|
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.
|
||||||
can replace objects on the destination can substitute a snapshot they built.
|
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.
|
||||||
The recipient string is not stored at the destination, but a compromised
|
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.
|
||||||
backed-up host has it. Defences live on the destination side — bucket
|
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.
|
||||||
versioning or object lock, credentials for the source host that cannot delete
|
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.
|
||||||
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
|
2. **Blobs are written before metadata** - A snapshot's metadata is only written after all its blobs are successfully uploaded
|
||||||
after all its blobs are successfully uploaded
|
3. **Metadata is written atomically** - Both db.zst.age and manifest.json.zst are written as complete files
|
||||||
3. **Metadata is written atomically** - Both db.zst.age and manifest.json.zst
|
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.)
|
||||||
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. 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
|
||||||
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
|
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
|
||||||
exits with an error, rather than treating that snapshot's blobs as
|
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
|
||||||
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
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
# 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).
|
|
||||||
+1
-1
@@ -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, commit date %s) starting up at %s.",
|
w.Bannerf("%s %s by %s (commit %s, built on %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))
|
||||||
|
|||||||
+42
-41
@@ -8,7 +8,6 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
"runtime"
|
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -31,7 +30,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 the default config,
|
// configYAMLIndent matches the 2-space indentation of defaultConfigTemplate,
|
||||||
// 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
|
||||||
@@ -51,22 +50,7 @@ var (
|
|||||||
"so the file was not changed")
|
"so the file was not changed")
|
||||||
)
|
)
|
||||||
|
|
||||||
// macOS is the value of runtime.GOOS on macOS.
|
const defaultConfigTemplate = `# vaultik configuration
|
||||||
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 ────────────────────────────────────────────────────────────────
|
||||||
@@ -184,10 +168,7 @@ 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
|
||||||
@@ -208,10 +189,7 @@ const appsSnapshotConfig = `
|
|||||||
- "/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:
|
||||||
@@ -333,8 +311,7 @@ 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(defaultConfigText(runtime.GOOS)),
|
err = os.WriteFile(path, []byte(defaultConfigTemplate), configFileMode)
|
||||||
configFileMode)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("writing config file: %w", err)
|
return fmt.Errorf("writing config file: %w", err)
|
||||||
}
|
}
|
||||||
@@ -443,10 +420,11 @@ number, otherwise as a map.
|
|||||||
|
|
||||||
If the config loads, a change after which it would not, such as
|
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
|
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
|
config that does not load, such as the one config init writes before
|
||||||
are the file config init writes before storage_url is set, and one
|
storage_url is set or one with a ${ENV:...} reference to a variable
|
||||||
with a ${ENV:...} reference to a variable not set in this shell. In
|
not set here, is checked one setting at a time: only a change after
|
||||||
such a config, an unknown key or a value of the wrong type is refused.
|
which the changed setting cannot be read, such as one giving it the
|
||||||
|
wrong type, is refused.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
vaultik config set storage_url "file:///mnt/backups"
|
vaultik config set storage_url "file:///mnt/backups"
|
||||||
@@ -483,13 +461,6 @@ func writeConfigSet(out *ui.Writer, path, key, value string) error {
|
|||||||
|
|
||||||
keys := strings.Split(key, ".")
|
keys := strings.Split(key, ".")
|
||||||
|
|
||||||
if keys[0] != config.EnvSection {
|
|
||||||
_, err = config.SettingType(keys)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err = yamlPathSet(root, keys, value)
|
err = yamlPathSet(root, keys, value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -589,7 +560,7 @@ func yamlPathOnly(root *yaml.Node, keys []string) (*yaml.Node, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// marshalConfigYAML renders a config document tree with 2-space indentation,
|
// marshalConfigYAML renders a config document tree with 2-space indentation,
|
||||||
// matching the default config. yaml.Marshal defaults to 4 spaces, which
|
// matching defaultConfigTemplate. yaml.Marshal defaults to 4 spaces, which
|
||||||
// would reindent the whole file on the first `config set` despite the promise
|
// would reindent the whole file on the first `config set` despite the promise
|
||||||
// to preserve formatting.
|
// to preserve formatting.
|
||||||
func marshalConfigYAML(root *yaml.Node) ([]byte, error) {
|
func marshalConfigYAML(root *yaml.Node) ([]byte, error) {
|
||||||
@@ -745,11 +716,41 @@ 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, as s3.access_key_id and snapshots.home.exclude.0 do.
|
// config.Config, following the fields' yaml tags, as s3.access_key_id and
|
||||||
|
// snapshots.home.exclude.0 do.
|
||||||
func configKeyIsString(keys []string) bool {
|
func configKeyIsString(keys []string) bool {
|
||||||
typ, err := config.SettingType(keys)
|
typ := reflect.TypeFor[config.Config]()
|
||||||
|
|
||||||
return err == nil && typ.Kind() == reflect.String
|
for _, key := range keys {
|
||||||
|
switch {
|
||||||
|
case typ.Kind() == reflect.Map || typ.Kind() == reflect.Slice:
|
||||||
|
// The key is a snapshot name or a list index.
|
||||||
|
typ = typ.Elem()
|
||||||
|
case typ.Kind() == reflect.Struct:
|
||||||
|
field, ok := yamlField(typ, key)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
typ = field.Type
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return typ.Kind() == reflect.String
|
||||||
|
}
|
||||||
|
|
||||||
|
// yamlField returns the field of struct type typ whose yaml tag names key.
|
||||||
|
func yamlField(typ reflect.Type, key string) (reflect.StructField, bool) {
|
||||||
|
for field := range typ.Fields() {
|
||||||
|
name, _, _ := strings.Cut(field.Tag.Get("yaml"), ",")
|
||||||
|
if name == key {
|
||||||
|
return field, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return reflect.StructField{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// yamlSetInMapping resolves (creating if needed) the value node for the
|
// yamlSetInMapping resolves (creating if needed) the value node for the
|
||||||
|
|||||||
@@ -13,15 +13,14 @@ import (
|
|||||||
"sneak.berlin/go/vaultik/internal/ui"
|
"sneak.berlin/go/vaultik/internal/ui"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestDefaultConfigParsesOnMacOS ensures the config `config init` writes on
|
// TestDefaultConfigTemplateParses ensures the init template is valid YAML
|
||||||
// macOS is valid YAML that unmarshals into the Config struct with the
|
// that unmarshals into the Config struct with the expected snapshots.
|
||||||
// expected snapshots.
|
func TestDefaultConfigTemplateParses(t *testing.T) {
|
||||||
func TestDefaultConfigParsesOnMacOS(t *testing.T) {
|
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
var cfg config.Config
|
var cfg config.Config
|
||||||
|
|
||||||
err := yaml.Unmarshal([]byte(defaultConfigText("darwin")), &cfg)
|
err := yaml.Unmarshal([]byte(defaultConfigTemplate), &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)
|
||||||
}
|
}
|
||||||
@@ -59,39 +58,6 @@ func TestDefaultConfigParsesOnMacOS(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestDefaultConfigOnLinux checks that the config `config init` writes on
|
|
||||||
// Linux loads with its home snapshot and without the apps snapshot, whose
|
|
||||||
// /Applications exists only on macOS.
|
|
||||||
func TestDefaultConfigOnLinux(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
path := filepath.Join(t.TempDir(), "config.yml")
|
|
||||||
|
|
||||||
err := os.WriteFile(path, []byte(defaultConfigText("linux")), configFileMode)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("write config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = writeConfigSet(ui.NewWithColor(&bytes.Buffer{}, false),
|
|
||||||
path, "storage_url", "file:///mnt/backups")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("config set storage_url: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg, err := config.Load(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("config.Load: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, ok := cfg.Snapshots["home"]; !ok {
|
|
||||||
t.Error("expected 'home' snapshot in the Linux default config")
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, ok := cfg.Snapshots["apps"]; ok {
|
|
||||||
t.Error("the Linux default config should have no 'apps' snapshot")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// testRecipient is a valid age public key.
|
// testRecipient is a valid age public key.
|
||||||
const testRecipient = "age1278m9q7dp3chsh2dcy82qk27v047zywyvtxwnj4cvt0z65jw6a7q5dqhfj"
|
const testRecipient = "age1278m9q7dp3chsh2dcy82qk27v047zywyvtxwnj4cvt0z65jw6a7q5dqhfj"
|
||||||
|
|
||||||
@@ -105,7 +71,7 @@ func TestConfigSetRecipientOnFreshConfig(t *testing.T) {
|
|||||||
|
|
||||||
path := filepath.Join(t.TempDir(), "config.yml")
|
path := filepath.Join(t.TempDir(), "config.yml")
|
||||||
|
|
||||||
err := os.WriteFile(path, []byte(defaultConfigText(macOS)), configFileMode)
|
err := os.WriteFile(path, []byte(defaultConfigTemplate), configFileMode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("write config: %v", err)
|
t.Fatalf("write config: %v", err)
|
||||||
}
|
}
|
||||||
@@ -225,7 +191,7 @@ func TestConfigSetIndexUnderMissingKey(t *testing.T) {
|
|||||||
// change after which the config would not load, with an error giving the
|
// 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,
|
// 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
|
// 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.
|
// read here, only a value of the wrong type is refused.
|
||||||
func TestConfigSetKeepsFileThatWouldNotLoad(t *testing.T) {
|
func TestConfigSetKeepsFileThatWouldNotLoad(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -237,14 +203,6 @@ func TestConfigSetKeepsFileThatWouldNotLoad(t *testing.T) {
|
|||||||
value string
|
value string
|
||||||
reason 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.
|
// Index 1 of the empty list the missing key gives.
|
||||||
{configWithoutRecipients, "age_recipients.1", testRecipient,
|
{configWithoutRecipients, "age_recipients.1", testRecipient,
|
||||||
"index out of range"},
|
"index out of range"},
|
||||||
@@ -262,7 +220,7 @@ func TestConfigSetKeepsFileThatWouldNotLoad(t *testing.T) {
|
|||||||
{levelReference, "blob_size_limit", "1MB",
|
{levelReference, "blob_size_limit", "1MB",
|
||||||
"blob_size_limit must be at least"},
|
"blob_size_limit must be at least"},
|
||||||
// The type check on a config that does not load yet.
|
// The type check on a config that does not load yet.
|
||||||
{defaultConfigText(macOS), "snapshots.apps.paths", "/srv",
|
{defaultConfigTemplate, "snapshots.apps.paths", "/srv",
|
||||||
"into []string"},
|
"into []string"},
|
||||||
// The type check on a config that cannot be read here.
|
// The type check on a config that cannot be read here.
|
||||||
{configWithUnsetReference, "snapshots.home.paths", "/x",
|
{configWithUnsetReference, "snapshots.home.paths", "/x",
|
||||||
@@ -343,39 +301,6 @@ func TestConfigSetRefusesOnlyItsOwnFailure(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestConfigSetWritesEnvSection checks that config set writes a key under
|
|
||||||
// the top-level env section, whose keys are not settings, on a config that
|
|
||||||
// loads.
|
|
||||||
func TestConfigSetWritesEnvSection(t *testing.T) {
|
|
||||||
const name = "VAULTIK_TEST_CONFIG_SET_ENV"
|
|
||||||
|
|
||||||
// Loading the changed config sets the variable; t.Setenv restores it
|
|
||||||
// after the test.
|
|
||||||
t.Setenv(name, "")
|
|
||||||
|
|
||||||
path := filepath.Join(t.TempDir(), "config.yml")
|
|
||||||
|
|
||||||
err := os.WriteFile(path, []byte(configWithoutRecipients), configFileMode)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("write config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = writeConfigSet(ui.NewWithColor(&bytes.Buffer{}, false),
|
|
||||||
path, "env."+name, "value")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("config set env.%s: %v", name, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := os.ReadFile(path) //nolint:gosec // G304: test-controlled path
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !strings.Contains(string(data), "env:\n "+name+": value\n") {
|
|
||||||
t.Errorf("config set env.%s wrote:\n%s", name, data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestConfigSetStringLooksLikeNumber sets string settings to values that
|
// TestConfigSetStringLooksLikeNumber sets string settings to values that
|
||||||
// YAML reads as numbers or booleans when they are unquoted, and checks that
|
// YAML reads as numbers or booleans when they are unquoted, and checks that
|
||||||
// config.Load returns each one unchanged.
|
// config.Load returns each one unchanged.
|
||||||
@@ -455,7 +380,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(defaultConfigText(macOS)), configFileMode)
|
err := os.WriteFile(path, []byte(defaultConfigTemplate), configFileMode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("write config: %v", err)
|
t.Fatalf("write config: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"sneak.berlin/go/vaultik/internal/config"
|
"sneak.berlin/go/vaultik/internal/config"
|
||||||
"sneak.berlin/go/vaultik/internal/log"
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
"sneak.berlin/go/vaultik/internal/vaultik"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewDatabaseCommand creates the database command group
|
// NewDatabaseCommand creates the database command group
|
||||||
@@ -91,12 +90,12 @@ Use --force to skip the confirmation prompt.`,
|
|||||||
|
|
||||||
var confirm string
|
var confirm string
|
||||||
|
|
||||||
// A failure to read the answer means no confirmation.
|
_, err = fmt.Scanln(&confirm)
|
||||||
_, err = fmt.Fscanln(cmd.InOrStdin(), &confirm)
|
|
||||||
if err != nil || confirm != "yes" {
|
if err != nil || confirm != "yes" {
|
||||||
_, _ = fmt.Fprintln(w, "Aborted.")
|
_, _ = fmt.Fprintln(w, "Aborted.")
|
||||||
|
|
||||||
return vaultik.ErrNotConfirmed
|
//nolint:nilerr // a failed/aborted confirmation is a clean abort
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
package cli //nolint:testpackage // sets the unexported rootFlags directly
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"errors"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"sneak.berlin/go/vaultik/internal/vaultik"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestDatabaseDeleteDeclinedIsError checks that answering anything but
|
|
||||||
// "yes" keeps the database and returns ErrNotConfirmed, so the command
|
|
||||||
// exits non-zero.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // mutates the process-global rootFlags
|
|
||||||
func TestDatabaseDeleteDeclinedIsError(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
dbPath, cfgPath := seedDatabaseDeleteConfig(t, dir)
|
|
||||||
|
|
||||||
setRootFlags(t, RootFlags{ConfigPath: cfgPath})
|
|
||||||
|
|
||||||
var out bytes.Buffer
|
|
||||||
|
|
||||||
cmd := newDatabaseDeleteCommand()
|
|
||||||
cmd.SetOut(&out)
|
|
||||||
cmd.SetErr(io.Discard)
|
|
||||||
cmd.SetIn(strings.NewReader("no\n"))
|
|
||||||
cmd.SetArgs(nil)
|
|
||||||
|
|
||||||
err := cmd.Execute()
|
|
||||||
if !errors.Is(err, vaultik.ErrNotConfirmed) {
|
|
||||||
t.Errorf("declined database delete returned %v, want %v",
|
|
||||||
err, vaultik.ErrNotConfirmed)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = os.Stat(dbPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("declined database delete removed the file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !strings.Contains(out.String(), "Aborted.") {
|
|
||||||
t.Errorf("declined database delete printed %q, want Aborted.",
|
|
||||||
out.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -13,7 +13,6 @@ 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
|
||||||
@@ -113,35 +112,6 @@ 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.
|
||||||
|
|||||||
@@ -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, " commit date: %s\n", globals.CommitDate)
|
_, _ = fmt.Fprintf(w, " build 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,7 +2,6 @@ package cli_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"runtime"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -50,42 +49,6 @@ 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
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -57,7 +56,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 configs/config.example.yml)")
|
"at least one snapshot must be configured (see 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(
|
||||||
@@ -287,37 +286,15 @@ func Load(path string) (*Config, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Parse reads YAML config data as Load does, without validating it. It
|
// Parse reads YAML config data as Load does, without validating it. It
|
||||||
// refuses a key that no setting has, applies default values for optional
|
// applies default values for optional fields, fills in ${...} references
|
||||||
// fields, fills in ${...} references using smartconfig, and expands ~ in
|
// using smartconfig, and expands ~ in paths.
|
||||||
// paths.
|
|
||||||
func Parse(data []byte) (*Config, error) {
|
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
|
// Load config using smartconfig for interpolation
|
||||||
sc, err := smartconfig.NewFromReader(bytes.NewReader(data))
|
sc, err := smartconfig.NewFromReader(bytes.NewReader(data))
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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,
|
||||||
|
|||||||
@@ -90,12 +90,12 @@ func TestConfigLoad(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestExampleConfigIsScrubbedAndLoads checks that the shipped
|
// TestExampleConfigIsScrubbedAndLoads checks that the shipped
|
||||||
// configs/config.example.yml carries only neutral placeholders (no real
|
// config.example.yml carries only neutral placeholders (no real credentials,
|
||||||
// credentials, private addresses, or internal host names) and still parses.
|
// 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("..", "..", "configs", "config.example.yml")
|
examplePath := filepath.Join("..", "..", "config.example.yml")
|
||||||
|
|
||||||
cfg, err := Load(examplePath)
|
cfg, err := Load(examplePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -361,7 +361,7 @@ func TestDocumentedSizeDefaults(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestDocumentedSizeExamples checks the 1MB minimum chunk_size and that
|
// TestDocumentedSizeExamples checks the 1MB minimum chunk_size and that
|
||||||
// every size the README, configs/config.example.yml and the config init template
|
// every size the README, config.example.yml and the config init template
|
||||||
// list as an example is accepted.
|
// list as an example is accepted.
|
||||||
func TestDocumentedSizeExamples(t *testing.T) {
|
func TestDocumentedSizeExamples(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|||||||
@@ -1,214 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -271,18 +271,16 @@ func (sm *SnapshotManager) CompleteSnapshot(
|
|||||||
return sm.MarkSnapshotComplete(ctx, snapshotID)
|
return sm.MarkSnapshotComplete(ctx, snapshotID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExportSnapshotMetadata exports snapshot metadata to the backup destination store
|
// ExportSnapshotMetadata exports snapshot metadata to S3
|
||||||
//
|
//
|
||||||
// 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. Vacuums the copy to remove the deleted data
|
// 4. Dumps the cleaned database to SQL
|
||||||
// 5. Compresses the copy with zstd
|
// 5. Compresses the SQL dump with zstd
|
||||||
// 6. Encrypts the compressed copy with age (always; recipients are required)
|
// 6. Encrypts the compressed data with age (always; recipients are required)
|
||||||
// 7. Builds the blob manifest from the copy, compressed but not encrypted
|
// 7. Uploads to S3 at: snapshots/{snapshot-id}.sql.zst[.age]
|
||||||
// 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
|
||||||
|
|||||||
@@ -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.UI.Beginf("Scanning snapshot metadata...")
|
v.stdoutf("Scanning snapshot metadata...\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
snapshotMetadata, snapshotIDs, skippedManifestCount, err := v.collectSnapshotMetadata()
|
snapshotMetadata, snapshotIDs, skippedManifestCount, err := v.collectSnapshotMetadata()
|
||||||
@@ -265,7 +265,7 @@ func (v *Vaultik) RemoteInfo(jsonOutput bool) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
v.UI.Beginf("Downloading %d manifest(s)...", manifestCount)
|
v.stdoutf("Downloading %d manifest(s)...\n", 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.UI.Beginf("Scanning blobs...")
|
v.stdoutf("Scanning blobs...\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
@@ -25,28 +24,19 @@ import (
|
|||||||
// that is not plugged in, cannot be listed. It is not an empty store, so
|
// that is not plugged in, cannot be listed. It is not an empty store, so
|
||||||
// no command may conclude from it that the local snapshots are gone.
|
// no command may conclude from it that the local snapshots are gone.
|
||||||
|
|
||||||
// newFileDestinationVaultik writes the fault source tree and returns a
|
// newFileDestinationVaultik returns a Vaultik that backs up the
|
||||||
// Vaultik that backs it up to a file:// destination at storeDir.
|
// snapshot named "first" to a file:// destination at storeDir.
|
||||||
func newFileDestinationVaultik(
|
func newFileDestinationVaultik(
|
||||||
ctx context.Context, t *testing.T, storeDir string,
|
ctx context.Context, t *testing.T, storeDir string,
|
||||||
) (*vaultik.Vaultik, *database.Repositories) {
|
) (*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")
|
||||||
writeFaultSourceTree(t, afero.NewOsFs(), dataDir)
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
return openFileDestinationVaultik(ctx, t, storeDir, dataDir,
|
writeFaultSourceTree(t, osFs, 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)
|
||||||
@@ -58,84 +48,22 @@ func openFileDestinationVaultik(
|
|||||||
repos := database.NewRepositories(db)
|
repos := database.NewRepositories(db)
|
||||||
cfg := changedFileConfig(dataDir, dbPath)
|
cfg := changedFileConfig(dataDir, dbPath)
|
||||||
|
|
||||||
return newBackupVaultik(ctx, cfg, store, repos, db, afero.NewOsFs()), repos
|
return newBackupVaultik(ctx, cfg, store, repos, db, osFs), repos
|
||||||
}
|
|
||||||
|
|
||||||
// firstBackup is one backup of the snapshot named "first" to a file://
|
|
||||||
// destination. dir holds its source tree in src, its store in store and
|
|
||||||
// its closed local index in index/index.sqlite. The first test that
|
|
||||||
// needs it makes it, and TestMain removes it. Every test that copies it
|
|
||||||
// backs up the same source tree, so no test may change that tree.
|
|
||||||
//
|
|
||||||
//nolint:gochecknoglobals // made once and shared by the tests that copy it
|
|
||||||
var firstBackup struct {
|
|
||||||
once sync.Once
|
|
||||||
dir string
|
|
||||||
ready bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestMain removes firstBackup once every test has run.
|
|
||||||
func TestMain(m *testing.M) {
|
|
||||||
code := m.Run()
|
|
||||||
|
|
||||||
_ = os.RemoveAll(firstBackup.dir)
|
|
||||||
|
|
||||||
os.Exit(code)
|
|
||||||
}
|
|
||||||
|
|
||||||
// makeFirstBackup writes the fault source tree and backs it up as the
|
|
||||||
// snapshot named "first", all in a new directory that it records in
|
|
||||||
// firstBackup.
|
|
||||||
func makeFirstBackup(ctx context.Context, t *testing.T) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
dir, err := os.MkdirTemp("", "vaultik-first-backup-")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
firstBackup.dir = dir
|
|
||||||
storeDir := filepath.Join(dir, "store")
|
|
||||||
dataDir := filepath.Join(dir, "src")
|
|
||||||
indexDir := filepath.Join(dir, "index")
|
|
||||||
|
|
||||||
require.NoError(t, os.Mkdir(storeDir, 0o750))
|
|
||||||
require.NoError(t, os.Mkdir(indexDir, 0o750))
|
|
||||||
writeFaultSourceTree(t, afero.NewOsFs(), dataDir)
|
|
||||||
|
|
||||||
v, _ := openFileDestinationVaultik(ctx, t, storeDir, dataDir,
|
|
||||||
filepath.Join(indexDir, "index.sqlite"))
|
|
||||||
require.NoError(t, backUp(v, "first"))
|
|
||||||
require.NoError(t, v.DB.Close())
|
|
||||||
|
|
||||||
firstBackup.ready = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// backUpToFileDestination creates the directory storeDir, as a user must
|
// backUpToFileDestination creates the directory storeDir, as a user must
|
||||||
// before the first backup, and returns a Vaultik whose file://
|
// before the first backup, and backs up the snapshot named "first" to a
|
||||||
// destination there holds a backup of the snapshot named "first". The
|
// file:// destination there. Everything the returned Vaultik prints
|
||||||
// store and the local index are copies of firstBackup's, which is faster
|
// after the backup goes to the returned buffer.
|
||||||
// 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(
|
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, *bytes.Buffer) {
|
||||||
t.Helper()
|
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.MkdirAll(storeDir, 0o750))
|
||||||
require.NoError(t, os.CopyFS(storeDir,
|
|
||||||
os.DirFS(filepath.Join(firstBackup.dir, "store"))))
|
|
||||||
|
|
||||||
indexDir := t.TempDir()
|
v, repos := newFileDestinationVaultik(ctx, t, storeDir)
|
||||||
require.NoError(t, os.CopyFS(indexDir,
|
require.NoError(t, backUp(v, "first"))
|
||||||
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
|
||||||
|
|||||||
@@ -267,7 +267,7 @@ func (v *Vaultik) PruneBlobs(opts *PruneOptions) error {
|
|||||||
|
|
||||||
if !v.confirmPrune(opts, len(unreferencedBlobs), totalSize,
|
if !v.confirmPrune(opts, len(unreferencedBlobs), totalSize,
|
||||||
dirsWithoutManifest) {
|
dirsWithoutManifest) {
|
||||||
return ErrNotConfirmed
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
v.deleteUnreferencedBlobs(unreferencedBlobs, allBlobs, result)
|
v.deleteUnreferencedBlobs(unreferencedBlobs, allBlobs, result)
|
||||||
|
|||||||
@@ -176,8 +176,7 @@ func TestPruneDeclinedKeepsMetadataDirWithoutManifest(t *testing.T) {
|
|||||||
|
|
||||||
env.v.Stdin = strings.NewReader("n\n")
|
env.v.Stdin = strings.NewReader("n\n")
|
||||||
|
|
||||||
require.ErrorIs(t, env.v.PruneBlobs(&vaultik.PruneOptions{}),
|
require.NoError(t, env.v.PruneBlobs(&vaultik.PruneOptions{}))
|
||||||
vaultik.ErrNotConfirmed)
|
|
||||||
|
|
||||||
assert.True(t, env.store.hasKey(leftDB),
|
assert.True(t, env.store.hasKey(leftDB),
|
||||||
"a declined prune must keep the directory")
|
"a declined prune must keep the directory")
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ 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
|
||||||
@@ -260,24 +259,3 @@ 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")
|
|
||||||
}
|
|
||||||
@@ -54,10 +54,9 @@ func TestPurgeSnapshots_QuietPromptListsSnapshots(t *testing.T) {
|
|||||||
v.UI.SetQuiet(true)
|
v.UI.SetQuiet(true)
|
||||||
|
|
||||||
// Stdin is empty, so the prompt reads no answer and cancels.
|
// Stdin is empty, so the prompt reads no answer and cancels.
|
||||||
err := v.PurgeSnapshotsWithOptions(&vaultik.SnapshotPurgeOptions{
|
require.NoError(t, v.PurgeSnapshotsWithOptions(&vaultik.SnapshotPurgeOptions{
|
||||||
KeepLatest: true,
|
KeepLatest: true,
|
||||||
})
|
}))
|
||||||
require.ErrorIs(t, err, vaultik.ErrNotConfirmed)
|
|
||||||
|
|
||||||
assert.Contains(t, out.String(), snapHomeT0)
|
assert.Contains(t, out.String(), snapHomeT0)
|
||||||
assert.Contains(t, out.String(), "Delete 1 snapshot(s)? [y/N]")
|
assert.Contains(t, out.String(), "Delete 1 snapshot(s)? [y/N]")
|
||||||
@@ -78,8 +77,7 @@ func TestPruneBlobs_QuietPromptShowsBlobTotal(t *testing.T) {
|
|||||||
addBlob(t, env.store.testStorer, testBlobHashA)
|
addBlob(t, env.store.testStorer, testBlobHashA)
|
||||||
|
|
||||||
// Stdin is empty, so the prompt reads no answer and cancels.
|
// Stdin is empty, so the prompt reads no answer and cancels.
|
||||||
require.ErrorIs(t, env.v.PruneBlobs(&vaultik.PruneOptions{}),
|
require.NoError(t, env.v.PruneBlobs(&vaultik.PruneOptions{}))
|
||||||
vaultik.ErrNotConfirmed)
|
|
||||||
|
|
||||||
assert.Contains(t, env.stdout.String(),
|
assert.Contains(t, env.stdout.String(),
|
||||||
"Found 1 unreferenced blob(s) totaling")
|
"Found 1 unreferenced blob(s) totaling")
|
||||||
|
|||||||
@@ -337,27 +337,6 @@ func TestRemoveSnapshot_DefaultRemovesMetadataNotBlobs(t *testing.T) {
|
|||||||
assert.Contains(t, out, "vaultik prune")
|
assert.Contains(t, out, "vaultik prune")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRemoveSnapshot_DeclinedIsError checks that answering no to the
|
|
||||||
// prompt keeps the snapshot and returns ErrNotConfirmed, so the command
|
|
||||||
// exits non-zero.
|
|
||||||
func TestRemoveSnapshot_DeclinedIsError(t *testing.T) {
|
|
||||||
log.Initialize(log.Config{})
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
store := newTestStorer()
|
|
||||||
addManifest(t, store, "snapshot-001", nil)
|
|
||||||
|
|
||||||
tv := vaultik.NewForTesting(store)
|
|
||||||
tv.Repositories = newLocalIndex(t, "snapshot-001")
|
|
||||||
tv.Stdin.WriteString("n\n")
|
|
||||||
|
|
||||||
_, err := tv.RemoveSnapshot("snapshot-001", &vaultik.RemoveOptions{})
|
|
||||||
|
|
||||||
require.ErrorIs(t, err, vaultik.ErrNotConfirmed)
|
|
||||||
assert.True(t, store.hasKey(remoteKeyPath("snapshot-001")))
|
|
||||||
assert.Contains(t, tv.Stdout.String(), "Cancelled")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRemoveSnapshot_DryRun(t *testing.T) {
|
func TestRemoveSnapshot_DryRun(t *testing.T) {
|
||||||
log.Initialize(log.Config{})
|
log.Initialize(log.Config{})
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|||||||
@@ -31,10 +31,9 @@ var (
|
|||||||
errPathsNotInSnapshot = errors.New("requested path(s) not in the snapshot")
|
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" +
|
"age secret key is missing; set it in the " +
|
||||||
"Set the VAULTIK_AGE_SECRET_KEY environment variable to your " +
|
"VAULTIK_AGE_SECRET_KEY environment variable or the " +
|
||||||
"age private key file:\n" +
|
"age_secret_key config file field")
|
||||||
" export VAULTIK_AGE_SECRET_KEY=\"$(cat vaultik_backup_private_key.txt)\"")
|
|
||||||
// errInvalidAgeSecretKey is returned when the configured key does not
|
// errInvalidAgeSecretKey is returned when the configured key does not
|
||||||
// parse as any age identity. It names the source but never the value,
|
// parse as any age identity. It names the source but never the value,
|
||||||
// which is secret, so the message is safe to print and log.
|
// which is secret, so the message is safe to print and log.
|
||||||
|
|||||||
@@ -106,3 +106,23 @@ func TestRestoreIdentitiesMissingKey(t *testing.T) {
|
|||||||
_, err := v.restoreIdentities()
|
_, err := v.restoreIdentities()
|
||||||
require.ErrorIs(t, err, errDecryptionKeyRequired)
|
require.ErrorIs(t, err, errDecryptionKeyRequired)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestMissingKeyErrorsNameBothSources asserts restore and deep verify
|
||||||
|
// return the same missing-key error, whose text names both places the key
|
||||||
|
// can be set: the VAULTIK_AGE_SECRET_KEY environment variable first since
|
||||||
|
// it is preferred, then the age_secret_key config file field.
|
||||||
|
func TestMissingKeyErrorsNameBothSources(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const want = "age secret key is missing; set it in " +
|
||||||
|
"the VAULTIK_AGE_SECRET_KEY environment variable or the " +
|
||||||
|
"age_secret_key config file field"
|
||||||
|
|
||||||
|
v := &Vaultik{Config: &config.Config{}}
|
||||||
|
|
||||||
|
_, err := v.restoreIdentities()
|
||||||
|
require.EqualError(t, err, want)
|
||||||
|
|
||||||
|
err = v.RunDeepVerify("any-snapshot", &VerifyOptions{})
|
||||||
|
require.EqualError(t, err, want)
|
||||||
|
}
|
||||||
@@ -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 five 64 KiB chunks each, nine files distributed across
|
// blobs A/B/C of ~6 MB each, nine files distributed across them, and
|
||||||
// them, and path-ordered names that interleave the blobs (a1, b1, c1,
|
// path-ordered names that interleave the blobs (a1, b1, c1, a2, b2,
|
||||||
// a2, b2, c2, …) so naive path-order processing would touch every blob
|
// c2, …) so naive path-order processing would touch every blob before
|
||||||
// before finishing any of them.
|
// finishing any of them.
|
||||||
// localitySource is one 64 KiB fixture file used by the locality test.
|
// localitySource is one 1 MiB fixture file used by the locality test.
|
||||||
type localitySource struct {
|
type localitySource struct {
|
||||||
path string
|
path string
|
||||||
data []byte
|
data []byte
|
||||||
@@ -65,10 +65,10 @@ type localityCopy struct {
|
|||||||
|
|
||||||
// localitySourceBytes is the size of each source file of the locality
|
// localitySourceBytes is the size of each source file of the locality
|
||||||
// fixture.
|
// fixture.
|
||||||
const localitySourceBytes = 64 * 1024
|
const localitySourceBytes = 1024 * 1024
|
||||||
|
|
||||||
// buildLocalityFixture writes the adversarial source layout described
|
// buildLocalityFixture writes the adversarial source layout described
|
||||||
// in TestRestoreLocalityAndReadAt: 15 sources of 64 KiB each (which the
|
// in TestRestoreLocalityAndReadAt: 15 sources of 1 MiB each (which the
|
||||||
// backup packs into 3 blobs of 5 chunks) plus 9 interleaved-name copies
|
// backup packs into 3 blobs of 5 chunks) plus 9 interleaved-name copies
|
||||||
// (3 per blob group).
|
// (3 per blob group).
|
||||||
func buildLocalityFixture(
|
func buildLocalityFixture(
|
||||||
|
|||||||
@@ -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 128 KiB random files plus 10 duplicates of
|
// The scenario: 30 unique 1 MB random files plus 10 duplicates of those
|
||||||
// those (40 files total, 3.75 MiB of unique content) get backed up with
|
// (40 files total, 30 MB of unique content) get backed up with a 10 MB
|
||||||
// a 1 MiB blob_size_limit. After backup the snapshot's encrypted blobs
|
// blob_size_limit. After backup the snapshot's encrypted blobs are
|
||||||
// are restored through Vaultik.Restore, and per-key Get counts on the
|
// 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 128 KiB random files plus 10 duplicates of a random subset. The
|
// unique 1 MB 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 = 128 * 1024
|
const fileSize = 1 * 1024 * 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,
|
||||||
"3.75 MiB of unique data at 1 MiB blob size should yield multiple blobs")
|
"30 MB of unique data at 10 MB 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))
|
||||||
@@ -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(16 * 1024)
|
chunkSize := int64(64 * 1024)
|
||||||
maxBlobSize := int64(1024 * 1024)
|
maxBlobSize := int64(10 * 1024 * 1024)
|
||||||
|
|
||||||
storer, err := storage.NewFileStorer(storeDir)
|
storer, err := storage.NewFileStorer(storeDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|||||||
@@ -881,12 +881,17 @@ func (v *Vaultik) confirmAndExecutePurge(
|
|||||||
|
|
||||||
var confirm string
|
var confirm string
|
||||||
|
|
||||||
// A failure to read the answer means no confirmation.
|
|
||||||
_, err := v.scanStdin(&confirm)
|
_, err := v.scanStdin(&confirm)
|
||||||
if err != nil || strings.ToLower(confirm) != "y" {
|
if err != nil {
|
||||||
v.printlnStdout("Cancelled")
|
v.printlnStdout("Cancelled")
|
||||||
|
|
||||||
return ErrNotConfirmed
|
return nil //nolint:nilerr // treat EOF or read error as "no"
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.ToLower(confirm) != "y" {
|
||||||
|
v.printlnStdout("Cancelled")
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
} else if !quiet {
|
} else if !quiet {
|
||||||
v.UI.Infof("The following snapshots will be deleted:")
|
v.UI.Infof("The following snapshots will be deleted:")
|
||||||
@@ -1014,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.UI.Beginf("Checking blob presence and sizes...")
|
v.stdoutf("Checking blob presence and sizes...\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
v.checkSnapshotDatabase(remoteKey, result)
|
v.checkSnapshotDatabase(remoteKey, result)
|
||||||
@@ -1485,7 +1490,7 @@ func (v *Vaultik) RemoveSnapshot(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !v.confirmRemoveSnapshot(snapshotID, opts) {
|
if !v.confirmRemoveSnapshot(snapshotID, opts) {
|
||||||
return result, ErrNotConfirmed
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Checked before the local record is deleted: it may be the last
|
// Checked before the local record is deleted: it may be the last
|
||||||
|
|||||||
@@ -187,17 +187,6 @@ 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.
|
||||||
@@ -213,17 +202,11 @@ func encodeRemoteManifest(t *testing.T, remoteKey, timestamp string) []byte {
|
|||||||
data, err := snapshot.EncodeManifest(manifest, 3)
|
data, err := snapshot.EncodeManifest(manifest, 3)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
return data
|
err = e.store.Put(context.Background(),
|
||||||
}
|
|
||||||
|
|
||||||
// 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.
|
||||||
@@ -362,9 +345,8 @@ func TestSnapshotListAndPurgePrintSizesLikeRemoteInfo(t *testing.T) {
|
|||||||
// Without --force and with nothing on stdin the purge lists what it
|
// Without --force and with nothing on stdin the purge lists what it
|
||||||
// would delete, reads no confirmation, and deletes nothing.
|
// would delete, reads no confirmation, and deletes nothing.
|
||||||
env.stdout.Reset()
|
env.stdout.Reset()
|
||||||
require.ErrorIs(t, env.v.PurgeSnapshotsWithOptions(
|
require.NoError(t, env.v.PurgeSnapshotsWithOptions(
|
||||||
&vaultik.SnapshotPurgeOptions{OlderThan: "1d"}),
|
&vaultik.SnapshotPurgeOptions{OlderThan: "1d"}))
|
||||||
vaultik.ErrNotConfirmed)
|
|
||||||
assert.Contains(t, findTableRow(t, env.stdout.String(), listLocalID),
|
assert.Contains(t, findTableRow(t, env.stdout.String(), listLocalID),
|
||||||
wantSize, "snapshot purge")
|
wantSize, "snapshot purge")
|
||||||
}
|
}
|
||||||
@@ -809,17 +791,9 @@ 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. The listing
|
// One past the cap, so exactly one snapshot is omitted.
|
||||||
// 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.putRemoteManifest(t, snapshot.RemoteSnapshotKey(
|
env.addRemote(t, fmt.Sprintf("otherhost_bulk_%04d", i), timestamp)
|
||||||
fmt.Sprintf("otherhost_bulk_%04d", i)), data)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
stderr := captureProcessStderr(t, func() {
|
stderr := captureProcessStderr(t, func() {
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ var errStorageBindingMismatch = errors.New(
|
|||||||
// nothing and return nil.
|
// nothing and return nil.
|
||||||
// - When the two differ, refuse with an error that tells the user
|
// - When the two differ, refuse with an error that tells the user
|
||||||
// how to recover (revert the config, or run `vaultik database
|
// how to recover (revert the config, or run `vaultik database
|
||||||
// delete` to discard the local index and rebuild against the new
|
// purge` to discard the local index and rebuild against the new
|
||||||
// destination on the next backup).
|
// destination on the next backup).
|
||||||
//
|
//
|
||||||
// Read-only inspection commands (remote info, snapshot list, etc.)
|
// Read-only inspection commands (remote info, snapshot list, etc.)
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ package vaultik
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
@@ -163,11 +162,6 @@ func (v *Vaultik) StartOperation(fn func()) func(context.Context) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CanDecrypt returns true if this Vaultik instance has decryption capabilities
|
|
||||||
func (v *Vaultik) CanDecrypt() bool {
|
|
||||||
return v.Config.AgeSecretKey != ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetFilesystem returns the filesystem instance used by Vaultik
|
// GetFilesystem returns the filesystem instance used by Vaultik
|
||||||
//
|
//
|
||||||
//nolint:ireturn // afero.Fs is the filesystem abstraction by design
|
//nolint:ireturn // afero.Fs is the filesystem abstraction by design
|
||||||
@@ -185,12 +179,6 @@ func (v *Vaultik) printlnStdout(args ...any) {
|
|||||||
_, _ = fmt.Fprintln(v.Stdout, args...)
|
_, _ = fmt.Fprintln(v.Stdout, args...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ErrNotConfirmed is returned when a confirmation prompt gets any answer
|
|
||||||
// but the one it asks for (`y` or `Y`, or `yes` for `database delete`), or
|
|
||||||
// stdin ends first. Nothing the prompt asked about is deleted, and the
|
|
||||||
// command exits non-zero.
|
|
||||||
var ErrNotConfirmed = errors.New("deletion not confirmed")
|
|
||||||
|
|
||||||
// scanStdin reads a line of input from stdin.
|
// scanStdin reads a line of input from stdin.
|
||||||
func (v *Vaultik) scanStdin(a ...any) (int, error) {
|
func (v *Vaultik) scanStdin(a ...any) (int, error) {
|
||||||
return fmt.Fscanln(v.Stdin, a...)
|
return fmt.Fscanln(v.Stdin, a...)
|
||||||
|
|||||||
+13
-19
@@ -22,9 +22,7 @@ import (
|
|||||||
|
|
||||||
// Sentinel errors for snapshot verification failures.
|
// Sentinel errors for snapshot verification failures.
|
||||||
var (
|
var (
|
||||||
errVerificationFailed = errors.New("verification failed")
|
errVerificationFailed = errors.New("verification failed")
|
||||||
errSecretKeyRequired = errors.New(
|
|
||||||
"VAULTIK_AGE_SECRET_KEY not set; required for deep verification")
|
|
||||||
errChunksOutOfOrder = errors.New("chunks out of order")
|
errChunksOutOfOrder = errors.New("chunks out of order")
|
||||||
errChunkHashMismatch = errors.New("chunk hash mismatch")
|
errChunkHashMismatch = errors.New("chunk hash mismatch")
|
||||||
errNegativeChunkLength = errors.New("chunk length is negative")
|
errNegativeChunkLength = errors.New("chunk length is negative")
|
||||||
@@ -106,13 +104,9 @@ func (v *Vaultik) RunDeepVerify(snapshotID string, opts *VerifyOptions) error {
|
|||||||
Mode: "deep",
|
Mode: "deep",
|
||||||
}
|
}
|
||||||
|
|
||||||
if !v.CanDecrypt() {
|
|
||||||
return v.deepVerifyFailure(result, opts,
|
|
||||||
errSecretKeyRequired.Error(), errSecretKeyRequired)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse the age secret key once, the same way restore does, and reuse
|
// Parse the age secret key once, the same way restore does, and reuse
|
||||||
// the identities for the database and every blob.
|
// the identities for the database and every blob. A missing key fails
|
||||||
|
// here, before anything is downloaded.
|
||||||
identities, err := v.restoreIdentities()
|
identities, err := v.restoreIdentities()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return v.deepVerifyFailure(result, opts, err.Error(), err)
|
return v.deepVerifyFailure(result, opts, err.Error(), err)
|
||||||
@@ -190,7 +184,7 @@ func (v *Vaultik) loadVerificationData(
|
|||||||
log.Info("Downloading manifest", "remote_key", remoteKey)
|
log.Info("Downloading manifest", "remote_key", remoteKey)
|
||||||
|
|
||||||
if !opts.JSON && !v.UI.Quiet() {
|
if !opts.JSON && !v.UI.Quiet() {
|
||||||
v.UI.Beginf("Downloading manifest...")
|
v.stdoutf("Downloading manifest...\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
manifest, err := v.downloadManifestByKey(remoteKey)
|
manifest, err := v.downloadManifestByKey(remoteKey)
|
||||||
@@ -205,9 +199,9 @@ func (v *Vaultik) loadVerificationData(
|
|||||||
"manifest_total_size", ubytes(manifest.TotalCompressedSize))
|
"manifest_total_size", ubytes(manifest.TotalCompressedSize))
|
||||||
|
|
||||||
if !opts.JSON && !v.UI.Quiet() {
|
if !opts.JSON && !v.UI.Quiet() {
|
||||||
v.UI.Completef("Manifest loaded: %d blobs (%s)",
|
v.stdoutf("Manifest loaded: %d blobs (%s)\n",
|
||||||
manifest.BlobCount, ubytes(manifest.TotalCompressedSize))
|
manifest.BlobCount, ubytes(manifest.TotalCompressedSize))
|
||||||
v.UI.Beginf("Downloading and decrypting database...")
|
v.stdoutf("Downloading and decrypting database...\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
tdb, err := v.downloadVerifiedSnapshotDB(
|
tdb, err := v.downloadVerifiedSnapshotDB(
|
||||||
@@ -235,7 +229,7 @@ func (v *Vaultik) loadVerificationData(
|
|||||||
"db_total_size", ubytes(dbTotalSize))
|
"db_total_size", ubytes(dbTotalSize))
|
||||||
|
|
||||||
if !opts.JSON && !v.UI.Quiet() {
|
if !opts.JSON && !v.UI.Quiet() {
|
||||||
v.UI.Completef("Database loaded: %d blobs (%s)",
|
v.stdoutf("Database loaded: %d blobs (%s)\n",
|
||||||
len(dbBlobs), ubytes(dbTotalSize))
|
len(dbBlobs), ubytes(dbTotalSize))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,7 +289,7 @@ func (v *Vaultik) runVerificationSteps(
|
|||||||
identities []age.Identity,
|
identities []age.Identity,
|
||||||
) error {
|
) error {
|
||||||
if !opts.JSON && !v.UI.Quiet() {
|
if !opts.JSON && !v.UI.Quiet() {
|
||||||
v.UI.Beginf("Verifying manifest against database...")
|
v.stdoutf("Verifying manifest against database...\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
err := v.verifyManifestAgainstDatabase(manifest, dbBlobs)
|
err := v.verifyManifestAgainstDatabase(manifest, dbBlobs)
|
||||||
@@ -304,8 +298,8 @@ func (v *Vaultik) runVerificationSteps(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !opts.JSON && !v.UI.Quiet() {
|
if !opts.JSON && !v.UI.Quiet() {
|
||||||
v.UI.Completef("Manifest verified.")
|
v.stdoutf("Manifest verified.\n")
|
||||||
v.UI.Beginf("Checking blob existence in remote storage...")
|
v.stdoutf("Checking blob existence in remote storage...\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
err = v.verifyBlobExistenceFromDB(dbBlobs, result)
|
err = v.verifyBlobExistenceFromDB(dbBlobs, result)
|
||||||
@@ -314,8 +308,8 @@ func (v *Vaultik) runVerificationSteps(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !opts.JSON && !v.UI.Quiet() {
|
if !opts.JSON && !v.UI.Quiet() {
|
||||||
v.UI.Completef("All blobs exist.")
|
v.stdoutf("All blobs exist.\n")
|
||||||
v.UI.Beginf("Downloading and verifying blob contents (%d blobs, %s)...",
|
v.stdoutf("Downloading and verifying blob contents (%d blobs, %s)...\n",
|
||||||
len(dbBlobs), ubytes(totalSize))
|
len(dbBlobs), ubytes(totalSize))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -790,7 +784,7 @@ func (v *Vaultik) performDeepVerificationFromDB(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if !opts.JSON && !v.UI.Quiet() {
|
if !opts.JSON && !v.UI.Quiet() {
|
||||||
v.UI.Progressf("Verified %d/%d blobs (%d remaining) - %s/%s - elapsed %s, eta %s",
|
v.stdoutf(" Verified %d/%d blobs (%d remaining) - %s/%s - elapsed %s, eta %s\n",
|
||||||
i+1, len(blobs), remaining,
|
i+1, len(blobs), remaining,
|
||||||
ubytes(bytesProcessed),
|
ubytes(bytesProcessed),
|
||||||
ubytes(totalBytesExpected),
|
ubytes(totalBytesExpected),
|
||||||
|
|||||||
@@ -100,51 +100,3 @@ func TestVerify_QuietSuppressesReport(t *testing.T) {
|
|||||||
require.Empty(t, stdout.String(),
|
require.Empty(t, stdout.String(),
|
||||||
"a failed verify must write no report under --quiet")
|
"a failed verify must write no report under --quiet")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestVerify_DeepStatusLineUsesUIMarker checks that the deep verify
|
|
||||||
// status lines go through internal/ui: marker-prefixed, and silenced by
|
|
||||||
// --quiet.
|
|
||||||
func TestVerify_DeepStatusLineUsesUIMarker(t *testing.T) {
|
|
||||||
log.Initialize(log.Config{})
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewOsFs()
|
|
||||||
tempDir := t.TempDir()
|
|
||||||
|
|
||||||
dataDir := filepath.Join(tempDir, "source")
|
|
||||||
storeDir := t.TempDir()
|
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
|
||||||
|
|
||||||
require.NoError(t, fs.MkdirAll(dataDir, 0o755))
|
|
||||||
require.NoError(t, afero.WriteFile(fs,
|
|
||||||
filepath.Join(dataDir, "data.bin"),
|
|
||||||
bytesPattern("marker-", 64*1024), 0o644))
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
cfg, storer, snapshotID := runFileStorageBackup(
|
|
||||||
ctx, t, fs, dataDir, storeDir, dbPath, 32*1024, 128*1024)
|
|
||||||
|
|
||||||
var stdout bytes.Buffer
|
|
||||||
|
|
||||||
v := &vaultik.Vaultik{
|
|
||||||
Config: cfg,
|
|
||||||
Storage: storer,
|
|
||||||
Fs: fs,
|
|
||||||
Stdout: &stdout,
|
|
||||||
Stderr: io.Discard,
|
|
||||||
UI: ui.NewWithColor(&stdout, false),
|
|
||||||
}
|
|
||||||
v.SetContext(ctx)
|
|
||||||
|
|
||||||
opts := &vaultik.VerifyOptions{Deep: true}
|
|
||||||
|
|
||||||
require.NoError(t, v.VerifySnapshotWithOptions(snapshotID, opts))
|
|
||||||
require.Contains(t, stdout.String(),
|
|
||||||
"\n"+ui.Marker+" Downloading manifest...\n")
|
|
||||||
|
|
||||||
stdout.Reset()
|
|
||||||
v.UI.SetQuiet(true)
|
|
||||||
require.NoError(t, v.VerifySnapshotWithOptions(snapshotID, opts))
|
|
||||||
require.NotContains(t, stdout.String(), "Downloading manifest")
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"devDependencies": {
|
|
||||||
"prettier": "3.8.1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+1
-80
@@ -3,20 +3,11 @@
|
|||||||
# this repo. Idempotent: every install is guarded by a check so already
|
# this repo. Idempotent: every install is guarded by a check so already
|
||||||
# installed tools are skipped. Base tooling comes from nix, apt, brew,
|
# installed tools are skipped. Base tooling comes from nix, apt, brew,
|
||||||
# or apk (detected in that order); assumes NOTHING is present (not git,
|
# or apk (detected in that order); assumes NOTHING is present (not git,
|
||||||
# make, or go). Node is used directly if installed; otherwise it is
|
# make, or go).
|
||||||
# installed at a pinned version via nvm (installing nvm itself first,
|
|
||||||
# from a hash-verified release archive, never curl | sh).
|
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|
||||||
# Pinned versions, 2026-07-06
|
|
||||||
NODE_VERSION="22.17.0"
|
|
||||||
NVM_VERSION="0.40.3"
|
|
||||||
# sha256 of https://github.com/nvm-sh/nvm/archive/refs/tags/v0.40.3.tar.gz
|
|
||||||
NVM_SHA256="5f4d6aaa04a177dc93c985e31dbc411ab6b8c6e1e21d8015dbc1372625fcd1d0"
|
|
||||||
YARN_VERSION="1.22.22"
|
|
||||||
|
|
||||||
PKGMGR=""
|
PKGMGR=""
|
||||||
SUDO=""
|
SUDO=""
|
||||||
|
|
||||||
@@ -57,69 +48,6 @@ missing() {
|
|||||||
! command -v "$1" >/dev/null 2>&1
|
! command -v "$1" >/dev/null 2>&1
|
||||||
}
|
}
|
||||||
|
|
||||||
# verify_sha256 <file> <expected-hash>
|
|
||||||
verify_sha256() {
|
|
||||||
if command -v sha256sum >/dev/null 2>&1; then
|
|
||||||
actual="$(sha256sum "$1" | cut -d' ' -f1)"
|
|
||||||
else
|
|
||||||
actual="$(shasum -a 256 "$1" | cut -d' ' -f1)"
|
|
||||||
fi
|
|
||||||
if [ "$actual" != "$2" ]; then
|
|
||||||
echo "bootstrap: sha256 mismatch for $1" >&2
|
|
||||||
echo " expected: $2" >&2
|
|
||||||
echo " actual: $actual" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# nvm is a bash script; run a command in a bash with nvm loaded
|
|
||||||
nvm_sh() {
|
|
||||||
bash -c ". \"\$HOME/.nvm/nvm.sh\" && $*"
|
|
||||||
}
|
|
||||||
|
|
||||||
ensure_nvm() {
|
|
||||||
[ -s "$HOME/.nvm/nvm.sh" ] && return 0
|
|
||||||
# nvm prerequisites; nvm itself requires bash
|
|
||||||
if missing bash; then pkg_install bash bash bash bash; fi
|
|
||||||
if missing curl; then pkg_install curl curl curl curl; fi
|
|
||||||
if missing git; then pkg_install git git git git; fi
|
|
||||||
tmp="$(mktemp -d)"
|
|
||||||
curl -fsSL -o "$tmp/nvm.tar.gz" \
|
|
||||||
"https://github.com/nvm-sh/nvm/archive/refs/tags/v${NVM_VERSION}.tar.gz"
|
|
||||||
verify_sha256 "$tmp/nvm.tar.gz" "$NVM_SHA256"
|
|
||||||
mkdir -p "$HOME/.nvm"
|
|
||||||
tar -xzf "$tmp/nvm.tar.gz" -C "$HOME/.nvm" --strip-components=1
|
|
||||||
rm -rf "$tmp"
|
|
||||||
}
|
|
||||||
|
|
||||||
ensure_node() {
|
|
||||||
if ! missing node; then return 0; fi
|
|
||||||
ensure_nvm
|
|
||||||
nvm_sh "nvm install $NODE_VERSION"
|
|
||||||
}
|
|
||||||
|
|
||||||
ensure_yarn() {
|
|
||||||
if ! missing yarn; then return 0; fi
|
|
||||||
if ! missing corepack; then
|
|
||||||
corepack enable
|
|
||||||
corepack prepare "yarn@$YARN_VERSION" --activate
|
|
||||||
elif [ -s "$HOME/.nvm/nvm.sh" ]; then
|
|
||||||
nvm_sh "nvm use $NODE_VERSION >/dev/null && corepack enable && \
|
|
||||||
corepack prepare yarn@$YARN_VERSION --activate"
|
|
||||||
else
|
|
||||||
npm install -g "yarn@$YARN_VERSION"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
install_js_deps() {
|
|
||||||
if missing yarn && [ -s "$HOME/.nvm/nvm.sh" ]; then
|
|
||||||
nvm_sh "nvm use $NODE_VERSION >/dev/null && cd \"$ROOT\" && \
|
|
||||||
yarn install --frozen-lockfile"
|
|
||||||
else
|
|
||||||
yarn install --frozen-lockfile
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# Docker is a hard requirement, not a nice-to-have: script/lint and
|
# Docker is a hard requirement, not a nice-to-have: script/lint and
|
||||||
# script/test build the lint and test phases of the Dockerfile, the only
|
# script/test build the lint and test phases of the Dockerfile, the only
|
||||||
# place the linter and the tests run, and script/check and
|
# place the linter and the tests run, and script/check and
|
||||||
@@ -175,8 +103,6 @@ main() {
|
|||||||
# Base tooling (every repo)
|
# Base tooling (every repo)
|
||||||
if missing git; then pkg_install git git git git; fi
|
if missing git; then pkg_install git git git git; fi
|
||||||
if missing make; then pkg_install gnumake make make make; fi
|
if missing make; then pkg_install gnumake make make make; fi
|
||||||
# script/install-go, script/install-goreleaser and nvm download with it.
|
|
||||||
if missing curl; then pkg_install curl curl curl curl; fi
|
|
||||||
|
|
||||||
# Go toolchain: the host's own, or else the version go.mod names,
|
# Go toolchain: the host's own, or else the version go.mod names,
|
||||||
# hash-verified, in .tool/go. That directory is not on the caller's
|
# hash-verified, in .tool/go. That directory is not on the caller's
|
||||||
@@ -205,11 +131,6 @@ main() {
|
|||||||
|
|
||||||
go mod download
|
go mod download
|
||||||
|
|
||||||
# prettier, which script/fmt and script/fmt-check run on Markdown.
|
|
||||||
ensure_node
|
|
||||||
ensure_yarn
|
|
||||||
install_js_deps
|
|
||||||
|
|
||||||
# Last, so that everything installable is installed before the one
|
# Last, so that everything installable is installed before the one
|
||||||
# thing this script cannot install decides the outcome.
|
# thing this script cannot install decides the outcome.
|
||||||
require_docker
|
require_docker
|
||||||
|
|||||||
-20
@@ -4,31 +4,11 @@ set -eu
|
|||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|
||||||
# Must match the pin in script/bootstrap.
|
|
||||||
NODE_VERSION="22.17.0"
|
|
||||||
|
|
||||||
# script/bootstrap installs node and yarn under nvm and leaves neither
|
|
||||||
# on the PATH of the shell that called it, so resolve the pinned
|
|
||||||
# toolchain here the way bootstrap's own install step does. nvm is a
|
|
||||||
# bash script, hence the subshell.
|
|
||||||
run_yarn() {
|
|
||||||
if command -v yarn >/dev/null 2>&1; then
|
|
||||||
exec yarn "$@"
|
|
||||||
fi
|
|
||||||
if [ ! -s "$HOME/.nvm/nvm.sh" ]; then
|
|
||||||
echo "fmt: no yarn; run script/bootstrap first" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
exec bash -c '. "$HOME/.nvm/nvm.sh" && nvm use "$1" >/dev/null &&
|
|
||||||
shift && exec yarn "$@"' bash "$NODE_VERSION" "$@"
|
|
||||||
}
|
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
# Where script/bootstrap installs Go when the host has none.
|
# Where script/bootstrap installs Go when the host has none.
|
||||||
PATH="$PATH:$ROOT/.tool/go/bin"
|
PATH="$PATH:$ROOT/.tool/go/bin"
|
||||||
go fmt ./...
|
go fmt ./...
|
||||||
run_yarn run prettier --write '**/*.md' --tab-width 4 --prose-wrap always
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
+1
-21
@@ -3,30 +3,11 @@
|
|||||||
# writing. It checks every Go file outside .tool, which is more than
|
# writing. It checks every Go file outside .tool, which is more than
|
||||||
# script/fmt formats: `go fmt ./...` skips `testdata` directories and
|
# script/fmt formats: `go fmt ./...` skips `testdata` directories and
|
||||||
# files and directories whose names start with `.` or `_`. Fix a file
|
# files and directories whose names start with `.` or `_`. Fix a file
|
||||||
# only this reports with `gofmt -w`. Markdown is checked with prettier.
|
# only this reports with `gofmt -w`.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|
||||||
# Must match the pin in script/bootstrap.
|
|
||||||
NODE_VERSION="22.17.0"
|
|
||||||
|
|
||||||
# script/bootstrap installs node and yarn under nvm and leaves neither
|
|
||||||
# on the PATH of the shell that called it, so resolve the pinned
|
|
||||||
# toolchain here the way bootstrap's own install step does. nvm is a
|
|
||||||
# bash script, hence the subshell.
|
|
||||||
run_yarn() {
|
|
||||||
if command -v yarn >/dev/null 2>&1; then
|
|
||||||
exec yarn "$@"
|
|
||||||
fi
|
|
||||||
if [ ! -s "$HOME/.nvm/nvm.sh" ]; then
|
|
||||||
echo "fmt-check: no yarn; run script/bootstrap first" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
exec bash -c '. "$HOME/.nvm/nvm.sh" && nvm use "$1" >/dev/null &&
|
|
||||||
shift && exec yarn "$@"' bash "$NODE_VERSION" "$@"
|
|
||||||
}
|
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
# Where script/bootstrap installs Go when the host has none.
|
# Where script/bootstrap installs Go when the host has none.
|
||||||
@@ -42,7 +23,6 @@ main() {
|
|||||||
echo "$unformatted" >&2
|
echo "$unformatted" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
run_yarn run prettier --check '**/*.md' --tab-width 4 --prose-wrap always
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
|
||||||
# yarn lockfile v1
|
|
||||||
|
|
||||||
|
|
||||||
prettier@3.8.1:
|
|
||||||
version "3.8.1"
|
|
||||||
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.8.1.tgz#edf48977cf991558f4fcbd8a3ba6015ba2a3a173"
|
|
||||||
integrity sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==
|
|
||||||
Reference in new issue
Block a user