Compare commits
93
Commits
main
...
v1.0.0-rc2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a57750a7b | ||
|
|
2590f49583 | ||
|
|
5b8d84e38a | ||
|
|
7a950ea2aa | ||
|
|
16b03dfda5 | ||
|
|
2125a9d451 | ||
|
|
a0de8bfdb7 | ||
|
|
291c39174f | ||
|
|
65036bba1b | ||
|
|
54ad279dd6 | ||
|
|
d156a02c49 | ||
|
|
aeac897805 | ||
|
|
d8f4f62f41 | ||
|
|
9fd88a34c7 | ||
|
|
5db2e920e5 | ||
|
|
66ea7f5f45 | ||
|
|
dd3667c8e9 | ||
|
|
30285c7a8c | ||
|
|
8d5e6f3399 | ||
|
|
abad8161bc | ||
|
|
05a2c86d65 | ||
|
|
d8f78a1f45 | ||
|
|
2c087be577 | ||
|
|
37d525c322 | ||
|
|
f89fac34f7 | ||
|
|
5d8c515192 | ||
|
|
42eb50b151 | ||
|
|
25ee9211d3 | ||
|
|
3361e9ebd9 | ||
|
|
fb8abcd049 | ||
|
|
6aceaaf086 | ||
|
|
3b2b9cec84 | ||
|
|
dfa3bbc9e1 | ||
|
|
740f474c59 | ||
|
|
27e9798da5 | ||
|
|
4193ced1f6 | ||
|
|
e48bafccef | ||
|
|
bffd4578fc | ||
|
|
f4ab1ba5ae | ||
|
|
005927a7fc | ||
|
|
09a0c01c43 | ||
|
|
8c54e0ff75 | ||
|
|
d1213ecb34 | ||
|
|
5f6455ba17 | ||
|
|
0288735aaf | ||
|
|
35eed79b1a | ||
|
|
94412d9ef3 | ||
|
|
167c0a0746 | ||
|
|
07c957f84b | ||
|
|
c9a519c233 | ||
|
|
fa56165fe4 | ||
|
|
34f61fb408 | ||
|
|
05bf73c48a | ||
|
|
79a73fa122 | ||
|
|
0d0368df81 | ||
|
|
c06c4d201b | ||
|
|
70f008a21a | ||
|
|
fce253fa39 | ||
|
|
e459a66099 | ||
|
|
32c4a46b49 | ||
|
|
b5389a62b5 | ||
|
|
d87202fb70 | ||
|
|
e161343eac | ||
|
|
d53202eb86 | ||
|
|
8b22ae8d42 | ||
|
|
3fc8a8f2f4 | ||
|
|
7696f83258 | ||
|
|
5d1118d143 | ||
|
|
b57ce2277d | ||
|
|
8496404d8b | ||
|
|
cdc60c4dfa | ||
|
|
49eed7a3e5 | ||
|
|
85d4ef118d | ||
|
|
b06f992152 | ||
|
|
5d685f03ce | ||
|
|
f59086c0e5 | ||
|
|
d276d891da | ||
|
|
5aa5ba5544 | ||
|
|
315b6483b8 | ||
|
|
14fc4c9893 | ||
|
|
66c80a70e3 | ||
|
|
81f83b29f6 | ||
|
|
c4adb72d80 | ||
|
|
4a167e153a | ||
|
|
ea72697992 | ||
|
|
713be502bd | ||
|
|
35cf985c18 | ||
|
|
070090124a | ||
|
|
584444b619 | ||
|
|
b30e79ee45 | ||
|
|
d886a9026f | ||
|
|
6e1f499048 | ||
|
|
d24f5dc33c |
+79
-10
@@ -1,10 +1,79 @@
|
|||||||
.git
|
# .dockerignore does NOT use .gitignore semantics. Docker matches with
|
||||||
.gitea
|
# moby/patternmatcher: filepath.Match plus `**`, so `*` does not cross
|
||||||
*.md
|
# `/` and an unprefixed pattern is anchored at the context root. Every
|
||||||
LICENSE
|
# depth-independent pattern therefore needs `**/`, or `config/.env` and
|
||||||
vaultik
|
# `certs/server.key` still ship while this file reads as solved. Only
|
||||||
dist
|
# genuinely root-anchored entries go unprefixed. Never transplant these
|
||||||
.tool
|
# into .gitignore, where `**/` is wrong.
|
||||||
coverage.out
|
#
|
||||||
coverage.html
|
# Matching is case-sensitive, so secrets use character ranges rather
|
||||||
.DS_Store
|
# than an ALL-CAPS twin, which would still miss `Server.Key`.
|
||||||
|
#
|
||||||
|
# Extend with this repo's own host-built artifacts, written anchored:
|
||||||
|
# `/myapp`, never `**/myapp`, which also matches `cmd/myapp/` and
|
||||||
|
# deletes the package directory from the context.
|
||||||
|
|
||||||
|
# .git is sent without its config. Without a VERSION build argument the
|
||||||
|
# stage that compiles runs `git describe --tags --always` on .git, which
|
||||||
|
# does not need .git/config; that file can hold a credential, such as a
|
||||||
|
# password in a remote URL or the token the CI checkout step stores there.
|
||||||
|
# Each submodule keeps a config with the same exposure in its git directory
|
||||||
|
# under .git/modules/, nested again for a submodule's own submodules, or in
|
||||||
|
# its own .git directory when it keeps one.
|
||||||
|
# KNOWN GAP: a submodule whose name has a `config` segment (`config`,
|
||||||
|
# `deploy/config`, `config/lib`) loses its whole git directory, because
|
||||||
|
# `**/.git/modules/**/config` also matches that segment's directory
|
||||||
|
# under .git/modules/. Go's version stamping then fails the build;
|
||||||
|
# nothing leaks. Name such a submodule without that segment:
|
||||||
|
# `git submodule add --name`.
|
||||||
|
**/.git/config
|
||||||
|
**/.git/modules/**/config
|
||||||
|
|
||||||
|
# Agent scratch: one full checkout of the repo per in-flight agent.
|
||||||
|
# Anchored because it occurs once where agents run at the repo root.
|
||||||
|
# KNOWN GAP: a repo running agents in subdirectories still ships
|
||||||
|
# `services/api/.claude/` and must add its own anchored entry.
|
||||||
|
.claude
|
||||||
|
|
||||||
|
# Environment files. `*.env` covers bare `.env` and the `prod.env`
|
||||||
|
# convention. Re-include a committed template with a negation if the
|
||||||
|
# build needs one: `!docs/example.env`.
|
||||||
|
**/*.[eE][nN][vV]
|
||||||
|
**/.[eE][nN][vV].*
|
||||||
|
**/.[eE][nN][vV][rR][cC]
|
||||||
|
|
||||||
|
# Private keys and the bundles carrying them. Public certificates
|
||||||
|
# (*.crt, *.cer) are deliberately absent: they are legitimate inputs.
|
||||||
|
**/*.[pP][eE][mM]
|
||||||
|
**/*.[kK][eE][yY]
|
||||||
|
**/*.[pP]12
|
||||||
|
**/*.[pP][fF][xX]
|
||||||
|
**/[iI][dD]_[rR][sS][aA]
|
||||||
|
**/[iI][dD]_[dD][sS][aA]
|
||||||
|
**/[iI][dD]_[eE][cC][dD][sS][aA]
|
||||||
|
**/[iI][dD]_[eE][cC][dD][sS][aA]_[sS][kK]
|
||||||
|
**/[iI][dD]_[eE][dD]25519
|
||||||
|
**/[iI][dD]_[eE][dD]25519_[sS][kK]
|
||||||
|
|
||||||
|
# Dependencies: restored inside the image, never copied in.
|
||||||
|
**/node_modules
|
||||||
|
|
||||||
|
# OS metadata.
|
||||||
|
**/.DS_Store
|
||||||
|
**/Thumbs.db
|
||||||
|
|
||||||
|
# Editor state: never a build input, and it churns COPY.
|
||||||
|
**/*.swp
|
||||||
|
**/*.swo
|
||||||
|
**/*~
|
||||||
|
**/*.bak
|
||||||
|
**/.idea
|
||||||
|
**/.vscode
|
||||||
|
**/*.sublime-*
|
||||||
|
|
||||||
|
# This repo's own host-built artifacts.
|
||||||
|
/vaultik
|
||||||
|
/dist
|
||||||
|
/.tool
|
||||||
|
/coverage.out
|
||||||
|
/coverage.html
|
||||||
|
|||||||
@@ -10,3 +10,6 @@ insert_final_newline = true
|
|||||||
|
|
||||||
[Makefile]
|
[Makefile]
|
||||||
indent_style = tab
|
indent_style = tab
|
||||||
|
|
||||||
|
[*.go]
|
||||||
|
indent_style = tab
|
||||||
|
|||||||
@@ -1,14 +1,9 @@
|
|||||||
name: check
|
name: check
|
||||||
on:
|
on: [push]
|
||||||
push:
|
|
||||||
branches: [main, next]
|
|
||||||
pull_request:
|
|
||||||
branches: [main, next]
|
|
||||||
jobs:
|
jobs:
|
||||||
check:
|
check:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
# actions/checkout v4, 2024-09-16
|
# actions/checkout v4.2.2, 2026-02-22
|
||||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||||
- name: Build and check
|
- run: script/cibuild
|
||||||
run: script/cibuild
|
|
||||||
|
|||||||
@@ -16,11 +16,12 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
# goreleaser is not a compiler: it shells out to `go` for the
|
# goreleaser is not a compiler: it shells out to `go` for the
|
||||||
# `before:` hook and for every one of the four cross-compiles.
|
# `before:` hook and for every one of the four cross-compiles.
|
||||||
# Nothing else in this repo puts a Go toolchain on the runner --
|
# Without this step the release either fails at the before-hook or,
|
||||||
# check.yml runs script/cibuild, which does all of its work inside
|
# worse, ships binaries built by whatever Go the runner happens to
|
||||||
# the digest-pinned Dockerfile images -- so without this step the
|
# carry. check.yml's runner gets the same Go through
|
||||||
# release either fails at the before-hook or, worse, ships binaries
|
# script/bootstrap, which calls script/install-go, and uses it only
|
||||||
# built by whatever Go the runner happens to carry.
|
# for `go mod download` and gofmt; it compiles inside the
|
||||||
|
# digest-pinned Dockerfile images.
|
||||||
#
|
#
|
||||||
# actions/setup-go would pin the action by commit sha, but the Go
|
# actions/setup-go would pin the action by commit sha, but the Go
|
||||||
# tarball it downloads at runtime is verified against no value in
|
# tarball it downloads at runtime is verified against no value in
|
||||||
|
|||||||
+57
-23
@@ -1,28 +1,62 @@
|
|||||||
# Binary
|
|
||||||
/vaultik
|
|
||||||
|
|
||||||
# goreleaser output
|
|
||||||
/dist/
|
|
||||||
|
|
||||||
# Locally installed pinned tools (script/install-goreleaser)
|
|
||||||
/.tool/
|
|
||||||
|
|
||||||
# Test artifacts
|
|
||||||
*.out
|
|
||||||
*.test
|
|
||||||
coverage.html
|
|
||||||
coverage.out
|
|
||||||
|
|
||||||
# IDE
|
|
||||||
.vscode/
|
|
||||||
.idea/
|
|
||||||
*.swp
|
|
||||||
*.swo
|
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
# Local config for development
|
# Editors
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
*.bak
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.sublime-*
|
||||||
|
|
||||||
|
# Agent scratch (worktrees of this repo, created and destroyed by
|
||||||
|
# in-flight tooling). Unanchored: .gitignore patterns already match at
|
||||||
|
# every depth, so no prefix is wanted here. This is not a .dockerignore
|
||||||
|
# entry and must not be given a `**/` prefix on the way into one.
|
||||||
|
.claude/
|
||||||
|
|
||||||
|
# Node
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Secrets. Unanchored like every entry above, so each matches at every
|
||||||
|
# depth. Matching is case-sensitive on Linux, so names use character
|
||||||
|
# ranges rather than a lowercase form that misses `Server.Key`.
|
||||||
|
|
||||||
|
# Environment files. `*.env` covers bare `.env` and the `prod.env`
|
||||||
|
# convention. Only the templates `example.env` and `sample.env` are
|
||||||
|
# re-included below. A repository that commits any other template adds
|
||||||
|
# its own negation after these lines, for example `!.env.example`.
|
||||||
|
*.[eE][nN][vV]
|
||||||
|
.[eE][nN][vV].*
|
||||||
|
.[eE][nN][vV][rR][cC]
|
||||||
|
!example.env
|
||||||
|
!sample.env
|
||||||
|
|
||||||
|
# Private keys and the bundles carrying them.
|
||||||
|
*.[pP][eE][mM]
|
||||||
|
*.[kK][eE][yY]
|
||||||
|
*.[pP]12
|
||||||
|
*.[pP][fF][xX]
|
||||||
|
[iI][dD]_[rR][sS][aA]
|
||||||
|
[iI][dD]_[dD][sS][aA]
|
||||||
|
[iI][dD]_[eE][cC][dD][sS][aA]
|
||||||
|
[iI][dD]_[eE][cC][dD][sS][aA]_[sS][kK]
|
||||||
|
[iI][dD]_[eE][dD]25519
|
||||||
|
[iI][dD]_[eE][dD]25519_[sS][kK]
|
||||||
|
|
||||||
|
# Go build and test output.
|
||||||
|
*.log
|
||||||
|
*.out
|
||||||
|
*.test
|
||||||
|
coverage.html
|
||||||
|
|
||||||
|
# This repo's own host-built artifacts.
|
||||||
|
/vaultik
|
||||||
|
/dist/
|
||||||
|
/.tool/
|
||||||
|
|
||||||
|
# Local configs for development; they hold storage credentials.
|
||||||
local-config.yaml
|
local-config.yaml
|
||||||
dev-config.yaml
|
dev-config.yaml
|
||||||
|
|||||||
+71
-2
@@ -10,14 +10,21 @@ run:
|
|||||||
|
|
||||||
linters:
|
linters:
|
||||||
default: all
|
default: all
|
||||||
|
enable:
|
||||||
|
# Successor to the deprecated gomodguard. Named explicitly, rather than
|
||||||
|
# left to `default: all`, because it carries the module policy below.
|
||||||
|
- gomodguard_v2
|
||||||
disable:
|
disable:
|
||||||
# Genuinely incompatible with project patterns
|
# Genuinely incompatible with project patterns
|
||||||
- exhaustruct # Requires all struct fields
|
- exhaustruct # Requires all struct fields
|
||||||
- depguard # Dependency allow/block lists
|
- exhaustruct_v5 # Requires all struct fields (successor to exhaustruct)
|
||||||
- godot # Requires comments to end with periods
|
- godot # Requires comments to end with periods
|
||||||
- wsl # Deprecated, replaced by wsl_v5
|
|
||||||
- wrapcheck # Too verbose for internal packages
|
- wrapcheck # Too verbose for internal packages
|
||||||
- varnamelen # Short names like db, id are idiomatic Go
|
- varnamelen # Short names like db, id are idiomatic Go
|
||||||
|
# Deprecated: the warning is attached to the old name, so it is
|
||||||
|
# silenced by disabling that name, not by enabling the successor.
|
||||||
|
- wsl # Deprecated, replaced by wsl_v5
|
||||||
|
- gomodguard # Deprecated, replaced by gomodguard_v2
|
||||||
settings:
|
settings:
|
||||||
lll:
|
lll:
|
||||||
line-length: 88
|
line-length: 88
|
||||||
@@ -28,6 +35,68 @@ linters:
|
|||||||
max-complexity: 15
|
max-complexity: 15
|
||||||
dupl:
|
dupl:
|
||||||
threshold: 100
|
threshold: 100
|
||||||
|
depguard:
|
||||||
|
# Test-support code must not be compiled into the shipped binary. A
|
||||||
|
# test-support package exists to hand a test privileges the program
|
||||||
|
# itself must never have, so a file that is not a test must not import
|
||||||
|
# one. Test files, and the files inside a package whose directory name
|
||||||
|
# ends in `test`, are where that code belongs, and are exempt.
|
||||||
|
#
|
||||||
|
# The deny list below is the one part of this file a repository is
|
||||||
|
# expected to extend, and the only part it may. depguard matches an
|
||||||
|
# import path against a list of prefixes, so it cannot be told "any path
|
||||||
|
# whose last segment ends in test"; a repository's own test-support
|
||||||
|
# packages have to be named here one at a time, by full import path,
|
||||||
|
# under a module path that differs from repository to repository. Add
|
||||||
|
# them; change nothing else.
|
||||||
|
rules:
|
||||||
|
test-support:
|
||||||
|
list-mode: lax
|
||||||
|
files:
|
||||||
|
- "$all"
|
||||||
|
- "!$test"
|
||||||
|
- "!**/*test/**"
|
||||||
|
deny:
|
||||||
|
- pkg: net/http/httptest
|
||||||
|
desc: >-
|
||||||
|
Test-support code belongs in test files and in packages whose
|
||||||
|
directory name ends in test, not in the shipped binary.
|
||||||
|
- pkg: sneak.berlin/go/vaultik/internal/storage/faultstore
|
||||||
|
desc: >-
|
||||||
|
Test-support code belongs in test files and in packages whose
|
||||||
|
directory name ends in test, not in the shipped binary.
|
||||||
|
# Only decisions already recorded in the Go package defaults are
|
||||||
|
# listed here. Every entry matches the module path exactly.
|
||||||
|
gomodguard_v2:
|
||||||
|
blocked:
|
||||||
|
- module: github.com/rs/zerolog
|
||||||
|
recommendations:
|
||||||
|
- log/slog
|
||||||
|
reason: "Structured logging is stdlib log/slog."
|
||||||
|
# One entry per pre-fork module path, because the later releases
|
||||||
|
# are separate paths. A prefix match would be shorter but would
|
||||||
|
# also reach github.com/go-redis/redismock, the test double for
|
||||||
|
# the successor these entries recommend.
|
||||||
|
- module: github.com/go-redis/redis
|
||||||
|
recommendations:
|
||||||
|
- github.com/redis/go-redis/v9
|
||||||
|
reason: "Pre-fork module; use the maintained go-redis v9."
|
||||||
|
- module: github.com/go-redis/redis/v7
|
||||||
|
recommendations:
|
||||||
|
- github.com/redis/go-redis/v9
|
||||||
|
reason: "Pre-fork module; use the maintained go-redis v9."
|
||||||
|
- module: github.com/go-redis/redis/v8
|
||||||
|
recommendations:
|
||||||
|
- github.com/redis/go-redis/v9
|
||||||
|
reason: "Pre-fork module; use the maintained go-redis v9."
|
||||||
|
- module: github.com/sergi/go-diff
|
||||||
|
recommendations:
|
||||||
|
- github.com/aymanbagabas/go-udiff
|
||||||
|
reason: "No unified diff output; use go-udiff."
|
||||||
|
- module: github.com/hexops/gotextdiff
|
||||||
|
recommendations:
|
||||||
|
- github.com/aymanbagabas/go-udiff
|
||||||
|
reason: "Unmaintained fork; use go-udiff."
|
||||||
|
|
||||||
issues:
|
issues:
|
||||||
max-issues-per-linter: 0
|
max-issues-per-linter: 0
|
||||||
|
|||||||
+1
-3
@@ -47,9 +47,7 @@ checksum:
|
|||||||
# A snapshot is not a release and must not name itself like one. The
|
# A snapshot is not a release and must not name itself like one. The
|
||||||
# previous `{{ incpatch .Version }}-next` derived a plausible-looking
|
# previous `{{ incpatch .Version }}-next` derived a plausible-looking
|
||||||
# release number from the last tag -- and with no tags in the repo at
|
# release number from the last tag -- and with no tags in the repo at
|
||||||
# all, from goreleaser's fabricated v0.0.0. This produces the same
|
# all, from goreleaser's fabricated v0.0.0.
|
||||||
# string script/version produces for an untagged build, so a snapshot
|
|
||||||
# binary and a `make vaultik` binary of the same clean commit agree.
|
|
||||||
snapshot:
|
snapshot:
|
||||||
version_template: "dev-{{ slice .FullCommit 0 12 }}"
|
version_template: "dev-{{ slice .FullCommit 0 12 }}"
|
||||||
|
|
||||||
|
|||||||
@@ -102,14 +102,29 @@ Version: 2025-06-08
|
|||||||
build files are acceptable in the root, but source code and other files
|
build files are acceptable in the root, but source code and other files
|
||||||
should be organized in appropriate subdirectories.
|
should be organized in appropriate subdirectories.
|
||||||
|
|
||||||
13. Pre-1.0: NEVER write database migrations. There are no live databases
|
13. Pre-1.0: NEVER add a database migration. Migrations are supported, but
|
||||||
anywhere — every user's local index can be rebuilt from a fresh full
|
nothing is installed anywhere yet, so there is nothing to migrate. To
|
||||||
backup. To change the schema, edit `internal/database/schema/001.sql`
|
change the schema, edit `internal/database/schema/001.sql` (and any
|
||||||
(and any code that touches the affected tables) directly; do not add new
|
code that touches the affected tables) directly. After 1.0, each schema
|
||||||
numbered schema files. Those numbered files and the `schema_migrations`
|
change is a new numbered file in that directory and a released file is
|
||||||
table they populate only bootstrap a fresh database — they are not an
|
never edited; an existing local database is then migrated when vaultik
|
||||||
upgrade path. The local index is disposable until 1.0 ships and is
|
is updated. Before 1.0, a local database left on an older schema is
|
||||||
tagged; once 1.0 is tagged that clause expires and the question of
|
deleted and re-created by a full backup. See
|
||||||
upgrading existing indexes returns. See [`docs/DATAMODEL.md`](docs/DATAMODEL.md)
|
[`docs/DATAMODEL.md`](docs/DATAMODEL.md#schema-migrations).
|
||||||
for the full explanation.
|
|
||||||
|
|
||||||
|
14. Never use `git add -A`. Stage only the files you intentionally
|
||||||
|
changed.
|
||||||
|
|
||||||
|
15. Commit messages carry no attribution or advertising trailers for the
|
||||||
|
tool that helped write the code, or for its vendor: the owner is the
|
||||||
|
sole author of code written with a tool.
|
||||||
|
|
||||||
|
16. Run the whole test suite with `make test` every time, and read its full
|
||||||
|
output. Never run `go test`, a single test or a single package, and
|
||||||
|
never grep the output.
|
||||||
|
|
||||||
|
17. Do not stop working on a task until the definition of done given in the
|
||||||
|
initial instruction is met: all of the work, not part or most of it.
|
||||||
|
|
||||||
|
18. For estimates: backing up over 2.5Gbit/s ethernet to an S3 server
|
||||||
|
backed by 2000MB/sec SSD takes about 4 seconds per gigabyte.
|
||||||
|
|||||||
+28
-10
@@ -54,10 +54,12 @@ The database tracks five primary entities and their relationships:
|
|||||||
|
|
||||||
#### 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, source_path (for restore path stripping), mtime
|
- Path, mtime
|
||||||
- Size, mode, ownership (uid, gid)
|
- Size, mode, ownership (uid, gid)
|
||||||
- Symlink target (if applicable)
|
- Symlink target (if applicable)
|
||||||
|
|
||||||
|
It also stores `source_path`, the source directory the scan found it under, made absolute and with symlinks resolved. Restore does not read it.
|
||||||
|
|
||||||
#### 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)
|
||||||
@@ -82,9 +84,11 @@ The final storage unit uploaded to S3. Contains many compressed and encrypted ch
|
|||||||
Blob creation process:
|
Blob creation process:
|
||||||
1. Chunks are accumulated (up to MaxBlobSize, typically 10GB)
|
1. Chunks are accumulated (up to MaxBlobSize, typically 10GB)
|
||||||
2. As each chunk is added, its uncompressed bytes are fed to a running SHA-256
|
2. As each chunk is added, its uncompressed bytes are fed to a running SHA-256
|
||||||
3. Concurrently, the same bytes are compressed with zstd, then encrypted with age (recipients configured in config), and streamed to storage
|
3. Concurrently, the same bytes are compressed with zstd, then encrypted with age (recipients configured in config), and written to a temporary file
|
||||||
4. On finalize, the blob's name is the double SHA-256 of the uncompressed contents — `hex(SHA256(SHA256(...)))` — not a hash of the compressed, encrypted bytes
|
4. On finalize, the blob's name is the double SHA-256 of the uncompressed contents — `hex(SHA256(SHA256(...)))` — not a hash of the compressed, encrypted bytes
|
||||||
5. Uploaded to `blobs/{hash[0:2]}/{hash[2:4]}/{hash}`
|
5. The finished file is uploaded to `blobs/{hash[0:2]}/{hash[2:4]}/{hash}` and then deleted
|
||||||
|
|
||||||
|
A backup needs free temporary space, because each blob is written whole to a temporary file before it is uploaded (up to about `blob_size_limit`; an rclone destination that cannot stream uploads needs about twice that) and the metadata export writes copies of the local index. Temporary files go to `$TMPDIR` (default `/tmp`); with `TMPDIR` unset, SQLite writes one of those copies to `/var/tmp`.
|
||||||
|
|
||||||
#### BlobChunk (`database.BlobChunk`)
|
#### BlobChunk (`database.BlobChunk`)
|
||||||
Maps chunks to their position within blobs:
|
Maps chunks to their position within blobs:
|
||||||
@@ -275,8 +279,8 @@ Key methods:
|
|||||||
- `Scan(ctx, path, snapshotID)` → Main scan operation
|
- `Scan(ctx, path, snapshotID)` → Main scan operation
|
||||||
|
|
||||||
Scan phases:
|
Scan phases:
|
||||||
1. **Phase 0**: Detect deleted files from previous snapshots
|
1. **Phase 1**: Walk directory, identify files needing processing
|
||||||
2. **Phase 1**: Walk directory, identify files needing processing
|
2. **Phase 1b**: Count as deleted the files that the previous snapshot of the same name lists, the walk did not find, and are gone from disk; a file that is excluded, or inside an excluded directory, is not counted
|
||||||
3. **Phase 2**: Process files (chunk → pack → upload)
|
3. **Phase 2**: Process files (chunk → pack → upload)
|
||||||
|
|
||||||
#### SnapshotManager
|
#### SnapshotManager
|
||||||
@@ -307,6 +311,8 @@ Repository interfaces:
|
|||||||
|
|
||||||
```
|
```
|
||||||
CreateSnapshot(opts)
|
CreateSnapshot(opts)
|
||||||
|
│
|
||||||
|
├─► markDestination() // Refuse a destination without the marker, then write it
|
||||||
│
|
│
|
||||||
├─► PruneDatabase() // Critical: avoid dedup errors
|
├─► PruneDatabase() // Critical: avoid dedup errors
|
||||||
│
|
│
|
||||||
@@ -316,13 +322,13 @@ CreateSnapshot(opts)
|
|||||||
│ │
|
│ │
|
||||||
│ ├─► scanner.Scan(ctx, path, snapshotID)
|
│ ├─► scanner.Scan(ctx, path, snapshotID)
|
||||||
│ │ │
|
│ │ │
|
||||||
│ │ ├─► Phase 0: detectDeletedFiles()
|
|
||||||
│ │ │
|
|
||||||
│ │ ├─► Phase 1: scanPhase()
|
│ │ ├─► Phase 1: scanPhase()
|
||||||
│ │ │ Walk directory
|
│ │ │ Walk directory
|
||||||
│ │ │ Check file metadata changes
|
│ │ │ Check file metadata changes
|
||||||
│ │ │ Build list of files to process
|
│ │ │ Build list of files to process
|
||||||
│ │ │
|
│ │ │
|
||||||
|
│ │ ├─► Phase 1b: detectDeletedFiles()
|
||||||
|
│ │ │
|
||||||
│ │ └─► Phase 2: processPhase()
|
│ │ └─► Phase 2: processPhase()
|
||||||
│ │ For each file:
|
│ │ For each file:
|
||||||
│ │ chunker.ChunkReaderStreaming()
|
│ │ chunker.ChunkReaderStreaming()
|
||||||
@@ -335,10 +341,10 @@ CreateSnapshot(opts)
|
|||||||
│ │
|
│ │
|
||||||
│ └─► Accumulate statistics
|
│ └─► Accumulate statistics
|
||||||
│
|
│
|
||||||
├─► SnapshotManager.UpdateSnapshotStatsExtended()
|
|
||||||
│
|
|
||||||
├─► SnapshotManager.PopulateSnapshotBlobs() // record referenced blobs
|
├─► SnapshotManager.PopulateSnapshotBlobs() // record referenced blobs
|
||||||
│
|
│
|
||||||
|
├─► SnapshotManager.UpdateSnapshotStatsExtended()
|
||||||
|
│
|
||||||
├─► SnapshotManager.ExportSnapshotMetadata()
|
├─► SnapshotManager.ExportSnapshotMetadata()
|
||||||
│ │
|
│ │
|
||||||
│ ├─► Copy database to temp file
|
│ ├─► Copy database to temp file
|
||||||
@@ -353,7 +359,7 @@ CreateSnapshot(opts)
|
|||||||
|
|
||||||
## Deduplication Strategy
|
## Deduplication Strategy
|
||||||
|
|
||||||
1. **File-level**: Files unchanged since last backup are skipped (metadata comparison: size, mtime, mode, uid, gid)
|
1. **File-level**: Files and symlinks unchanged since last backup are skipped (metadata comparison: size, mtime, mode, uid, gid, and a symlink's target), unless the file lists a chunk that no uploaded blob holds; such a file is re-chunked
|
||||||
|
|
||||||
2. **Chunk-level**: Chunks are content-addressed by SHA256 hash. If a chunk hash already exists in the database, the chunk data is not re-uploaded.
|
2. **Chunk-level**: Chunks are content-addressed by SHA256 hash. If a chunk hash already exists in the database, the chunk data is not re-uploaded.
|
||||||
|
|
||||||
@@ -363,6 +369,8 @@ CreateSnapshot(opts)
|
|||||||
|
|
||||||
```
|
```
|
||||||
bucket/
|
bucket/
|
||||||
|
├── vaultik-destination.txt # Marker written by every backup
|
||||||
|
│
|
||||||
├── blobs/
|
├── blobs/
|
||||||
│ └── {hash[0:2]}/
|
│ └── {hash[0:2]}/
|
||||||
│ └── {hash[2:4]}/
|
│ └── {hash[2:4]}/
|
||||||
@@ -374,6 +382,16 @@ bucket/
|
|||||||
└── manifest.json.zst # Blob list (for pruning/verification)
|
└── manifest.json.zst # Blob list (for pruning/verification)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
A destination without `vaultik-destination.txt` is treated as not present
|
||||||
|
while the local index holds snapshots stored there (`checkDestinationMarker`
|
||||||
|
in `internal/vaultik/destination_marker.go`): `snapshot create` refuses to
|
||||||
|
write to it, `prune`, `snapshot purge` and `remote nuke` fail, and
|
||||||
|
`snapshot list` and `snapshot remove` behave as for a destination that cannot
|
||||||
|
be listed. This keeps those commands from taking an unplugged volume's empty
|
||||||
|
mount point for a destination whose snapshots are all gone. The `file://`
|
||||||
|
backend never creates its destination directory, so a write to a missing one
|
||||||
|
fails.
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
# Rules
|
|
||||||
|
|
||||||
Read the rules in AGENTS.md and follow them.
|
|
||||||
|
|
||||||
# Memory
|
|
||||||
|
|
||||||
* Claude is an inanimate tool. The spam that Claude attempts to insert into
|
|
||||||
commit messages (which it erroneously refers to as "attribution") is not
|
|
||||||
attribution, as I am the sole author of code created using Claude. It is
|
|
||||||
corporate advertising for Anthropic and is therefore completely
|
|
||||||
unacceptable in commit messages.
|
|
||||||
|
|
||||||
* NEVER use `git add -A`. Always add only the files you intentionally
|
|
||||||
changed.
|
|
||||||
|
|
||||||
* Tests should always be run before committing code. No commits should be
|
|
||||||
made that do not pass tests.
|
|
||||||
|
|
||||||
* Code should always be formatted before committing. Do not commit
|
|
||||||
unformatted code.
|
|
||||||
|
|
||||||
* Code should always be linted before committing. Do not commit
|
|
||||||
unlinted code.
|
|
||||||
|
|
||||||
* The test suite is fast and local. When running tests, don't run
|
|
||||||
individual parts of the test suite, always run the whole thing by running
|
|
||||||
"make test".
|
|
||||||
|
|
||||||
* Do not stop working on a task until you have reached the definition of
|
|
||||||
done provided to you in the initial instruction. Don't do part or most of
|
|
||||||
the work, do all of the work until the criteria for done are met.
|
|
||||||
|
|
||||||
* We do not need to support migrations; schema upgrades can be handled by
|
|
||||||
deleting the local state file and doing a full backup to re-create it.
|
|
||||||
|
|
||||||
* When testing on a 2.5Gbit/s ethernet to an s3 server backed by 2000MB/sec SSD,
|
|
||||||
estimate about 4 seconds per gigabyte of backup time.
|
|
||||||
|
|
||||||
* When running tests, don't run individual tests, or grep the output. run
|
|
||||||
the entire test suite every time and read the full output.
|
|
||||||
|
|
||||||
* When running tests, don't run individual tests, or try to grep the output.
|
|
||||||
never run "go test". only ever run "make test" to run the full test
|
|
||||||
suite, and examine the full output.
|
|
||||||
+86
-85
@@ -1,96 +1,97 @@
|
|||||||
# This file has no lint stage, deliberately.
|
# Lint phase. The linter is invoked directly rather than through `make
|
||||||
#
|
# lint` or `script/lint`, which are themselves a docker build and would
|
||||||
# Linting lives in Dockerfile.lint, built by script/lint, and
|
# recurse into a daemon that does not exist in a build step.
|
||||||
# script/cibuild builds both. A lint stage here would have to either
|
# golangci/golangci-lint:v2.14.0, 2026-10-05
|
||||||
# shell out to `make lint` -- which is now `docker build`, so
|
FROM golangci/golangci-lint:v2.14.0@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f AS lint
|
||||||
# docker-in-docker inside a BuildKit step with no daemon -- or call
|
WORKDIR /src
|
||||||
# golangci-lint directly, which would mean a second, independently
|
COPY go.mod go.sum ./
|
||||||
# bumpable digest pin for the linter alongside the one in
|
# Without the time limit a stalled connection to the module proxy holds
|
||||||
# Dockerfile.lint. Two pins for one tool is the drift that
|
# this step, and every build on the host sharing it, until the kernel
|
||||||
# https://git.eeqj.de/sneak/vaultik/issues/78 was filed over. See
|
# drops the connection, which can take an hour. The limit is well above
|
||||||
# https://git.eeqj.de/sneak/vaultik/issues/113 for the ruling.
|
# the three minutes a working download can take on a busy build host.
|
||||||
#
|
RUN for attempt in 1 2 3; do \
|
||||||
# Consequence, stated rather than left to be discovered: script/docker
|
timeout 300 go mod download && exit 0; \
|
||||||
# builds this file only and therefore does not lint. `make fmt-check`
|
echo "go mod download attempt $attempt failed or ran over 300s" >&2; \
|
||||||
# and `make test` still run here, so what a green build of this file
|
done; \
|
||||||
# means is "formatted, tested, and it compiles" -- the lint verdict
|
echo "module download failed: giving up after 3 attempts" >&2; \
|
||||||
# comes from script/lint or script/cibuild.
|
exit 1
|
||||||
|
COPY . .
|
||||||
|
# `golangci-lint run` silently ignores an unknown top-level key in
|
||||||
|
# .golangci.yml, such as a misspelt `linters:`; `config verify` fails on it.
|
||||||
|
RUN golangci-lint config verify --config .golangci.yml
|
||||||
|
RUN golangci-lint run --config .golangci.yml ./...
|
||||||
|
|
||||||
# Build stage
|
# Test phase. -race needs cgo and so a C compiler, which the Debian Go
|
||||||
|
# image ships and the alpine one does not.
|
||||||
|
# golang:1.26.1 (Debian trixie), 2026-10-05
|
||||||
|
FROM golang:1.26.1@sha256:cd78d88e00afadbedd272f977d375a6247455f3a4b1178f8ae8bbcb201743a8a AS test
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
# Limited and retried as in the lint phase above.
|
||||||
|
RUN for attempt in 1 2 3; do \
|
||||||
|
timeout 300 go mod download && exit 0; \
|
||||||
|
echo "go mod download attempt $attempt failed or ran over 300s" >&2; \
|
||||||
|
done; \
|
||||||
|
echo "module download failed: giving up after 3 attempts" >&2; \
|
||||||
|
exit 1
|
||||||
|
COPY . .
|
||||||
|
RUN go test -timeout 90s -race -cover ./... || \
|
||||||
|
{ echo "--- Rerunning with -v for details ---"; \
|
||||||
|
go test -timeout 90s -race -v ./...; exit 1; }
|
||||||
|
|
||||||
|
# Build stage. Nothing is wanted from either phase above; the copies
|
||||||
|
# are what make BuildKit build them first, so this stage cannot run
|
||||||
|
# unless lint and test passed.
|
||||||
# golang:1.26.1-alpine, 2026-03-17
|
# golang:1.26.1-alpine, 2026-03-17
|
||||||
FROM golang:1.26.1-alpine@sha256:2389ebfa5b7f43eeafbd6be0c3700cc46690ef842ad962f6c5bd6be49ed82039 AS builder
|
FROM golang:1.26.1-alpine@sha256:2389ebfa5b7f43eeafbd6be0c3700cc46690ef842ad962f6c5bd6be49ed82039 AS builder
|
||||||
|
COPY --from=lint /src/go.sum /dev/null
|
||||||
# Build tooling: make, plus a C toolchain because `go test -race` needs cgo.
|
COPY --from=test /src/go.sum /dev/null
|
||||||
# The sqlite driver is pure Go (modernc.org/sqlite), so no sqlite library or
|
RUN apk add --no-cache git
|
||||||
# CLI is required.
|
# A tar-stream context keeps the sender's file owners, which git refuses.
|
||||||
RUN apk add --no-cache make build-base
|
RUN git config --system --add safe.directory /src
|
||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
# Copy go mod files first for better layer caching
|
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
# Limited and retried as in the lint phase above.
|
||||||
|
RUN for attempt in 1 2 3; do \
|
||||||
# Copy source code
|
timeout 300 go mod download && exit 0; \
|
||||||
|
echo "go mod download attempt $attempt failed or ran over 300s" >&2; \
|
||||||
|
done; \
|
||||||
|
echo "module download failed: giving up after 3 attempts" >&2; \
|
||||||
|
exit 1
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Run the format check and the tests.
|
# The VERSION build arg when one is given, otherwise
|
||||||
#
|
# `git describe --tags --always` on the .git in the build context. The
|
||||||
# CHECK_EPOCH must stay immediately above these RUNs. These layers are
|
# commit and its date always come from that .git. With .git present, a
|
||||||
# keyed on its value, so they are cache-eligible only for a value
|
# version that is still empty, dev or unknown, or a commit or date that
|
||||||
# already built against this same tree. script/cibuild and script/docker
|
# is unknown, fails the build: git is missing or could not read the
|
||||||
# each pass a fresh value on every invocation, which is what makes their
|
# checkout, as when .git is a file pointing outside the context. A
|
||||||
# green mean the checks really executed.
|
# context without .git, such as a source export, stamps "dev" and an
|
||||||
#
|
# "unknown" commit and date.
|
||||||
# The value is expanded into each check command rather than left to a
|
ARG VERSION
|
||||||
# bare declaration, so the cache miss does not depend on BuildKit's
|
RUN VERSION="${VERSION:-$(git describe --tags --always || echo dev)}"; \
|
||||||
# unreferenced-ARG handling staying as it is. It also puts the epoch in
|
commit="$(git rev-parse HEAD || echo unknown)"; \
|
||||||
# the build log, where a reader can see the layer was keyed fresh.
|
commit_date="$(git show -s --format=%cs HEAD || echo unknown)"; \
|
||||||
#
|
if [ -e .git ]; then \
|
||||||
# The guard is what makes a build that omits --build-arg fail instead of
|
case "$VERSION" in ""|dev|unknown) \
|
||||||
# lie. An unset ARG is an empty string, and an empty string is a
|
echo "version is '$VERSION' although .git is present" >&2; \
|
||||||
# perfectly stable cache key: without the guard the first such build
|
exit 1 ;; \
|
||||||
# runs the checks and every one after it on an unchanged tree replays
|
esac; \
|
||||||
# these layers from cache, executes nothing, and still exits 0. Failed
|
if [ "$commit" = unknown ] || [ "$commit_date" = unknown ]; then \
|
||||||
# steps are never cached, so the guard fails on EVERY invocation rather
|
echo "commit is '$commit' and its date '$commit_date'" \
|
||||||
# than once -- a bare `docker build .` is a loud error, not a quiet
|
"although .git is present" >&2; \
|
||||||
# green. Do not give CHECK_EPOCH a default value; a default would
|
exit 1; \
|
||||||
# satisfy the guard with a constant and restore the hole.
|
fi; \
|
||||||
#
|
fi; \
|
||||||
# Everything above this line (apk, go.mod, `go mod download`) is
|
globals=sneak.berlin/go/vaultik/internal/globals; \
|
||||||
# deliberately outside the busted range and keeps caching.
|
CGO_ENABLED=0 go build -trimpath \
|
||||||
ARG CHECK_EPOCH
|
-ldflags="-s -w -X ${globals}.Version=${VERSION} \
|
||||||
RUN [ -n "$CHECK_EPOCH" ] || exit 1
|
-X ${globals}.Commit=${commit} \
|
||||||
RUN echo "check epoch: ${CHECK_EPOCH}" && make fmt-check
|
-X ${globals}.CommitDate=${commit_date}" \
|
||||||
RUN echo "check epoch: ${CHECK_EPOCH}" && make test
|
-o /vaultik ./cmd/vaultik
|
||||||
|
|
||||||
# Version, commit and build date are computed on the host by
|
# Runtime stage, and the last one: a plain `docker build .` builds this
|
||||||
# script/docker and script/cibuild (where .git exists) and passed in as
|
# stage's chain and nothing else.
|
||||||
# build args. The build context excludes .git (see .dockerignore), so
|
|
||||||
# the build cannot derive them itself: it used to try, with `git
|
|
||||||
# rev-parse` inside this stage, and always got "unknown". VERSION comes
|
|
||||||
# from script/version, the source of truth shared with the Makefile, so
|
|
||||||
# it carries the same tag / dev-<sha> / -dirty rules and a Docker image
|
|
||||||
# reports the same string a local build of the same tree would.
|
|
||||||
#
|
|
||||||
# The defaults are the fallback for a bare `docker build .` that passes
|
|
||||||
# none of them: an unset arg would otherwise stamp an empty string and
|
|
||||||
# produce an image that cannot report its own version, commit or date.
|
|
||||||
# They match what an out-of-git build reports elsewhere.
|
|
||||||
#
|
|
||||||
# These ARGs sit here, after the checks, rather than at the top of the
|
|
||||||
# stage: every commit changes their values, and a value change
|
|
||||||
# invalidates all layers below the ARG. Declared up top they would bust
|
|
||||||
# `go mod download`; here they only rekey this build layer, which the
|
|
||||||
# COPY of the sources above already rebuilds on any change anyway.
|
|
||||||
ARG VERSION=dev
|
|
||||||
ARG COMMIT=unknown
|
|
||||||
ARG COMMIT_DATE=unknown
|
|
||||||
|
|
||||||
# Build (pure Go, no CGO required since we use modernc.org/sqlite)
|
|
||||||
RUN CGO_ENABLED=0 go build -ldflags "-X 'sneak.berlin/go/vaultik/internal/globals.Version=${VERSION}' -X 'sneak.berlin/go/vaultik/internal/globals.Commit=${COMMIT}' -X 'sneak.berlin/go/vaultik/internal/globals.CommitDate=${COMMIT_DATE}'" -o /vaultik ./cmd/vaultik
|
|
||||||
|
|
||||||
# Runtime stage
|
|
||||||
# alpine:3.21, 2026-02-25
|
# alpine:3.21, 2026-02-25
|
||||||
FROM alpine:3.21@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709
|
FROM alpine:3.21@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709
|
||||||
|
|
||||||
|
|||||||
-104
@@ -1,104 +0,0 @@
|
|||||||
# Lint image.
|
|
||||||
#
|
|
||||||
# Every lint run in this repo happens inside this image, invoked through
|
|
||||||
# script/lint, and linting is a BUILD STEP rather than a container
|
|
||||||
# command: a successful build of this file IS a clean lint. That shape
|
|
||||||
# also works where the docker daemon is remote and bind mounts are
|
|
||||||
# impossible, which `docker run` against a mounted worktree does not.
|
|
||||||
#
|
|
||||||
# This FROM line is the single source of truth for the linter version in
|
|
||||||
# this repo. Nothing else pins golangci-lint: the product Dockerfile has
|
|
||||||
# no lint stage, deliberately, so there is no second digest to bump and
|
|
||||||
# no pair of pins that can drift apart. Bump the tag AND the digest here
|
|
||||||
# and nowhere else.
|
|
||||||
#
|
|
||||||
# Note for readers coming from REPO_POLICIES.md: that document still
|
|
||||||
# describes the older pattern, a lint stage inside the product
|
|
||||||
# Dockerfile wired up with `COPY --from=lint /src/go.sum /dev/null`.
|
|
||||||
# That pattern is superseded here by the owner's ruling recorded in
|
|
||||||
# https://git.eeqj.de/sneak/vaultik/issues/113 -- lint runs in its own
|
|
||||||
# image, per run, with its own cache and its own lock, which is what
|
|
||||||
# makes concurrent runs on one host safe. The policy text is org-wide
|
|
||||||
# and is being amended separately; this file is what this repo does.
|
|
||||||
#
|
|
||||||
# golangci/golangci-lint:v2.12.2, 2026-08-10
|
|
||||||
FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240
|
|
||||||
|
|
||||||
WORKDIR /src
|
|
||||||
|
|
||||||
# Copy the dependency manifests first so the module download layer stays
|
|
||||||
# cached until they change. Everything above the ARG below is cacheable
|
|
||||||
# on purpose; a cold module download on every lint would make the inner
|
|
||||||
# loop unusable and buys nothing, because it is not what the gate is
|
|
||||||
# asserting.
|
|
||||||
COPY go.mod go.sum ./
|
|
||||||
RUN go mod download
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Force the check layers to execute on every invocation.
|
|
||||||
#
|
|
||||||
# CHECK_EPOCH must stay immediately above the RUNs below. Those layers
|
|
||||||
# are keyed on its value, so they are cache-eligible only for a value
|
|
||||||
# already built against this same tree; script/lint and script/cibuild
|
|
||||||
# each pass a fresh value on every invocation, which is what makes their
|
|
||||||
# green mean the linter really ran. Without it, `docker build -f
|
|
||||||
# Dockerfile.lint .` on an unchanged tree exits 0 in well under a second
|
|
||||||
# having linted nothing.
|
|
||||||
#
|
|
||||||
# The value is expanded into each check command itself rather than left
|
|
||||||
# to a bare declaration, so the cache miss does not depend on BuildKit's
|
|
||||||
# unreferenced-ARG handling staying as it is. It also puts the epoch in
|
|
||||||
# the build log, where a reader can see the layer was keyed fresh.
|
|
||||||
#
|
|
||||||
# The guard is what makes a build that omits --build-arg fail instead of
|
|
||||||
# lie. An unset ARG is an empty string, and an empty string is a
|
|
||||||
# perfectly stable cache key: without the guard the first such build
|
|
||||||
# lints and every one after it on an unchanged tree replays this layer,
|
|
||||||
# executes nothing, and still exits 0. Failed steps are never cached, so
|
|
||||||
# the guard fails on EVERY invocation rather than once. Do not give
|
|
||||||
# CHECK_EPOCH a default value; a default would satisfy the guard with a
|
|
||||||
# constant and restore the hole.
|
|
||||||
ARG CHECK_EPOCH
|
|
||||||
RUN [ -n "$CHECK_EPOCH" ] || exit 1
|
|
||||||
|
|
||||||
# Validate .golangci.yml before linting with it.
|
|
||||||
#
|
|
||||||
# This is not belt-and-braces; it closes a hole that `golangci-lint run`
|
|
||||||
# leaves wide open. `run` rejects YAML it cannot PARSE, but it silently
|
|
||||||
# IGNORES an unknown top-level KEY. Renaming `linters:` to `linterz:` --
|
|
||||||
# one character -- discards `default: all`, the whole disable list and
|
|
||||||
# every threshold, leaves only golangci-lint's small default linter set
|
|
||||||
# running, and exits 0 reporting `0 issues.` on a tree the real config
|
|
||||||
# fails. Demonstrated on this repo at this pin, recorded on
|
|
||||||
# https://git.eeqj.de/sneak/vaultik/pulls/114: with a planted
|
|
||||||
# over-length line, `script/lint` exits 1 naming the `revive` finding
|
|
||||||
# with `linters:` and exits 0 with `linterz:`. A set-but-ineffective
|
|
||||||
# config quietly falling back to defaults is precisely the false-green
|
|
||||||
# class this gate exists to eliminate, so it must not sit in the gate's
|
|
||||||
# own configuration.
|
|
||||||
#
|
|
||||||
# `config verify` catches it, and it does so OFFLINE at this pinned
|
|
||||||
# version -- verified, not assumed. Under `docker run --network none`
|
|
||||||
# against the pinned digest it exits 0 on this repo's config and exits 3
|
|
||||||
# on the `linterz:` variant with `additional properties 'linterz' not
|
|
||||||
# allowed`. An earlier revision of this file asserted the opposite, that
|
|
||||||
# the schema is fetched over live HTTPS from an unpinned URL, and used
|
|
||||||
# that to justify omitting this line. That claim was false at v2.12.2;
|
|
||||||
# the schema is embedded. If a future bump reintroduces a network fetch
|
|
||||||
# the failure is loud and this comment is where to record it.
|
|
||||||
#
|
|
||||||
# It is keyed on CHECK_EPOCH, like the lint run below, so it executes on
|
|
||||||
# every invocation. Content-addressing alone would arguably be enough --
|
|
||||||
# .golangci.yml arrives through `COPY . .`, so a cache hit here implies
|
|
||||||
# a byte-identical config was validated when the layer really ran. That
|
|
||||||
# argument is exactly the one that would also excuse caching the lint
|
|
||||||
# layer, and this repo has ruled it insufficient: a cached check layer
|
|
||||||
# checks nothing, and the cost of being wrong is silent. Forcing it costs
|
|
||||||
# milliseconds and puts the epoch in the log, where a reader can see that
|
|
||||||
# this validation ran rather than being replayed.
|
|
||||||
RUN echo "check epoch: ${CHECK_EPOCH}" && \
|
|
||||||
golangci-lint config verify --config .golangci.yml
|
|
||||||
|
|
||||||
RUN echo "check epoch: ${CHECK_EPOCH}" && \
|
|
||||||
golangci-lint run --config .golangci.yml ./...
|
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
.PHONY: all bootstrap setup check test lint lint-fix fmt fmt-check build clean deps test-coverage local install release release-snapshot docker hooks
|
.PHONY: all bootstrap setup check test lint lint-fix fmt fmt-check build clean deps test-coverage local install release release-snapshot docker hooks
|
||||||
|
|
||||||
# Version number, derived from git by script/version -- the tag when
|
# Where script/bootstrap installs Go when the host has none.
|
||||||
# HEAD is on one, otherwise dev-<sha>. This used to be a hardcoded
|
export PATH := $(PATH):$(CURDIR)/.tool/go/bin
|
||||||
|
|
||||||
|
# Version number, derived from git by script/version (`git describe
|
||||||
|
# --tags --always --dirty`). This used to be a hardcoded
|
||||||
# constant, which meant every local build claimed to be a release that
|
# constant, which meant every local build claimed to be a release that
|
||||||
# had never been tagged.
|
# had never been tagged.
|
||||||
VERSION := $(shell script/version)
|
VERSION := $(shell script/version)
|
||||||
@@ -40,9 +43,10 @@ setup:
|
|||||||
check:
|
check:
|
||||||
@script/check
|
@script/check
|
||||||
|
|
||||||
# Run tests only. This runs the ENTIRE suite -- there is no separate
|
# Run tests only, by building the test phase of the Dockerfile. This
|
||||||
# integration target and no build-tagged subset held back. In
|
# runs the ENTIRE suite -- there is no separate integration target and
|
||||||
# particular internal/vaultik/integration_test.go, which does full
|
# no build-tagged subset held back. In particular
|
||||||
|
# internal/vaultik/integration_test.go, which does full
|
||||||
# chunk -> pack -> encrypt -> upload -> restore round-trips, runs here.
|
# chunk -> pack -> encrypt -> upload -> restore round-trips, runs here.
|
||||||
# A `test-integration` target used to exist and was removed: no file in
|
# A `test-integration` target used to exist and was removed: no file in
|
||||||
# the repo carried a build tag, so `-tags=integration` selected nothing
|
# the repo carried a build tag, so `-tags=integration` selected nothing
|
||||||
@@ -87,17 +91,17 @@ clean:
|
|||||||
go clean
|
go clean
|
||||||
|
|
||||||
# Install dependencies. The linter is deliberately not installed here:
|
# Install dependencies. The linter is deliberately not installed here:
|
||||||
# script/lint lints by building Dockerfile.lint, whose FROM line is the
|
# script/lint lints by building the lint phase of the Dockerfile, whose
|
||||||
# single source of truth for the linter version. A second, separately
|
# FROM line is the single source of truth for the linter version. A
|
||||||
# pinned copy on PATH could drift from it and make a local `make lint`
|
# second, separately pinned copy on PATH could drift from it and make a
|
||||||
# disagree with CI.
|
# local `make lint` disagree with CI.
|
||||||
deps:
|
deps:
|
||||||
go mod download
|
go mod download
|
||||||
|
|
||||||
# Run tests with coverage. -count=1 for the same reason script/test
|
# Run tests with coverage, on the host. -count=1 because without it an
|
||||||
# uses it: without it an unchanged package is served from Go's test
|
# unchanged package is served from Go's test result cache, and a
|
||||||
# result cache, and a coverage profile assembled from cached results
|
# coverage profile assembled from cached results describes a run that
|
||||||
# describes a run that did not happen.
|
# did not happen.
|
||||||
test-coverage:
|
test-coverage:
|
||||||
go test -v -count=1 -coverprofile=coverage.out ./...
|
go test -v -count=1 -coverprofile=coverage.out ./...
|
||||||
go tool cover -html=coverage.out -o coverage.html
|
go tool cover -html=coverage.out -o coverage.html
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ grep 'public key' vaultik_backup_private_key.txt
|
|||||||
vaultik config set age_recipients.0 age1YOUR_PUBLIC_KEY_HERE
|
vaultik config set age_recipients.0 age1YOUR_PUBLIC_KEY_HERE
|
||||||
vaultik config set storage_url "file:///Volumes/usbstick/mybackup"
|
vaultik config set storage_url "file:///Volumes/usbstick/mybackup"
|
||||||
|
|
||||||
|
# create the destination directory; vaultik never creates it, so a backup
|
||||||
|
# to a volume that is not plugged in fails instead of filling the disk
|
||||||
|
mkdir /Volumes/usbstick/mybackup
|
||||||
|
|
||||||
# macOS only: grant your terminal app Full Disk Access first
|
# macOS only: grant your terminal app Full Disk Access first
|
||||||
# (System Settings → Privacy & Security → Full Disk Access), otherwise
|
# (System Settings → Privacy & Security → Full Disk Access), otherwise
|
||||||
# the backup will abort with a permission error on protected directories
|
# the backup will abort with a permission error on protected directories
|
||||||
@@ -40,7 +44,8 @@ Features:
|
|||||||
|
|
||||||
* modern encryption ([age](https://age-encryption.org/), X25519 + ChaCha20-Poly1305)
|
* modern encryption ([age](https://age-encryption.org/), X25519 + ChaCha20-Poly1305)
|
||||||
* content-defined chunking with deduplication (FastCDC)
|
* content-defined chunking with deduplication (FastCDC)
|
||||||
* incremental backups (only changed files are re-chunked)
|
* incremental backups (a file is re-chunked only when it changed or a
|
||||||
|
chunk it lists is held by no uploaded blob)
|
||||||
* multithreaded zstd compression at configurable levels
|
* multithreaded zstd compression at configurable levels
|
||||||
* content-addressed immutable storage
|
* content-addressed immutable storage
|
||||||
* local state tracking in SQLite (enables write-only incremental backups)
|
* local state tracking in SQLite (enables write-only incremental backups)
|
||||||
@@ -91,6 +96,14 @@ vaultik snapshot restore <snapshot-id> ~/vaultik-restore
|
|||||||
# 0 3 * * * vaultik snapshot create --cron --prune --keep-newer-than 4w
|
# 0 3 * * * vaultik snapshot create --cron --prune --keep-newer-than 4w
|
||||||
```
|
```
|
||||||
|
|
||||||
|
On a destination shared between hosts or users, the prune in that cron line
|
||||||
|
relies on `prune_grace_period` (default `24h`) to leave the blobs of a backup
|
||||||
|
another one is still running, so it must be longer than the longest backup
|
||||||
|
run. It does not protect a blob that backup reuses from an earlier run that
|
||||||
|
stopped before its manifest: the prune can delete such a blob, and the new
|
||||||
|
snapshot then cannot be restored. To not depend on the grace period, give each
|
||||||
|
host its own prefix or directory. See [locking](#locking).
|
||||||
|
|
||||||
## restoring on another machine
|
## restoring on another machine
|
||||||
|
|
||||||
Restoring on a host that never ran the backup — a replacement machine
|
Restoring on a host that never ran the backup — a replacement machine
|
||||||
@@ -177,7 +190,7 @@ vaultik version
|
|||||||
* `--verbose`, `-v`: Enable verbose output (on stderr — see below)
|
* `--verbose`, `-v`: Enable verbose output (on stderr — see below)
|
||||||
* `--debug`: Enable debug output (on stderr — see below)
|
* `--debug`: Enable debug output (on stderr — see below)
|
||||||
* `--quiet`, `-q`: Suppress non-error output (also suppresses startup banner)
|
* `--quiet`, `-q`: Suppress non-error output (also suppresses startup banner)
|
||||||
* `--skip-errors`: Skip files that cannot be read when creating a snapshot, or that cannot be restored when restoring, instead of aborting. Packing and storage errors (which would leave a chunk recorded but not stored) still abort the run.
|
* `--skip-errors`: Skip files that cannot be read when creating a snapshot, or that cannot be restored when restoring, instead of aborting. Packing and storage errors while creating a snapshot (which would leave a chunk recorded but not stored) still abort the run. A run that skipped anything still finishes but exits non-zero: `snapshot create` keeps the snapshot and counts each file or directory it skipped as failed. A file removed during the backup is also counted as failed but does not change the exit status.
|
||||||
|
|
||||||
### locking
|
### locking
|
||||||
|
|
||||||
@@ -187,7 +200,22 @@ process-wide lock at `$XDG_DATA_HOME/vaultik/vaultik.pid`
|
|||||||
(`~/.local/share/vaultik/vaultik.pid` on Linux) for the whole run. Only
|
(`~/.local/share/vaultik/vaultik.pid` on Linux) for the whole run. Only
|
||||||
one of them runs at a time: a second one exits immediately with an
|
one of them runs at a time: a second one exits immediately with an
|
||||||
"already running" error rather than waiting, so two writers can never
|
"already running" error rather than waiting, so two writers can never
|
||||||
corrupt the local index or the destination store.
|
corrupt the local index.
|
||||||
|
|
||||||
|
The lock covers one user on one machine. It does not stop another user,
|
||||||
|
or another host, from writing to the same destination store at the same
|
||||||
|
time. A backup uploads its blobs before the manifest that lists them, so a
|
||||||
|
prune from elsewhere at that moment finds blobs no snapshot lists. It
|
||||||
|
deletes such a blob only once it is older than `prune_grace_period`
|
||||||
|
(default `24h`), which therefore must be longer than the longest backup run
|
||||||
|
of any host or user sharing the destination. The grace period does not
|
||||||
|
protect a blob a backup reuses from an earlier run that stopped before its
|
||||||
|
manifest: a backup skips the upload of a blob already on the destination, so
|
||||||
|
that blob keeps the time of its first upload, and a prune from elsewhere
|
||||||
|
during the backup can delete it; the new snapshot then cannot be restored.
|
||||||
|
`remote nuke` deletes everything regardless. To not depend on the grace
|
||||||
|
period, give each host or user its own prefix (or directory) on the
|
||||||
|
destination.
|
||||||
|
|
||||||
Read-only commands — `info`, `snapshot list`, `snapshot verify`, and
|
Read-only commands — `info`, `snapshot list`, `snapshot verify`, and
|
||||||
`remote info` — do not take the lock and are never blocked, so they run
|
`remote info` — do not take the lock and are never blocked, so they run
|
||||||
@@ -199,8 +227,10 @@ local index or the destination store. `config`, `database delete`,
|
|||||||
### stdout and stderr
|
### stdout and stderr
|
||||||
|
|
||||||
Log output — everything from `--verbose` and `--debug`, and every
|
Log output — everything from `--verbose` and `--debug`, and every
|
||||||
warning and error the logger emits — goes to **stderr**. stdout carries
|
warning and error the logger emits — goes to **stderr**, and so does the
|
||||||
the output you asked for: tables, and the documents produced by `--json`.
|
startup banner. stdout carries the output you asked for: tables, the
|
||||||
|
documents produced by `--json`, `config get` values, and completion
|
||||||
|
scripts.
|
||||||
|
|
||||||
This means `vaultik snapshot list --verbose > out.txt` captures the
|
This means `vaultik snapshot list --verbose > out.txt` captures the
|
||||||
listing and leaves the diagnostics on your terminal. To capture both,
|
listing and leaves the diagnostics on your terminal. To capture both,
|
||||||
@@ -268,8 +298,17 @@ existing file. Created with mode `0600` since it will contain credentials.
|
|||||||
**`config set`**: Set a scalar config value by dotted YAML path
|
**`config set`**: Set a scalar config value by dotted YAML path
|
||||||
(e.g. `vaultik config set compression_level 9`,
|
(e.g. `vaultik config set compression_level 9`,
|
||||||
`vaultik config set storage_url "file:///mnt/backups"`). Comments and
|
`vaultik config set storage_url "file:///mnt/backups"`). Comments and
|
||||||
formatting in the file are preserved; intermediate maps are created as
|
formatting in the file are preserved. Missing keys along the path are
|
||||||
needed.
|
created: as a list when the next path element is a number (so
|
||||||
|
`vaultik config set age_recipients.0 age1...` works on a file without
|
||||||
|
`age_recipients`), otherwise as a map. If the config loads, a change
|
||||||
|
after which it would not, such as `vaultik config set compression_level 99`,
|
||||||
|
is refused and the file is left unchanged. A config that does not load,
|
||||||
|
such as the one `config init` writes before `storage_url` is set or one
|
||||||
|
with a `${ENV:...}` reference to a variable not set in this shell, is
|
||||||
|
checked one setting at a time: only a change after which the changed
|
||||||
|
setting cannot be read, such as one giving it the wrong type (a single
|
||||||
|
value where a list belongs or a word where a number belongs), is refused.
|
||||||
|
|
||||||
**`snapshot create`**: Perform incremental backup of configured snapshots.
|
**`snapshot create`**: Perform incremental backup of configured snapshots.
|
||||||
* Optional snapshot names argument to create specific snapshots (default: all)
|
* Optional snapshot names argument to create specific snapshots (default: all)
|
||||||
@@ -280,16 +319,28 @@ needed.
|
|||||||
* `--cron`: Silent on total success; warnings and errors are still printed
|
* `--cron`: Silent on total success; warnings and errors are still printed
|
||||||
(for crontab)
|
(for crontab)
|
||||||
* `--prune`: After backup, drop older snapshots of each backed-up name and
|
* `--prune`: After backup, drop older snapshots of each backed-up name and
|
||||||
remove orphaned blobs from remote storage. By default keeps only the latest
|
remove orphaned blobs older than `prune_grace_period` from remote storage,
|
||||||
snapshot per name; use `--keep-newer-than` for a rolling window.
|
as `prune` does. By default keeps only the latest
|
||||||
|
snapshot per name; use `--keep-newer-than` for a rolling window. When
|
||||||
|
`--skip-errors` skipped a file or directory it could not read or list, the
|
||||||
|
prune does not run and every older snapshot is kept, since one of them may
|
||||||
|
be the only one that holds what was skipped; the command says so and exits
|
||||||
|
non-zero. A file removed during the backup does not stop the prune.
|
||||||
* `--keep-newer-than <duration>`: With `--prune`, keep snapshots newer than
|
* `--keep-newer-than <duration>`: With `--prune`, keep snapshots newer than
|
||||||
this duration instead of only the latest (e.g. `4w`, `30d`, `6mo`, `1y`)
|
this duration instead of only the latest (e.g. `4w`, `30d`, `6mo`, `1y`).
|
||||||
|
Without `--prune`, or with a value that is not a valid duration, the
|
||||||
|
command fails before the backup starts.
|
||||||
|
* Fails before it writes anything when a `file://` destination directory
|
||||||
|
does not exist, or when the local index holds snapshots and the
|
||||||
|
destination has no `vaultik-destination.txt` (see
|
||||||
|
[remote storage layout](#remote-storage-layout))
|
||||||
|
|
||||||
**`snapshot list`**: Show every snapshot known to this host — the union
|
**`snapshot list`**: Show every snapshot known to this host — the union
|
||||||
of the local index and the backup destination store — with timestamps
|
of the local index and the backup destination store — with timestamps
|
||||||
and three sizes per snapshot (compressed remote size; total
|
and three sizes per snapshot (compressed remote size; total
|
||||||
uncompressed chunk size; size of chunks newly referenced by that
|
uncompressed chunk size; size of the chunks that snapshot stored that
|
||||||
snapshot).
|
were not stored before). Both chunk sizes are recorded when the
|
||||||
|
snapshot's backup runs.
|
||||||
|
|
||||||
Listing the destination store does **not** require the age secret key,
|
Listing the destination store does **not** require the age secret key,
|
||||||
so it works in vaultik's intended configuration, where the backed-up
|
so it works in vaultik's intended configuration, where the backed-up
|
||||||
@@ -302,24 +353,33 @@ only the hash, so hostname and snapshot name exist solely in the local
|
|||||||
index and in the encrypted per-snapshot database. Snapshots found only
|
index and in the encrypted per-snapshot database. Snapshots found only
|
||||||
on the destination store are therefore listed as
|
on the destination store are therefore listed as
|
||||||
`<remote only:<abbreviated remote key>>` and show `<remote only>` in
|
`<remote only:<abbreviated remote key>>` and show `<remote only>` in
|
||||||
the uncompressed and "new chunk" columns, which can only be computed
|
the uncompressed and "new chunk" columns, which come only from the
|
||||||
from the local index. Their timestamp and compressed size are real,
|
local index. Their timestamp and compressed size are real,
|
||||||
read from the manifest.
|
read from the manifest.
|
||||||
|
|
||||||
Snapshots in the local index with no counterpart on the destination
|
Snapshots in the local index with no counterpart on the destination
|
||||||
store are reported below the table as drift, with the `vaultik prune`
|
store are reported below the table as drift, with the `vaultik prune`
|
||||||
invocation that reconciles them.
|
invocation that reconciles them. If the local index is bound to a
|
||||||
|
different destination than the configured one (after a change to
|
||||||
|
`storage_url`, or `s3.endpoint`, `s3.bucket` or `s3.prefix`), the
|
||||||
|
command says so instead and gives the same two ways out that `prune`
|
||||||
|
gives: revert the configuration, or run `vaultik database delete`.
|
||||||
|
|
||||||
If the destination store cannot be listed (unmounted volume,
|
If the destination store cannot be listed (unmounted volume,
|
||||||
permission denied, network down), the command warns, falls back to the
|
permission denied, network down), the command warns, falls back to the
|
||||||
local index alone, and still exits zero.
|
local index alone, and still exits zero. It does the same when the
|
||||||
|
destination has no `vaultik-destination.txt` while the local index
|
||||||
|
holds snapshots stored there, as when a volume mounted at the
|
||||||
|
destination is unplugged and leaves its empty mount point behind.
|
||||||
* `--json`: Output in JSON format. Each entry carries `locally_tracked`
|
* `--json`: Output in JSON format. Each entry carries `locally_tracked`
|
||||||
(whether the snapshot is in the local index), `remote_key` (the full
|
(whether the snapshot is in the local index), `remote_key` (the full
|
||||||
64-character storage key), and `remote_present` (whether it was seen
|
64-character storage key), and `remote_present` (whether it was seen
|
||||||
on the destination store, or `null` if the destination could not be
|
on the destination store, or `null` if the destination could not be
|
||||||
listed). Warnings about an unlistable destination, unreadable
|
listed). Warnings about an unlistable destination, a local index
|
||||||
manifests, and a truncated listing all go to stderr through the
|
bound to a different destination (with both destinations and the
|
||||||
logger, so stdout stays a single parseable document.
|
same two ways out), unreadable manifests, and a truncated listing all
|
||||||
|
go to stderr through the logger, so stdout stays a single parseable
|
||||||
|
document.
|
||||||
|
|
||||||
**`snapshot verify`**: Verify snapshot integrity.
|
**`snapshot verify`**: Verify snapshot integrity.
|
||||||
* Default (shallow): checks that every blob the manifest lists is present in
|
* Default (shallow): checks that every blob the manifest lists is present in
|
||||||
@@ -345,17 +405,26 @@ snapshot from the local index and strips the snapshot's metadata from
|
|||||||
the backup destination store. Blobs are NOT touched — deleting blobs
|
the backup destination store. Blobs are NOT touched — deleting blobs
|
||||||
requires reading every remaining remote manifest (the destination store
|
requires reading every remaining remote manifest (the destination store
|
||||||
may hold snapshots this host doesn't know about), which is what
|
may hold snapshots this host doesn't know about), which is what
|
||||||
`vaultik prune` does. On success the command prints the exact `vaultik
|
`vaultik prune` does. The command prints the `vaultik prune` invocation
|
||||||
prune` invocation to run as a follow-up. Local row cleanup (files,
|
to run as a follow-up only when it removed the snapshot's metadata from
|
||||||
chunks, blobs the snapshot was the last referrer for) runs
|
the destination store, and never under `--json`. An ID that names no
|
||||||
automatically. If the destination store is unreachable, the local-DB
|
snapshot in the local index or on the destination store is an error;
|
||||||
removal still completes and a warning is emitted; rerun `vaultik prune`
|
with `--local-only`, the snapshot must be in the local index. Local row
|
||||||
once the store is reachable to finish remote cleanup. To wipe everything
|
cleanup (files, chunks, blobs the snapshot was the last referrer for)
|
||||||
on the destination in one go, use `vaultik remote nuke --force`.
|
runs automatically. If removing the snapshot's metadata from the
|
||||||
|
destination store fails (for example because the store is unreachable,
|
||||||
|
or the local index holds snapshots and the destination has no
|
||||||
|
`vaultik-destination.txt`), the local-DB removal still completes and a
|
||||||
|
warning is emitted; run `vaultik snapshot remove <snapshot-id>` again
|
||||||
|
once the store is reachable to remove the snapshot's metadata from it
|
||||||
|
(`vaultik prune` does not). For a snapshot that is only on the
|
||||||
|
destination store, that failure is an error. To wipe everything on the
|
||||||
|
destination in one go, use `vaultik remote nuke --force`.
|
||||||
* `--local-only`: Skip remote cleanup; only touch the local index
|
* `--local-only`: Skip remote cleanup; only touch the local index
|
||||||
* `--dry-run`: Show what would be deleted without deleting
|
* `--dry-run`: Show what would be deleted without deleting
|
||||||
* `--force`: Skip confirmation prompt
|
* `--force`: Skip confirmation prompt
|
||||||
* `--json`: Output result as JSON
|
* `--json`: Output result as JSON. Also skips the confirmation prompt, as
|
||||||
|
`--force` does.
|
||||||
|
|
||||||
**`snapshot restore`**: Restore files from a backup snapshot.
|
**`snapshot restore`**: Restore files from a backup snapshot.
|
||||||
* Requires `VAULTIK_AGE_SECRET_KEY` environment variable
|
* Requires `VAULTIK_AGE_SECRET_KEY` environment variable
|
||||||
@@ -363,10 +432,14 @@ on the destination in one go, use `vaultik remote nuke --force`.
|
|||||||
store — its remote key (or an unambiguous leading part of it) as shown
|
store — its remote key (or an unambiguous leading part of it) as shown
|
||||||
by `snapshot list`. See
|
by `snapshot list`. See
|
||||||
[restoring on another machine](#restoring-on-another-machine).
|
[restoring on another machine](#restoring-on-another-machine).
|
||||||
* Optional path arguments to restore specific files/directories (default: all)
|
* Optional path arguments to restore specific files/directories (default: all).
|
||||||
|
A path that matches nothing in the snapshot is named in a warning, and the
|
||||||
|
command exits non-zero after restoring the paths that match.
|
||||||
* Preserves file permissions, timestamps, ownership (ownership requires root),
|
* Preserves file permissions, timestamps, ownership (ownership requires root),
|
||||||
symlinks, and empty directories
|
symlinks, and empty directories
|
||||||
* `--verify`: After restoring, verify every file's chunk hashes match
|
* `--verify`: After restoring, verify every restored file's chunk hashes
|
||||||
|
match. A file skipped under `--skip-errors` is reported as failed to
|
||||||
|
restore and is not verified.
|
||||||
|
|
||||||
**`prune`**: Tidy up everything that isn't needed. Runs three passes:
|
**`prune`**: Tidy up everything that isn't needed. Runs three passes:
|
||||||
(1) reconcile the local index against the destination store — any
|
(1) reconcile the local index against the destination store — any
|
||||||
@@ -374,32 +447,66 @@ local snapshot whose remote metadata is missing is dropped from the
|
|||||||
local index; (2) delete orphaned local rows (files, chunks, blobs no
|
local index; (2) delete orphaned local rows (files, chunks, blobs no
|
||||||
longer referenced by any completed snapshot); (3) list every remote
|
longer referenced by any completed snapshot); (3) list every remote
|
||||||
manifest on the destination store to compute the still-referenced blob
|
manifest on the destination store to compute the still-referenced blob
|
||||||
set and delete any blob not in that set. Step (3) reads all remote
|
set and delete any blob not in that set that is older than
|
||||||
manifests — network cost scales with the number of snapshots. `snapshot
|
`prune_grace_period` (default `24h`). A newer one is kept and counted: it
|
||||||
create --prune` runs the same cleanup automatically; this is the
|
may belong to a backup still running, on this host or another sharing the
|
||||||
manual entry point for the same work.
|
destination store, that has not yet uploaded its manifest. The grace period
|
||||||
|
does not protect a blob such a backup reuses from an earlier run that stopped
|
||||||
|
before its manifest: that blob keeps the time of its first upload, so it can
|
||||||
|
be deleted. Step (3) reads all remote manifests — network cost scales with
|
||||||
|
the number of snapshots.
|
||||||
|
`snapshot create --prune` runs the same cleanup automatically; this is the
|
||||||
|
manual entry point for the same work. If the destination store cannot
|
||||||
|
be listed, or the local index holds snapshots and the destination has
|
||||||
|
no `vaultik-destination.txt` (an unplugged volume's empty mount point),
|
||||||
|
prune fails and deletes nothing. `snapshot purge` does the same. If a
|
||||||
|
blob cannot be deleted, the command (and `snapshot create --prune`)
|
||||||
|
exits non-zero.
|
||||||
* `--force`: Skip confirmation prompt
|
* `--force`: Skip confirmation prompt
|
||||||
* `--json`: Output stats as JSON
|
* `--json`: Output stats as JSON. Also skips the confirmation prompt, as
|
||||||
|
`--force` does. A blob that cannot be deleted is counted in
|
||||||
|
`blobs_failed`, and the document is printed before the non-zero exit.
|
||||||
|
A blob kept for being newer than `prune_grace_period`, or because the
|
||||||
|
listing of the destination gives no time for it, is counted in
|
||||||
|
`blobs_too_new` and not in `blobs_found`.
|
||||||
|
|
||||||
**`info`**: Display system configuration, storage settings, encryption
|
**`info`**: Display system configuration, storage settings, encryption
|
||||||
recipients, and local database statistics.
|
recipients, and local database statistics.
|
||||||
|
|
||||||
**`remote info`**: Show storage backend type and location plus detailed
|
**`remote info`**: Show storage backend type and location plus detailed
|
||||||
remote storage inventory: per-snapshot metadata sizes, blob counts, and
|
remote storage inventory: per-snapshot metadata sizes, blob counts, and
|
||||||
orphaned blob detection.
|
orphaned blob detection. A name under `metadata/` that is not a remote
|
||||||
|
key is skipped with a warning and is not printed. If a listed
|
||||||
|
`manifest.json.zst` cannot be read, or sits under a skipped name, the
|
||||||
|
orphaned blob figures are reported as unknown; `--json` gives them as
|
||||||
|
`null`, lists the remote key of each unreadable manifest in
|
||||||
|
`unreadable_manifests` and counts the manifests under skipped names in
|
||||||
|
`skipped_manifest_count`. An unreadable manifest also leaves its
|
||||||
|
snapshot's blob count and blob size unknown, `null` in `--json`. A file
|
||||||
|
under `blobs/` whose name is not a blob hash is not counted as a blob,
|
||||||
|
since `prune` never deletes it.
|
||||||
* `--json`: Output as JSON
|
* `--json`: Output as JSON
|
||||||
|
|
||||||
**`remote nuke`**: Delete every snapshot's metadata and every blob from the
|
**`remote nuke`**: Delete every object under `metadata/` and `blobs/` on
|
||||||
backup destination store, leaving the bucket prefix empty. Destructive and
|
the backup destination store, whatever its name, and then
|
||||||
irreversible. This is the single supported way to wipe the entire
|
`vaultik-destination.txt`, leaving the bucket prefix empty. Destructive
|
||||||
destination store.
|
and irreversible. This is the single supported way to wipe the entire
|
||||||
|
destination store. A snapshot is removed from the local index only once
|
||||||
|
its metadata is deleted from the destination store. If the destination
|
||||||
|
store cannot be listed, or the local index holds snapshots and the
|
||||||
|
destination has no `vaultik-destination.txt` (an unplugged volume's
|
||||||
|
empty mount point), the command fails before it removes anything. If a
|
||||||
|
listing afterwards still finds anything under `metadata/` or `blobs/`,
|
||||||
|
the command names each object left and exits non-zero; running it again
|
||||||
|
deletes what is left.
|
||||||
* `--force`: Required to confirm destruction.
|
* `--force`: Required to confirm destruction.
|
||||||
|
|
||||||
**`database delete`**: Delete the local SQLite state database file
|
**`database delete`**: Delete the local SQLite state database file
|
||||||
entirely. Remote storage is unaffected; the next backup will do a full
|
entirely. Remote storage is unaffected; the next backup will do a full
|
||||||
scan and re-deduplicate against existing remote blobs, and the local
|
scan and re-deduplicate against existing remote blobs, and the local
|
||||||
index will re-bind to the currently configured storage destination.
|
index will re-bind to the currently configured storage destination.
|
||||||
Use this after changing `storage_url` to a different destination.
|
Use this after changing `storage_url`, or `s3.endpoint`, `s3.bucket` or
|
||||||
|
`s3.prefix`, to a different destination.
|
||||||
* `--force`: Skip confirmation prompt
|
* `--force`: Skip confirmation prompt
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -413,11 +520,37 @@ object store. Credentials are read from `s3.access_key_id` and
|
|||||||
`s3.secret_access_key` in the config file.
|
`s3.secret_access_key` in the config file.
|
||||||
|
|
||||||
**Local filesystem** (`file:///path/to/backup`): Stores blobs and metadata on
|
**Local filesystem** (`file:///path/to/backup`): Stores blobs and metadata on
|
||||||
a local or mounted filesystem. Useful for testing or backing up to a NAS.
|
a local or mounted filesystem. Useful for testing or backing up to a NAS. The
|
||||||
|
path must be absolute; a relative one is refused when the config loads.
|
||||||
|
Percent-escapes in the path are decoded, so `my%20stick` and `my stick` name
|
||||||
|
the same directory. The directory must exist before the first backup: vaultik
|
||||||
|
never creates it. A directory on a removable volume is gone while the volume is
|
||||||
|
not mounted, so a backup then fails instead of writing to the disk under the
|
||||||
|
mount point. The mount point itself stays behind when the volume is unplugged:
|
||||||
|
there only `vaultik-destination.txt` stops such a backup, and only while the
|
||||||
|
local index holds snapshots.
|
||||||
|
|
||||||
**Rclone** (`rclone://remote/path`): Uses rclone's 70+ supported cloud
|
**Rclone** (`rclone://remote/path`): Uses rclone's 70+ supported cloud
|
||||||
providers. Requires rclone to be configured separately (`rclone config`).
|
providers. Requires rclone to be configured separately (`rclone config`).
|
||||||
|
|
||||||
|
An upload cut off part-way leaves nothing under the object's name on S3, which
|
||||||
|
shows an object only once its upload has completed, and on the local filesystem
|
||||||
|
backend, which writes a temporary file and renames it into place. Rclone
|
||||||
|
remotes with a server-side move (local and sftp among them) are written under a
|
||||||
|
temporary name ending in `.partial` and moved into place. Rclone remotes without
|
||||||
|
one are written in place: where such a remote shows a file while it is still
|
||||||
|
being written, a killed upload can leave a truncated object under its name,
|
||||||
|
which a later backup takes for complete. A leftover `.partial` file is ignored
|
||||||
|
and can be deleted.
|
||||||
|
|
||||||
|
A destination shared between hosts or users, each with its own local index,
|
||||||
|
relies on `prune_grace_period` to keep one's prune from deleting the blobs of
|
||||||
|
another's backup still running, so it must be longer than the longest backup
|
||||||
|
run. It does not protect a blob that backup reuses from an earlier run that
|
||||||
|
stopped before its manifest, which the prune can delete. To not depend on the
|
||||||
|
grace period, give each host its own prefix (or directory). See
|
||||||
|
[locking](#locking).
|
||||||
|
|
||||||
Legacy S3 configuration via `s3.*` fields (endpoint, bucket, prefix, etc.) is
|
Legacy S3 configuration via `s3.*` fields (endpoint, bucket, prefix, etc.) is
|
||||||
still supported for backward compatibility. `storage_url` takes precedence if
|
still supported for backward compatibility. `storage_url` takes precedence if
|
||||||
both are set.
|
both are set.
|
||||||
@@ -434,6 +567,7 @@ For an implementation-level view of the internals — the data model, the
|
|||||||
|
|
||||||
```
|
```
|
||||||
<bucket>/<prefix>/
|
<bucket>/<prefix>/
|
||||||
|
├── vaultik-destination.txt # Marker written by every backup
|
||||||
├── blobs/
|
├── blobs/
|
||||||
│ └── <aa>/<bb>/<full_blob_hash>
|
│ └── <aa>/<bb>/<full_blob_hash>
|
||||||
└── metadata/
|
└── metadata/
|
||||||
@@ -442,6 +576,16 @@ For an implementation-level view of the internals — the data model, the
|
|||||||
└── manifest.json.zst # Unencrypted blob list (for pruning)
|
└── manifest.json.zst # Unencrypted blob list (for pruning)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
* `vaultik-destination.txt` holds one fixed line of text, with no host
|
||||||
|
name, path or time. A destination without it is treated as not
|
||||||
|
present while the local index holds snapshots stored there:
|
||||||
|
`snapshot create` refuses to write to it, `prune`, `snapshot purge`
|
||||||
|
and `remote nuke` fail, and `snapshot list` and `snapshot remove`
|
||||||
|
behave as for a destination that cannot be listed. This keeps an
|
||||||
|
unplugged volume's empty mount point from being taken for a
|
||||||
|
destination whose snapshots are all gone. Mount
|
||||||
|
the volume if it is removable; if the destination was emptied on
|
||||||
|
purpose, run `vaultik database delete` and back up again.
|
||||||
* Blobs are two-level directory sharded using the first 4 hex chars of the blob hash
|
* Blobs are two-level directory sharded using the first 4 hex chars of the blob hash
|
||||||
* `db.zst.age` is a binary SQLite database (zstd compressed, age encrypted)
|
* `db.zst.age` is a binary SQLite database (zstd compressed, age encrypted)
|
||||||
containing all file metadata, chunk mappings, and relationships for the snapshot
|
containing all file metadata, chunk mappings, and relationships for the snapshot
|
||||||
@@ -493,14 +637,16 @@ format does and does not protect.
|
|||||||
1. List all snapshot manifests
|
1. List all snapshot manifests
|
||||||
2. Build set of all referenced blob hashes
|
2. Build set of all referenced blob hashes
|
||||||
3. List all blobs in storage
|
3. List all blobs in storage
|
||||||
4. Delete any blob not in the referenced set
|
4. Delete any blob not in the referenced set that is older than
|
||||||
|
`prune_grace_period`
|
||||||
|
|
||||||
### chunking and deduplication
|
### chunking and deduplication
|
||||||
|
|
||||||
* Content-defined chunking using the FastCDC algorithm
|
* Content-defined chunking using the FastCDC algorithm
|
||||||
* Average chunk size: configurable (default 10MB)
|
* Average chunk size: configurable (default 10MB)
|
||||||
* Deduplication at file level (unchanged files skipped) and chunk level
|
* Deduplication at file level (unchanged files skipped, unless a chunk
|
||||||
(identical chunks across files stored once)
|
the file lists is held by no uploaded blob) and chunk level (identical
|
||||||
|
chunks across files stored once)
|
||||||
* Multiple chunks packed into blobs to reduce object count
|
* Multiple chunks packed into blobs to reduce object count
|
||||||
|
|
||||||
### encryption
|
### encryption
|
||||||
@@ -528,15 +674,16 @@ complete annotated example also lives in
|
|||||||
|
|
||||||
| Field | Default | Description |
|
| Field | Default | Description |
|
||||||
|-------|---------|-------------|
|
|-------|---------|-------------|
|
||||||
| `age_recipients` | (required) | Age public keys for encryption |
|
| `age_recipients` | (required by `snapshot create`) | Age public keys for encryption. Other commands run without one, so a machine that only restores can leave it empty |
|
||||||
| `age_secret_key` | (unset) | Age private key for decryption (`snapshot restore`, `snapshot verify --deep`). Setting it in the config file places the private key on the backed-up host, defeating the public-key-only design (see "why" above). Prefer the `VAULTIK_AGE_SECRET_KEY` environment variable, supplied only on the machine you restore from. |
|
| `age_secret_key` | (unset) | Age private key for decryption (`snapshot restore`, `snapshot verify --deep`). Setting it in the config file places the private key on the backed-up host, defeating the public-key-only design (see "why" above). Prefer the `VAULTIK_AGE_SECRET_KEY` environment variable, supplied only on the machine you restore from. |
|
||||||
| `snapshots` | (required) | Named snapshot definitions with paths and excludes |
|
| `snapshots` | (required) | Named snapshot definitions with paths and excludes |
|
||||||
| `storage_url` | | Storage backend URL (`s3://`, `file://`, `rclone://`) |
|
| `storage_url` | | Storage backend URL (`s3://`, `file://`, `rclone://`) |
|
||||||
| `s3.*` | | Legacy S3 configuration (endpoint, bucket, credentials) |
|
| `s3.*` | | Legacy S3 configuration (endpoint, bucket, credentials) |
|
||||||
| `exclude` | | Global exclude patterns (applied to all snapshots) |
|
| `exclude` | | Global exclude patterns (applied to all snapshots) |
|
||||||
| `chunk_size` | `10MB` | Average chunk size for content-defined chunking |
|
| `chunk_size` | `10MB` | Average chunk size for content-defined chunking |
|
||||||
| `blob_size_limit` | `10GB` | Maximum blob size before splitting. Must be at least four times `chunk_size` (the largest chunk the chunker can emit), otherwise a single-chunk blob could exceed the limit |
|
| `blob_size_limit` | `10GB` | Maximum blob size before splitting. Must be at least four times `chunk_size` (the largest chunk the chunker can emit), otherwise a single-chunk blob could exceed the limit. A backup needs free temporary space, because each blob is written whole to a temporary file before it is uploaded (up to about `blob_size_limit`; an rclone destination that cannot stream uploads needs about twice that) and the metadata export writes copies of the local index. Temporary files go to `$TMPDIR` (default `/tmp`); with `TMPDIR` unset, SQLite writes one of those copies to `/var/tmp` |
|
||||||
| `compression_level` | `3` | zstd compression level (1-19) |
|
| `compression_level` | `3` | zstd compression level (1-19) |
|
||||||
|
| `prune_grace_period` | `24h` | How old a blob that no snapshot lists must be before `prune` deletes it. A backup uploads its blobs before the manifest that lists them, so on a destination shared between hosts or users this must be longer than the longest backup run (see [locking](#locking)). It does not protect a blob a backup reuses from an earlier run that stopped before its manifest: that blob keeps the time of its first upload, so a prune can delete it. A duration such as `36h` or `90m`; `0s` deletes such blobs at once |
|
||||||
| `hostname` | system hostname | Hostname used in snapshot IDs |
|
| `hostname` | system hostname | Hostname used in snapshot IDs |
|
||||||
| `index_path` | platform data dir | Local SQLite index path |
|
| `index_path` | platform data dir | Local SQLite index path |
|
||||||
|
|
||||||
@@ -559,13 +706,13 @@ complete annotated example also lives in
|
|||||||
sequentially. Restore speed is bound by single-stream throughput.
|
sequentially. Restore speed is bound by single-stream throughput.
|
||||||
* **Device nodes, named pipes, and sockets are silently skipped.** Only
|
* **Device nodes, named pipes, and sockets are silently skipped.** Only
|
||||||
regular files, directories, and symlinks are backed up.
|
regular files, directories, and symlinks are backed up.
|
||||||
* **No upgrade path between versions.** There is no supported way to carry
|
* **Before 1.0, an update can make the local index unusable.** Vaultik
|
||||||
an existing local index across a schema change; if the local SQLite
|
supports schema migrations, but none are added before 1.0 because
|
||||||
schema changes between versions, delete the local database (`vaultik
|
there is no installed base yet. If an update leaves your local index
|
||||||
database delete`) and run a full backup. Remote storage is unaffected.
|
unusable, run `vaultik database delete` and then a full backup; remote
|
||||||
(The binary does embed numbered schema files and a `schema_migrations`
|
storage is unaffected. After 1.0, the local index is migrated when
|
||||||
table to bootstrap a fresh database — see [`docs/DATAMODEL.md`](docs/DATAMODEL.md)
|
vaultik is updated. See
|
||||||
— but that is not an upgrade path.)
|
[`docs/DATAMODEL.md`](docs/DATAMODEL.md#schema-migrations).
|
||||||
* **Files that change during backup may be inconsistent.** There is no
|
* **Files that change during backup may be inconsistent.** There is no
|
||||||
filesystem snapshot or freeze. If a file is modified between the scan
|
filesystem snapshot or freeze. If a file is modified between the scan
|
||||||
and chunk phases, the backed-up copy may reflect a partial write.
|
and chunk phases, the backed-up copy may reflect a partial write.
|
||||||
@@ -577,22 +724,18 @@ complete annotated example also lives in
|
|||||||
|
|
||||||
## roadmap
|
## roadmap
|
||||||
|
|
||||||
Items still to do before / shortly after 1.0. Loosely ordered by
|
Work planned after 1.0. Loosely ordered by priority.
|
||||||
priority.
|
|
||||||
|
|
||||||
### correctness and operability
|
### correctness and operability
|
||||||
|
|
||||||
* **Security audit of the encryption implementation.** Pre-1.0
|
* **Outside security audit.** Before 1.0 the encryption and
|
||||||
blocker if we're advertising "secure" at the top of this README.
|
blob-generation code was reviewed: every bug the review found was
|
||||||
age + zstd + content-defined chunking is mostly off-the-shelf
|
fixed, and the risks it accepted are listed in
|
||||||
pieces, but the seams (key handling, recipient parsing, manifest
|
[Accepted Risks](docs/REPOSTRUCTURE.md#accepted-risks). No outside
|
||||||
trust boundary, restore-time identity validation) need an outside
|
audit has been done. age + zstd + content-defined chunking
|
||||||
read.
|
is mostly off-the-shelf pieces, but the seams (key handling,
|
||||||
* **Error-condition tests.** Today's coverage is the happy path
|
recipient parsing, manifest trust boundary, restore-time identity
|
||||||
plus a few specific regressions. Need fault-injection coverage:
|
validation) need an outside read.
|
||||||
network failures mid-blob, disk-full during restore, corrupted /
|
|
||||||
truncated / missing blobs, partial uploads, kill -9 between
|
|
||||||
manifest and db.zst.age writes.
|
|
||||||
* **Verify restored content end-to-end in CI.** The current
|
* **Verify restored content end-to-end in CI.** The current
|
||||||
integration test does this for a small synthetic snapshot but
|
integration test does this for a small synthetic snapshot but
|
||||||
not at scale. A nightly job against a multi-GB representative
|
not at scale. A nightly job against a multi-GB representative
|
||||||
@@ -615,13 +758,15 @@ priority.
|
|||||||
doesn't resume from where it stopped or skip already-present
|
doesn't resume from where it stopped or skip already-present
|
||||||
files. A `--resume` mode that checks targets before fetching
|
files. A `--resume` mode that checks targets before fetching
|
||||||
blobs would matter for very large restores.
|
blobs would matter for very large restores.
|
||||||
|
* **Daemon mode.** A long-running mode that watches for file
|
||||||
|
changes so frequent backups, such as hourly, skip the full scan.
|
||||||
|
It adds little for the usual runs from cron every 12 to 36 hours.
|
||||||
|
See [issue #204](https://git.eeqj.de/sneak/vaultik/issues/204).
|
||||||
|
|
||||||
### usability
|
### usability
|
||||||
|
|
||||||
* **Man pages and richer `--help` examples.** Cobra generates
|
* **Man pages and richer `--help` examples.** Cobra generates
|
||||||
basic help; man pages would be a separate target.
|
basic help; man pages would be a separate target.
|
||||||
* **`--bwlimit` style human-readable size flags** across the
|
|
||||||
command surface where they're currently raw integers.
|
|
||||||
* **`vaultik snapshot diff <a> <b>`** — show which files changed
|
* **`vaultik snapshot diff <a> <b>`** — show which files changed
|
||||||
between two snapshots without restoring either.
|
between two snapshots without restoring either.
|
||||||
* **Status reporting hook for `--cron`.** When a backup fails
|
* **Status reporting hook for `--cron`.** When a backup fails
|
||||||
@@ -631,12 +776,11 @@ priority.
|
|||||||
|
|
||||||
### infrastructure
|
### infrastructure
|
||||||
|
|
||||||
* **Cross-version schema upgrades.** There is no upgrade path between
|
* **Schema migrations after 1.0.** Migrations are supported, but none
|
||||||
released versions — pre-1.0 schema changes are handled by `vaultik
|
are added before 1.0 because there is no installed base yet. After
|
||||||
database delete` plus a full re-scan (see
|
1.0, each schema change is a new migration, so an existing local
|
||||||
[`docs/DATAMODEL.md`](docs/DATAMODEL.md)). Post-1.0 we'll need a
|
index is migrated when vaultik is updated (see
|
||||||
migration story to keep existing index databases usable across
|
[`docs/DATAMODEL.md`](docs/DATAMODEL.md#schema-migrations)).
|
||||||
upgrades.
|
|
||||||
* **Storage backend coverage tests.** S3, file://, and rclone://
|
* **Storage backend coverage tests.** S3, file://, and rclone://
|
||||||
all share the Storer interface but the rclone path is the least
|
all share the Storer interface but the rclone path is the least
|
||||||
exercised in CI.
|
exercised in CI.
|
||||||
@@ -646,8 +790,8 @@ priority.
|
|||||||
## output style
|
## output style
|
||||||
|
|
||||||
Every command's user-facing output is governed by `internal/ui`, in one
|
Every command's user-facing output is governed by `internal/ui`, in one
|
||||||
of two ways. Color is enabled when stdout is a TTY and the `NO_COLOR`
|
of two ways. Color is enabled when the stream written to is a TTY and
|
||||||
environment variable is unset (https://no-color.org/).
|
the `NO_COLOR` environment variable is unset (https://no-color.org/).
|
||||||
|
|
||||||
* **Status, progress, warnings, and errors** go through the `internal/ui`
|
* **Status, progress, warnings, and errors** go through the `internal/ui`
|
||||||
message methods below: marker-prefixed, colored on a TTY, and — except
|
message methods below: marker-prefixed, colored on a TTY, and — except
|
||||||
@@ -657,24 +801,28 @@ environment variable is unset (https://no-color.org/).
|
|||||||
`config init`, `config set`, and `database delete`.
|
`config init`, `config set`, and `database delete`.
|
||||||
* **The data a command exists to produce** is written plain, with no
|
* **The data a command exists to produce** is written plain, with no
|
||||||
marker and no color, because a marker would corrupt a table or a
|
marker and no color, because a marker would corrupt a table or a
|
||||||
parsed document. This covers the `version`, `info`, and `remote info`
|
parsed document. This covers the `version`, `info`, `remote info` and
|
||||||
reports, the `snapshot list` table, `config get` values, and every
|
`snapshot verify` reports, the `snapshot list` table, `config get`
|
||||||
`--json` document. `--quiet` silences the human reports and tables
|
values, and every `--json` document. `--quiet` silences the human
|
||||||
(`version`, `info`, `remote info`, `snapshot list`) but never the
|
reports and tables (`version`, `info`, `remote info`, `snapshot
|
||||||
machine-consumed `config get` value or the `--json` documents, which a
|
verify`, `snapshot list`) but never the machine-consumed `config get`
|
||||||
script depends on. The `database delete` confirmation prompt is also
|
value or the `--json` documents, which a script depends on. The
|
||||||
written this way and always shown: it is an interactive exchange the
|
confirmation prompts of `database delete`, `prune`, `snapshot purge`
|
||||||
operator must see.
|
and `snapshot remove`, each with what it asks to delete, are also
|
||||||
|
written this way and always shown: they are an interactive exchange
|
||||||
|
the operator must see.
|
||||||
|
|
||||||
`internal/ui` writes to stdout; it is the output the user asked for.
|
`internal/ui` writes to stdout; it is the output the user asked for. The
|
||||||
Structured log records are a different thing and go through
|
exceptions are the startup banner and the error a failed command ends
|
||||||
`internal/log`, which writes to stderr (see "stdout and stderr" above).
|
with, which go to stderr. Structured log records are a different thing
|
||||||
|
and go through `internal/log`, which writes to stderr (see "stdout and
|
||||||
|
stderr" above).
|
||||||
|
|
||||||
Message classes:
|
Message classes:
|
||||||
|
|
||||||
| Class | Marker | Alignment | Use for |
|
| Class | Marker | Alignment | Use for |
|
||||||
|-------|--------|-----------|---------|
|
|-------|--------|-----------|---------|
|
||||||
| Banner | none | column 0 | The startup line printed once per invocation |
|
| Banner | none | column 0 | The startup line printed once per invocation, on stderr |
|
||||||
| Begin | `》` (white) | column 0 | An operation is about to start (present-continuous verb) |
|
| Begin | `》` (white) | column 0 | An operation is about to start (present-continuous verb) |
|
||||||
| Complete | `》` (green) | column 0 | An operation just finished (past-tense verb) |
|
| Complete | `》` (green) | column 0 | An operation just finished (past-tense verb) |
|
||||||
| Info | `》` (white) | column 0 | Neutral status update |
|
| Info | `》` (white) | column 0 | Neutral status update |
|
||||||
@@ -729,12 +877,11 @@ regardless of color setting (emoji are not color).
|
|||||||
## requirements
|
## requirements
|
||||||
|
|
||||||
* Go 1.26 or later
|
* Go 1.26 or later
|
||||||
* Docker, with a reachable daemon, to lint, check, or commit:
|
* Docker, with a reachable daemon, to test, lint, check, or commit:
|
||||||
`script/lint` lints by building `Dockerfile.lint`, which runs the
|
`script/test` and `script/lint` build the `test` and `lint` phases of
|
||||||
digest-pinned `golangci-lint` image as a build step, and `make check`
|
the `Dockerfile`, and `make check` and the pre-commit hook both run
|
||||||
and the pre-commit hook both run it. A `golangci-lint` installed on
|
them. A `golangci-lint` installed on `PATH` is not a substitute and is
|
||||||
`PATH` is not a substitute and is never used on a host, whatever its
|
never used on a host, whatever its version.
|
||||||
version.
|
|
||||||
* S3-compatible object storage (or local filesystem, or rclone remote)
|
* S3-compatible object storage (or local filesystem, or rclone remote)
|
||||||
|
|
||||||
## development workflow
|
## development workflow
|
||||||
@@ -765,16 +912,20 @@ standard: normalized scripts in `script/` are the entrypoints for the
|
|||||||
development workflow, and the Makefile targets are thin shims that call
|
development workflow, and the Makefile targets are thin shims that call
|
||||||
them. We provide:
|
them. We provide:
|
||||||
|
|
||||||
* `script/bootstrap` — install all development dependencies (go, Go
|
* `script/bootstrap` — install all development dependencies (Go, Go
|
||||||
module download). It deliberately does not install `golangci-lint`;
|
module download). A host without Go gets the `go.mod` version through
|
||||||
see `script/lint` below.
|
`script/install-go`, in `.tool/go`, which `script/bootstrap` itself,
|
||||||
|
the `Makefile`, `script/fmt`, `script/fmt-check`, `script/precommit`
|
||||||
|
and `script/release` add to their `PATH`. It
|
||||||
|
deliberately does not install `golangci-lint`; see `script/lint`
|
||||||
|
below.
|
||||||
* `script/setup` — make a fresh clone ready for development: runs
|
* `script/setup` — make a fresh clone ready for development: runs
|
||||||
`script/bootstrap`, then `script/install-precommit`
|
`script/bootstrap`, then `script/install-precommit`
|
||||||
* `script/projectname` — print the project name (used for the Docker
|
* `script/projectname` — print the project name (used for the Docker
|
||||||
image tag)
|
image tag)
|
||||||
* `script/version` — print the version string to bake into the binary.
|
* `script/version` — print the version string to bake into the binary.
|
||||||
The `Makefile`'s `LDFLAGS` call this; it is the single source of truth
|
The `Makefile`'s `LDFLAGS` call this. See [releasing](#releasing) for
|
||||||
for the version. See [releasing](#releasing) for the rules.
|
the rules.
|
||||||
* `script/install-goreleaser` — install the pinned `goreleaser` into
|
* `script/install-goreleaser` — install the pinned `goreleaser` into
|
||||||
`.tool/bin` from a sha256-verified release archive. Idempotent, and
|
`.tool/bin` from a sha256-verified release archive. Idempotent, and
|
||||||
called by `script/bootstrap`; the release workflow calls it directly
|
called by `script/bootstrap`; the release workflow calls it directly
|
||||||
@@ -782,100 +933,60 @@ them. We provide:
|
|||||||
`script/bootstrap` insists on.
|
`script/bootstrap` insists on.
|
||||||
* `script/install-go` — install the Go toolchain named by `go.mod`'s
|
* `script/install-go` — install the Go toolchain named by `go.mod`'s
|
||||||
`go` directive into `.tool/go` from a sha256-verified `go.dev`
|
`go` directive into `.tool/go` from a sha256-verified `go.dev`
|
||||||
archive, and put it on `PATH`. Idempotent. Called only by the release
|
archive, for Linux or macOS on amd64 or arm64. Idempotent. On a CI
|
||||||
workflow, which needs a host Go for `goreleaser` to shell out to;
|
runner it also puts `.tool/go/bin` on `PATH` for the steps that
|
||||||
nothing else on the release runner does. `actions/setup-go` is not
|
follow. Called by the release workflow, which needs a host Go for
|
||||||
used because it verifies the downloaded toolchain against no value in
|
`goreleaser` to shell out to, and by `script/bootstrap` on a host
|
||||||
this repo. Bumping Go edits `go.mod`, the checksum in this script, and
|
without Go. `actions/setup-go` is not used because it verifies the
|
||||||
the `Dockerfile` `golang` digest together.
|
downloaded toolchain against no value in this repo. Bumping Go edits
|
||||||
|
`go.mod`, the checksums in this script, and the two `golang` digests
|
||||||
|
in the `Dockerfile` together.
|
||||||
* `script/release` — cross-compile and publish the release artifacts
|
* `script/release` — cross-compile and publish the release artifacts
|
||||||
with the pinned `goreleaser`. Refuses a `goreleaser` on `PATH` whose
|
with the pinned `goreleaser`. Refuses a `goreleaser` on `PATH` whose
|
||||||
version is not the pinned one, on the same reasoning as `script/lint`.
|
version is not the pinned one, because a different version would build
|
||||||
|
a different release from the same tag.
|
||||||
* `script/release-snapshot` — the same build with no publishing and no
|
* `script/release-snapshot` — the same build with no publishing and no
|
||||||
tagging, into `./dist`
|
tagging, into `./dist`
|
||||||
* `script/test` — run the test suite (verbose rerun on failure). This
|
* `script/test` — run the test suite by building the `test` phase of
|
||||||
runs *everything*: there is no separate integration target and no
|
the `Dockerfile` (verbose rerun on failure). This runs *everything*:
|
||||||
build-tagged subset held back, so the full round-trip tests in
|
there is no separate integration target and no build-tagged subset
|
||||||
`internal/vaultik/integration_test.go` run on every invocation. It
|
held back, so the full round-trip tests in
|
||||||
passes `-count=1`, which disables Go's test result cache. That is
|
`internal/vaultik/integration_test.go` run on every invocation. The
|
||||||
deliberate and it is not free: on this repo's suite it costs about 11
|
90s `-timeout` is a hang backstop rather than a performance budget; it
|
||||||
seconds on every repeat run (measured, back to back: 0.4s cached
|
applies per test binary, to test execution only.
|
||||||
versus 11.6s with `-count=1`). That is the price of the run meaning
|
* `script/lint` — lint by building the `lint` phase of the
|
||||||
anything, because without it an unchanged package prints
|
`Dockerfile`, which runs `golangci-lint config verify` and then
|
||||||
`ok <pkg> (cached)`, which is indistinguishable from a package that
|
`golangci-lint run --config .golangci.yml ./...` as build steps in
|
||||||
really ran, so the whole suite can report a full set of `ok` lines in
|
the digest-pinned `golangci-lint` image. Nothing lints on the host.
|
||||||
under half a second having executed nothing. The `-timeout` is a hang
|
That `FROM` line is the only pin of the linter version, and it changes
|
||||||
backstop rather than a performance budget — it applies per test binary
|
in the same commit as a re-vendored `.golangci.yml`.
|
||||||
to test execution only, not to compilation — and is set well above the
|
|
||||||
slowest package's measured runtime. Its 120s value deliberately
|
|
||||||
diverges from the 30s `REPO_POLICIES.md` mandates; the reasoning is in
|
|
||||||
the comment in the script, and issue #101 proposes amending the policy
|
|
||||||
text.
|
|
||||||
* `script/lint` — lint by building `Dockerfile.lint`, which runs
|
|
||||||
`golangci-lint run --config .golangci.yml ./...` as a build step
|
|
||||||
inside the digest-pinned `golangci-lint` image, so a successful build
|
|
||||||
*is* a clean lint. Nothing lints on the host, at any version, ever;
|
|
||||||
the script requires Docker and fails loudly rather than falling back
|
|
||||||
to a `golangci-lint` on `PATH`. That `FROM` line is the single source
|
|
||||||
of truth for the linter version — bump it there and nowhere else.
|
|
||||||
|
|
||||||
It takes no arguments, because a build step has no command line to
|
|
||||||
pass flags to, and it passes a fresh `--build-arg CHECK_EPOCH` on
|
|
||||||
every invocation so the lint layer cannot be replayed from cache (see
|
|
||||||
`script/cibuild` below for what that mechanism defends against). To
|
|
||||||
watch the linter execute, run it as
|
|
||||||
`BUILDKIT_PROGRESS=plain script/lint` and check that the lint layer
|
|
||||||
says `RUN … golangci-lint` rather than `CACHED`.
|
|
||||||
|
|
||||||
One container per run means one lint cache and one `golangci-lint`
|
|
||||||
lock per run, both private to it and discarded with it, so concurrent
|
|
||||||
runs on one host cannot contaminate or block each other.
|
|
||||||
* `script/lint-fix` — apply the linter's autofixes (rewrites files),
|
* `script/lint-fix` — apply the linter's autofixes (rewrites files),
|
||||||
using the same pinned image, parsed out of `Dockerfile.lint`. It
|
using the same pinned image, parsed out of the `lint` phase's `FROM`
|
||||||
cannot be a build step, because fixes have to land in the worktree, so
|
line. It cannot be a build step, because fixes have to land in the
|
||||||
it bind-mounts the tree into a `docker run` and therefore needs a
|
worktree, so it bind-mounts the tree into a `docker run` and therefore
|
||||||
*local* daemon. It is a developer convenience and never a gate: no
|
needs a *local* daemon. It is a developer convenience and never a
|
||||||
gate reads its exit status. Run `make lint` afterwards to find out
|
gate: no gate reads its exit status. Run `make lint` afterwards to
|
||||||
whether the tree is clean.
|
find out whether the tree is clean.
|
||||||
* `script/fmt` — format all code (writes)
|
* `script/fmt` — format all code (writes)
|
||||||
* `script/fmt-check` — check formatting (read-only)
|
* `script/fmt-check` — check formatting (read-only). It runs `gofmt` on
|
||||||
|
the host, over every Go file outside `.tool`.
|
||||||
* `script/check` — run `script/test`, `script/lint`, and
|
* `script/check` — run `script/test`, `script/lint`, and
|
||||||
`script/fmt-check`. This is authoritative *because* `script/lint`
|
`script/fmt-check`.
|
||||||
builds `Dockerfile.lint`: a local `make check` and CI cannot disagree
|
|
||||||
about lint findings.
|
|
||||||
* `script/docker` — build the Docker image tagged via
|
* `script/docker` — build the Docker image tagged via
|
||||||
`script/projectname`. Passes a fresh `--build-arg CHECK_EPOCH` for the
|
`script/projectname`, stamped with the version
|
||||||
same reason `script/cibuild` does, so a local image build cannot be
|
`git describe --tags --always --dirty` gives on the host, or `unknown`
|
||||||
green on checks it replayed from cache. It builds the *product* image
|
outside a git checkout. The image's build stage depends on the `lint`
|
||||||
only, and the product `Dockerfile` has no lint stage, so it does not
|
and `test` phases, so this lints and tests too.
|
||||||
lint: a green here means formatted, tested, and it compiles.
|
* `script/cibuild` — CI entrypoint: runs `script/bootstrap`,
|
||||||
* `script/cibuild` — CI entrypoint, and the full gate. Two builds, in
|
`script/check`, and then the same image build as `script/docker`.
|
||||||
order: `Dockerfile.lint` (the linter, as a build step) and then
|
`.gitea/workflows/check.yml` runs it on every push.
|
||||||
`Dockerfile` (`make fmt-check` and `make test` in its builder stage,
|
|
||||||
then the product image). Either failing fails the script. It runs the
|
|
||||||
checks in the same containers CI does, from a clean copy of the tree,
|
|
||||||
so it also catches anything that depends on host state.
|
|
||||||
`.gitea/workflows/check.yml` runs it on every push to `main` and
|
|
||||||
`next` and on every pull request against either.
|
|
||||||
|
|
||||||
It passes a fresh `--build-arg CHECK_EPOCH` to each build, unique per
|
Every `docker build` in these scripts passes `--no-cache`, because on
|
||||||
invocation, which both files declare immediately above their check
|
an unchanged tree a cached check layer is replayed without running and
|
||||||
`RUN`s and expand into each check command. Those layers are keyed on
|
the build still exits 0. A plain `docker build .` is therefore no
|
||||||
that value, so a new value re-runs them even on a byte-identical tree,
|
evidence that the checks ran. The cost is that `script/cibuild` runs
|
||||||
and a green from this script means the checks executed. Dependency and
|
the `lint` and `test` phases twice: once in `script/check` and again
|
||||||
module layers sit above the `ARG` and still cache, so a build is not
|
in the image build.
|
||||||
cold.
|
|
||||||
|
|
||||||
A build that supplies no `CHECK_EPOCH` — a bare `docker build .` or
|
|
||||||
`docker build -f Dockerfile.lint .` — fails rather than lying. An
|
|
||||||
unset `ARG` is an empty string and an empty string is a stable cache
|
|
||||||
key, so without a guard such a build would serve every check layer
|
|
||||||
from cache, execute nothing, and still exit 0. Each file therefore
|
|
||||||
asserts the value is non-empty before running anything, and because
|
|
||||||
failed steps are never cached that assertion fires on every
|
|
||||||
invocation rather than once. Use `script/lint`, `script/docker` or
|
|
||||||
`script/cibuild`, which pass the arg; a bare `docker build` is a loud
|
|
||||||
error.
|
|
||||||
* `script/precommit` — pre-commit gate: `go mod tidy` + `go fmt` (must
|
* `script/precommit` — pre-commit gate: `go mod tidy` + `go fmt` (must
|
||||||
not change files), then `script/check`
|
not change files), then `script/check`
|
||||||
* `script/install-precommit` — install the git pre-commit hook that
|
* `script/install-precommit` — install the git pre-commit hook that
|
||||||
@@ -886,24 +997,35 @@ them. We provide:
|
|||||||
### version numbers
|
### version numbers
|
||||||
|
|
||||||
The version a binary reports comes from git, not from a constant in a
|
The version a binary reports comes from git, not from a constant in a
|
||||||
file. `script/version` decides it, and everything that stamps a binary
|
file. It is `git describe --tags --always --dirty`, which
|
||||||
agrees with it:
|
`script/version` runs for the `Makefile`, and `script/docker` and
|
||||||
|
`script/cibuild` run themselves:
|
||||||
|
|
||||||
* `HEAD` is exactly on a tag → that tag with a leading `v` stripped, so
|
* `HEAD` is exactly on a tag → that tag, such as `v1.0.0`.
|
||||||
the tag `v1.0.0` produces `vaultik 1.0.0`, matching the archive name
|
* a commit after a tag → `<tag>-<N>-g<short sha>`.
|
||||||
`vaultik_1.0.0_linux_amd64.tar.gz`. `goreleaser` strips the prefix the
|
* no tag reachable → the short commit sha.
|
||||||
same way.
|
* any of these, with uncommitted changes to tracked files → a `-dirty`
|
||||||
* anything else → `dev-<12 chars of the commit sha>`.
|
|
||||||
* either, with uncommitted changes to tracked files → a `-dirty`
|
|
||||||
suffix, because a modified checkout of a tag is not that tag.
|
suffix, because a modified checkout of a tag is not that tag.
|
||||||
|
|
||||||
A build that is not a release never names itself like one. `vaultik
|
A `docker build .` of a clone, with no build arguments, runs the same
|
||||||
version` says so in as many words on a development build, and
|
`git describe` (without `--dirty`) on the `.git` in its build context,
|
||||||
`goreleaser --snapshot` stamps the same `dev-<sha>` string rather than
|
so it stamps the same value for a clean commit; the build fails if the
|
||||||
inventing the next patch number. If `script/version` cannot be run at
|
context carries `.git` and no version, commit or commit date comes out.
|
||||||
all, `make` stops with an error instead of building an unversioned
|
A binary built without
|
||||||
binary, and a binary that somehow carries an empty version string still
|
git metadata reports `dev`, or `unknown` when `script/docker` or
|
||||||
reports itself as a development build.
|
`script/cibuild` built it outside a git checkout.
|
||||||
|
|
||||||
|
`goreleaser` stamps a release binary with the tag minus its leading
|
||||||
|
`v`, so the tag `v1.0.0` produces `vaultik 1.0.0`, matching the archive
|
||||||
|
name `vaultik_1.0.0_linux_amd64.tar.gz`. `goreleaser --snapshot` stamps
|
||||||
|
`dev-<12 chars of the commit sha>` rather than inventing the next patch
|
||||||
|
number. `vaultik version` calls a build a development build when its
|
||||||
|
version is `dev`, `unknown`, `dev-<sha>`, the short commit sha or
|
||||||
|
`<tag>-<N>-g<short sha>`, with or without `-dirty`; only a plain tag,
|
||||||
|
such as `v1.0.0` or `1.0.0`, is a release. If `script/version` cannot
|
||||||
|
be run at all, `make` stops with an error instead of building an
|
||||||
|
unversioned binary, and a binary that somehow carries an empty version
|
||||||
|
string still reports itself as a development build.
|
||||||
|
|
||||||
### cutting a release
|
### cutting a release
|
||||||
|
|
||||||
@@ -932,14 +1054,14 @@ It is passed to `goreleaser` as `GITEA_TOKEN`. The runner's automatic
|
|||||||
token is deliberately not used: it is not guaranteed to carry release
|
token is deliberately not used: it is not guaranteed to carry release
|
||||||
write access.
|
write access.
|
||||||
|
|
||||||
The Go toolchain that compiles the released binaries comes from an
|
The Go toolchain that compiles the released binaries is installed by
|
||||||
`actions/setup-go` step pinned by commit sha, reading its version from
|
`script/install-go`, which downloads the version named by `go.mod`
|
||||||
`go.mod` (currently `1.26.1`, the same version the `Dockerfile` builder
|
(currently `1.26.1`, the same version the `Dockerfile` builder stage
|
||||||
stage pins by digest). `goreleaser` shells out to `go` for every
|
pins by digest) and refuses the archive unless its sha256 matches the
|
||||||
|
value committed in the script. `goreleaser` shells out to `go` for every
|
||||||
cross-compile, so without that step the release would either fail
|
cross-compile, so without that step the release would either fail
|
||||||
outright or ship binaries built by whatever unpinned toolchain the
|
outright or ship binaries built by whatever unpinned toolchain the
|
||||||
runner happened to carry — the one unpinned thing in an otherwise
|
runner happened to carry.
|
||||||
hash-pinned release path.
|
|
||||||
|
|
||||||
To rehearse the whole build without publishing or tagging anything:
|
To rehearse the whole build without publishing or tagging anything:
|
||||||
|
|
||||||
|
|||||||
+357
-86
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: Repository Policies
|
title: Repository Policies
|
||||||
last_modified: 2026-07-06
|
last_modified: 2026-10-04
|
||||||
---
|
---
|
||||||
|
|
||||||
This document covers repository structure, tooling, and workflow standards. Code
|
This document covers repository structure, tooling, and workflow standards. Code
|
||||||
@@ -60,17 +60,28 @@ style conventions are in separate documents:
|
|||||||
prerequisite since nvm requires bash. yarn is then pinned via
|
prerequisite since nvm requires bash. yarn is then pinned via
|
||||||
`corepack prepare yarn@<version> --activate`. Never install "latest" or "lts";
|
`corepack prepare yarn@<version> --activate`. Never install "latest" or "lts";
|
||||||
always exact versions. `script/cibuild` runs the CI build: it changes to the
|
always exact versions. `script/cibuild` runs the CI build: it changes to the
|
||||||
repo root and runs `docker build .`; the Gitea workflow calls it. Four further
|
repo root, runs `script/bootstrap`, runs `script/check`, and builds the image
|
||||||
scripts are our own extensions to the standard: `script/check` runs
|
with the version; the Gitea workflow calls it. **`script/cibuild` runs
|
||||||
`script/test`, `script/lint`, and `script/fmt-check`; `script/precommit` is
|
`script/bootstrap` first**, because the workflow checks out the repo and runs
|
||||||
what the git pre-commit hook runs, and it calls `script/check`;
|
nothing else, while `script/fmt-check` runs the formatter on the host: on a
|
||||||
`script/install-precommit` installs the git pre-commit hook (the `make hooks`
|
pristine checkout with nothing installed the run dies there, after the
|
||||||
target shims to it); and `script/projectname` (literally that filename) simply
|
containerised gates have passed. **The bootstrap alone is not enough**:
|
||||||
outputs the project's name. Scripts that need the name call
|
`script/bootstrap` installs node and yarn under nvm and leaves neither on the
|
||||||
`script/projectname` — e.g. `script/docker` assembles its image tag from it —
|
`PATH` of the shell that called it, so a bare `yarn` still exits 127. The host
|
||||||
so those scripts stay byte-identical across all repos. Repo-type-specific
|
entrypoints that need yarn — `script/fmt` and `script/fmt-check` — therefore
|
||||||
pre-commit extras (e.g. `go mod tidy` verification in Go repos) belong in
|
source nvm for the pinned node version before invoking it, exactly as
|
||||||
`script/precommit`, not in the hook itself. Model scripts are at
|
`script/bootstrap`'s own install step does. A runner carrying nothing but
|
||||||
|
docker and git then gets through `script/check`. Four further scripts are our
|
||||||
|
own extensions to the standard: `script/check` runs `script/test`,
|
||||||
|
`script/lint` and `script/fmt-check`; `script/precommit` is what the git
|
||||||
|
pre-commit hook runs, and it calls `script/check`; `script/install-precommit`
|
||||||
|
installs the git pre-commit hook (the `make hooks` target shims to it); and
|
||||||
|
`script/projectname` (literally that filename) simply outputs the project's
|
||||||
|
name. Scripts that need the name call `script/projectname` — e.g.
|
||||||
|
`script/docker` assembles its image tag from it — so those scripts stay
|
||||||
|
byte-identical across all repos. Repo-type-specific pre-commit extras (e.g.
|
||||||
|
`go mod tidy` verification in Go repos) belong in `script/precommit`, not in
|
||||||
|
the hook itself. Model scripts are at
|
||||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/script/<name>`. The README
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/script/<name>`. The README
|
||||||
must document the provided scripts in an **Entrypoints** section (see the
|
must document the provided scripts in an **Entrypoints** section (see the
|
||||||
README requirements below).
|
README requirements below).
|
||||||
@@ -89,87 +100,198 @@ style conventions are in separate documents:
|
|||||||
contributor should be able to understand the entire development workflow by
|
contributor should be able to understand the entire development workflow by
|
||||||
reading the Makefile.
|
reading the Makefile.
|
||||||
|
|
||||||
- Every repo should have a `Dockerfile`. All Dockerfiles must run `make check`
|
- Every repo should have a `Dockerfile`, and it carries the repo's gates: a
|
||||||
as a build step so the build fails if the branch is not green. For non-server
|
`lint` phase and a `test` phase, with the final stage depending on both so the
|
||||||
repos, the Dockerfile should bring up a development environment and run
|
image cannot be built unless they pass. For non-server repos the final stage
|
||||||
`make check`. For server repos, `make check` should run as an early build
|
brings up a development environment; for server repos it is the runtime image.
|
||||||
stage before the final image is assembled. Dockerfiles install development
|
The gate phases and the build stage start from their pinned base images and
|
||||||
prerequisites by running `script/bootstrap` rather than duplicating installs
|
install what those images lack either inline, as the canonical Go `Dockerfile`
|
||||||
inline; COPY `script/` and the dependency manifests (`package.json` +
|
below does for `git`, or by running `script/bootstrap`, as the `prompts`
|
||||||
`yarn.lock`, `go.mod` + `go.sum`, etc.) before running it so the bootstrap
|
repo's own `Dockerfile` does for its yarn packages. The development
|
||||||
layer stays cached until dependencies change.
|
environment stage installs development prerequisites by running
|
||||||
|
`script/bootstrap` rather than duplicating its installs inline. A stage that
|
||||||
|
runs `script/bootstrap` COPYs `script/` and the dependency manifests
|
||||||
|
(`package.json` + `yarn.lock`, `go.mod` + `go.sum`, etc.) before running it.
|
||||||
|
|
||||||
- **Dockerfiles must use a separate lint stage for fail-fast feedback.** Go
|
- **Linting and testing run in Docker, as phases of the `Dockerfile`.** There is
|
||||||
repos use a multistage build where linting runs in an independent stage based
|
no separate lint file. `script/lint` and `script/test` each build one phase
|
||||||
on the `golangci/golangci-lint` image (pinned by hash). This stage runs
|
and nothing else:
|
||||||
`make fmt-check` and `make lint` before the full build begins. The build stage
|
|
||||||
then declares an explicit dependency on the lint stage via
|
|
||||||
`COPY --from=lint /src/go.sum /dev/null`, which forces BuildKit to complete
|
|
||||||
linting before proceeding to compilation and tests. This ensures lint failures
|
|
||||||
surface in seconds rather than minutes, without blocking on dependency
|
|
||||||
download or compilation in the build stage.
|
|
||||||
|
|
||||||
The standard pattern for a Go repo Dockerfile is:
|
```sh
|
||||||
|
docker build --no-cache --target lint -t "$(script/projectname)-lint" .
|
||||||
|
docker build --no-cache --target test -t "$(script/projectname)-test" .
|
||||||
|
```
|
||||||
|
|
||||||
|
**A stage that is not the last one in the file is built only when the final
|
||||||
|
stage's chain depends on it, or when `--target` names it.** That is why the
|
||||||
|
two gates are always invoked by name here, and why the final stage carries a
|
||||||
|
`COPY --from=` of a harmless file from each of them: without that edge a
|
||||||
|
plain `docker build .` builds the last stage alone and exits 0 having linted
|
||||||
|
and tested nothing.
|
||||||
|
|
||||||
|
**Every `docker build` in `script/` is tagged**, here and in
|
||||||
|
`script/cibuild` and `script/docker`. An untagged build leaves a dangling
|
||||||
|
image behind on every invocation, on every developer host and every CI
|
||||||
|
runner; a tagged one replaces the previous image.
|
||||||
|
|
||||||
|
Inside a phase the tool is invoked directly — `golangci-lint`, `go test`,
|
||||||
|
`eslint`, `prettier` — never through `make lint` or `script/test`, which are
|
||||||
|
themselves a `docker build` and would recurse into a daemon that does not
|
||||||
|
exist in a build step. Formatting is the exception and stays on the host:
|
||||||
|
`script/fmt` writes the working tree, and `script/fmt-check` is its
|
||||||
|
read-only twin.
|
||||||
|
|
||||||
|
**No lint verdict may come from a host invocation of the linter.** On a
|
||||||
|
shared host golangci-lint reads a result cache keyed on file content rather
|
||||||
|
than location, so a second checkout of the same content is served the first
|
||||||
|
one's findings, and a host-global lock in `$TMPDIR` makes concurrent runs
|
||||||
|
exit non-zero with `parallel golangci-lint is running` — a status a caller
|
||||||
|
cannot tell from real findings. Both have produced wrong verdicts in this
|
||||||
|
org, in both directions. A container has its own cache, its own `TMPDIR` and
|
||||||
|
a digest-pinned binary, so neither is reachable.
|
||||||
|
|
||||||
|
- **Any build that runs checks is built with `--no-cache`.** Docker invalidates
|
||||||
|
a `COPY` layer only when the copied content changes, so on an unchanged tree
|
||||||
|
the check `RUN` is served from cache, nothing executes, and the build still
|
||||||
|
exits 0. Every `docker build` in `script/` therefore passes `--no-cache`:
|
||||||
|
`script/lint`, `script/test`, `script/cibuild` and `script/docker` are the
|
||||||
|
four, and there is no fifth — `script/check` runs the two gate phases and
|
||||||
|
`script/fmt-check`, and builds no image of its own. A bare `docker build .` is
|
||||||
|
not evidence that anything ran: a sub-second build reporting success is a
|
||||||
|
cache hit, not a result. Never invalidate by pruning — `docker builder prune`
|
||||||
|
and friends destroy a build cache shared with every other build on the host.
|
||||||
|
When a check is added or changed, prove it works by planting a defect it must
|
||||||
|
catch and watching the run fail on it, then revert the defect. A green run
|
||||||
|
alone shows neither that the check ran nor that it covers what it should.
|
||||||
|
|
||||||
|
- **The gate phases are separate stages, and the build stage depends on both.**
|
||||||
|
The lint phase is based on the `golangci/golangci-lint` image (pinned by
|
||||||
|
hash), so lint failures surface in seconds rather than after a full compile,
|
||||||
|
and the test phase is based on the Debian Go image. The canonical Go repo
|
||||||
|
`Dockerfile`:
|
||||||
|
|
||||||
```dockerfile
|
```dockerfile
|
||||||
# Lint stage — fast feedback on formatting and lint issues
|
# Lint phase
|
||||||
# golangci/golangci-lint:v2.x.x, YYYY-MM-DD
|
# golangci/golangci-lint:v2.x.x, YYYY-MM-DD
|
||||||
FROM golangci/golangci-lint@sha256:... AS lint
|
FROM golangci/golangci-lint@sha256:... AS lint
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN make fmt-check
|
RUN golangci-lint run --config .golangci.yml ./...
|
||||||
RUN make lint
|
|
||||||
|
|
||||||
# Build stage
|
# Test phase. -race needs cgo and so a C compiler, which the Debian Go
|
||||||
# golang:1.x-alpine, YYYY-MM-DD
|
# image ships and the alpine one does not.
|
||||||
FROM golang@sha256:... AS builder
|
# golang:1.x, YYYY-MM-DD
|
||||||
|
FROM golang@sha256:... AS test
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
# Force BuildKit to run the lint stage before proceeding
|
|
||||||
COPY --from=lint /src/go.sum /dev/null
|
|
||||||
|
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN make test
|
RUN go test -timeout 90s -race -cover ./... || \
|
||||||
|
{ echo "--- Rerunning with -v for details ---"; \
|
||||||
|
go test -timeout 90s -race -v ./...; exit 1; }
|
||||||
|
|
||||||
ARG VERSION=dev
|
# Build stage. Nothing is wanted from either phase above; the copies
|
||||||
RUN CGO_ENABLED=0 go build -trimpath \
|
# are what make BuildKit build them first, so this stage cannot run
|
||||||
-ldflags="-s -w -X main.Version=${VERSION}" \
|
# unless lint and test passed.
|
||||||
-o /app ./cmd/app/
|
# golang:1.x-alpine, YYYY-MM-DD
|
||||||
|
FROM golang@sha256:... AS builder
|
||||||
|
COPY --from=lint /src/go.sum /dev/null
|
||||||
|
COPY --from=test /src/go.sum /dev/null
|
||||||
|
RUN apk add --no-cache git
|
||||||
|
# A tar-stream context keeps the sender's file owners, which git refuses.
|
||||||
|
RUN git config --system --add safe.directory /src
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
|
||||||
# Runtime stage
|
# The VERSION build arg when one is given, otherwise
|
||||||
|
# `git describe --tags --always` on the .git in the build context. With
|
||||||
|
# .git present, a version that is still empty, dev or unknown fails the
|
||||||
|
# build: git is missing or could not read the checkout.
|
||||||
|
ARG VERSION
|
||||||
|
RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \
|
||||||
|
if [ -e .git ]; then \
|
||||||
|
case "$VERSION" in ""|dev|unknown) \
|
||||||
|
echo "version is '$VERSION' although .git is present" >&2; \
|
||||||
|
exit 1 ;; \
|
||||||
|
esac; \
|
||||||
|
fi; \
|
||||||
|
CGO_ENABLED=0 go build -trimpath \
|
||||||
|
-ldflags="-s -w -X main.Version=${VERSION}" \
|
||||||
|
-o /app ./cmd/app/
|
||||||
|
|
||||||
|
# Runtime stage, and the last one
|
||||||
FROM alpine@sha256:...
|
FROM alpine@sha256:...
|
||||||
COPY --from=builder /app /usr/local/bin/app
|
COPY --from=builder /app /usr/local/bin/app
|
||||||
ENTRYPOINT ["app"]
|
ENTRYPOINT ["app"]
|
||||||
```
|
```
|
||||||
|
|
||||||
Key points:
|
Key points:
|
||||||
- The lint stage uses the `golangci/golangci-lint` image directly (it
|
- The lint phase uses the `golangci/golangci-lint` image directly (it has
|
||||||
includes both Go and the linter), so there is no need to install the
|
both Go and the linter), so nothing needs installing.
|
||||||
linter separately.
|
- `COPY --from=<phase> /src/go.sum /dev/null` is a no-op copy whose only
|
||||||
- `COPY --from=lint /src/go.sum /dev/null` is a no-op file copy that creates
|
purpose is the ordering edge. BuildKit runs stages in parallel by default,
|
||||||
a stage dependency. BuildKit runs stages in parallel by default; without
|
and a stage nothing depends on is not built at all, so without these two
|
||||||
this line, the build stage would not wait for lint to finish and a lint
|
lines a red gate would not fail the build.
|
||||||
failure might not fail the overall build.
|
- Keep the runtime stage last, and if you add a stage after it, give it the
|
||||||
|
same two copies. A plain `docker build .` builds the last stage's chain
|
||||||
|
and nothing else.
|
||||||
- If the project uses `//go:embed` directives that reference build artifacts
|
- If the project uses `//go:embed` directives that reference build artifacts
|
||||||
(e.g. a web frontend compiled in a separate stage), the lint stage must
|
(e.g. a web frontend compiled in a separate stage), the lint phase must
|
||||||
create placeholder files so the embed directives resolve. Example:
|
create placeholder files so the embed directives resolve. Example:
|
||||||
`RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`.
|
`RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`.
|
||||||
The lint stage should not depend on the actual build output — it exists to
|
- If the project requires CGO or system libraries for linting, install them
|
||||||
fail fast.
|
in the lint phase. The `golangci/golangci-lint` image is Debian-based and
|
||||||
- If the project requires CGO or system libraries for linting (e.g.
|
has no `apk`, so install with `apt-get` under the Debian package name
|
||||||
`vips-dev`), install them in the lint stage with `apk add`.
|
(`libvips-dev`, where alpine says `vips-dev`), and delete the package
|
||||||
- The build stage runs `make test` after compilation setup. Tests run in the
|
lists in the same `RUN`, so the layer does not keep them:
|
||||||
build stage, not the lint stage, because they may require compiled
|
|
||||||
artifacts or heavier dependencies.
|
```dockerfile
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends libvips-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
```
|
||||||
|
|
||||||
|
- `.dockerignore` lets `.git` into the build context. It keeps out every git
|
||||||
|
`config` at any depth (`**/.git/config`, `**/.git/modules/**/config`): the
|
||||||
|
repository's own, each submodule's under `.git/modules/`, and that of a
|
||||||
|
submodule keeping its own `.git` directory. `git describe` does not need
|
||||||
|
them, and each can hold a credential: a password in a remote URL, or the
|
||||||
|
token the CI checkout step stores there. A submodule whose name has a
|
||||||
|
`config` segment (`config`, `deploy/config`, `config/lib`) loses its whole
|
||||||
|
git directory to `**/.git/modules/**/config`, and Go's version stamping
|
||||||
|
then fails the build: give it a name without that segment
|
||||||
|
(`git submodule add --name`). The stage that compiles has `git` (the
|
||||||
|
Debian Go image has it; an alpine one needs `apk add --no-cache git`) and
|
||||||
|
takes the version from the `VERSION` build argument when one is given,
|
||||||
|
otherwise from `git describe --tags --always`. That gives the tag on a
|
||||||
|
tagged commit; on a later commit, the tag, the number of commits since it
|
||||||
|
and the short commit (`v1.2.3-4-gabc1234`); and the short commit when no
|
||||||
|
tag is reachable. The stage that compiles also marks its working directory
|
||||||
|
safe for git (`git config --system --add safe.directory /src`): a context
|
||||||
|
sent as a tar stream keeps the sender's file owners, and git refuses a
|
||||||
|
checkout owned by another user, so the version would come out empty.
|
||||||
|
`ARG VERSION` has no default, and the build fails if the context carries
|
||||||
|
`.git` and the version still comes out empty, `dev` or `unknown`. A plain
|
||||||
|
`docker build .` with no build arguments must succeed; a Dockerfile that
|
||||||
|
refuses an empty build argument drops that refusal and keeps the argument.
|
||||||
|
|
||||||
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
|
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
|
||||||
runs `script/cibuild` (which runs `docker build .`) on push. Since the
|
runs `script/cibuild` on push, and checks out the repo as its only other step.
|
||||||
Dockerfile already runs `make check`, a successful build implies all checks
|
That script bootstraps, runs the gate phases, and then builds the image, so a
|
||||||
pass.
|
successful run means every check passed; a bare `docker build .` does not
|
||||||
|
carry the same guarantee, because its gate phases may come from the cache. The
|
||||||
|
image build is uncached and so runs the gate phases a second time. That is the
|
||||||
|
price of the rule above, and it is worth paying: the image that ships is built
|
||||||
|
from a run of its own gates rather than from a cache entry. A separate
|
||||||
|
workflow limited to `main` by a `branches` list under `on: push` cannot be
|
||||||
|
checked by review: to try a change to it, add the feature branch to that list
|
||||||
|
and push, then remove the branch from the list again before merging. Keep any
|
||||||
|
job in it that publishes behind `if: github.ref_name == 'main'`, so the run
|
||||||
|
from the feature branch publishes nothing.
|
||||||
|
|
||||||
- Use platform-standard formatters: `black` for Python, `prettier` for
|
- Use platform-standard formatters: `black` for Python, `prettier` for
|
||||||
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
|
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
|
||||||
@@ -189,14 +311,21 @@ style conventions are in separate documents:
|
|||||||
module under test to verify it compiles/parses. There is no excuse for
|
module under test to verify it compiles/parses. There is no excuse for
|
||||||
`make test` to be a no-op.
|
`make test` to be a no-op.
|
||||||
|
|
||||||
- `make test` must complete in under 20 seconds. Add a 30-second timeout in the
|
- `make test` must complete in under 60 seconds. That is the hard cap, and a
|
||||||
Makefile.
|
suite that exceeds it fails. Under 20 seconds is the target. A suite between
|
||||||
|
20 and 60 seconds is still green, but the overage must be filed as an
|
||||||
|
improvement bug against that repo. Add a 90-second timeout to the test
|
||||||
|
invocation (`go test -timeout 90s`). The backstop deliberately sits above the
|
||||||
|
hard cap so that it catches a genuinely hung test rather than a merely slow
|
||||||
|
one.
|
||||||
|
|
||||||
- **`make test` should use the conditional verbose rerun pattern.** Run tests
|
- **The test command should use the conditional verbose rerun pattern.** Run
|
||||||
without `-v` (verbose) first. If tests fail, automatically rerun with `-v` to
|
tests without `-v` (verbose) first. If tests fail, automatically rerun with
|
||||||
show full output. This keeps CI logs and `docker build` output clean on
|
`-v` to show full output. This keeps CI logs and `docker build` output clean
|
||||||
success (just package/suite summaries) while providing full diagnostic detail
|
on success (just package/suite summaries) while providing full diagnostic
|
||||||
on failure (every test case, every assertion). The general shell pattern:
|
detail on failure (every test case, every assertion). The command lives in the
|
||||||
|
`test` phase of the `Dockerfile`, since `script/test` builds that phase; the
|
||||||
|
Makefile form below is the same pattern for any repo-local invocation:
|
||||||
|
|
||||||
```makefile
|
```makefile
|
||||||
test:
|
test:
|
||||||
@@ -209,11 +338,26 @@ style conventions are in separate documents:
|
|||||||
|
|
||||||
```makefile
|
```makefile
|
||||||
test:
|
test:
|
||||||
@go test -timeout 30s -race -cover ./... || \
|
@go test -count=1 -timeout 90s -race -cover ./... || \
|
||||||
{ echo "--- Rerunning with -v for details ---"; \
|
{ echo "--- Rerunning with -v for details ---"; \
|
||||||
go test -timeout 30s -race -v ./...; exit 1; }
|
go test -count=1 -timeout 90s -race -v ./...; exit 1; }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`-count=1` is required on both invocations: it defeats Go's test _result_
|
||||||
|
cache, so neither run can report a stored pass in place of running the
|
||||||
|
tests. It leaves the build cache alone, so it costs the runtime of the suite
|
||||||
|
and no recompilation.
|
||||||
|
|
||||||
|
That cache is Go's own, separate from Docker's layer cache. Go stores a
|
||||||
|
passing result in its cache directory (`GOCACHE`), and when the same tests
|
||||||
|
run again on unchanged code it prints that result, marked `(cached)`,
|
||||||
|
without running them. That matters on a developer's machine, where this
|
||||||
|
target runs and the directory lasts from one run to the next. The `test`
|
||||||
|
phase of the `Dockerfile` needs no `-count=1`: its base image holds no
|
||||||
|
result for this repo's tests and nothing before its `go test` step runs a
|
||||||
|
test, so there is nothing to replay. `--no-cache` (above) is what makes that
|
||||||
|
step run on an unchanged tree.
|
||||||
|
|
||||||
Python example:
|
Python example:
|
||||||
|
|
||||||
```makefile
|
```makefile
|
||||||
@@ -239,10 +383,84 @@ style conventions are in separate documents:
|
|||||||
must be in `.gitignore`. No exceptions.
|
must be in `.gitignore`. No exceptions.
|
||||||
|
|
||||||
- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
|
- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
|
||||||
editor files (`.swp`, `*~`), language build artifacts, and `node_modules/`.
|
editor files (`.swp`, `*~`), in-repo agent scratch directories (`.claude/`),
|
||||||
Fetch the standard `.gitignore` from
|
language build artifacts, and `node_modules/`. Fetch the standard `.gitignore`
|
||||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
|
from `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when
|
||||||
a new repo.
|
setting up a new repo. These patterns are written to `.gitignore`'s own
|
||||||
|
semantics, in which an unanchored pattern already matches at every depth; they
|
||||||
|
are not a `.dockerignore` and must not be transplanted into one unmodified.
|
||||||
|
|
||||||
|
- **`.dockerignore` does not use `.gitignore` semantics, and copying patterns
|
||||||
|
across unmodified leaves secrets in the build context.** Docker matches with
|
||||||
|
`moby/patternmatcher`: `filepath.Match` semantics plus a `**` extension, so
|
||||||
|
`*` does not cross `/` and a pattern without a leading `**/` is anchored at
|
||||||
|
the build-context root. A `.dockerignore` listing `.env`, `*.pem` and `*.key`
|
||||||
|
therefore excludes only the copies at the repository root, while `config/.env`
|
||||||
|
and `certs/server.key` still reach the context and can land in an image layer
|
||||||
|
— which is more dangerous than a short file with no secret patterns at all,
|
||||||
|
because it reads as solved and stops anyone looking. Give every
|
||||||
|
depth-independent pattern the `**/` prefix and leave only genuinely
|
||||||
|
root-anchored entries unprefixed: `.claude`, and the repo's own host-built
|
||||||
|
binary, written `/myapp` and never `**/myapp`, which would also match
|
||||||
|
`cmd/myapp/` and delete the package directory from the context. Matching is
|
||||||
|
case-sensitive, and an ALL-CAPS twin per pattern still misses `Server.Key`, so
|
||||||
|
secret names use character ranges — `**/*.[kK][eE][yY]`, `**/*.[pP][eE][mM]`,
|
||||||
|
and likewise for `.envrc` and the extensionless SSH keys. Where such a pattern
|
||||||
|
also catches something the build needs, re-include it with a negation
|
||||||
|
(`!docs/example.env`); deleting the pattern reopens the exposure for every
|
||||||
|
other file it covers. Fetch the standard `.dockerignore` from
|
||||||
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore` and extend
|
||||||
|
it with the repo's own artifacts.
|
||||||
|
|
||||||
|
- **In-repo agent scratch belongs in both files, written to each file's own
|
||||||
|
semantics.** `.claude/` holds one worktree per in-flight agent — an entire
|
||||||
|
additional checkout of the repo — so under `COPY . .` the build context
|
||||||
|
inflates by a multiple of the repo and another session's unreviewed work can
|
||||||
|
be copied into an image layer. In `.gitignore` the entry is `.claude/`,
|
||||||
|
unanchored. In `.dockerignore` it is `.claude`, anchored and with **no** `**/`
|
||||||
|
prefix, because the prefixed form would also delete any nested directory of
|
||||||
|
that name from the build. Anchoring carries a known gap that the canonical
|
||||||
|
`.dockerignore` states in its own comment, since consuming repos receive the
|
||||||
|
file and not the tracker: the directory is created in the agent's working
|
||||||
|
directory, so a repo running agents in subdirectories still ships
|
||||||
|
`services/api/.claude/` and must add its own anchored entry there.
|
||||||
|
|
||||||
|
- **A plain `docker build .` of a clone stamps the version that
|
||||||
|
`git describe --tags --always` gives**, derived from the `.git` in the build
|
||||||
|
context as the canonical `Dockerfile` above shows. Without its failure check,
|
||||||
|
a missing `git` or an unreadable checkout would leave `-X main.Version=` empty
|
||||||
|
and the build would still exit 0. `script/docker` and `script/cibuild` pass
|
||||||
|
the version they compute on the host; it takes precedence. They do this
|
||||||
|
byte-identically across repos:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Own line: a failing command substitution inside an argument does not
|
||||||
|
# trip `set -e`, so the inline form degrades to an empty constant.
|
||||||
|
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
|
||||||
|
[ -n "$version" ] || version="unknown"
|
||||||
|
docker build --no-cache \
|
||||||
|
--build-arg VERSION="$version" \
|
||||||
|
-t "$(script/projectname)" .
|
||||||
|
```
|
||||||
|
|
||||||
|
`--always` makes an untagged repo yield an abbreviated commit hash rather
|
||||||
|
than failing, and the `[ -n "$version" ]` line is the single place the
|
||||||
|
fallback is applied — a live check that fires on a build from an export with
|
||||||
|
no `.git` and on a repository with no commits yet. Do not fold it into the
|
||||||
|
substitution as `|| echo unknown`, which makes the guard unreachable. The
|
||||||
|
Dockerfile's side is `ARG VERSION` in the stage that compiles, declared
|
||||||
|
there because `ARG` is stage-scoped; passing `VERSION` to a repo whose
|
||||||
|
Dockerfile declares no such `ARG` is ignored and costs nothing, which is why
|
||||||
|
the scripts stay byte-identical. One consequence for CI: the standard
|
||||||
|
checkout action clones shallow and fetches no tags, so a repo that embeds a
|
||||||
|
tag-derived version must set `fetch-depth: 0` on its checkout step.
|
||||||
|
|
||||||
|
- **Verify `.dockerignore` by enumerating the image, not by reading the
|
||||||
|
patterns.** Plant files at the root _and_ at least two directories deep, build
|
||||||
|
a probe image that does `COPY . .`, and list what actually landed
|
||||||
|
(`docker run --rm --entrypoint find IMAGE /app`). The `transferring context`
|
||||||
|
size is not a substitute: a nested secret is a few bytes, and BuildKit
|
||||||
|
transfers only the delta from the previous build.
|
||||||
|
|
||||||
- **No build artifacts in version control.** Code-derived data (compiled
|
- **No build artifacts in version control.** Code-derived data (compiled
|
||||||
bundles, minified output, generated assets) must never be committed to the
|
bundles, minified output, generated assets) must never be committed to the
|
||||||
@@ -258,9 +476,56 @@ style conventions are in separate documents:
|
|||||||
- Make all changes on a feature branch. You can do whatever you want on a
|
- Make all changes on a feature branch. You can do whatever you want on a
|
||||||
feature branch.
|
feature branch.
|
||||||
|
|
||||||
- `.golangci.yml` is standardized and must _NEVER_ be modified by an agent, only
|
- `.golangci.yml` is standardized. The vendored copy in a consuming repo must
|
||||||
manually by the user. Fetch from
|
_NEVER_ be modified by an agent: fetch it from
|
||||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml`.
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml` and keep it
|
||||||
|
byte-identical, so that no repo can quietly loosen its own linting. Linter
|
||||||
|
configuration changes are made to the canonical copy in the `prompts` repo and
|
||||||
|
reach consuming repos by re-vendoring; an agent may open a PR against
|
||||||
|
canonical, which only the user merges. One list is exempt from byte-identity,
|
||||||
|
because it cannot be written once for every repo: the `deny` list of the
|
||||||
|
`test-support` depguard rule, where a repo names its own test-support packages
|
||||||
|
by full import path. A repo adds entries there and changes nothing else, and a
|
||||||
|
re-vendor carries its entries forward. The canonical golangci-lint version is
|
||||||
|
v2.14.0 (released 2026-09-24), pinned as the digest of the lint phase's base
|
||||||
|
image
|
||||||
|
(`golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f`,
|
||||||
|
which reports `2.14.0 built with go1.27.0 from 114493f9`). A module's `go`
|
||||||
|
directive must not name a newer Go minor version than the one golangci-lint
|
||||||
|
was built with, or golangci-lint refuses to lint it: this release lints
|
||||||
|
`go 1.27.1` but not `go 1.28`. That digest is the only pin, since no repo
|
||||||
|
installs golangci-lint on the host. A repo sets the lint phase digest to the
|
||||||
|
one named here and re-vendors `.golangci.yml` in the same commit, whichever of
|
||||||
|
the two prompted the change: the canonical copy can name linters that an older
|
||||||
|
golangci-lint rejects, and a newer golangci-lint can add linters that
|
||||||
|
`default: all` switches on until the canonical copy disables them.
|
||||||
|
|
||||||
|
- **`script/bootstrap` installs a pinned tool by comparing versions, never by
|
||||||
|
testing presence.** An `if ! command -v <tool>; then install; fi` guard tests
|
||||||
|
`PATH` only, so on an already-provisioned machine the pin is inert and a
|
||||||
|
version bump is a silent no-op — while the Dockerfile, installing into a clean
|
||||||
|
image, gets the pinned version, so a local `make check` and `make docker` can
|
||||||
|
disagree about what the tool even is. The canonical form:
|
||||||
|
- compares the installed version against the pin over the **whole** version
|
||||||
|
token; a parser that stops at the first `-` reports `2.12.2` for a host
|
||||||
|
running `2.12.2-rc1` and skips the install;
|
||||||
|
- treats absent, non-zero, empty or unrecognised `--version` output as a
|
||||||
|
mismatch, so the failure direction is a redundant install and never a
|
||||||
|
skipped one;
|
||||||
|
- after installing, re-resolves the binary the way callers do — `hash -r`,
|
||||||
|
then through `PATH`, not through the directory the installer wrote to —
|
||||||
|
and fails naming the resolved path, since an install that a shadowing
|
||||||
|
binary hides succeeds while changing nothing any caller sees;
|
||||||
|
- is actually called, and prints the version on both success paths: a
|
||||||
|
function defined and never invoked has the same exit status and the same
|
||||||
|
empty output as one that worked.
|
||||||
|
|
||||||
|
Keep it POSIX sh: no arrays, no `[[`, no `grep -P`.
|
||||||
|
|
||||||
|
A Go tool a repo needs on the host is installed with `go install` pinned to
|
||||||
|
a commit hash (`go install <package>@<commit hash>`). It is never tracked as
|
||||||
|
a `go.mod` tool dependency or through a `tools.go` file, either of which
|
||||||
|
pulls the tool's own dependencies into the repo's `go.mod` and `go.sum`.
|
||||||
|
|
||||||
- When pinning images or packages by hash, add a comment above the reference
|
- When pinning images or packages by hash, add a comment above the reference
|
||||||
with the version and date (YYYY-MM-DD).
|
with the version and date (YYYY-MM-DD).
|
||||||
@@ -374,12 +639,14 @@ style conventions are in separate documents:
|
|||||||
settings.
|
settings.
|
||||||
|
|
||||||
- Avoid putting files in the repo root unless necessary. Root should contain
|
- Avoid putting files in the repo root unless necessary. Root should contain
|
||||||
only project-level config files (`README.md`, `Makefile`, `Dockerfile`,
|
only project-level config files (`README.md`, `AGENTS.md`, `Makefile`,
|
||||||
`LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, and
|
`Dockerfile`, `LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`,
|
||||||
language-specific config). Everything else goes in a subdirectory. Canonical
|
and language-specific config). Everything else goes in a subdirectory.
|
||||||
subdirectory names:
|
Canonical subdirectory names:
|
||||||
- `bin/` — executable scripts and tools
|
- `bin/` — executable scripts and tools
|
||||||
- `cmd/` — Go command entrypoints
|
- `cmd/` — Go command entrypoints; thin only: one `main.go` per binary whose
|
||||||
|
body is a single call into `internal/` or `pkg/`, no project logic in
|
||||||
|
`cmd/`
|
||||||
- `configs/` — configuration templates and examples
|
- `configs/` — configuration templates and examples
|
||||||
- `deploy/` — deployment manifests (k8s, compose, terraform)
|
- `deploy/` — deployment manifests (k8s, compose, terraform)
|
||||||
- `docs/` — documentation and markdown (README.md stays in root)
|
- `docs/` — documentation and markdown (README.md stays in root)
|
||||||
@@ -406,3 +673,7 @@ style conventions are in separate documents:
|
|||||||
- Go: `go.mod`, `go.sum`, `.golangci.yml`
|
- Go: `go.mod`, `go.sum`, `.golangci.yml`
|
||||||
- JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore`
|
- JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore`
|
||||||
- Python: `pyproject.toml`
|
- Python: `pyproject.toml`
|
||||||
|
|
||||||
|
- Guidance for coding agents lives in one `AGENTS.md` at the repository root. It
|
||||||
|
is never committed under a file or directory named after one agent tool, such
|
||||||
|
as `CLAUDE.md` or `.claude/`, and never split into separate memory files.
|
||||||
|
|||||||
@@ -14,17 +14,623 @@ pre-1.0
|
|||||||
|
|
||||||
# Next Step
|
# Next Step
|
||||||
|
|
||||||
Define the remaining scope for the first tagged release under the 1.0.0
|
The 1.0 work is complete on `next`: the scope settled on
|
||||||
milestone, then cut that tag. The mechanism to cut it now exists and is
|
[issue #125](https://git.eeqj.de/sneak/vaultik/issues/125) has landed,
|
||||||
exercised; what is left is the scope decision, which is the owner's.
|
and so has every fix from the defect hunt before the release candidate
|
||||||
This step deliberately names one version number: it previously said
|
([issue #288](https://git.eeqj.de/sneak/vaultik/issues/288)). What is
|
||||||
"cut v0.1.0" while the `Makefile` baked in `1.0.0-rc.1` and the issue
|
left is merging `next` to `main` and tagging the release, both the
|
||||||
milestone said 1.0.0, and three different answers to "what is the next
|
owner's; a `v*` tag runs the release build in
|
||||||
release" is exactly the contradiction
|
`.gitea/workflows/release.yml`.
|
||||||
[issue #65](https://git.eeqj.de/sneak/vaultik/issues/65) was filed over.
|
|
||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 2026-10-14: Stopped `snapshot create --prune` pruning after a backup
|
||||||
|
that skipped files it could not read
|
||||||
|
([issue #382](https://git.eeqj.de/sneak/vaultik/issues/382)). Such a
|
||||||
|
prune could delete the only snapshot still holding those files.
|
||||||
|
|
||||||
|
- 2026-10-14: Made `snapshot create --skip-errors` exit non-zero,
|
||||||
|
instead of 0, when it skips anything it cannot read
|
||||||
|
([issue #310](https://git.eeqj.de/sneak/vaultik/issues/310)).
|
||||||
|
|
||||||
|
- 2026-10-14: Kept the local index when `remote nuke` cannot list or
|
||||||
|
find the destination store
|
||||||
|
([issue #292](https://git.eeqj.de/sneak/vaultik/issues/292)). Nuke
|
||||||
|
had cleared the local snapshot records anyway; it now fails before
|
||||||
|
removing anything.
|
||||||
|
|
||||||
|
- 2026-10-14: Stopped counting directories and unchanged symlinks as
|
||||||
|
files to back up
|
||||||
|
([issue #305](https://git.eeqj.de/sneak/vaultik/issues/305)).
|
||||||
|
|
||||||
|
- 2026-10-14: Refused a backup destination that an unplugged volume
|
||||||
|
left empty, as the owner ruled
|
||||||
|
([issue #239](https://git.eeqj.de/sneak/vaultik/issues/239)). Every
|
||||||
|
backup now writes `vaultik-destination.txt` at the destination root,
|
||||||
|
and while the local index holds snapshots stored there, a destination
|
||||||
|
without it is treated as not present.
|
||||||
|
|
||||||
|
- 2026-10-14: Warned about restored ownership only when an entry's
|
||||||
|
owner or group differs from the recorded one
|
||||||
|
([issue #316](https://git.eeqj.de/sneak/vaultik/issues/316)); a
|
||||||
|
restore by a non-root user had warned even when none did.
|
||||||
|
|
||||||
|
- 2026-10-14: Made `snapshot list` report a changed destination instead
|
||||||
|
of advising `prune`, which refuses in that case
|
||||||
|
([issue #317](https://git.eeqj.de/sneak/vaultik/issues/317)).
|
||||||
|
|
||||||
|
- 2026-10-14: Counted a file as deleted by a backup only if the
|
||||||
|
previous snapshot of the same name listed it
|
||||||
|
([issue #304](https://git.eeqj.de/sneak/vaultik/issues/304)); files
|
||||||
|
that snapshot did not list had been counted too.
|
||||||
|
|
||||||
|
- 2026-10-14: Named each prefix once in restore, verify and purge
|
||||||
|
errors, some of which repeated it
|
||||||
|
([issue #327](https://git.eeqj.de/sneak/vaultik/issues/327)). A deep
|
||||||
|
verify log line that named S3 whatever the destination now names
|
||||||
|
storage.
|
||||||
|
|
||||||
|
- 2026-10-14: Refused credentials in a storage URL's endpoint or rclone
|
||||||
|
remote name, which were accepted and could then be printed
|
||||||
|
([issue #322](https://git.eeqj.de/sneak/vaultik/issues/322)).
|
||||||
|
|
||||||
|
- 2026-10-14: Ran `$EDITOR` through the shell in `config edit`, so a
|
||||||
|
value with arguments, such as `code --wait`, no longer fails
|
||||||
|
([issue #315](https://git.eeqj.de/sneak/vaultik/issues/315)).
|
||||||
|
|
||||||
|
- 2026-10-14: Made `snapshot remove` fail, instead of exiting 0, when
|
||||||
|
it cannot delete the metadata of a snapshot that is only on the
|
||||||
|
destination store
|
||||||
|
([issue #340](https://git.eeqj.de/sneak/vaultik/issues/340)). The
|
||||||
|
README and the help now say when it prints the `vaultik prune`
|
||||||
|
follow-up.
|
||||||
|
|
||||||
|
- 2026-10-14: Labelled the totals in the local index cleanup before
|
||||||
|
each backup as totals
|
||||||
|
([issue #306](https://git.eeqj.de/sneak/vaultik/issues/306)). Each
|
||||||
|
was shown as `(N remain)`, which read as rows left to clean up.
|
||||||
|
|
||||||
|
- 2026-10-13: Freed a cached blob once no file still to restore needs
|
||||||
|
it ([issue #324](https://git.eeqj.de/sneak/vaultik/issues/324)); one
|
||||||
|
also used by a file outside the selected paths had stayed in the
|
||||||
|
temporary cache until the restore ended.
|
||||||
|
|
||||||
|
- 2026-10-13: Kept the whole startup error when it contains `): `
|
||||||
|
([issue #320](https://git.eeqj.de/sneak/vaultik/issues/320)). Such
|
||||||
|
an error was cut at its last `): `, so the `blob_size_limit` one lost
|
||||||
|
the setting's name.
|
||||||
|
|
||||||
|
- 2026-10-13: Silenced the status lines of `prune` and snapshot removal
|
||||||
|
under `--cron` and `--quiet`
|
||||||
|
([issue #294](https://git.eeqj.de/sneak/vaultik/issues/294)).
|
||||||
|
|
||||||
|
- 2026-10-13: Read the rclone config file that `RCLONE_CONFIG` names
|
||||||
|
([issue #321](https://git.eeqj.de/sneak/vaultik/issues/321)); a
|
||||||
|
remote defined only there had been reported as not found.
|
||||||
|
|
||||||
|
- 2026-10-13: Made `/**/` in an exclude pattern match zero directories,
|
||||||
|
as in gitignore
|
||||||
|
([issue #338](https://git.eeqj.de/sneak/vaultik/issues/338));
|
||||||
|
`a/**/*.log` had missed `a/x.log`.
|
||||||
|
|
||||||
|
- 2026-10-13: Made the `chunk_size` and `blob_size_limit` defaults and
|
||||||
|
the `chunk_size` minimum 1000-based, like the size parser and the
|
||||||
|
docs ([issue #313](https://git.eeqj.de/sneak/vaultik/issues/313)).
|
||||||
|
|
||||||
|
- 2026-10-13: Kept a snapshot's remote metadata when `snapshot purge`
|
||||||
|
cannot remove it from the local index
|
||||||
|
([issue #337](https://git.eeqj.de/sneak/vaultik/issues/337)); the
|
||||||
|
purge had deleted the metadata anyway and exited 0.
|
||||||
|
|
||||||
|
- 2026-10-13: Made `snapshot restore` fail for a requested path that
|
||||||
|
matches nothing, which it had ignored
|
||||||
|
([issue #309](https://git.eeqj.de/sneak/vaultik/issues/309)).
|
||||||
|
|
||||||
|
- 2026-10-13: Made `remote nuke` delete every object under `metadata/`
|
||||||
|
and `blobs/`
|
||||||
|
([issue #291](https://git.eeqj.de/sneak/vaultik/issues/291)); it had
|
||||||
|
left some, such as a stray `.DS_Store`, while reporting the store
|
||||||
|
empty.
|
||||||
|
|
||||||
|
- 2026-10-13: Made `config set` create a list for an index under a
|
||||||
|
missing key
|
||||||
|
([issue #308](https://git.eeqj.de/sneak/vaultik/issues/308)); it had
|
||||||
|
written a map, after which no command could load the config.
|
||||||
|
|
||||||
|
- 2026-10-13: Listed a `file://` destination through a symlinked
|
||||||
|
`metadata/` or `blobs/`, which had listed as empty
|
||||||
|
([issue #296](https://git.eeqj.de/sneak/vaultik/issues/296)). A link
|
||||||
|
to a missing target now fails the listing
|
||||||
|
([issue #332](https://git.eeqj.de/sneak/vaultik/issues/332)).
|
||||||
|
|
||||||
|
- 2026-10-13: Made `snapshot verify --deep --json` report its blob
|
||||||
|
counts ([issue #314](https://git.eeqj.de/sneak/vaultik/issues/314));
|
||||||
|
a failed run had reported 0 missing and 0 verified.
|
||||||
|
|
||||||
|
- 2026-10-13: Limited and retried the module download in the Docker
|
||||||
|
build ([issue #351](https://git.eeqj.de/sneak/vaultik/issues/351)),
|
||||||
|
which had hung for as long as a stalled connection to the module
|
||||||
|
proxy lasted.
|
||||||
|
|
||||||
|
- 2026-10-13: Recorded each snapshot's uncompressed size when its
|
||||||
|
backup runs
|
||||||
|
([issue #360](https://git.eeqj.de/sneak/vaultik/issues/360)). A later
|
||||||
|
backup could change an older snapshot's UNCOMPRESSED SIZE in
|
||||||
|
`snapshot list`.
|
||||||
|
|
||||||
|
- 2026-10-13: Stopped `snapshot restore --skip-errors --verify`
|
||||||
|
reporting a file that failed to restore again as a checksum failure
|
||||||
|
([issue #323](https://git.eeqj.de/sneak/vaultik/issues/323)).
|
||||||
|
|
||||||
|
- 2026-10-13: Recorded the stored size of a blob a backup finds already
|
||||||
|
stored, not the size of its local copy, which can differ, so
|
||||||
|
`snapshot verify` could fail on a good snapshot
|
||||||
|
([issue #300](https://git.eeqj.de/sneak/vaultik/issues/300)).
|
||||||
|
|
||||||
|
- 2026-10-13: Stopped the local index and the blob upload step ending
|
||||||
|
the process on a failed close of query results or a failed temp file
|
||||||
|
cleanup ([issue #345](https://git.eeqj.de/sneak/vaultik/issues/345));
|
||||||
|
both are now returned as errors.
|
||||||
|
|
||||||
|
- 2026-10-13: Closed the snapshot database's storage reader too when
|
||||||
|
restore or deep verify is interrupted
|
||||||
|
([issue #356](https://git.eeqj.de/sneak/vaultik/issues/356)).
|
||||||
|
|
||||||
|
- 2026-10-13: Made `prune` and `remote nuke` fail when a delete on the
|
||||||
|
backup destination store fails
|
||||||
|
([issue #290](https://git.eeqj.de/sneak/vaultik/issues/290)).
|
||||||
|
|
||||||
|
- 2026-10-13: Made an S3 listing that the server cuts short an error
|
||||||
|
([issue #297](https://git.eeqj.de/sneak/vaultik/issues/297)); `prune`
|
||||||
|
had taken it as complete and could delete blobs still in use.
|
||||||
|
|
||||||
|
- 2026-10-13: Made shallow `snapshot verify` report a blob it could not
|
||||||
|
check apart from a missing one
|
||||||
|
([issue #318](https://git.eeqj.de/sneak/vaultik/issues/318)); any
|
||||||
|
error checking a blob had counted as missing, its cause dropped.
|
||||||
|
|
||||||
|
- 2026-10-13: Restored the other files when a file's chunk is in no
|
||||||
|
blob under `--skip-errors`
|
||||||
|
([issue #326](https://git.eeqj.de/sneak/vaultik/issues/326)); that
|
||||||
|
file had stopped the restore before anything was restored.
|
||||||
|
|
||||||
|
- 2026-10-13: Recorded each snapshot's new chunk size when its backup
|
||||||
|
runs ([issue #311](https://git.eeqj.de/sneak/vaultik/issues/311)).
|
||||||
|
`snapshot list` could show a NEW CHUNK SIZE of 0 B for a snapshot
|
||||||
|
whose change was a modified file.
|
||||||
|
|
||||||
|
- 2026-10-13: Made a backup interrupted during a local index write exit
|
||||||
|
as interrupted
|
||||||
|
([issue #301](https://git.eeqj.de/sneak/vaultik/issues/301)); it could
|
||||||
|
end with `FATAL: failed to rollback transaction` and exit 1 instead of
|
||||||
|
130.
|
||||||
|
|
||||||
|
- 2026-10-13: Printed `snapshot list` and `snapshot purge` sizes like
|
||||||
|
every other command
|
||||||
|
([issue #312](https://git.eeqj.de/sneak/vaultik/issues/312)). They,
|
||||||
|
and the `bytes` value in terminal log lines, were 1024-based but
|
||||||
|
labelled KB or MB, and now use 1000-based units.
|
||||||
|
|
||||||
|
- 2026-10-13: Closed each blob's storage reader when restore or deep
|
||||||
|
verify is interrupted, which ends a stalled read unless it is stuck in
|
||||||
|
the kernel ([issue #325](https://git.eeqj.de/sneak/vaultik/issues/325)).
|
||||||
|
|
||||||
|
- 2026-10-13: Made a mistyped subcommand under `snapshot`, `config`,
|
||||||
|
`remote`, `database` or `completion` an error
|
||||||
|
([issue #319](https://git.eeqj.de/sneak/vaultik/issues/319)); each had
|
||||||
|
printed its help and exited 0.
|
||||||
|
|
||||||
|
- 2026-10-13: Made an exclude pattern starting with `**/` match
|
||||||
|
directly under the snapshot path too, as in gitignore
|
||||||
|
([issue #303](https://git.eeqj.de/sneak/vaultik/issues/303)).
|
||||||
|
|
||||||
|
- 2026-10-13: Refused a relative `file://` storage URL and decoded its
|
||||||
|
percent-escapes
|
||||||
|
([issue #299](https://git.eeqj.de/sneak/vaultik/issues/299)); a
|
||||||
|
relative path had moved with the working directory.
|
||||||
|
|
||||||
|
- 2026-10-13: Removed the blob temp file when a backup stops part-way
|
||||||
|
([issue #302](https://git.eeqj.de/sneak/vaultik/issues/302)); the
|
||||||
|
half-written blob had stayed in `$TMPDIR`.
|
||||||
|
|
||||||
|
- 2026-10-13: Checked `--keep-newer-than` before a backup starts
|
||||||
|
([issue #307](https://git.eeqj.de/sneak/vaultik/issues/307)); an
|
||||||
|
invalid value had been read only by the prune after the backup, so
|
||||||
|
retention never ran.
|
||||||
|
|
||||||
|
- 2026-10-13: Stopped `remote info` counting a file under `blobs/` that
|
||||||
|
is not a blob as an orphaned blob
|
||||||
|
([issue #295](https://git.eeqj.de/sneak/vaultik/issues/295)). A stray
|
||||||
|
file such as `.DS_Store` was reported as an orphaned blob and the
|
||||||
|
report advised running `prune`, which skips such a file and deleted
|
||||||
|
nothing. `remote info` now takes its blob list from the same listing
|
||||||
|
`prune` uses, in the table and in `--json`.
|
||||||
|
|
||||||
|
- 2026-10-13: Made `snapshot remove` fail for a snapshot that does not
|
||||||
|
exist ([issue #293](https://git.eeqj.de/sneak/vaultik/issues/293)).
|
||||||
|
The command never checked the ID: it reported the snapshot removed
|
||||||
|
from the local database and from the destination store and exited 0,
|
||||||
|
under `--json`, `--dry-run` and `--local-only` too. An ID that names
|
||||||
|
no snapshot in the local index or on the destination store is now an
|
||||||
|
error; with `--local-only`, the snapshot must be in the local index.
|
||||||
|
The command now reports a removal from either side only when one
|
||||||
|
happened, and `remote_removed` is true only when metadata was deleted.
|
||||||
|
|
||||||
|
- 2026-10-12: Bound the local index to the destination an `s3.*` config
|
||||||
|
names ([issue #298](https://git.eeqj.de/sneak/vaultik/issues/298)).
|
||||||
|
Only `storage_url` was checked, so after `s3.bucket` or `s3.prefix`
|
||||||
|
changed, the next backup treated every chunk as already stored,
|
||||||
|
uploaded nothing, and completed a snapshot that could not be restored.
|
||||||
|
Without `storage_url`, the local index is now bound to `s3.endpoint`,
|
||||||
|
`s3.bucket` and `s3.prefix`, and a change is refused as a changed
|
||||||
|
`storage_url` is.
|
||||||
|
|
||||||
|
- 2026-10-12: Rejected a retention duration longer than about 292 years
|
||||||
|
([issue #289](https://git.eeqj.de/sneak/vaultik/issues/289)). The
|
||||||
|
number given to `--older-than` or `--keep-newer-than` was multiplied
|
||||||
|
by the length of its unit with no limit, so from `293y`, `15251w`,
|
||||||
|
`3559mo` or `106752d`, or a sum of parts such as `200y200y`, the
|
||||||
|
result wrapped to a negative duration. The cutoff then lay in the
|
||||||
|
future, every snapshot of the name was deleted, the one just made
|
||||||
|
included, and the command exited 0. Such a duration is now rejected
|
||||||
|
as invalid and nothing is deleted.
|
||||||
|
|
||||||
|
- 2026-10-08: Made a backup cancelled under `--skip-errors` stop at once
|
||||||
|
([issue #286](https://git.eeqj.de/sneak/vaultik/issues/286)). After
|
||||||
|
Ctrl-C or SIGTERM, phase 2 treated the cancellation error from each
|
||||||
|
remaining file like an unreadable file: it opened the file, printed an
|
||||||
|
error line for it, counted it as failed and went on to the next one.
|
||||||
|
The run now stops at the first file after the cancellation, and no
|
||||||
|
file is counted as failed because of it.
|
||||||
|
|
||||||
|
- 2026-10-08: Made `remote nuke` delete the `.partial` files that
|
||||||
|
uploads cut off part-way leave on the destination store
|
||||||
|
([issue #281](https://git.eeqj.de/sneak/vaultik/issues/281)). The
|
||||||
|
file and rclone listings skip such a file, so the command left it in
|
||||||
|
place and still reported the store empty. It now removes them under
|
||||||
|
`metadata/` and `blobs/` as its last step.
|
||||||
|
|
||||||
|
- 2026-10-08: Made a local index error while recording a directory or
|
||||||
|
symlink stop a backup under `--skip-errors`
|
||||||
|
([issue #284](https://git.eeqj.de/sneak/vaultik/issues/284)). Phase 2
|
||||||
|
only records such an entry in the local index, since it has no data
|
||||||
|
to open or read, but an error doing so was skipped like an unreadable
|
||||||
|
file. The snapshot then completed without the entry, and a restore
|
||||||
|
did not recreate it. The error now stops the backup, as it does
|
||||||
|
without the flag.
|
||||||
|
|
||||||
|
- 2026-10-08: Counted a file that a backup could not store as failed
|
||||||
|
([issue #280](https://git.eeqj.de/sneak/vaultik/issues/280)). A file
|
||||||
|
that phase 1 counted and phase 2 could not open, because it was
|
||||||
|
unreadable under `--skip-errors` or removed in between, was reported
|
||||||
|
in the summary as unchanged with its bytes as backed up, and the
|
||||||
|
`snapshots` row's `file_count` and `total_size` included it. The
|
||||||
|
summary now counts it as failed, and its data total and the row leave
|
||||||
|
it out.
|
||||||
|
|
||||||
|
- 2026-10-08: Made `remote info` report a snapshot's blob count and
|
||||||
|
blob size as unknown when its manifest cannot be read
|
||||||
|
([issue #272](https://git.eeqj.de/sneak/vaultik/issues/272)). The
|
||||||
|
orphan figures were already unknown in that case, but the snapshot's
|
||||||
|
row still gave 0 blobs and 0 B, in the table and in `--json`. The row
|
||||||
|
now reads `unknown` and `--json` gives `null`. A directory with no
|
||||||
|
manifest still shows 0, since the orphan figures count its blobs as
|
||||||
|
orphaned.
|
||||||
|
|
||||||
|
- 2026-10-08: Kept the progress line of a snapshot with more than one
|
||||||
|
path within 100%
|
||||||
|
([issue #271](https://git.eeqj.de/sneak/vaultik/issues/271)). The
|
||||||
|
bytes and files processed counted every path, but the totals they
|
||||||
|
were divided by held only the current path's, so a second path
|
||||||
|
smaller than the first showed more than 100% and an ETA of `unknown`.
|
||||||
|
The totals now add up over the paths scanned so far. The rate behind
|
||||||
|
the ETA restarts when each path's scan phase ends, so that phase does
|
||||||
|
not lower the rate while the path is processed; until then the rate
|
||||||
|
is the previous path's.
|
||||||
|
|
||||||
|
- 2026-10-08: Made a second `snapshot create` of one name succeed when
|
||||||
|
it starts in the same second as the first
|
||||||
|
([issue #270](https://git.eeqj.de/sneak/vaultik/issues/270)). The
|
||||||
|
timestamp in a snapshot ID is in whole seconds, so the second run got
|
||||||
|
the first run's ID and failed with `UNIQUE constraint failed:
|
||||||
|
snapshots.id`. When the local index already has a snapshot with the
|
||||||
|
ID, the create now waits a second and takes a new timestamp.
|
||||||
|
|
||||||
|
- 2026-10-07: Documented that `--json` skips the confirmation prompt of
|
||||||
|
`snapshot remove` and `prune`
|
||||||
|
([issue #268](https://git.eeqj.de/sneak/vaultik/issues/268)). Both
|
||||||
|
commands delete without asking under `--json`, since a prompt on stdout
|
||||||
|
would break the JSON document, but the help and the README described
|
||||||
|
only `--force` as skipping it. The `--json` help of both commands and
|
||||||
|
their README entries now say so.
|
||||||
|
|
||||||
|
- 2026-10-07: Made a symlink whose target cannot be read stop the backup
|
||||||
|
([issue #269](https://git.eeqj.de/sneak/vaultik/issues/269)). It was
|
||||||
|
left out of the snapshot with only a debug log line, even without
|
||||||
|
`--skip-errors`. It now aborts the run, or with `--skip-errors` is
|
||||||
|
skipped with the `Failed to access` error line that any other entry
|
||||||
|
the scan cannot read gets.
|
||||||
|
|
||||||
|
- 2026-10-07: Stopped a killed rclone upload from leaving a truncated
|
||||||
|
object under its key
|
||||||
|
([issue #266](https://git.eeqj.de/sneak/vaultik/issues/266)). The
|
||||||
|
rclone backend wrote each object straight to its key, so killing an
|
||||||
|
upload to a local or sftp remote left a truncated object there; the
|
||||||
|
next backup found the key with `Stat`, skipped the upload and recorded
|
||||||
|
a snapshot that could not be restored. On a remote with a server-side
|
||||||
|
move, an object is now written under a temporary name ending in
|
||||||
|
`.partial` and moved into place, and listings skip such names. Remotes
|
||||||
|
without one are still written in place.
|
||||||
|
|
||||||
|
- 2026-10-07: Made an interrupted command exit 130 and say so
|
||||||
|
([issue #267](https://git.eeqj.de/sneak/vaultik/issues/267)). Ctrl-C
|
||||||
|
or SIGTERM during `snapshot create`, `snapshot restore` or `snapshot
|
||||||
|
verify` exited 0 with no error line (`snapshot verify --json` exited
|
||||||
|
1, also without one), so a `--cron` run that never finished looked
|
||||||
|
like a success. A command stopped by either signal now exits 130 and
|
||||||
|
prints `interrupted before the command finished` on stderr, under
|
||||||
|
`--cron` and `--json` too.
|
||||||
|
|
||||||
|
- 2026-10-07: Corrected documentation, help text and comments that were
|
||||||
|
false about the code
|
||||||
|
([issue #233](https://git.eeqj.de/sneak/vaultik/issues/233)). A blob
|
||||||
|
is not streamed to storage. The README, `ARCHITECTURE.md` and
|
||||||
|
`config.example.yml` now say a backup needs free temporary space,
|
||||||
|
because each blob is written whole to a temporary file before it is
|
||||||
|
uploaded (up to about `blob_size_limit`; an rclone destination that
|
||||||
|
cannot stream uploads needs about twice that) and the metadata export
|
||||||
|
writes copies of the local index. Temporary files go to `$TMPDIR`
|
||||||
|
(default `/tmp`); with `TMPDIR` unset, SQLite writes one of those
|
||||||
|
copies to `/var/tmp`. Also corrected: the snapshot ID format, what restore
|
||||||
|
reads and how incomplete snapshots are removed in `docs/DATAMODEL.md`,
|
||||||
|
what `source_path` holds, the `index_path` and config file defaults,
|
||||||
|
what `snapshot remove` cleans up, how the release gets its Go
|
||||||
|
toolchain, and the `script/release` and `script/fmt-check` comments.
|
||||||
|
|
||||||
|
- 2026-10-07: Cut the time the `internal/vaultik` and `internal/database`
|
||||||
|
tests take ([issue #235](https://git.eeqj.de/sneak/vaultik/issues/235)).
|
||||||
|
Most of the `internal/vaultik` time went to 24 tests that ran one at a
|
||||||
|
time only because they call `log.Initialize`; they now call it before
|
||||||
|
`t.Parallel()`, as the package's other tests do. `TestLargeDatasets`
|
||||||
|
committed each of its 1,500 inserts on its own and now makes them in
|
||||||
|
one transaction. `TestDedupOnlySnapshotRestores` gives its second
|
||||||
|
backup its own snapshot name instead of sleeping past the one-second
|
||||||
|
timestamp in the snapshot ID.
|
||||||
|
|
||||||
|
- 2026-10-07: Made two messages say only what is true
|
||||||
|
([issue #240](https://git.eeqj.de/sneak/vaultik/issues/240)). A config
|
||||||
|
file that others can read was warned about as containing S3
|
||||||
|
credentials even when it set none, as a `file://` config does. The
|
||||||
|
warning now says the file may contain S3 credentials only when
|
||||||
|
`s3.access_key_id` or `s3.secret_access_key` is set, since either may
|
||||||
|
come from a `${...}` reference rather than the file, and otherwise
|
||||||
|
says the file is readable by others. `snapshot purge` against a
|
||||||
|
destination store it could not list gave an error with
|
||||||
|
`listing remote snapshots:` in it twice; the prefix now appears once.
|
||||||
|
|
||||||
|
- 2026-10-07: Made `s3.part_size` set the multipart upload part size
|
||||||
|
([issue #232](https://git.eeqj.de/sneak/vaultik/issues/232)). It was
|
||||||
|
loaded and defaulted but never passed to the S3 client, whose uploader
|
||||||
|
used a fixed 10MiB part. It now reaches the uploader for `storage_url`
|
||||||
|
and for the `s3.*` fields, and a part size S3 refuses, below 5MiB or
|
||||||
|
above 5GiB, `0` included, fails at config load. A blob too large for
|
||||||
|
S3's limit of 10,000 parts at the configured size is uploaded in larger
|
||||||
|
parts. The docs gave the default as `5MB`, which the config file reads
|
||||||
|
as 5,000,000 bytes, below the minimum; they now say `5MiB`.
|
||||||
|
|
||||||
|
- 2026-10-07: Made per-name retention work when the hostname contains `_`
|
||||||
|
([issue #230](https://git.eeqj.de/sneak/vaultik/issues/230)). A
|
||||||
|
snapshot ID is `hostname_name_timestamp`, and the name was read as
|
||||||
|
everything between the first and the last `_`, so with
|
||||||
|
`hostname: my_host` the name `home` came out as `host_home`.
|
||||||
|
`snapshot purge --keep-latest --snapshot home` then printed "No
|
||||||
|
snapshots to delete", and `snapshot create --prune` purged nothing
|
||||||
|
without a message. The name is now read using the hostname the
|
||||||
|
`snapshots` table stores with each snapshot, cut at its first `.` as it
|
||||||
|
is in the ID.
|
||||||
|
|
||||||
|
- 2026-10-07: Made `remote info` stop reporting a snapshot's blobs as
|
||||||
|
orphaned when its manifest cannot be read, and stop printing raw
|
||||||
|
names from under `metadata/`
|
||||||
|
([issue #228](https://git.eeqj.de/sneak/vaultik/issues/228)). A
|
||||||
|
manifest it failed to read was skipped, so that snapshot's blobs were
|
||||||
|
counted as orphaned and the report advised running `vaultik prune`.
|
||||||
|
The orphan figures are now unknown in that case, with no prune
|
||||||
|
advice, and `--json` gives them as `null` with the unreadable remote
|
||||||
|
keys in `unreadable_manifests`. A name under `metadata/` that is not
|
||||||
|
64 lowercase hex characters is now skipped with a warning instead of
|
||||||
|
being printed, control characters included. A manifest under a
|
||||||
|
skipped name is then not read either, so it also leaves the orphan
|
||||||
|
figures unknown, and `--json` counts such manifests in
|
||||||
|
`skipped_manifest_count`. A directory with no manifest in it, as left
|
||||||
|
by an interrupted backup, leaves the figures known.
|
||||||
|
|
||||||
|
- 2026-10-07: Made `config set` keep a string that looks like a number
|
||||||
|
([issue #229](https://git.eeqj.de/sneak/vaultik/issues/229)). It wrote
|
||||||
|
every value unquoted, and `config.Load` reads the file through untyped
|
||||||
|
YAML, so an access key `00112233` loaded as `38043` and a hostname `007`
|
||||||
|
as `7`. A value for a string setting in `config.Config` is now tagged as
|
||||||
|
a YAML string, which the file quotes wherever YAML would read a number or
|
||||||
|
a boolean; other settings are still written unquoted.
|
||||||
|
|
||||||
|
- 2026-10-07: Made a backup notice a file rewritten with its size
|
||||||
|
unchanged and a new mtime in the same second as the one in the index
|
||||||
|
([issue #226](https://git.eeqj.de/sneak/vaultik/issues/226)). The
|
||||||
|
`files` table held mtime in whole seconds and the scanner compared
|
||||||
|
whole seconds, so every later snapshot kept the old content. A new
|
||||||
|
`mtime_nsec` column holds the nanoseconds within the second that
|
||||||
|
`mtime` holds, and the scanner compares the full mtime. A local index
|
||||||
|
created before the change lacks the column and is rebuilt with
|
||||||
|
`vaultik database delete` and a full backup.
|
||||||
|
|
||||||
|
- 2026-10-07: Made taking the process-wide lock atomic
|
||||||
|
([issue #227](https://git.eeqj.de/sneak/vaultik/issues/227)). The lock
|
||||||
|
read `vaultik.pid`, checked whether that PID was alive and then wrote
|
||||||
|
its own, so two writers started together could both pass the check and
|
||||||
|
both run. It is now an `flock` on `vaultik.pid`, held until the run
|
||||||
|
ends; the kernel drops it when the process exits, so a crash leaves no
|
||||||
|
lock behind. A clean exit now empties the file instead of deleting it,
|
||||||
|
because deleting it would let two later runs each lock a different
|
||||||
|
file.
|
||||||
|
|
||||||
|
- 2026-10-06: Made `snapshot remove --json` write only its document to
|
||||||
|
stdout when the destination store cannot be reached
|
||||||
|
([issue #251](https://git.eeqj.de/sneak/vaultik/issues/251)). Its
|
||||||
|
warning that the snapshot's metadata was left on the destination store
|
||||||
|
went to stdout ahead of the document, breaking `| jq` on a command that
|
||||||
|
exited 0. Under `--json` the warning now reaches stderr only, through
|
||||||
|
the logger. The warning, the README and the command's help said
|
||||||
|
`vaultik prune` would finish the cleanup, but `prune` never removes
|
||||||
|
snapshot metadata; they now say to run `vaultik snapshot remove` for the
|
||||||
|
snapshot again once the destination store is reachable.
|
||||||
|
|
||||||
|
- 2026-10-06: Made the backup summary and the `snapshots` row count each
|
||||||
|
file, byte and upload once
|
||||||
|
([issue #225](https://git.eeqj.de/sneak/vaultik/issues/225)). The
|
||||||
|
scanner added a file's bytes again for each new chunk and counted a
|
||||||
|
file as unchanged for each chunk already stored, so a first backup
|
||||||
|
reported twice its size and "backed up" could go negative. Upload
|
||||||
|
figures came from the progress reporter, which `--cron` turns off, and
|
||||||
|
`blob_count` counted earlier paths' blobs again for each later path.
|
||||||
|
The scanner now counts uploads itself; `blob_size`,
|
||||||
|
`blob_uncompressed_size` and `compression_ratio` describe the blobs
|
||||||
|
the snapshot references, and `docs/DATAMODEL.md` now says
|
||||||
|
`chunk_count` and `blob_count` count what the run added.
|
||||||
|
|
||||||
|
- 2026-10-06: Made command output follow the README's stdout and stderr
|
||||||
|
rules ([issue #224](https://git.eeqj.de/sneak/vaultik/issues/224)). The
|
||||||
|
startup banner went to stdout, so a `completion` script or a
|
||||||
|
`config get` value started with it; the banner now goes to stderr. A
|
||||||
|
failing `remote info`, `prune` or `snapshot remove` under `--json`
|
||||||
|
printed nothing on either stream, and now reports its error on stderr.
|
||||||
|
`snapshot verify --quiet` printed its whole report; it now prints
|
||||||
|
none, and a failure still reaches stderr with the same exit status.
|
||||||
|
|
||||||
|
- 2026-10-06: Made a backup without `--cron` of a snapshot with two or
|
||||||
|
more `paths` complete instead of panicking with `close of closed
|
||||||
|
channel` ([issue #253](https://git.eeqj.de/sneak/vaultik/issues/253)).
|
||||||
|
`Scan` runs once per path and started and stopped the progress
|
||||||
|
reporter each time, and a second stop panics. The reporter is now
|
||||||
|
started and stopped once per snapshot, around the scans of all its
|
||||||
|
paths.
|
||||||
|
|
||||||
|
- 2026-10-06: Made a restore path argument select only that path and
|
||||||
|
what is beneath it
|
||||||
|
([issue #223](https://git.eeqj.de/sneak/vaultik/issues/223)). The
|
||||||
|
lookup matched with SQL `LIKE`, so `/home/u/doc` also restored
|
||||||
|
`doc2`, `DOC` and `doc.txt.bak`, and a `_` or `%` in the path acted
|
||||||
|
as a wildcard. A backup used the same lookup to load the known files
|
||||||
|
of each configured path, so files of a longer sibling path were
|
||||||
|
counted as deleted. `FileRepository.ListUnderPath`, which replaces
|
||||||
|
`ListByPrefix`, returns the file at the path and every file whose
|
||||||
|
path starts with the path plus `/`, compared case-sensitively.
|
||||||
|
|
||||||
|
- 2026-10-06: Made restore return an error instead of panicking on a
|
||||||
|
malformed snapshot database
|
||||||
|
([issue #231](https://git.eeqj.de/sneak/vaultik/issues/231)). A chunk
|
||||||
|
hash shorter than 16 characters crashed the error message naming it,
|
||||||
|
and `--verify` dereferenced a missing `chunks` row and allocated
|
||||||
|
whatever chunk size the database gave. Those messages now go through
|
||||||
|
`shortHash`, a missing row is an error, and `--verify` rejects a
|
||||||
|
negative size and hashes each chunk as a stream.
|
||||||
|
|
||||||
|
- 2026-10-06: Made `s3://bucket/prefix` and `s3://bucket/prefix/` the same
|
||||||
|
destination ([issue #222](https://git.eeqj.de/sneak/vaultik/issues/222)).
|
||||||
|
The S3 client put the prefix directly in front of each key, so a prefix
|
||||||
|
without a trailing slash stored `prefixblobs/...`. A non-empty prefix is
|
||||||
|
now joined to every key with one `/`, giving the README's
|
||||||
|
`<bucket>/<prefix>/blobs/...` layout. The `s3.prefix` config setting goes
|
||||||
|
through the same client and gets the same join.
|
||||||
|
|
||||||
|
- 2026-10-06: Made restore apply owners, modes and times in an order
|
||||||
|
that keeps them
|
||||||
|
([issue #219](https://git.eeqj.de/sneak/vaultik/issues/219)). A
|
||||||
|
directory got its stored mode and mtime before its contents were
|
||||||
|
written, so a read-only directory came back without its files and a
|
||||||
|
non-empty one carried the time of the restore. Directories are now
|
||||||
|
created owner-only and get their stored owner, mode and mtime once the
|
||||||
|
restore loop is done, each before its parent. A file's mode is applied
|
||||||
|
after its chown, which on Linux clears setuid and setgid, and a
|
||||||
|
symlink gets its stored owner (as root) and mtime on the link itself.
|
||||||
|
|
||||||
|
- 2026-10-06: Made `go.mod` what `go mod tidy` writes, so the
|
||||||
|
pre-commit hook no longer stops every commit
|
||||||
|
([issue #246](https://git.eeqj.de/sneak/vaultik/issues/246)). A test
|
||||||
|
in `internal/cli` imports `github.com/spf13/pflag` directly, but
|
||||||
|
`go.mod` still marked it `// indirect`, and `script/precommit` fails
|
||||||
|
whenever the tidy changes `go.mod`. It is now in the direct `require`
|
||||||
|
block.
|
||||||
|
|
||||||
|
- 2026-10-06: Made the README's steps for restoring on another machine
|
||||||
|
work ([issue #221](https://git.eeqj.de/sneak/vaultik/issues/221)).
|
||||||
|
`config init` wrote a placeholder recipient that `config.Load`
|
||||||
|
rejects, so every command on the new machine failed before reaching
|
||||||
|
the store. The file now has an empty `age_recipients` list,
|
||||||
|
`config.Load` accepts an empty list, and `snapshot create` refuses to
|
||||||
|
run without a recipient.
|
||||||
|
|
||||||
|
- 2026-10-06: Made `snapshot restore --skip-errors` skip the files that
|
||||||
|
need a blob it cannot download
|
||||||
|
([issue #218](https://git.eeqj.de/sneak/vaultik/issues/218)). A missing
|
||||||
|
or damaged blob ended the restore even with the flag, after restoring
|
||||||
|
whichever files happened to come first. Every file that needs such a
|
||||||
|
blob is now reported as failed, the rest are restored, and the command
|
||||||
|
still exits non-zero. Without the flag the blob error still aborts.
|
||||||
|
|
||||||
|
- 2026-10-06: Re-vendored the canonical files from `sneak/prompts` at
|
||||||
|
`dd4027b` ([issue #213](https://git.eeqj.de/sneak/vaultik/issues/213)).
|
||||||
|
Linting and testing are now the `lint` and `test` phases of the
|
||||||
|
`Dockerfile`, and the build stage depends on both. `Dockerfile.lint`,
|
||||||
|
`CHECK_EPOCH` and the tests that checked them are gone; every
|
||||||
|
`docker build` in `script/` passes `--no-cache` instead. golangci-lint
|
||||||
|
is v2.14.0, with its new findings fixed in the code, and the rules in
|
||||||
|
`CLAUDE.md` now live in `AGENTS.md`.
|
||||||
|
|
||||||
|
- 2026-10-06: Made the local index actually run in WAL mode with a busy
|
||||||
|
timeout ([issue #217](https://git.eeqj.de/sneak/vaultik/issues/217)).
|
||||||
|
The connection settings were written in a form the SQLite driver
|
||||||
|
ignores, so the index ran without either and `snapshot list` or `info`
|
||||||
|
during a backup could make the backup's next write fail with
|
||||||
|
`database is locked`. They are now `_pragma=` parameters, and the
|
||||||
|
metadata export copies the open index with `VACUUM INTO`, because a
|
||||||
|
copy of the file alone misses rows still in the `-wal` file.
|
||||||
|
|
||||||
|
- 2026-10-06: Made a backup record the real uid and gid of files,
|
||||||
|
directories and symlinks
|
||||||
|
([issue #216](https://git.eeqj.de/sneak/vaultik/issues/216)). The
|
||||||
|
scanner asked the stat result for `Uid()` and `Gid()` methods, which
|
||||||
|
`*syscall.Stat_t` does not have, so every entry was stored as `0:0`
|
||||||
|
and a restore as root gave every file to root. It now reads the
|
||||||
|
`Uid` and `Gid` fields of `*syscall.Stat_t`.
|
||||||
|
|
||||||
|
- 2026-10-06: Made a `file://` destination whose directory is missing
|
||||||
|
count as one that cannot be listed
|
||||||
|
([issue #220](https://git.eeqj.de/sneak/vaultik/issues/220)). The file
|
||||||
|
backend listed a missing directory as an empty store, so with the
|
||||||
|
quickstart's USB stick unplugged `snapshot list` reported every local
|
||||||
|
snapshot as missing from the store, `snapshot remove` claimed to have
|
||||||
|
removed metadata it never reached, and `prune` dropped every local
|
||||||
|
snapshot record. Listing a missing destination directory is now an
|
||||||
|
error; a first backup still creates the directory.
|
||||||
|
|
||||||
|
- 2026-10-06: Made `--older-than` and `--keep-newer-than` reject a
|
||||||
|
duration with characters outside its number-and-unit parts
|
||||||
|
([issue #215](https://git.eeqj.de/sneak/vaultik/issues/215)). The
|
||||||
|
parser picked out the parts it recognised and skipped the rest, so
|
||||||
|
`1.0y` became zero and `--prune --keep-newer-than 1.0y` deleted every
|
||||||
|
snapshot of the backed-up names, the new one included. `1.0y`,
|
||||||
|
`1,5y`, `30 days`, `x7d` and a bare `0` are now errors; decimals still
|
||||||
|
work in Go units such as `1.5h`.
|
||||||
|
|
||||||
|
- 2026-10-06: Made a backup re-chunk a known file that lists a chunk no
|
||||||
|
uploaded blob holds
|
||||||
|
([issue #214](https://git.eeqj.de/sneak/vaultik/issues/214)). File
|
||||||
|
rows are shared by every snapshot and updated in place, so removing or
|
||||||
|
pruning the only snapshot that referenced a changed file's current
|
||||||
|
blob left an older snapshot keeping a row that matched the disk while
|
||||||
|
no blob held its chunks. The next backup skipped the file and
|
||||||
|
completed a snapshot that could not restore it.
|
||||||
|
|
||||||
- 2026-09-22: Routed the last direct-to-stdout command output through
|
- 2026-09-22: Routed the last direct-to-stdout command output through
|
||||||
`internal/ui`
|
`internal/ui`
|
||||||
([issue #149](https://git.eeqj.de/sneak/vaultik/issues/149)). The
|
([issue #149](https://git.eeqj.de/sneak/vaultik/issues/149)). The
|
||||||
@@ -693,4 +1299,5 @@ release" is exactly the contradiction
|
|||||||
|
|
||||||
# Future Steps
|
# Future Steps
|
||||||
|
|
||||||
None queued; the release-scoping item is now the Next Step.
|
Work planned after 1.0 is listed in the README
|
||||||
|
[roadmap](README.md#roadmap).
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package main_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestDockerfileBoundsEveryModuleDownload fails unless every
|
||||||
|
// `go mod download` in the Dockerfile runs under a time limit, is tried
|
||||||
|
// a fixed number of times, and then fails the build with a message that
|
||||||
|
// names the module download (issue #351). Like the version tests, this
|
||||||
|
// parses the Dockerfile; that a stalled download really is cut off and
|
||||||
|
// retried was checked by hand.
|
||||||
|
func TestDockerfileBoundsEveryModuleDownload(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var downloads []string
|
||||||
|
|
||||||
|
for _, instruction := range instructions(t, productDockerfile) {
|
||||||
|
if strings.Contains(instruction, "go mod download") {
|
||||||
|
downloads = append(downloads, instruction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NotEmpty(t, downloads, "%s must download modules",
|
||||||
|
productDockerfile)
|
||||||
|
|
||||||
|
for _, download := range downloads {
|
||||||
|
assert.Contains(t, download, "timeout 300 go mod download",
|
||||||
|
"%s: a module download must have a time limit: %s",
|
||||||
|
productDockerfile, download)
|
||||||
|
assert.Contains(t, download, "for attempt in 1 2 3",
|
||||||
|
"%s: a module download must be retried: %s",
|
||||||
|
productDockerfile, download)
|
||||||
|
assert.Contains(t, download, "module download failed",
|
||||||
|
"%s: a module download that never succeeds must say so: %s",
|
||||||
|
productDockerfile, download)
|
||||||
|
assert.True(t, strings.HasSuffix(download, "exit 1"),
|
||||||
|
"%s: a module download that never succeeds must fail the"+
|
||||||
|
" build: %s", productDockerfile, download)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,92 +11,139 @@ import (
|
|||||||
// This file guards the version stamping of the product image (issue
|
// This file guards the version stamping of the product image (issue
|
||||||
// #75). The failure it protects against is silent: the image still
|
// #75). The failure it protects against is silent: the image still
|
||||||
// builds and runs, but `vaultik version` inside it reports "commit:
|
// builds and runs, but `vaultik version` inside it reports "commit:
|
||||||
// unknown", so an operator cannot tell which source produced a given
|
// unknown" or a version of "dev", so an operator cannot tell which
|
||||||
// backup. .dockerignore excludes .git, so the build cannot derive the
|
// source produced a given backup. The build takes the version as a
|
||||||
// commit itself; the values must be computed on the host and passed in.
|
// build arg, which script/docker computes on the host, and otherwise
|
||||||
|
// derives it from the .git in its context; the commit and its date
|
||||||
|
// always come from that .git.
|
||||||
//
|
//
|
||||||
// These are parses of the committed files, for the same reason the lint
|
// These are parses of the committed files, because shelling out to
|
||||||
// guards next door are: shelling out to docker would nest a build
|
// docker would nest a build inside `make test`. That `vaultik version`
|
||||||
// inside `make test`. That `vaultik version` in the built image really
|
// in the built image really prints the host's version is verified by
|
||||||
// prints the host's version is verified by hand and recorded on the
|
// hand.
|
||||||
// pull request.
|
|
||||||
|
|
||||||
// dockerScript is script/docker, relative to the repository root.
|
// The files under guard, relative to the repository root.
|
||||||
const dockerScript = "script/docker"
|
const (
|
||||||
|
productDockerfile = "Dockerfile"
|
||||||
|
dockerScript = "script/docker"
|
||||||
|
)
|
||||||
|
|
||||||
// versionArgs are the ldflag targets the build stamps and, matching
|
// TestProductDockerfileTakesVersionAsBuildArg fails unless the build
|
||||||
// them, the build args the host must supply. The names line up so the
|
// declares ARG VERSION, with no default, and stamps it into the binary
|
||||||
// same list checks both files.
|
// whenever it is given, ahead of the value derived in the container.
|
||||||
func versionArgs() []string {
|
func TestProductDockerfileTakesVersionAsBuildArg(t *testing.T) {
|
||||||
return []string{"VERSION", "COMMIT", "COMMIT_DATE"}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestProductDockerfileTakesVersionAsBuildArgs fails unless the build
|
|
||||||
// declares each version arg and stamps it into the binary by ldflag
|
|
||||||
// reference, rather than computing it in the container.
|
|
||||||
func TestProductDockerfileTakesVersionAsBuildArgs(t *testing.T) {
|
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
found := instructions(t, productDockerfile)
|
found := instructions(t, productDockerfile)
|
||||||
|
|
||||||
for _, arg := range versionArgs() {
|
require.Contains(t, found, "ARG VERSION",
|
||||||
require.GreaterOrEqual(t, indexOf(found, "ARG "+arg), 0,
|
"%s must declare `ARG VERSION`, with no default, so the host can"+
|
||||||
"%s must declare `ARG %s` so the host can pass it in",
|
" pass it in", productDockerfile)
|
||||||
productDockerfile, arg)
|
|
||||||
|
|
||||||
assertLdflagReferences(t, found, arg)
|
assertLdflagReferences(t, found, "VERSION")
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestProductDockerfileDoesNotDeriveVersionItself is the anti-regression
|
// TestProductDockerfileDerivesVersionFromGit fails unless a build given
|
||||||
// for the original defect: the container ran `git rev-parse`, but .git
|
// no VERSION, such as a plain `docker build .` of a clone, takes it from
|
||||||
// is not in the build context, so it always resolved to "unknown". No
|
// `git describe` of the .git in its context, stamps "dev" when the
|
||||||
// git command may reach into a build that cannot see the history.
|
// context has no .git, and fails rather than stamp "dev" when that .git
|
||||||
func TestProductDockerfileDoesNotDeriveVersionItself(t *testing.T) {
|
// yields no version. The commit and its date come from the same .git,
|
||||||
|
// and the build fails rather than stamp them "unknown" when it is
|
||||||
|
// present.
|
||||||
|
func TestProductDockerfileDerivesVersionFromGit(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
text := instructionText(readRepoFile(t, productDockerfile))
|
found := instructions(t, productDockerfile)
|
||||||
|
|
||||||
assert.NotContains(t, text, "git ",
|
buildAt := indexContaining(found, "go build")
|
||||||
"%s must not run git: .git is excluded from the build context, so"+
|
require.GreaterOrEqual(t, buildAt, 0, "%s must build", productDockerfile)
|
||||||
" any value it derives is wrong. Pass version, commit and date"+
|
|
||||||
" in as build args instead.", productDockerfile)
|
assert.Contains(t, found[buildAt], "git describe --tags --always || echo dev",
|
||||||
|
"%s must derive the version from git when no VERSION is given,"+
|
||||||
|
" and stamp dev when the context has no .git", productDockerfile)
|
||||||
|
assert.Contains(t, found[buildAt], "[ -e .git ]",
|
||||||
|
"%s must fail when the context carries .git but yields no version",
|
||||||
|
productDockerfile)
|
||||||
|
assert.Contains(t, found[buildAt], "git rev-parse HEAD",
|
||||||
|
"%s must stamp the commit from git", productDockerfile)
|
||||||
|
assert.Contains(t, found[buildAt], "git show -s --format=%cs HEAD",
|
||||||
|
"%s must stamp the commit date from git", productDockerfile)
|
||||||
|
assert.Contains(t, found[buildAt],
|
||||||
|
`[ "$commit" = unknown ] || [ "$commit_date" = unknown ]`,
|
||||||
|
"%s must fail when the context carries .git but yields no commit"+
|
||||||
|
" or date", productDockerfile)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestDockerScriptComputesVersionOnTheHost fails unless script/docker
|
// TestDockerScriptComputesVersionOnTheHost fails unless script/docker
|
||||||
// derives each value where .git exists and passes it as a build arg,
|
// passes the version it derives where .git exists as a build arg.
|
||||||
// with VERSION coming from script/version so a Docker build reports the
|
|
||||||
// same string a local build of the same tree would.
|
|
||||||
func TestDockerScriptComputesVersionOnTheHost(t *testing.T) {
|
func TestDockerScriptComputesVersionOnTheHost(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
script := readRepoFile(t, dockerScript)
|
script := readRepoFile(t, dockerScript)
|
||||||
|
|
||||||
for _, arg := range versionArgs() {
|
assert.Contains(t, script, "--build-arg VERSION=",
|
||||||
assert.Contains(t, script, "--build-arg "+arg+"=",
|
"%s must pass --build-arg VERSION to the build", dockerScript)
|
||||||
"%s must pass --build-arg %s to the build", dockerScript, arg)
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Contains(t, script, "/version",
|
|
||||||
"%s must take VERSION from script/version, the source of truth"+
|
|
||||||
" shared with the Makefile", dockerScript)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// assertLdflagReferences fails unless some build instruction stamps the
|
// assertLdflagReferences fails unless the build instruction uses the
|
||||||
// named variable from the ARG (a ${arg} reference), not from a value
|
// named ARG whenever it is given (a ${arg:- reference), so a value
|
||||||
// computed inside the container.
|
// passed in is not overridden by one derived inside the container.
|
||||||
func assertLdflagReferences(t *testing.T, found []string, arg string) {
|
func assertLdflagReferences(t *testing.T, found []string, arg string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
for _, instruction := range found {
|
for _, instruction := range found {
|
||||||
if strings.HasPrefix(instruction, "RUN ") &&
|
if strings.HasPrefix(instruction, "RUN ") &&
|
||||||
strings.Contains(instruction, "go build") &&
|
strings.Contains(instruction, "go build") &&
|
||||||
strings.Contains(instruction, "${"+arg+"}") {
|
strings.Contains(instruction, "${"+arg+":-") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
assert.Fail(t, "version arg is declared but never stamped",
|
assert.Fail(t, "version arg is declared but never stamped",
|
||||||
"the go build in %s must reference ${%s} in its ldflags, or the"+
|
"the go build in %s must use ${%s:-...}, or the arg is passed and"+
|
||||||
" arg is passed and discarded", productDockerfile, arg)
|
" discarded", productDockerfile, arg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// instructions returns the Dockerfile's instructions, one per element,
|
||||||
|
// with comments and blank lines dropped and continuation lines joined,
|
||||||
|
// so a multi-line RUN is one string.
|
||||||
|
func instructions(t *testing.T, name string) []string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var (
|
||||||
|
out []string
|
||||||
|
continued string
|
||||||
|
isContinued bool
|
||||||
|
)
|
||||||
|
|
||||||
|
for line := range strings.SplitSeq(readRepoFile(t, name), "\n") {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if !isContinued && (trimmed == "" || strings.HasPrefix(trimmed, "#")) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
isContinued = strings.HasSuffix(trimmed, `\`)
|
||||||
|
continued += strings.TrimSuffix(trimmed, `\`)
|
||||||
|
|
||||||
|
if isContinued {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
out = append(out, strings.Join(strings.Fields(continued), " "))
|
||||||
|
continued = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// indexContaining returns the position of the first instruction
|
||||||
|
// containing want; -1 if there is none.
|
||||||
|
func indexContaining(found []string, want string) int {
|
||||||
|
for i, instruction := range found {
|
||||||
|
if strings.Contains(instruction, want) {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return -1
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,367 +0,0 @@
|
|||||||
package main_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
// This file guards the shape of the lint gate. Every property asserted
|
|
||||||
// here is one whose loss is SILENT: the build still exits 0, the gate
|
|
||||||
// still looks green, and nothing was linted or tested.
|
|
||||||
//
|
|
||||||
// The gate is a build step. script/lint builds Dockerfile.lint, which
|
|
||||||
// runs golangci-lint as a RUN instruction, so a successful build is a
|
|
||||||
// clean lint. BuildKit will happily replay that RUN from cache on an
|
|
||||||
// unchanged tree in well under a second, which is why the check layers
|
|
||||||
// are keyed on a CHECK_EPOCH build arg that the calling script
|
|
||||||
// regenerates per invocation, and why an empty value is a hard error
|
|
||||||
// rather than a stable cache key.
|
|
||||||
//
|
|
||||||
// These are parses rather than invocations. Shelling out to docker from
|
|
||||||
// the test suite would nest a build inside `make test`, which itself
|
|
||||||
// runs inside a build in CI. The one property a parse cannot establish
|
|
||||||
// -- that a real finding actually fails the build -- is verified by
|
|
||||||
// hand against a deliberately broken tree, recorded on the pull
|
|
||||||
// request.
|
|
||||||
//
|
|
||||||
// One property is deliberately NOT tested here: that no script runs the
|
|
||||||
// linter on the host. script/lint is the only lint entry point, and it
|
|
||||||
// runs golangci-lint only inside the container; keeping it that way is a
|
|
||||||
// review matter, not something a test in this file establishes.
|
|
||||||
|
|
||||||
// The files under guard, relative to the repository root.
|
|
||||||
const (
|
|
||||||
lintDockerfile = "Dockerfile.lint"
|
|
||||||
productDockerfile = "Dockerfile"
|
|
||||||
lintScript = "script/lint"
|
|
||||||
cibuildScript = "script/cibuild"
|
|
||||||
)
|
|
||||||
|
|
||||||
// linterBinary is the linter's command name, used to locate the
|
|
||||||
// config-verify and lint steps in Dockerfile.lint.
|
|
||||||
const linterBinary = "golangci-lint"
|
|
||||||
|
|
||||||
// checkEpochARG is the declaration, with no default value. A default
|
|
||||||
// would satisfy the non-empty guard with a constant, and a constant is
|
|
||||||
// a stable cache key: the checks would be replayed from cache forever
|
|
||||||
// after the first build.
|
|
||||||
const checkEpochARG = "ARG CHECK_EPOCH"
|
|
||||||
|
|
||||||
// checkEpochGuard is what turns a build that omits --build-arg into a
|
|
||||||
// loud failure instead of a quiet green. Failed steps are never cached,
|
|
||||||
// so it fires on every such invocation rather than once.
|
|
||||||
const checkEpochGuard = `RUN [ -n "$CHECK_EPOCH" ] || exit 1`
|
|
||||||
|
|
||||||
// freshEpoch is the epoch computation the calling scripts must use, as
|
|
||||||
// a bare assignment on its own line. Inline in an argument, a failing
|
|
||||||
// `date` would not abort under `set -eu`; CHECK_EPOCH would become the
|
|
||||||
// empty string, and the guard above would be the only thing standing
|
|
||||||
// between that and a permanently cached green. `$$` is required because
|
|
||||||
// `date +%s` is second-granular and busybox silently drops `%N`, so
|
|
||||||
// without the pid two concurrent runs in one second can collide.
|
|
||||||
const freshEpoch = `epoch="$(date +%s%N)$$"`
|
|
||||||
|
|
||||||
// TestLintDockerfilePinsTheLinterByDigest fails if the lint image stops
|
|
||||||
// being pinned. An unpinned tag makes the gate's verdict depend on
|
|
||||||
// whatever the registry currently serves under that name.
|
|
||||||
func TestLintDockerfilePinsTheLinterByDigest(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
from := ""
|
|
||||||
|
|
||||||
for _, instruction := range instructions(t, lintDockerfile) {
|
|
||||||
if strings.HasPrefix(instruction, "FROM ") {
|
|
||||||
from = instruction
|
|
||||||
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
require.NotEmpty(t, from, "%s declares no FROM", lintDockerfile)
|
|
||||||
assert.Contains(t, from, "golangci/golangci-lint",
|
|
||||||
"the lint image must be the golangci-lint image")
|
|
||||||
assert.Contains(t, from, "@sha256:",
|
|
||||||
"the lint image must be pinned by digest, not by tag alone")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestLintDockerfileCannotBeCachedGreen pins the whole cache-busting
|
|
||||||
// mechanism in the file that lints: the declaration with no default,
|
|
||||||
// the non-empty guard, and the value expanded into the lint command
|
|
||||||
// itself rather than merely declared.
|
|
||||||
func TestLintDockerfileCannotBeCachedGreen(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
found := instructions(t, lintDockerfile)
|
|
||||||
|
|
||||||
argAt := indexOf(found, checkEpochARG)
|
|
||||||
require.GreaterOrEqual(t, argAt, 0,
|
|
||||||
"%s must declare `%s` with no default value",
|
|
||||||
lintDockerfile, checkEpochARG)
|
|
||||||
|
|
||||||
assert.GreaterOrEqual(t, indexOf(found, checkEpochGuard), argAt,
|
|
||||||
"%s must guard against an empty CHECK_EPOCH with `%s`",
|
|
||||||
lintDockerfile, checkEpochGuard)
|
|
||||||
|
|
||||||
assertEpochExpandedInto(t, found[argAt:], "golangci-lint run")
|
|
||||||
|
|
||||||
// Dependency layers must stay above the ARG, or every lint run
|
|
||||||
// re-downloads the module cache and the inner loop becomes
|
|
||||||
// unusable.
|
|
||||||
download := indexOf(found, "RUN go mod download")
|
|
||||||
require.GreaterOrEqual(t, download, 0,
|
|
||||||
"%s must download modules in their own layer", lintDockerfile)
|
|
||||||
assert.Less(t, download, argAt,
|
|
||||||
"`%s` must come after `go mod download` so dependency layers"+
|
|
||||||
" still cache", checkEpochARG)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestLintDockerfileVerifiesTheLinterConfig guards the validation of
|
|
||||||
// .golangci.yml itself. `golangci-lint run` rejects a config it cannot
|
|
||||||
// parse but silently IGNORES an unknown top-level key, so renaming
|
|
||||||
// `linters:` to `linterz:` discards `default: all` and every threshold
|
|
||||||
// and still exits 0 reporting no issues. `config verify` is what turns
|
|
||||||
// that into a failure, and it has to run BEFORE the lint, or the lint
|
|
||||||
// spends a minute reporting a verdict from a config already known to be
|
|
||||||
// wrong.
|
|
||||||
func TestLintDockerfileVerifiesTheLinterConfig(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
found := instructions(t, lintDockerfile)
|
|
||||||
verify := linterBinary + " config verify"
|
|
||||||
|
|
||||||
verifyAt := indexContaining(found, verify)
|
|
||||||
require.GreaterOrEqual(t, verifyAt, 0,
|
|
||||||
"%s must run `%s --config .golangci.yml`: without it a typo'd"+
|
|
||||||
" top-level key in .golangci.yml is silently ignored and the"+
|
|
||||||
" gate passes with only the default linter set", lintDockerfile,
|
|
||||||
verify)
|
|
||||||
|
|
||||||
runAt := indexContaining(found, linterBinary+" run")
|
|
||||||
require.GreaterOrEqual(t, runAt, 0, "%s must lint", lintDockerfile)
|
|
||||||
assert.Less(t, verifyAt, runAt,
|
|
||||||
"%s must verify the config before linting with it", lintDockerfile)
|
|
||||||
|
|
||||||
// Keyed on the epoch like every other check layer, so it executes
|
|
||||||
// per invocation rather than being replayed. A cached validation
|
|
||||||
// validates nothing.
|
|
||||||
assertEpochExpandedInto(t, found, verify)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestProductDockerfileCannotBeCachedGreen holds the same line for the
|
|
||||||
// checks that remain in the product image build.
|
|
||||||
func TestProductDockerfileCannotBeCachedGreen(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
found := instructions(t, productDockerfile)
|
|
||||||
|
|
||||||
argAt := indexOf(found, checkEpochARG)
|
|
||||||
require.GreaterOrEqual(t, argAt, 0,
|
|
||||||
"%s must declare `%s` with no default value",
|
|
||||||
productDockerfile, checkEpochARG)
|
|
||||||
|
|
||||||
assert.GreaterOrEqual(t, indexOf(found, checkEpochGuard), argAt,
|
|
||||||
"%s must guard against an empty CHECK_EPOCH", productDockerfile)
|
|
||||||
|
|
||||||
assertEpochExpandedInto(t, found[argAt:], "make fmt-check")
|
|
||||||
assertEpochExpandedInto(t, found[argAt:], "make test")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestProductDockerfileDoesNotLint records the split deliberately: the
|
|
||||||
// linter lives in Dockerfile.lint and nowhere else, so there is exactly
|
|
||||||
// one digest pinning it. A lint stage reintroduced here would either be
|
|
||||||
// docker-in-docker (`make lint` is now `docker build`) or a second,
|
|
||||||
// independently bumpable pin.
|
|
||||||
func TestProductDockerfileDoesNotLint(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
contents := readRepoFile(t, productDockerfile)
|
|
||||||
|
|
||||||
for _, forbidden := range []string{"golangci", "make lint"} {
|
|
||||||
assert.NotContains(t, instructionText(contents), forbidden,
|
|
||||||
"%s must not lint: the linter is pinned once, in %s",
|
|
||||||
productDockerfile, lintDockerfile)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestLintScriptBuildsTheLintDockerfileWithAFreshEpoch is the other
|
|
||||||
// half of the mechanism. The Dockerfile's guard only rejects an EMPTY
|
|
||||||
// epoch; a constant non-empty one would satisfy it and still be served
|
|
||||||
// from cache forever.
|
|
||||||
func TestLintScriptBuildsTheLintDockerfileWithAFreshEpoch(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
script := readRepoFile(t, lintScript)
|
|
||||||
|
|
||||||
assertBareEpochAssignment(t, script, lintScript)
|
|
||||||
assert.Contains(t, script, `--build-arg CHECK_EPOCH="$epoch"`,
|
|
||||||
"%s must pass the fresh epoch to the build", lintScript)
|
|
||||||
assert.Contains(t, script, lintDockerfile,
|
|
||||||
"%s must build %s", lintScript, lintDockerfile)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestCibuildBuildsBothDockerfilesWithFreshEpochs guards the CI gate:
|
|
||||||
// dropping either build silently removes a whole class of check from
|
|
||||||
// CI while leaving it green.
|
|
||||||
func TestCibuildBuildsBothDockerfilesWithFreshEpochs(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
script := readRepoFile(t, cibuildScript)
|
|
||||||
|
|
||||||
assertBareEpochAssignment(t, script, cibuildScript)
|
|
||||||
assert.Equal(t, 2, strings.Count(script, freshEpoch),
|
|
||||||
"%s must compute a fresh epoch for each of its two builds",
|
|
||||||
cibuildScript)
|
|
||||||
assert.Equal(t, 2,
|
|
||||||
strings.Count(script, `--build-arg CHECK_EPOCH="$epoch"`),
|
|
||||||
"%s must pass a fresh epoch to both builds", cibuildScript)
|
|
||||||
assert.Contains(t, script, "-f Dockerfile.lint",
|
|
||||||
"%s must build %s", cibuildScript, lintDockerfile)
|
|
||||||
}
|
|
||||||
|
|
||||||
// assertEpochExpandedInto fails unless some instruction runs the named
|
|
||||||
// command with the epoch expanded into it. Expansion, not mere
|
|
||||||
// declaration: an ARG that no instruction references is not guaranteed
|
|
||||||
// to key the layer, and the expansion also puts the value in the build
|
|
||||||
// log where a reader can see the layer was keyed fresh.
|
|
||||||
func assertEpochExpandedInto(t *testing.T, found []string, command string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
for _, instruction := range found {
|
|
||||||
if !strings.HasPrefix(instruction, "RUN ") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.Contains(instruction, command) &&
|
|
||||||
strings.Contains(instruction, "${CHECK_EPOCH}") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Fail(t, "no epoch-keyed layer runs the command",
|
|
||||||
"`%s` must run in a layer that expands ${CHECK_EPOCH}, or it"+
|
|
||||||
" will be replayed from cache without executing", command)
|
|
||||||
}
|
|
||||||
|
|
||||||
// assertBareEpochAssignment fails unless the script computes the epoch
|
|
||||||
// as a bare assignment on its own line.
|
|
||||||
func assertBareEpochAssignment(t *testing.T, script, name string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
for line := range strings.SplitSeq(script, "\n") {
|
|
||||||
if strings.TrimSpace(line) == freshEpoch {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Fail(t, "no bare epoch assignment",
|
|
||||||
"%s must compute `%s` as a bare assignment on its own line, so"+
|
|
||||||
" `set -e` catches a failing date instead of quietly"+
|
|
||||||
" building with an empty epoch", name, freshEpoch)
|
|
||||||
}
|
|
||||||
|
|
||||||
// instructions returns the Dockerfile's instructions, one per element,
|
|
||||||
// with comments and blank lines dropped and continuation lines joined,
|
|
||||||
// so a multi-line RUN is one string.
|
|
||||||
func instructions(t *testing.T, name string) []string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
return strings.Split(instructionText(readRepoFile(t, name)), "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// instructionText is instructions' parse, before splitting: it is also
|
|
||||||
// what a "must not contain" assertion should look at, so that a word
|
|
||||||
// appearing only in a comment is not mistaken for behaviour.
|
|
||||||
func instructionText(contents string) string {
|
|
||||||
var (
|
|
||||||
out []string
|
|
||||||
continued string
|
|
||||||
isContinued bool
|
|
||||||
)
|
|
||||||
|
|
||||||
for line := range strings.SplitSeq(contents, "\n") {
|
|
||||||
trimmed := strings.TrimSpace(line)
|
|
||||||
if !isContinued && (trimmed == "" || strings.HasPrefix(trimmed, "#")) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
isContinued = strings.HasSuffix(trimmed, `\`)
|
|
||||||
continued += strings.TrimSuffix(trimmed, `\`)
|
|
||||||
|
|
||||||
if isContinued {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
out = append(out, strings.Join(strings.Fields(continued), " "))
|
|
||||||
continued = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
return strings.Join(out, "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// indexOf returns the position of the first instruction equal to, or
|
|
||||||
// beginning with, want; -1 if there is none. An `ARG NAME=default`
|
|
||||||
// counts as beginning with `ARG NAME`, so a declared arg is found
|
|
||||||
// whether or not it carries a default.
|
|
||||||
func indexOf(found []string, want string) int {
|
|
||||||
for i, instruction := range found {
|
|
||||||
if instruction == want ||
|
|
||||||
strings.HasPrefix(instruction, want+" ") ||
|
|
||||||
strings.HasPrefix(instruction, want+"=") {
|
|
||||||
return i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|
||||||
// indexContaining returns the position of the first instruction
|
|
||||||
// containing want; -1 if there is none.
|
|
||||||
func indexContaining(found []string, want string) int {
|
|
||||||
for i, instruction := range found {
|
|
||||||
if strings.Contains(instruction, want) {
|
|
||||||
return i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|
||||||
// readRepoFile reads a file by its path relative to the repository
|
|
||||||
// root.
|
|
||||||
func readRepoFile(t *testing.T, name string) string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
//nolint:gosec // G304: the path is a constant relative to this repo
|
|
||||||
contents, err := os.ReadFile(filepath.Join(repoRoot(t), name))
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
return string(contents)
|
|
||||||
}
|
|
||||||
|
|
||||||
// repoRoot returns the repository root. The test binary runs with its
|
|
||||||
// package directory as the working directory, so the root is found by
|
|
||||||
// walking up until the module file appears.
|
|
||||||
func repoRoot(t *testing.T) string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
dir, err := os.Getwd()
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
for {
|
|
||||||
_, err = os.Stat(filepath.Join(dir, "go.mod"))
|
|
||||||
if err == nil {
|
|
||||||
return dir
|
|
||||||
}
|
|
||||||
|
|
||||||
parent := filepath.Dir(dir)
|
|
||||||
require.NotEqual(t, dir, parent,
|
|
||||||
"walked to the filesystem root without finding a go.mod")
|
|
||||||
|
|
||||||
dir = parent
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
package main_test
|
package main_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -84,14 +86,48 @@ func TestBuildTargetBuildsTheBinary(t *testing.T) {
|
|||||||
"`make build` must depend on the rule that builds the binary")
|
"`make build` must depend on the rule that builds the binary")
|
||||||
}
|
}
|
||||||
|
|
||||||
// readMakefile returns the contents of the repository's Makefile. The
|
// readMakefile returns the contents of the repository's Makefile.
|
||||||
// root is located by the shared walk in lintdocker_test.go.
|
|
||||||
func readMakefile(t *testing.T) string {
|
func readMakefile(t *testing.T) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
return readRepoFile(t, "Makefile")
|
return readRepoFile(t, "Makefile")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// readRepoFile reads a file by its path relative to the repository
|
||||||
|
// root.
|
||||||
|
func readRepoFile(t *testing.T, name string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
//nolint:gosec // G304: the path is a constant relative to this repo
|
||||||
|
contents, err := os.ReadFile(filepath.Join(repoRoot(t), name))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
return string(contents)
|
||||||
|
}
|
||||||
|
|
||||||
|
// repoRoot returns the repository root. The test binary runs with its
|
||||||
|
// package directory as the working directory, so the root is found by
|
||||||
|
// walking up until the module file appears.
|
||||||
|
func repoRoot(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
dir, err := os.Getwd()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
for {
|
||||||
|
_, err = os.Stat(filepath.Join(dir, "go.mod"))
|
||||||
|
if err == nil {
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
parent := filepath.Dir(dir)
|
||||||
|
require.NotEqual(t, dir, parent,
|
||||||
|
"walked to the filesystem root without finding a go.mod")
|
||||||
|
|
||||||
|
dir = parent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// phonyTargets returns every name declared phony, across all .PHONY
|
// phonyTargets returns every name declared phony, across all .PHONY
|
||||||
// lines.
|
// lines.
|
||||||
func phonyTargets(makefile string) []string {
|
func phonyTargets(makefile string) []string {
|
||||||
|
|||||||
+31
-8
@@ -3,7 +3,8 @@
|
|||||||
# Copy this file and uncomment/modify the values you need
|
# Copy this file and uncomment/modify the values you need
|
||||||
|
|
||||||
# Age recipient public keys for encryption
|
# Age recipient public keys for encryption
|
||||||
# This is REQUIRED - backups are encrypted to these public keys
|
# Backups are encrypted to these public keys. snapshot create needs at least
|
||||||
|
# one; listing, verifying and restoring do not
|
||||||
# Generate with: age-keygen | grep "public key"
|
# Generate with: age-keygen | grep "public key"
|
||||||
age_recipients:
|
age_recipients:
|
||||||
- age1cj2k2addawy294f6k2gr2mf9gps9r3syplryxca3nvxj3daqm96qfp84tz
|
- age1cj2k2addawy294f6k2gr2mf9gps9r3syplryxca3nvxj3daqm96qfp84tz
|
||||||
@@ -286,19 +287,23 @@ storage_url: "rclone://myremote/path/to/backups"
|
|||||||
# #use_ssl: true
|
# #use_ssl: true
|
||||||
#
|
#
|
||||||
# # Part size for multipart uploads
|
# # Part size for multipart uploads
|
||||||
# # Minimum 5MB, affects memory usage during upload
|
# # Minimum 5MiB, maximum 5GiB; affects memory usage during upload
|
||||||
# # Supports: 5MB, 10M, 100MiB, etc.
|
# # A blob too large for 10,000 parts of this size gets larger parts
|
||||||
# # Default: 5MB
|
# # Supports: 10MB, 16MiB, 100MiB, etc. (5MB is below the minimum)
|
||||||
# #part_size: 5MB
|
# # Default: 5MiB
|
||||||
|
# #part_size: 5MiB
|
||||||
|
|
||||||
# Path to local SQLite index database
|
# Path to local SQLite index database
|
||||||
# This database tracks file state for incremental backups
|
# This database tracks file state for incremental backups
|
||||||
# Default: /var/lib/vaultik/index.sqlite
|
# Default: the platform data directory, e.g.
|
||||||
#index_path: /var/lib/vaultik/index.sqlite
|
# macOS: ~/Library/Application Support/vaultik/index.sqlite
|
||||||
|
# Linux: ~/.local/share/vaultik/index.sqlite
|
||||||
|
#index_path: /path/to/index.sqlite
|
||||||
|
|
||||||
# Average chunk size for content-defined chunking
|
# Average chunk size for content-defined chunking
|
||||||
# Smaller chunks = better deduplication but more metadata
|
# Smaller chunks = better deduplication but more metadata
|
||||||
# Supports: 10MB, 5M, 1GB, 500KB, 64MiB, etc.
|
# Minimum 1MB
|
||||||
|
# Supports: 10MB, 5M, 1GB, 64MiB, etc.
|
||||||
# Default: 10MB
|
# Default: 10MB
|
||||||
#chunk_size: 10MB
|
#chunk_size: 10MB
|
||||||
|
|
||||||
@@ -309,6 +314,12 @@ storage_url: "rclone://myremote/path/to/backups"
|
|||||||
# Chunking uses no secret (the FastCDC parameters are fixed and public). At a
|
# Chunking uses no secret (the FastCDC parameters are fixed and public). At a
|
||||||
# large limit a blob holds hundreds of chunks, so individual chunk lengths are
|
# large limit a blob holds hundreds of chunks, so individual chunk lengths are
|
||||||
# not visible in its size; lowering the limit toward chunk_size exposes them.
|
# not visible in its size; lowering the limit toward chunk_size exposes them.
|
||||||
|
# A backup needs free temporary space, because each blob is written whole to
|
||||||
|
# a temporary file before it is uploaded (up to about blob_size_limit; an
|
||||||
|
# rclone destination that cannot stream uploads needs about twice that) and
|
||||||
|
# the metadata export writes copies of the local index. Temporary files go to
|
||||||
|
# $TMPDIR (default /tmp); with TMPDIR unset, SQLite writes one of those copies
|
||||||
|
# to /var/tmp.
|
||||||
# Supports: 1GB, 10G, 500MB, 1GiB, etc.
|
# Supports: 1GB, 10G, 500MB, 1GiB, etc.
|
||||||
# Default: 10GB
|
# Default: 10GB
|
||||||
#blob_size_limit: 10GB
|
#blob_size_limit: 10GB
|
||||||
@@ -317,6 +328,18 @@ storage_url: "rclone://myremote/path/to/backups"
|
|||||||
# Higher = better compression but slower
|
# Higher = better compression but slower
|
||||||
# Default: 3
|
# Default: 3
|
||||||
compression_level: 5
|
compression_level: 5
|
||||||
|
|
||||||
|
# How old a blob that no snapshot lists must be before prune deletes it
|
||||||
|
# A backup uploads its blobs before the manifest that lists them, so on a
|
||||||
|
# destination shared between hosts or users this must be longer than the
|
||||||
|
# longest backup run
|
||||||
|
# It does not protect a blob a backup reuses from an earlier run that stopped
|
||||||
|
# before its manifest: that blob keeps the time of its first upload, so a
|
||||||
|
# prune can delete it
|
||||||
|
# Supports: 36h, 90m, etc.; 0s deletes them at once
|
||||||
|
# Default: 24h
|
||||||
|
#prune_grace_period: 24h
|
||||||
|
|
||||||
# Hostname to use in backup metadata
|
# Hostname to use in backup metadata
|
||||||
# Default: system hostname
|
# Default: system hostname
|
||||||
#hostname: myserver
|
#hostname: myserver
|
||||||
|
|||||||
+36
-37
@@ -6,33 +6,28 @@ Vaultik uses a local SQLite database to track file metadata, chunk mappings, and
|
|||||||
|
|
||||||
**Important Notes:**
|
**Important Notes:**
|
||||||
|
|
||||||
This section is the authoritative explanation of the schema/migration story;
|
|
||||||
other documents (the README and `AGENTS.md`) link here.
|
|
||||||
|
|
||||||
- **No upgrade path between versions (pre-1.0)**: Vaultik has no supported way to
|
|
||||||
carry an existing local index across a schema change. The index is disposable
|
|
||||||
— if the on-disk schema changes between versions, delete the local SQLite
|
|
||||||
database (`vaultik database delete`) and run a full backup. Remote storage is
|
|
||||||
unaffected; the new index re-deduplicates against existing remote blobs. This
|
|
||||||
is the standing project policy, and it is separate from the schema bootstrap
|
|
||||||
described next.
|
|
||||||
- **Schema bootstrap**: a fresh database is populated from numbered SQL files
|
|
||||||
embedded in the binary under `internal/database/schema/`. `000.sql` creates the
|
|
||||||
`schema_migrations` table; `001.sql` creates the application tables. On opening
|
|
||||||
a database the code applies each numbered file that has not yet run and records
|
|
||||||
its version in `schema_migrations`. This bootstraps a new database; it does not
|
|
||||||
upgrade an existing one between released versions.
|
|
||||||
- **Changing the schema (pre-1.0)**: edit `internal/database/schema/001.sql` (and
|
|
||||||
the code that touches the affected tables) directly. Do not add new numbered
|
|
||||||
files — there is no installed base to migrate.
|
|
||||||
- **Disposability expires at 1.0**: the index is treated as disposable only until
|
|
||||||
1.0 ships and is tagged. Once 1.0 is tagged that clause expires and the
|
|
||||||
question of upgrading existing indexes returns. It is deliberately left open
|
|
||||||
here.
|
|
||||||
- **Version Compatibility**: In rare cases, you may need to use the same version
|
- **Version Compatibility**: In rare cases, you may need to use the same version
|
||||||
of Vaultik to restore a backup as was used to create it. This ensures
|
of Vaultik to restore a backup as was used to create it. This ensures
|
||||||
compatibility with the metadata format stored in S3.
|
compatibility with the metadata format stored in S3.
|
||||||
|
|
||||||
|
## Schema Migrations
|
||||||
|
|
||||||
|
Vaultik supports schema migrations. They are the numbered SQL files in
|
||||||
|
`internal/database/schema/`, embedded in the binary: `000.sql` creates the
|
||||||
|
`schema_migrations` table, which records each migration that has run, and
|
||||||
|
`001.sql` creates the application tables. `database.New` opens a database and
|
||||||
|
applies, in order, every migration that database has not yet recorded.
|
||||||
|
|
||||||
|
**Before 1.0** no migrations are added, because nothing is installed anywhere
|
||||||
|
yet. A schema change edits `001.sql` (and the code that uses the affected
|
||||||
|
tables) directly. A local database created before the change has already
|
||||||
|
recorded `001.sql` as run, so it keeps the old schema and can become unusable;
|
||||||
|
`vaultik database delete` followed by a full backup rebuilds it.
|
||||||
|
|
||||||
|
**After 1.0** each schema change is a new numbered file, so an existing local
|
||||||
|
database is migrated the first time an updated vaultik opens it. A file that
|
||||||
|
has shipped in a release is never edited.
|
||||||
|
|
||||||
## Database Tables
|
## Database Tables
|
||||||
|
|
||||||
### 1. `files`
|
### 1. `files`
|
||||||
@@ -41,7 +36,8 @@ 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 as Unix timestamp
|
- `mtime` (INTEGER NOT NULL) - Modification time, whole seconds since the Unix epoch
|
||||||
|
- `mtime_nsec` (INTEGER NOT NULL) - Nanoseconds within that second, 0 to 999999999
|
||||||
- `size` (INTEGER NOT NULL) - File size in bytes
|
- `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
|
||||||
@@ -94,7 +90,7 @@ Stores information about packed, compressed, and encrypted blob files.
|
|||||||
- `created_ts` (INTEGER NOT NULL) - Creation timestamp
|
- `created_ts` (INTEGER NOT NULL) - Creation timestamp
|
||||||
- `finished_ts` (INTEGER) - Finalization timestamp (NULL if in progress)
|
- `finished_ts` (INTEGER) - Finalization timestamp (NULL if in progress)
|
||||||
- `uncompressed_size` (INTEGER NOT NULL DEFAULT 0) - Total size of chunks before compression
|
- `uncompressed_size` (INTEGER NOT NULL DEFAULT 0) - Total size of chunks before compression
|
||||||
- `compressed_size` (INTEGER NOT NULL DEFAULT 0) - Size after compression and encryption
|
- `compressed_size` (INTEGER NOT NULL DEFAULT 0) - Size after compression and encryption. Once uploaded, the size of the object in storage: when the blob was already stored, under another compression level or other recipients, this can differ from the size of the local copy
|
||||||
- `uploaded_ts` (INTEGER) - Upload completion timestamp (NULL if not uploaded)
|
- `uploaded_ts` (INTEGER) - Upload completion timestamp (NULL if not uploaded)
|
||||||
|
|
||||||
**Purpose:** Tracks blob lifecycle from creation through upload. The UUID primary key allows immediate association of chunks with blobs.
|
**Purpose:** Tracks blob lifecycle from creation through upload. The UUID primary key allows immediate association of chunks with blobs.
|
||||||
@@ -115,17 +111,19 @@ Maps chunks to the blobs that contain them.
|
|||||||
Tracks backup snapshots.
|
Tracks backup snapshots.
|
||||||
|
|
||||||
**Columns:**
|
**Columns:**
|
||||||
- `id` (TEXT PRIMARY KEY) - Snapshot ID (format: hostname-YYYYMMDD-HHMMSSZ)
|
- `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 unique chunks
|
- `chunk_count` (INTEGER) - Number of chunks this snapshot stored that were not stored before
|
||||||
- `blob_count` (INTEGER) - Number of blobs referenced
|
- `new_chunk_size` (INTEGER) - Total size of those chunks before compression; `snapshot list` shows it as NEW CHUNK SIZE
|
||||||
|
- `blob_count` (INTEGER) - Number of blobs this snapshot created
|
||||||
- `total_size` (INTEGER) - Total size of all files
|
- `total_size` (INTEGER) - Total size of all files
|
||||||
- `blob_size` (INTEGER) - Total size of all blobs (compressed)
|
- `uncompressed_chunk_size` (INTEGER) - Total size before compression of the distinct chunks the snapshot's files reference, recorded when its backup ran; `snapshot list` shows it as UNCOMPRESSED SIZE
|
||||||
|
- `blob_size` (INTEGER) - Total compressed size of all referenced blobs
|
||||||
- `blob_uncompressed_size` (INTEGER) - Total uncompressed size of all referenced blobs
|
- `blob_uncompressed_size` (INTEGER) - Total uncompressed size of all referenced blobs
|
||||||
- `compression_ratio` (REAL) - Compression ratio achieved
|
- `compression_ratio` (REAL) - Compression ratio achieved
|
||||||
- `compression_level` (INTEGER) - Compression level used for this snapshot
|
- `compression_level` (INTEGER) - Compression level used for this snapshot
|
||||||
@@ -187,7 +185,7 @@ Tracks blob upload metrics.
|
|||||||
- `UPDATE blobs SET blob_hash = ?, finished_ts = ?` - Finalize blob after packing
|
- `UPDATE blobs SET blob_hash = ?, finished_ts = ?` - Finalize blob after packing
|
||||||
|
|
||||||
4. **Upload**
|
4. **Upload**
|
||||||
- `UPDATE blobs SET uploaded_ts = ?` - Mark blob as uploaded
|
- `UPDATE blobs SET uploaded_ts = ?, compressed_size = ?` - Mark blob as uploaded, with the size of the object in storage (the upload is skipped when the blob is already stored)
|
||||||
- `INSERT INTO uploads` - Record upload metrics with snapshot_id
|
- `INSERT INTO uploads` - Record upload metrics with snapshot_id
|
||||||
- `INSERT INTO snapshot_blobs` - Associate blob with snapshot
|
- `INSERT INTO snapshot_blobs` - Associate blob with snapshot
|
||||||
|
|
||||||
@@ -200,6 +198,7 @@ Tracks blob upload metrics.
|
|||||||
1. **Change Detection**
|
1. **Change Detection**
|
||||||
- `SELECT * FROM files WHERE path = ?` - Get previous file metadata
|
- `SELECT * FROM files WHERE path = ?` - Get previous file metadata
|
||||||
- Compare mtime, size, mode to detect changes
|
- Compare mtime, size, mode to detect changes
|
||||||
|
- Re-chunk a file that lists a chunk no uploaded blob holds, even when its metadata is unchanged
|
||||||
- Skip unchanged files but still add to `snapshot_files`
|
- Skip unchanged files but still add to `snapshot_files`
|
||||||
|
|
||||||
2. **Chunk Reuse**
|
2. **Chunk Reuse**
|
||||||
@@ -221,23 +220,23 @@ The `{remote-key}` directory name is a one-way hash of the human snapshot ID, so
|
|||||||
### 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 snapshot metadata from S3
|
1. Downloads and decrypts the snapshot's metadata database (`db.zst.age`) from S3
|
||||||
2. Downloads required blobs based on manifest
|
2. Downloads the blobs holding the chunks of the files being restored, found through that database's `blob_chunks` table; the manifest is not read
|
||||||
3. Reconstructs files from decrypted and decompressed chunks
|
3. Reconstructs files from decrypted and decompressed chunks
|
||||||
|
|
||||||
### 5. Pruning
|
### 5. Pruning
|
||||||
|
|
||||||
1. **Identify Unreferenced Blobs**
|
1. **Identify Unreferenced Blobs**
|
||||||
- Query blobs not referenced by any remaining snapshot
|
- Query blobs not referenced by any remaining snapshot
|
||||||
- Delete from S3 and local database
|
- Delete from S3, once older than `prune_grace_period`, and from the local
|
||||||
|
database
|
||||||
|
|
||||||
### 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. Check if metadata exists in S3
|
2. Delete each one and all its associations, without checking S3 for its metadata
|
||||||
3. If no metadata, delete snapshot and all associations
|
3. Clean up orphaned files, chunks, and blobs
|
||||||
4. Clean up orphaned files, chunks, and blobs
|
|
||||||
|
|
||||||
## Repository Pattern
|
## Repository Pattern
|
||||||
|
|
||||||
@@ -284,7 +283,7 @@ This ensures consistency, especially important for operations like:
|
|||||||
|
|
||||||
3. **Batch Operations**: Where possible, operations are batched within transactions
|
3. **Batch Operations**: Where possible, operations are batched within transactions
|
||||||
|
|
||||||
4. **Write-Ahead Logging**: SQLite WAL mode is enabled for better concurrency
|
4. **Write-Ahead Logging**: The local index runs in SQLite WAL mode with a 10-second busy timeout, so a read-only command such as `snapshot list` can read it while a backup writes to it. Committed rows can sit in the `-wal` file beside the index until a checkpoint, so the metadata export copies the index through SQLite (`VACUUM INTO`), not as a file
|
||||||
|
|
||||||
## Data Integrity
|
## Data Integrity
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ This document describes the structure and organization of data stored in the S3
|
|||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
Vaultik stores all backup data in an S3-compatible object store. The repository consists of two main components:
|
Vaultik stores all backup data in an S3-compatible object store. The repository consists of two main components, plus a marker file:
|
||||||
1. **Blobs** - The actual backup data (content-addressed, encrypted)
|
1. **Blobs** - The actual backup data (content-addressed, encrypted)
|
||||||
2. **Metadata** - Snapshot information and manifests (partially encrypted)
|
2. **Metadata** - Snapshot information and manifests (partially encrypted)
|
||||||
|
|
||||||
@@ -12,6 +12,7 @@ Vaultik stores all backup data in an S3-compatible object store. The repository
|
|||||||
|
|
||||||
```
|
```
|
||||||
<bucket>/<prefix>/
|
<bucket>/<prefix>/
|
||||||
|
├── vaultik-destination.txt
|
||||||
├── blobs/
|
├── blobs/
|
||||||
│ └── <hash[0:2]>/
|
│ └── <hash[0:2]>/
|
||||||
│ └── <hash[2:4]>/
|
│ └── <hash[2:4]>/
|
||||||
@@ -24,6 +25,10 @@ Vaultik stores all backup data in an S3-compatible object store. The repository
|
|||||||
|
|
||||||
The metadata subdirectory is named with the **remote key**, a one-way hash of the snapshot ID, not with the human-readable snapshot ID itself. See [Remote Key Derivation](#remote-key-derivation).
|
The metadata subdirectory is named with the **remote key**, a one-way hash of the snapshot ID, not with the human-readable snapshot ID itself. See [Remote Key Derivation](#remote-key-derivation).
|
||||||
|
|
||||||
|
## Marker File (`vaultik-destination.txt`)
|
||||||
|
|
||||||
|
Every backup writes this file at the root before it uploads anything. It holds one fixed line of plain text and names no host, path or time. A destination without it is treated as not present while the local index holds snapshots stored there: `snapshot create` refuses to write to it, and `prune`, `snapshot purge`, `snapshot remove`, `snapshot list` and `remote nuke` behave as for a destination that cannot be listed. An unplugged volume leaves an empty directory at its mount point, and without the marker that directory would read as a destination whose snapshots are all gone. `remote nuke` deletes the marker last.
|
||||||
|
|
||||||
## Blobs Directory (`blobs/`)
|
## Blobs Directory (`blobs/`)
|
||||||
|
|
||||||
### Structure
|
### Structure
|
||||||
@@ -161,7 +166,7 @@ The prune operation is safe because:
|
|||||||
1. It keeps every blob listed in any snapshot's manifest and deletes only blobs that no manifest references
|
1. It keeps every blob listed in any snapshot's manifest and deletes only blobs that no manifest references
|
||||||
2. Manifests are unencrypted and can be read without keys
|
2. Manifests are unencrypted and can be read without keys
|
||||||
3. If any manifest cannot be downloaded or decoded, prune deletes nothing and exits with an error, rather than treating that snapshot's blobs as unreferenced
|
3. If any manifest cannot be downloaded or decoded, prune deletes nothing and exits with an error, rather than treating that snapshot's blobs as unreferenced
|
||||||
4. Prune requires exclusive access to the destination: running it during a concurrent backup can race a snapshot whose manifest is not yet written, so do not prune while a backup is in progress
|
4. It deletes an unreferenced blob only once the blob is older than `prune_grace_period` (default `24h`). A backup uploads its blobs before the manifest that lists them, so a prune running meanwhile, on this host or another sharing the destination, leaves them as long as no backup run takes longer than the grace period. The grace period does not protect a blob a backup reuses from an earlier run that stopped before its manifest: the backup skips the upload of a blob already on the destination, so that blob keeps the time of its first upload, and a prune before the backup's manifest is written can delete it; the new snapshot then cannot be restored. `remote nuke` has no such check
|
||||||
|
|
||||||
## Restoration Requirements
|
## Restoration Requirements
|
||||||
|
|
||||||
|
|||||||
@@ -20,9 +20,12 @@ require (
|
|||||||
github.com/rclone/rclone v1.72.1
|
github.com/rclone/rclone v1.72.1
|
||||||
github.com/spf13/afero v1.15.0
|
github.com/spf13/afero v1.15.0
|
||||||
github.com/spf13/cobra v1.10.1
|
github.com/spf13/cobra v1.10.1
|
||||||
|
github.com/spf13/pflag v1.0.10
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
|
go.uber.org/dig v1.19.0
|
||||||
go.uber.org/fx v1.24.0
|
go.uber.org/fx v1.24.0
|
||||||
golang.org/x/sync v0.18.0
|
golang.org/x/sync v0.18.0
|
||||||
|
golang.org/x/sys v0.38.0
|
||||||
golang.org/x/term v0.37.0
|
golang.org/x/term v0.37.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
modernc.org/sqlite v1.38.0
|
modernc.org/sqlite v1.38.0
|
||||||
@@ -225,7 +228,6 @@ require (
|
|||||||
github.com/smarty/assertions v1.16.0 // indirect
|
github.com/smarty/assertions v1.16.0 // indirect
|
||||||
github.com/sony/gobreaker v1.0.0 // indirect
|
github.com/sony/gobreaker v1.0.0 // indirect
|
||||||
github.com/spacemonkeygo/monkit/v3 v3.0.25-0.20251022131615-eb24eb109368 // indirect
|
github.com/spacemonkeygo/monkit/v3 v3.0.25-0.20251022131615-eb24eb109368 // indirect
|
||||||
github.com/spf13/pflag v1.0.10 // indirect
|
|
||||||
github.com/t3rm1n4l/go-mega v0.0.0-20251031123324-a804aaa87491 // indirect
|
github.com/t3rm1n4l/go-mega v0.0.0-20251031123324-a804aaa87491 // indirect
|
||||||
github.com/tidwall/gjson v1.18.0 // indirect
|
github.com/tidwall/gjson v1.18.0 // indirect
|
||||||
github.com/tidwall/match v1.1.1 // indirect
|
github.com/tidwall/match v1.1.1 // indirect
|
||||||
@@ -255,7 +257,6 @@ require (
|
|||||||
go.opentelemetry.io/otel/metric v1.38.0 // indirect
|
go.opentelemetry.io/otel/metric v1.38.0 // indirect
|
||||||
go.opentelemetry.io/otel/trace v1.38.0 // indirect
|
go.opentelemetry.io/otel/trace v1.38.0 // indirect
|
||||||
go.shabbyrobe.org/gocovmerge v0.0.0-20230507111327-fa4f82cfbf4d // indirect
|
go.shabbyrobe.org/gocovmerge v0.0.0-20230507111327-fa4f82cfbf4d // indirect
|
||||||
go.uber.org/dig v1.19.0 // indirect
|
|
||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
go.uber.org/zap v1.27.0 // indirect
|
go.uber.org/zap v1.27.0 // indirect
|
||||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||||
@@ -263,7 +264,6 @@ require (
|
|||||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||||
golang.org/x/net v0.47.0 // indirect
|
golang.org/x/net v0.47.0 // indirect
|
||||||
golang.org/x/oauth2 v0.33.0 // indirect
|
golang.org/x/oauth2 v0.33.0 // indirect
|
||||||
golang.org/x/sys v0.38.0 // indirect
|
|
||||||
golang.org/x/text v0.31.0 // indirect
|
golang.org/x/text v0.31.0 // indirect
|
||||||
golang.org/x/time v0.14.0 // indirect
|
golang.org/x/time v0.14.0 // indirect
|
||||||
golang.org/x/tools v0.38.0 // indirect
|
golang.org/x/tools v0.38.0 // indirect
|
||||||
|
|||||||
@@ -245,6 +245,17 @@ func (p *Packer) Flush(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DiscardCurrentBlob closes and removes the temp file of the blob being
|
||||||
|
// packed, if any, and drops that blob. A caller that stops before Flush
|
||||||
|
// must call it, or the temp file is left behind. Thread-safe.
|
||||||
|
func (p *Packer) DiscardCurrentBlob() {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
|
||||||
|
p.cleanupTempFile()
|
||||||
|
p.currentBlob = nil
|
||||||
|
}
|
||||||
|
|
||||||
// FinalizeBlob finalizes the current blob being assembled.
|
// FinalizeBlob finalizes the current blob being assembled.
|
||||||
// This compresses the accumulated chunks, encrypts the result, and computes
|
// This compresses the accumulated chunks, encrypts the result, and computes
|
||||||
// the content-addressed hash. The finalized blob is either passed to the
|
// the content-addressed hash. The finalized blob is either passed to the
|
||||||
|
|||||||
+54
-38
@@ -8,12 +8,12 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/adrg/xdg"
|
"github.com/adrg/xdg"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"go.uber.org/dig"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
"sneak.berlin/go/vaultik/internal/config"
|
"sneak.berlin/go/vaultik/internal/config"
|
||||||
"sneak.berlin/go/vaultik/internal/database"
|
"sneak.berlin/go/vaultik/internal/database"
|
||||||
@@ -146,16 +146,12 @@ func (e *startupError) Error() string {
|
|||||||
// could not build arguments for function "Y" (file:line): failed to build U:
|
// could not build arguments for function "Y" (file:line): failed to build U:
|
||||||
// received non-nil error from function "Z" (file:line): <real error>
|
// received non-nil error from function "Z" (file:line): <real error>
|
||||||
//
|
//
|
||||||
// Users care about the real error, not the DI plumbing. We strip everything
|
// Users care about the real error, not the DI plumbing. dig.RootCause,
|
||||||
// up through the last "): " (which is always the close-paren of an fx
|
// from the library fx is built on, unwraps those layers and returns the
|
||||||
// function-location annotation followed by the wrapped error).
|
// real error whole. Cutting the text at "): " instead would also cut a
|
||||||
|
// real error that contains it, such as the blob_size_limit one.
|
||||||
func cleanStartupError(err error) error {
|
func cleanStartupError(err error) error {
|
||||||
msg := err.Error()
|
return &startupError{msg: dig.RootCause(err).Error()}
|
||||||
if idx := strings.LastIndex(msg, "): "); idx >= 0 {
|
|
||||||
msg = msg[idx+3:]
|
|
||||||
}
|
|
||||||
|
|
||||||
return &startupError{msg: msg}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunApp starts the fx application, blocks until it is asked to stop, and
|
// RunApp starts the fx application, blocks until it is asked to stop, and
|
||||||
@@ -200,12 +196,16 @@ func RunApp(ctx context.Context, app *fx.App) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// errReported marks a failure the operation has already shown the user
|
// errReported marks a failure the operation has already shown the user
|
||||||
// (and deliberately withheld under --json). Entry turns it into a
|
// (or, under `snapshot verify --json`, put in its document). Entry
|
||||||
// non-zero exit status without printing anything further, so the error
|
// turns it into a non-zero exit status without printing anything
|
||||||
// line is not doubled. It flows up from RunOperation through cobra to
|
// further, so the error line is not doubled. It flows up from
|
||||||
// Entry.
|
// RunOperation through cobra to Entry.
|
||||||
var errReported = errors.New("operation failed")
|
var errReported = errors.New("operation failed")
|
||||||
|
|
||||||
|
// errInterrupted marks an operation that SIGINT or SIGTERM stopped
|
||||||
|
// before it finished. Entry shows it and returns exitCodeInterrupted.
|
||||||
|
var errInterrupted = errors.New("interrupted before the command finished")
|
||||||
|
|
||||||
// RunOperation runs op against the Vaultik instance inside the fx app
|
// RunOperation runs op against the Vaultik instance inside the fx app
|
||||||
// and turns a failure into a returned error rather than an os.Exit from
|
// and turns a failure into a returned error rather than an os.Exit from
|
||||||
// within the goroutine. An os.Exit there skipped main's deferred
|
// within the goroutine. An os.Exit there skipped main's deferred
|
||||||
@@ -220,17 +220,21 @@ var errReported = errors.New("operation failed")
|
|||||||
// interrupt OnStop cancels op and waits for the goroutine to return, so
|
// interrupt OnStop cancels op and waits for the goroutine to return, so
|
||||||
// op's cleanup (removing decrypted scratch files) runs before the
|
// op's cleanup (removing decrypted scratch files) runs before the
|
||||||
// process exits; the wait is bounded by shutdownTimeout. report is
|
// process exits; the wait is bounded by shutdownTimeout. report is
|
||||||
// called with a non-canceled failure so the caller can log it (and
|
// called with a failure so the caller can show it to the user before
|
||||||
// suppress it under --json) before it becomes errReported. A context
|
// it becomes errReported.
|
||||||
// cancellation is the interrupt path, not a failure: it is neither
|
//
|
||||||
// reported nor counted as one.
|
// The run counts as interrupted unless op returned, without an
|
||||||
|
// interrupt having cancelled it, before RunWithApp returned. An
|
||||||
|
// interrupted op is not reported, whatever it returned; RunOperation
|
||||||
|
// returns errInterrupted instead.
|
||||||
func RunOperation(
|
func RunOperation(
|
||||||
ctx context.Context, opts AppOptions,
|
ctx context.Context, opts AppOptions,
|
||||||
op func(v *vaultik.Vaultik) error, report func(err error),
|
op func(v *vaultik.Vaultik) error, report func(err error),
|
||||||
) error {
|
) error {
|
||||||
var (
|
var (
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
failed bool
|
finished bool // op returned before any interrupt cancelled it
|
||||||
|
failed bool // op finished with an error
|
||||||
)
|
)
|
||||||
|
|
||||||
opts.Invokes = append(opts.Invokes,
|
opts.Invokes = append(opts.Invokes,
|
||||||
@@ -241,11 +245,21 @@ func RunOperation(
|
|||||||
OnStart: func(_ context.Context) error {
|
OnStart: func(_ context.Context) error {
|
||||||
stop = v.StartOperation(func() {
|
stop = v.StartOperation(func() {
|
||||||
err := op(v)
|
err := op(v)
|
||||||
if err != nil && !errors.Is(err, context.Canceled) {
|
|
||||||
report(err)
|
// Only stop, called from OnStop below, cancels the
|
||||||
|
// Vaultik context, so a live context means no
|
||||||
|
// interrupt cancelled op. Check the context, not
|
||||||
|
// err: an interrupted op need not return
|
||||||
|
// context.Canceled (`snapshot verify --json`
|
||||||
|
// returns a verification failure).
|
||||||
|
if v.Context().Err() == nil {
|
||||||
|
if err != nil {
|
||||||
|
report(err)
|
||||||
|
}
|
||||||
|
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
failed = true
|
finished = true
|
||||||
|
failed = err != nil
|
||||||
mu.Unlock()
|
mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,28 +292,34 @@ func RunOperation(
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// The goroutine sets failed before triggering the shutdown that lets
|
// RunWithApp returns only after the app was asked to stop, either by
|
||||||
// RunWithApp return, so the write is in place by the time we read it.
|
// an interrupt or by the goroutine's Shutdown call. When op finished
|
||||||
|
// without being cancelled, the goroutine set finished before that
|
||||||
|
// call. So if finished is unset here, an interrupt stopped the app,
|
||||||
|
// and op either returned after it was cancelled or is still running
|
||||||
|
// because the shutdown timed out.
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
defer mu.Unlock()
|
defer mu.Unlock()
|
||||||
|
|
||||||
if failed {
|
switch {
|
||||||
|
case !finished:
|
||||||
|
return errInterrupted
|
||||||
|
case failed:
|
||||||
return errReported
|
return errReported
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// runVaultikApp runs the standard single-operation command lifecycle
|
// runVaultikApp runs the standard single-operation command lifecycle
|
||||||
// shared by the snapshot list/purge/remove and remote nuke subcommands:
|
// shared by the snapshot list/purge/remove and remote nuke subcommands:
|
||||||
// resolve the config, then run op against the Vaultik instance through
|
// resolve the config, then run op against the Vaultik instance through
|
||||||
// RunOperation, reporting a failure prefixed with failMsg (suppressed
|
// RunOperation, reporting a failure prefixed with failMsg on stderr. mode
|
||||||
// while suppressErrors is true, e.g. under --json). mode says whether the
|
// says whether the command takes the PID lock. jsonOutput marks a command
|
||||||
// command takes the PID lock. jsonOutput marks a command whose stdout is a
|
// whose stdout is a JSON document: it quiets the UI but, unlike Quiet,
|
||||||
// JSON document: it quiets the UI but, unlike Quiet, leaves the stderr log
|
// leaves the stderr log level alone.
|
||||||
// level alone.
|
|
||||||
func runVaultikApp(
|
func runVaultikApp(
|
||||||
cmd *cobra.Command, mode lockMode, jsonOutput, suppressErrors bool,
|
cmd *cobra.Command, mode lockMode, jsonOutput bool,
|
||||||
failMsg string, op func(v *vaultik.Vaultik) error,
|
failMsg string, op func(v *vaultik.Vaultik) error,
|
||||||
) error {
|
) error {
|
||||||
configPath, err := ResolveConfigPath()
|
configPath, err := ResolveConfigPath()
|
||||||
@@ -319,10 +339,6 @@ func runVaultikApp(
|
|||||||
},
|
},
|
||||||
Mode: mode,
|
Mode: mode,
|
||||||
}, op, func(err error) {
|
}, op, func(err error) {
|
||||||
if suppressErrors {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Error(failMsg, "error", err)
|
log.Error(failMsg, "error", err)
|
||||||
ReportErrorf("%s: %v", failMsg, err)
|
ReportErrorf("%s: %v", failMsg, err)
|
||||||
})
|
})
|
||||||
|
|||||||
+47
-40
@@ -2,58 +2,65 @@ package cli //nolint:testpackage // needs access to unexported cleanStartupError
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"go.uber.org/fx"
|
||||||
|
"sneak.berlin/go/vaultik/internal/config"
|
||||||
"sneak.berlin/go/vaultik/internal/pidlock"
|
"sneak.berlin/go/vaultik/internal/pidlock"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// TestCleanStartupError starts a real fx app whose config has a
|
||||||
|
// blob_size_limit below the minimum. fx puts annotations naming the
|
||||||
|
// functions it was building in front of the config error; only those
|
||||||
|
// may be removed, although the config error itself contains "): ".
|
||||||
func TestCleanStartupError(t *testing.T) {
|
func TestCleanStartupError(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
tests := []struct {
|
path := filepath.Join(t.TempDir(), "config.yml")
|
||||||
name string
|
|
||||||
in string
|
err := os.WriteFile(path, []byte("snapshots:\n"+
|
||||||
want string
|
" test:\n"+
|
||||||
}{
|
" paths: [/tmp/vaultik-test-source]\n"+
|
||||||
{
|
"storage_url: file:///tmp/vaultik-test-storage\n"+
|
||||||
name: "real fx error chain",
|
"chunk_size: 10MB\n"+
|
||||||
in: `could not build arguments for function ` +
|
"blob_size_limit: 30MB\n"), 0o600)
|
||||||
`"sneak.berlin/go/vaultik/internal/cli".newSnapshotCreateCommand.func1.1 ` +
|
if err != nil {
|
||||||
`(/Users/user/dev/vaultik/internal/cli/snapshot.go:71): ` +
|
t.Fatalf("write config: %v", err)
|
||||||
`failed to build *vaultik.Vaultik: ` +
|
|
||||||
`could not build arguments for function ` +
|
|
||||||
`"sneak.berlin/go/vaultik/internal/vaultik".New ` +
|
|
||||||
`(/Users/user/dev/vaultik/internal/vaultik/vaultik.go:59): ` +
|
|
||||||
`failed to build storage.Storer: ` +
|
|
||||||
`received non-nil error from function ` +
|
|
||||||
`"sneak.berlin/go/vaultik/internal/storage".NewStorer ` +
|
|
||||||
`(/Users/user/dev/vaultik/internal/storage/module.go:23): ` +
|
|
||||||
`creating base path: mkdir /Volumes/BACKUPS: permission denied`,
|
|
||||||
want: `creating base path: mkdir /Volumes/BACKUPS: permission denied`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "no fx wrapping",
|
|
||||||
in: "plain error",
|
|
||||||
want: "plain error",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "single fx wrapping",
|
|
||||||
in: `received non-nil error from function "foo" (file.go:1): underlying problem`,
|
|
||||||
want: "underlying problem",
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
app := fx.New(
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
fx.NopLogger,
|
||||||
t.Parallel()
|
fx.Supply(config.Path(path)),
|
||||||
|
config.Module,
|
||||||
|
fx.Invoke(func(*config.Config) {}),
|
||||||
|
)
|
||||||
|
|
||||||
//nolint:err113 // test constructs errors from table input
|
err = app.Start(t.Context())
|
||||||
got := cleanStartupError(errors.New(tt.in)).Error()
|
if err == nil {
|
||||||
if got != tt.want {
|
t.Fatal("app started; want a blob_size_limit error")
|
||||||
t.Errorf("got %q, want %q", got, tt.want)
|
}
|
||||||
}
|
|
||||||
})
|
want := "failed to load config: invalid config: blob_size_limit must " +
|
||||||
|
"be at least the largest chunk the chunker can emit (chunk_size " +
|
||||||
|
"times the FastCDC size spread): need at least 40000000 bytes"
|
||||||
|
|
||||||
|
got := cleanStartupError(err).Error()
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCleanStartupErrorNotFromFx checks that an error fx did not wrap is
|
||||||
|
// shown unchanged.
|
||||||
|
func TestCleanStartupErrorNotFromFx(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
//nolint:err113 // a one-off error standing in for a startup failure
|
||||||
|
got := cleanStartupError(errors.New("plain error")).Error()
|
||||||
|
if got != "plain error" {
|
||||||
|
t.Errorf("got %q, want %q", got, "plain error")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+208
-32
@@ -7,11 +7,15 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
|
"sneak.berlin/go/vaultik/internal/config"
|
||||||
"sneak.berlin/go/vaultik/internal/ui"
|
"sneak.berlin/go/vaultik/internal/ui"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -31,6 +35,9 @@ const configDirMode = 0o755
|
|||||||
// yaml.Marshal's 4-space default.
|
// yaml.Marshal's 4-space default.
|
||||||
const configYAMLIndent = 2
|
const configYAMLIndent = 2
|
||||||
|
|
||||||
|
// yamlStringTag is YAML's tag for a string scalar.
|
||||||
|
const yamlStringTag = "!!str"
|
||||||
|
|
||||||
var (
|
var (
|
||||||
errConfigExists = errors.New("config file already exists")
|
errConfigExists = errors.New("config file already exists")
|
||||||
errEmptyConfig = errors.New("empty config file")
|
errEmptyConfig = errors.New("empty config file")
|
||||||
@@ -38,6 +45,9 @@ var (
|
|||||||
errNeedNumericIndex = errors.New("key is a list; use a numeric index")
|
errNeedNumericIndex = errors.New("key is a list; use a numeric index")
|
||||||
errIndexOutOfRange = errors.New("index out of range")
|
errIndexOutOfRange = errors.New("index out of range")
|
||||||
errNotMapOrList = errors.New("key is not a map or list")
|
errNotMapOrList = errors.New("key is not a map or list")
|
||||||
|
errWouldNotLoad = errors.New(
|
||||||
|
"vaultik would not load the config after this change, " +
|
||||||
|
"so the file was not changed")
|
||||||
)
|
)
|
||||||
|
|
||||||
const defaultConfigTemplate = `# vaultik configuration
|
const defaultConfigTemplate = `# vaultik configuration
|
||||||
@@ -45,16 +55,19 @@ const defaultConfigTemplate = `# vaultik configuration
|
|||||||
|
|
||||||
# ─── REQUIRED ────────────────────────────────────────────────────────────────
|
# ─── REQUIRED ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
# Age recipient public keys for encryption.
|
# Age recipient public keys for encryption. snapshot create needs at least
|
||||||
|
# one; listing, verifying and restoring do not, so a machine that only
|
||||||
|
# restores can leave this empty.
|
||||||
# Backups are encrypted to ALL listed recipients; any one of the corresponding
|
# Backups are encrypted to ALL listed recipients; any one of the corresponding
|
||||||
# private keys can decrypt. Adding a recipient later does not re-encrypt data
|
# private keys can decrypt. Adding a recipient later does not re-encrypt data
|
||||||
# already stored: deduplicated chunks and existing blobs stay encrypted to the
|
# already stored: deduplicated chunks and existing blobs stay encrypted to the
|
||||||
# earlier recipients, so a newly added key cannot restore them on its own (see
|
# earlier recipients, so a newly added key cannot restore them on its own (see
|
||||||
# docs/REPOSTRUCTURE.md, Accepted Risks). Generate a keypair with:
|
# docs/REPOSTRUCTURE.md, Accepted Risks). Generate a keypair and add its
|
||||||
|
# public key with:
|
||||||
# age-keygen -o vaultik_backup_private_key.txt
|
# age-keygen -o vaultik_backup_private_key.txt
|
||||||
# grep 'public key' vaultik_backup_private_key.txt
|
# grep 'public key' vaultik_backup_private_key.txt
|
||||||
age_recipients:
|
# vaultik config set age_recipients.0 age1...
|
||||||
- age1REPLACE_WITH_YOUR_PUBLIC_KEY
|
age_recipients: []
|
||||||
|
|
||||||
# Named snapshots. Each snapshot backs up one or more paths and can have its
|
# Named snapshots. Each snapshot backs up one or more paths and can have its
|
||||||
# own exclude patterns in addition to the global excludes below.
|
# own exclude patterns in addition to the global excludes below.
|
||||||
@@ -196,7 +209,7 @@ storage_url: ""
|
|||||||
# access_key_id: YOUR_ACCESS_KEY
|
# access_key_id: YOUR_ACCESS_KEY
|
||||||
# secret_access_key: YOUR_SECRET_KEY
|
# secret_access_key: YOUR_SECRET_KEY
|
||||||
# # region: us-east-1 # Default: us-east-1
|
# # region: us-east-1 # Default: us-east-1
|
||||||
# # part_size: 5MB # Multipart upload part size. Default: 5MB
|
# # part_size: 5MiB # Upload part size, 5MiB to 5GiB. Default: 5MiB
|
||||||
# # For the s3:// form, disable TLS with ?ssl=false in the URL, not use_ssl.
|
# # For the s3:// form, disable TLS with ?ssl=false in the URL, not use_ssl.
|
||||||
|
|
||||||
# ─── OPTIONAL ────────────────────────────────────────────────────────────────
|
# ─── OPTIONAL ────────────────────────────────────────────────────────────────
|
||||||
@@ -211,7 +224,7 @@ storage_url: ""
|
|||||||
|
|
||||||
# Average chunk size for content-defined chunking (FastCDC).
|
# Average chunk size for content-defined chunking (FastCDC).
|
||||||
# Smaller = better deduplication but more metadata overhead.
|
# Smaller = better deduplication but more metadata overhead.
|
||||||
# Accepts: 1MB, 10M, 64KB, etc.
|
# Minimum 1MB. Accepts: 1MB, 10M, 64MiB, etc.
|
||||||
# Default: 10MB
|
# Default: 10MB
|
||||||
# chunk_size: 10MB
|
# chunk_size: 10MB
|
||||||
|
|
||||||
@@ -226,6 +239,16 @@ storage_url: ""
|
|||||||
# Default: 3
|
# Default: 3
|
||||||
# compression_level: 3
|
# compression_level: 3
|
||||||
|
|
||||||
|
# How old a blob that no snapshot lists must be before prune deletes it.
|
||||||
|
# A backup uploads its blobs before the manifest that lists them, so on a
|
||||||
|
# destination shared between hosts or users this must be longer than the
|
||||||
|
# longest backup run. It does not protect a blob a backup reuses from an
|
||||||
|
# earlier run that stopped before its manifest: that blob keeps the time of
|
||||||
|
# its first upload, so a prune can delete it.
|
||||||
|
# Accepts: 36h, 90m, etc. 0s deletes them at once.
|
||||||
|
# Default: 24h
|
||||||
|
# prune_grace_period: 24h
|
||||||
|
|
||||||
# Hostname used in snapshot IDs. Default: system hostname.
|
# Hostname used in snapshot IDs. Default: system hostname.
|
||||||
# hostname: myserver
|
# hostname: myserver
|
||||||
|
|
||||||
@@ -242,6 +265,12 @@ func NewConfigCommand() *cobra.Command {
|
|||||||
Use: "config",
|
Use: "config",
|
||||||
Short: "Manage the configuration file",
|
Short: "Manage the configuration file",
|
||||||
Long: "Commands for creating, editing, and querying the vaultik config file.",
|
Long: "Commands for creating, editing, and querying the vaultik config file.",
|
||||||
|
// cobra checks Args only on a command that has a Run: without
|
||||||
|
// one, a mistyped subcommand printed help and exited 0.
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
Run: func(cmd *cobra.Command, _ []string) {
|
||||||
|
_ = cmd.Help()
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.AddCommand(newConfigInitCommand())
|
cmd.AddCommand(newConfigInitCommand())
|
||||||
@@ -314,8 +343,15 @@ func newConfigEditCommand() *cobra.Command {
|
|||||||
editor = "vi"
|
editor = "vi"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Run $EDITOR through the shell, as git does, so a value
|
||||||
|
// with arguments such as "code --wait" works. The path
|
||||||
|
// goes in as an argument ("$@"), not into the command
|
||||||
|
// text, so a path with spaces or quotes arrives whole.
|
||||||
|
// sh -c takes the word after the command as $0, which
|
||||||
|
// is why editor appears twice.
|
||||||
//nolint:gosec // G204: launching the operator's own $EDITOR is the point
|
//nolint:gosec // G204: launching the operator's own $EDITOR is the point
|
||||||
ed := exec.CommandContext(cmd.Context(), editor, path)
|
ed := exec.CommandContext(cmd.Context(),
|
||||||
|
"sh", "-c", editor+` "$@"`, editor, path)
|
||||||
ed.Stdin = os.Stdin
|
ed.Stdin = os.Stdin
|
||||||
ed.Stdout = os.Stdout
|
ed.Stdout = os.Stdout
|
||||||
ed.Stderr = os.Stderr
|
ed.Stderr = os.Stderr
|
||||||
@@ -376,8 +412,17 @@ func newConfigSetCommand() *cobra.Command {
|
|||||||
Use: "set <key> <value>",
|
Use: "set <key> <value>",
|
||||||
Short: "Set a config value by dotted path (e.g. compression_level 5)",
|
Short: "Set a config value by dotted path (e.g. compression_level 5)",
|
||||||
Long: `Sets a scalar config value addressed by dotted YAML path and writes
|
Long: `Sets a scalar config value addressed by dotted YAML path and writes
|
||||||
the file back, preserving comments and formatting. Intermediate maps
|
the file back, preserving comments and formatting. Missing keys along
|
||||||
are created as needed.
|
the path are created: as a list when the next path element is a
|
||||||
|
number, otherwise as a map.
|
||||||
|
|
||||||
|
If the config loads, a change after which it would not, such as
|
||||||
|
compression_level 99, is refused and the file is left unchanged. A
|
||||||
|
config that does not load, such as the one config init writes before
|
||||||
|
storage_url is set or one with a ${ENV:...} reference to a variable
|
||||||
|
not set here, is checked one setting at a time: only a change after
|
||||||
|
which the changed setting cannot be read, such as one giving it the
|
||||||
|
wrong type, is refused.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
vaultik config set storage_url "file:///mnt/backups"
|
vaultik config set storage_url "file:///mnt/backups"
|
||||||
@@ -407,7 +452,14 @@ func writeConfigSet(out *ui.Writer, path, key, value string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = yamlPathSet(root, strings.Split(key, "."), value)
|
oldData, err := marshalConfigYAML(root)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshaling config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
keys := strings.Split(key, ".")
|
||||||
|
|
||||||
|
err = yamlPathSet(root, keys, value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -417,6 +469,21 @@ func writeConfigSet(out *ui.Writer, path, key, value string) error {
|
|||||||
return fmt.Errorf("marshaling config: %w", err)
|
return fmt.Errorf("marshaling config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setting, err := yamlPathOnly(root, keys)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
settingData, err := marshalConfigYAML(setting)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshaling config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = checkConfigChange(oldData, data, settingData)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%w: %w", errWouldNotLoad, err)
|
||||||
|
}
|
||||||
|
|
||||||
err = os.WriteFile(path, data, configFileMode)
|
err = os.WriteFile(path, data, configFileMode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("writing config file: %w", err)
|
return fmt.Errorf("writing config file: %w", err)
|
||||||
@@ -438,6 +505,58 @@ func writeConfigSet(out *ui.Writer, path, key, value string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkConfigChange returns the reason vaultik would not load newData, the
|
||||||
|
// config after a change, if the change brings that failure in. If oldData,
|
||||||
|
// the config before it, loads, the new one must load too. A config that
|
||||||
|
// does not load, such as one still being filled in or one with a ${...}
|
||||||
|
// reference that cannot be filled in here, is checked one setting at a
|
||||||
|
// time: settingData, the changed setting on its own, must decode into the
|
||||||
|
// config type.
|
||||||
|
func checkConfigChange(oldData, newData, settingData []byte) error {
|
||||||
|
oldCfg, err := config.Parse(oldData)
|
||||||
|
if err == nil && oldCfg.Validate() == nil {
|
||||||
|
newCfg, err := config.Parse(newData)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return newCfg.Validate()
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = config.Parse(settingData)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// yamlPathOnly returns a document holding only the value at the dotted key
|
||||||
|
// path in root, inside the maps and lists that lead to it there.
|
||||||
|
func yamlPathOnly(root *yaml.Node, keys []string) (*yaml.Node, error) {
|
||||||
|
node, err := yamlPathGet(root, keys)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, key := range slices.Backward(keys) {
|
||||||
|
parent, err := yamlPathGet(root, keys[:i])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if parent.Kind == yaml.SequenceNode {
|
||||||
|
node = &yaml.Node{
|
||||||
|
Kind: yaml.SequenceNode, Content: []*yaml.Node{node},
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
keyNode := &yaml.Node{Kind: yaml.ScalarNode, Value: key}
|
||||||
|
node = &yaml.Node{
|
||||||
|
Kind: yaml.MappingNode, Content: []*yaml.Node{keyNode, node},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &yaml.Node{Kind: yaml.DocumentNode, Content: []*yaml.Node{node}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// marshalConfigYAML renders a config document tree with 2-space indentation,
|
// marshalConfigYAML renders a config document tree with 2-space indentation,
|
||||||
// matching defaultConfigTemplate. yaml.Marshal defaults to 4 spaces, which
|
// matching 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
|
||||||
@@ -544,8 +663,8 @@ func yamlPathGet(root *yaml.Node, keys []string) (*yaml.Node, error) {
|
|||||||
return node, nil
|
return node, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// yamlPathSet navigates a dotted key path, creating intermediate maps as
|
// yamlPathSet navigates a dotted key path, creating missing keys as needed,
|
||||||
// needed, and sets the final key to the given scalar value. Numeric path
|
// and sets the final key to the given scalar value. Numeric path
|
||||||
// components index into sequences; an index equal to the sequence length
|
// components index into sequences; an index equal to the sequence length
|
||||||
// appends a new element (e.g. "age_recipients.1" on a 1-element list).
|
// appends a new element (e.g. "age_recipients.1" on a 1-element list).
|
||||||
func yamlPathSet(root *yaml.Node, keys []string, value string) error {
|
func yamlPathSet(root *yaml.Node, keys []string, value string) error {
|
||||||
@@ -558,12 +677,12 @@ func yamlPathSet(root *yaml.Node, keys []string, value string) error {
|
|||||||
node = node.Content[0]
|
node = node.Content[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
for i, key := range keys {
|
for i := range keys {
|
||||||
last := i == len(keys)-1
|
last := i == len(keys)-1
|
||||||
|
|
||||||
switch node.Kind {
|
switch node.Kind {
|
||||||
case yaml.MappingNode:
|
case yaml.MappingNode:
|
||||||
node = yamlSetInMapping(node, key, value, last)
|
node = yamlSetInMapping(node, keys, i, value, last)
|
||||||
case yaml.SequenceNode:
|
case yaml.SequenceNode:
|
||||||
next, err := yamlSetInSequence(node, keys, i, value, last)
|
next, err := yamlSetInSequence(node, keys, i, value, last)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -580,17 +699,68 @@ func yamlPathSet(root *yaml.Node, keys []string, value string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// config.Load reads the file through untyped YAML, which turns an
|
||||||
|
// unquoted 00112233 into the number 38043 and 1e5 into 100000. Tagging
|
||||||
|
// a string setting as a string makes the encoder quote such a value.
|
||||||
|
// Other settings stay unquoted, so compression_level 9 is a number.
|
||||||
|
// The encoder refuses to write a value that is not valid UTF-8 as a
|
||||||
|
// string. Left untagged, such a value is written as base64 !!binary and
|
||||||
|
// loads back unchanged.
|
||||||
|
if configKeyIsString(keys) && utf8.ValidString(value) {
|
||||||
|
node.Tag = yamlStringTag
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// yamlSetInMapping resolves (creating if needed) the value node for key
|
// configKeyIsString reports whether the dotted key names a string in
|
||||||
// within a mapping node, setting it to value when it is the final path
|
// config.Config, following the fields' yaml tags, as s3.access_key_id and
|
||||||
// element, and returns the node to descend into.
|
// snapshots.home.exclude.0 do.
|
||||||
func yamlSetInMapping(node *yaml.Node, key, value string, last bool) *yaml.Node {
|
func configKeyIsString(keys []string) bool {
|
||||||
|
typ := reflect.TypeFor[config.Config]()
|
||||||
|
|
||||||
|
for _, key := range keys {
|
||||||
|
switch {
|
||||||
|
case typ.Kind() == reflect.Map || typ.Kind() == reflect.Slice:
|
||||||
|
// The key is a snapshot name or a list index.
|
||||||
|
typ = typ.Elem()
|
||||||
|
case typ.Kind() == reflect.Struct:
|
||||||
|
field, ok := yamlField(typ, key)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
typ = field.Type
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return typ.Kind() == reflect.String
|
||||||
|
}
|
||||||
|
|
||||||
|
// yamlField returns the field of struct type typ whose yaml tag names key.
|
||||||
|
func yamlField(typ reflect.Type, key string) (reflect.StructField, bool) {
|
||||||
|
for field := range typ.Fields() {
|
||||||
|
name, _, _ := strings.Cut(field.Tag.Get("yaml"), ",")
|
||||||
|
if name == key {
|
||||||
|
return field, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return reflect.StructField{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// yamlSetInMapping resolves (creating if needed) the value node for the
|
||||||
|
// path element keys[i] within a mapping node, setting it to value when it
|
||||||
|
// is the final path element, and returns the node to descend into.
|
||||||
|
func yamlSetInMapping(
|
||||||
|
node *yaml.Node, keys []string, i int, value string, last bool,
|
||||||
|
) *yaml.Node {
|
||||||
var valueNode *yaml.Node
|
var valueNode *yaml.Node
|
||||||
|
|
||||||
for j := 0; j+1 < len(node.Content); j += 2 {
|
for j := 0; j+1 < len(node.Content); j += 2 {
|
||||||
if node.Content[j].Value == key {
|
if node.Content[j].Value == keys[i] {
|
||||||
valueNode = node.Content[j+1]
|
valueNode = node.Content[j+1]
|
||||||
|
|
||||||
break
|
break
|
||||||
@@ -598,13 +768,8 @@ func yamlSetInMapping(node *yaml.Node, key, value string, last bool) *yaml.Node
|
|||||||
}
|
}
|
||||||
|
|
||||||
if valueNode == nil {
|
if valueNode == nil {
|
||||||
keyNode := &yaml.Node{Kind: yaml.ScalarNode, Value: key}
|
keyNode := &yaml.Node{Kind: yaml.ScalarNode, Value: keys[i]}
|
||||||
|
valueNode = newPathNode(keys, i, value)
|
||||||
valueNode = &yaml.Node{Kind: yaml.MappingNode}
|
|
||||||
if last {
|
|
||||||
valueNode = &yaml.Node{Kind: yaml.ScalarNode, Value: value}
|
|
||||||
}
|
|
||||||
|
|
||||||
node.Content = append(node.Content, keyNode, valueNode)
|
node.Content = append(node.Content, keyNode, valueNode)
|
||||||
} else if last {
|
} else if last {
|
||||||
setScalar(valueNode, value)
|
setScalar(valueNode, value)
|
||||||
@@ -632,12 +797,7 @@ func yamlSetInSequence(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if idx == len(node.Content) {
|
if idx == len(node.Content) {
|
||||||
newNode := &yaml.Node{Kind: yaml.MappingNode}
|
node.Content = append(node.Content, newPathNode(keys, i, value))
|
||||||
if last {
|
|
||||||
newNode = &yaml.Node{Kind: yaml.ScalarNode, Value: value}
|
|
||||||
}
|
|
||||||
|
|
||||||
node.Content = append(node.Content, newNode)
|
|
||||||
} else if last {
|
} else if last {
|
||||||
setScalar(node.Content[idx], value)
|
setScalar(node.Content[idx], value)
|
||||||
}
|
}
|
||||||
@@ -645,6 +805,22 @@ func yamlSetInSequence(
|
|||||||
return node.Content[idx], nil
|
return node.Content[idx], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// newPathNode returns the node to create for the missing path element
|
||||||
|
// keys[i]: the value when it is the final path element, otherwise an empty
|
||||||
|
// list when the next path element is a number and an empty map when not.
|
||||||
|
func newPathNode(keys []string, i int, value string) *yaml.Node {
|
||||||
|
if i == len(keys)-1 {
|
||||||
|
return &yaml.Node{Kind: yaml.ScalarNode, Value: value}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := strconv.Atoi(keys[i+1])
|
||||||
|
if err == nil {
|
||||||
|
return &yaml.Node{Kind: yaml.SequenceNode}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &yaml.Node{Kind: yaml.MappingNode}
|
||||||
|
}
|
||||||
|
|
||||||
// setScalar overwrites a node in place with a plain scalar value.
|
// setScalar overwrites a node in place with a plain scalar value.
|
||||||
func setScalar(n *yaml.Node, value string) {
|
func setScalar(n *yaml.Node, value string) {
|
||||||
n.Kind = yaml.ScalarNode
|
n.Kind = yaml.ScalarNode
|
||||||
|
|||||||
+376
-2
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -24,8 +25,10 @@ func TestDefaultConfigTemplateParses(t *testing.T) {
|
|||||||
t.Fatalf("default config template is not valid YAML: %v", err)
|
t.Fatalf("default config template is not valid YAML: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(cfg.AgeRecipients) != 1 {
|
// A placeholder recipient would fail config.Load, so the template
|
||||||
t.Errorf("expected 1 placeholder age recipient, got %d", len(cfg.AgeRecipients))
|
// leaves the list empty.
|
||||||
|
if len(cfg.AgeRecipients) != 0 {
|
||||||
|
t.Errorf("expected no age recipients, got %d", len(cfg.AgeRecipients))
|
||||||
}
|
}
|
||||||
|
|
||||||
home, ok := cfg.Snapshots["home"]
|
home, ok := cfg.Snapshots["home"]
|
||||||
@@ -55,6 +58,353 @@ func TestDefaultConfigTemplateParses(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// testRecipient is a valid age public key.
|
||||||
|
const testRecipient = "age1278m9q7dp3chsh2dcy82qk27v047zywyvtxwnj4cvt0z65jw6a7q5dqhfj"
|
||||||
|
|
||||||
|
// TestConfigSetRecipientOnFreshConfig follows the README quickstart: on the
|
||||||
|
// file `config init` writes, `config set age_recipients.0` and
|
||||||
|
// `config set storage_url` give a config that loads with that recipient.
|
||||||
|
// Neither is refused, although the config does not load until both are set;
|
||||||
|
// once it loads, a recipient that is not a valid key is.
|
||||||
|
func TestConfigSetRecipientOnFreshConfig(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), "config.yml")
|
||||||
|
|
||||||
|
err := os.WriteFile(path, []byte(defaultConfigTemplate), configFileMode)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := ui.NewWithColor(&bytes.Buffer{}, false)
|
||||||
|
|
||||||
|
err = writeConfigSet(out, path, "age_recipients.0", testRecipient)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("config set age_recipients.0: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = writeConfigSet(out, 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 len(cfg.AgeRecipients) != 1 || cfg.AgeRecipients[0] != testRecipient {
|
||||||
|
t.Errorf("age_recipients = %v, want [%s]", cfg.AgeRecipients, testRecipient)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = writeConfigSet(out, path, "age_recipients.0", "notakey")
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "not a valid recipient") {
|
||||||
|
t.Errorf("config set age_recipients.0 notakey gives error %v, "+
|
||||||
|
"want one saying it is not a valid recipient", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = config.Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("config.Load after the refused change: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// configWithoutRecipients loads, but has no age_recipients key and no
|
||||||
|
// snapshot named work.
|
||||||
|
const configWithoutRecipients = `storage_url: file:///mnt/backups
|
||||||
|
snapshots:
|
||||||
|
home:
|
||||||
|
paths:
|
||||||
|
- "~"
|
||||||
|
`
|
||||||
|
|
||||||
|
// configWithUnsetReference loads only where VAULTIK_TEST_UNSET_SECRET is
|
||||||
|
// set, as a backup job's environment might set it. No test sets it.
|
||||||
|
const configWithUnsetReference = configWithoutRecipients + `s3:
|
||||||
|
secret_access_key: ${ENV:VAULTIK_TEST_UNSET_SECRET}
|
||||||
|
`
|
||||||
|
|
||||||
|
// configWithLevelReference returns configWithoutRecipients with a ${FILE:...}
|
||||||
|
// reference on compression_level to a file holding 9, so that the config
|
||||||
|
// loads once the reference is filled in.
|
||||||
|
func configWithLevelReference(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
levelPath := filepath.Join(t.TempDir(), "level")
|
||||||
|
|
||||||
|
err := os.WriteFile(levelPath, []byte("9"), configFileMode)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write level: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return configWithoutRecipients +
|
||||||
|
"compression_level: ${FILE:" + levelPath + "}\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfigSetIndexUnderMissingKey checks that a list index under a key
|
||||||
|
// the file does not have creates a list, and that the result loads.
|
||||||
|
func TestConfigSetIndexUnderMissingKey(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
key string
|
||||||
|
value string
|
||||||
|
field func(cfg *config.Config) []string
|
||||||
|
}{
|
||||||
|
{"age_recipients.0", testRecipient,
|
||||||
|
func(cfg *config.Config) []string { return cfg.AgeRecipients }},
|
||||||
|
{"snapshots.work.paths.0", "/srv/work",
|
||||||
|
func(cfg *config.Config) []string { return cfg.Snapshots["work"].Paths }},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.key, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), "config.yml")
|
||||||
|
|
||||||
|
err := os.WriteFile(path, []byte(configWithoutRecipients), configFileMode)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = writeConfigSet(ui.NewWithColor(&bytes.Buffer{}, false),
|
||||||
|
path, tt.key, tt.value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("config set %s: %v", tt.key, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("config.Load: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := tt.field(cfg)
|
||||||
|
if len(got) != 1 || got[0] != tt.value {
|
||||||
|
t.Errorf("%s gives %v, want [%s]", tt.key, got, tt.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfigSetKeepsFileThatWouldNotLoad checks that config set refuses a
|
||||||
|
// change after which the config would not load, with an error giving the
|
||||||
|
// reason, and leaves the file as it was. On the file `config init` writes,
|
||||||
|
// which does not load until storage_url is set, and on one that cannot be
|
||||||
|
// read here, only a value of the wrong type is refused.
|
||||||
|
func TestConfigSetKeepsFileThatWouldNotLoad(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
levelReference := configWithLevelReference(t)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
config string
|
||||||
|
key string
|
||||||
|
value string
|
||||||
|
reason string
|
||||||
|
}{
|
||||||
|
// Index 1 of the empty list the missing key gives.
|
||||||
|
{configWithoutRecipients, "age_recipients.1", testRecipient,
|
||||||
|
"index out of range"},
|
||||||
|
// A list set to a single value.
|
||||||
|
{configWithoutRecipients, "snapshots.home.paths", "/srv",
|
||||||
|
"into []string"},
|
||||||
|
{configWithoutRecipients, "chunk_size", "fast",
|
||||||
|
"invalid size format"},
|
||||||
|
// Values of the right type that fail validation.
|
||||||
|
{configWithoutRecipients, "compression_level", "99",
|
||||||
|
"compression_level must be between 1 and 19"},
|
||||||
|
{configWithoutRecipients, "chunk_size", "1KB",
|
||||||
|
"chunk_size must be at least 1MB"},
|
||||||
|
// With its reference filled in, the config loads.
|
||||||
|
{levelReference, "blob_size_limit", "1MB",
|
||||||
|
"blob_size_limit must be at least"},
|
||||||
|
// The type check on a config that does not load yet.
|
||||||
|
{defaultConfigTemplate, "snapshots.apps.paths", "/srv",
|
||||||
|
"into []string"},
|
||||||
|
// The type check on a config that cannot be read here.
|
||||||
|
{configWithUnsetReference, "snapshots.home.paths", "/x",
|
||||||
|
"cannot unmarshal !!str `/x`"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.key+" "+tt.value, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), "config.yml")
|
||||||
|
|
||||||
|
err := os.WriteFile(path, []byte(tt.config), configFileMode)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = writeConfigSet(ui.NewWithColor(&bytes.Buffer{}, false),
|
||||||
|
path, tt.key, tt.value)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), tt.reason) {
|
||||||
|
t.Errorf("config set %s %s gives error %v, want one saying %q",
|
||||||
|
tt.key, tt.value, err, tt.reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path) //nolint:gosec // G304: test-controlled path
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if string(data) != tt.config {
|
||||||
|
t.Errorf("config set %s changed the file to:\n%s",
|
||||||
|
tt.key, data)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfigSetRefusesOnlyItsOwnFailure checks that setting another key
|
||||||
|
// works on a config with a ${...} reference on compression_level, which
|
||||||
|
// loads because loading fills the reference in, on one whose
|
||||||
|
// compression_level already fails to load, and on one with a reference
|
||||||
|
// that cannot be filled in here.
|
||||||
|
func TestConfigSetRefusesOnlyItsOwnFailure(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
configs := map[string]string{
|
||||||
|
"reference": configWithLevelReference(t),
|
||||||
|
"failing": configWithoutRecipients + "compression_level: fast\n",
|
||||||
|
"unset reference": configWithUnsetReference,
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, configYAML := range configs {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), "config.yml")
|
||||||
|
|
||||||
|
err := os.WriteFile(path, []byte(configYAML), configFileMode)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = writeConfigSet(ui.NewWithColor(&bytes.Buffer{}, false),
|
||||||
|
path, "hostname", "myhost")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("config set hostname: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path) //nolint:gosec // G304: test-controlled path
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(string(data), "hostname: myhost") {
|
||||||
|
t.Errorf("config set hostname wrote:\n%s", data)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfigSetStringLooksLikeNumber sets string settings to values that
|
||||||
|
// YAML reads as numbers or booleans when they are unquoted, and checks that
|
||||||
|
// config.Load returns each one unchanged.
|
||||||
|
func TestConfigSetStringLooksLikeNumber(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
key string
|
||||||
|
value string
|
||||||
|
field func(cfg *config.Config) string
|
||||||
|
}{
|
||||||
|
{"s3.access_key_id", "00112233",
|
||||||
|
func(cfg *config.Config) string { return cfg.S3.AccessKeyID }},
|
||||||
|
{"s3.secret_access_key", "12345678901234567890123456789012",
|
||||||
|
func(cfg *config.Config) string { return cfg.S3.SecretAccessKey }},
|
||||||
|
{"hostname", "007",
|
||||||
|
func(cfg *config.Config) string { return cfg.Hostname }},
|
||||||
|
{"s3.prefix", "1e5",
|
||||||
|
func(cfg *config.Config) string { return cfg.S3.Prefix }},
|
||||||
|
{"s3.bucket", "true",
|
||||||
|
func(cfg *config.Config) string { return cfg.S3.Bucket }},
|
||||||
|
{"s3.region", "FALSE",
|
||||||
|
func(cfg *config.Config) string { return cfg.S3.Region }},
|
||||||
|
{"snapshots.home.exclude.0", "1.10",
|
||||||
|
func(cfg *config.Config) string { return cfg.Snapshots["home"].Exclude[0] }},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.key+"="+tt.value, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cfg := loadAfterConfigSet(t, tt.key, tt.value)
|
||||||
|
|
||||||
|
got := tt.field(cfg)
|
||||||
|
if got != tt.value {
|
||||||
|
t.Errorf("%s = %q after config set %q", tt.key, got, tt.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfigSetNonUTF8Path checks that config set still accepts a value that
|
||||||
|
// is not valid UTF-8, such as a path with a Latin-1 file name, and that
|
||||||
|
// config.Load returns it unchanged.
|
||||||
|
func TestConfigSetNonUTF8Path(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const dir = "/srv/caf\xe9"
|
||||||
|
|
||||||
|
cfg := loadAfterConfigSet(t, "snapshots.home.paths.0", dir)
|
||||||
|
|
||||||
|
got := cfg.Snapshots["home"].Paths[0]
|
||||||
|
if got != dir {
|
||||||
|
t.Errorf("snapshots.home.paths.0 = %q, want %q", got, dir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfigSetNumberStaysNumber checks that a number set for an integer
|
||||||
|
// setting is still read as a number, not as a quoted string.
|
||||||
|
func TestConfigSetNumberStaysNumber(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const level = 9
|
||||||
|
|
||||||
|
cfg := loadAfterConfigSet(t, "compression_level", strconv.Itoa(level))
|
||||||
|
|
||||||
|
if cfg.CompressionLevel != level {
|
||||||
|
t.Errorf("compression_level = %d, want %d", cfg.CompressionLevel, level)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadAfterConfigSet writes the file `config init` writes, sets storage_url
|
||||||
|
// to a local directory so that the file passes validation, applies
|
||||||
|
// `config set key value` and returns what config.Load reads back.
|
||||||
|
func loadAfterConfigSet(t *testing.T, key, value string) *config.Config {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), "config.yml")
|
||||||
|
|
||||||
|
err := os.WriteFile(path, []byte(defaultConfigTemplate), configFileMode)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := ui.NewWithColor(&bytes.Buffer{}, false)
|
||||||
|
|
||||||
|
err = writeConfigSet(out, path, "storage_url", "file:///mnt/backups")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("config set storage_url: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = writeConfigSet(out, path, key, value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("config set %s: %v", key, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("config.Load: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
const testYAML = `# top comment
|
const testYAML = `# top comment
|
||||||
compression_level: 3
|
compression_level: 3
|
||||||
age_recipients:
|
age_recipients:
|
||||||
@@ -297,6 +647,30 @@ func TestWriteConfigSetTightensMode(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestConfigEditEditorWithArgument checks that an $EDITOR carrying an
|
||||||
|
// argument works and that a config path with a space reaches it whole.
|
||||||
|
// The "editor" is cp with the replacement file as its argument, so the
|
||||||
|
// config ends up with the replacement's content only if both arrived.
|
||||||
|
func TestConfigEditEditorWithArgument(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := seedFile(t, dir, "my config.yml", "compression_level: 3\n")
|
||||||
|
edited := seedFile(t, dir, "edited.yml", "compression_level: 9\n")
|
||||||
|
|
||||||
|
setRootFlags(t, RootFlags{ConfigPath: path})
|
||||||
|
t.Setenv("EDITOR", "cp '"+edited+"'")
|
||||||
|
|
||||||
|
mustExecute(t, newConfigEditCommand())
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path) //nolint:gosec // G304: test-controlled path
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reading config back: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if string(data) != "compression_level: 9\n" {
|
||||||
|
t.Errorf("config after edit = %q, want the edited content", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func splitPath(s string) []string {
|
func splitPath(s string) []string {
|
||||||
return strings.Split(s, ".")
|
return strings.Split(s, ".")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ func NewDatabaseCommand() *cobra.Command {
|
|||||||
Use: "database",
|
Use: "database",
|
||||||
Short: "Manage the local state database",
|
Short: "Manage the local state database",
|
||||||
Long: `Commands for managing the local SQLite state database.`,
|
Long: `Commands for managing the local SQLite state database.`,
|
||||||
|
// cobra checks Args only on a command that has a Run: without
|
||||||
|
// one, a mistyped subcommand printed help and exited 0.
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
Run: func(cmd *cobra.Command, _ []string) {
|
||||||
|
_ = cmd.Help()
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.AddCommand(
|
cmd.AddCommand(
|
||||||
|
|||||||
+25
-13
@@ -15,17 +15,25 @@ import (
|
|||||||
// the startup banner.
|
// the startup banner.
|
||||||
const shortCommitLen = 12
|
const shortCommitLen = 12
|
||||||
|
|
||||||
|
// exitCodeInterrupted is the exit status of a command that SIGINT or
|
||||||
|
// SIGTERM stopped. It is 128 plus SIGINT's number, 2, which is what a
|
||||||
|
// shell reports for a command stopped by Ctrl-C.
|
||||||
|
const exitCodeInterrupted = 130
|
||||||
|
|
||||||
// Entry is the main entry point for the CLI application.
|
// Entry is the main entry point for the CLI application.
|
||||||
// It prints the startup banner to stdout (unless a banner-suppressing
|
// It prints the startup banner to stderr (unless a banner-suppressing
|
||||||
// flag is present in os.Args — see bannerSuppressedInArgs), executes the
|
// flag is present in os.Args — see bannerSuppressedInArgs), executes the
|
||||||
// root cobra command, and routes any returned error through the
|
// root cobra command, and routes any returned error through the
|
||||||
// ui.Writer so the user sees a properly formatted "🛑 ERROR:" line.
|
// ui.Writer so the user sees a properly formatted "🛑 ERROR:" line.
|
||||||
|
// The banner goes to stderr because stdout carries only the output the
|
||||||
|
// user asked for, such as a completion script or a `config get` value.
|
||||||
//
|
//
|
||||||
// It returns the process exit code (0 on success, 1 on error) rather
|
// It returns the process exit code (0 on success, 130 when interrupted,
|
||||||
// than calling os.Exit, so that main's deferred profile writers run
|
// 1 on any other error) rather than calling os.Exit, so that main's
|
||||||
// before the process ends. See run in cmd/vaultik/main.go.
|
// deferred profile writers run before the process ends. See run in
|
||||||
|
// cmd/vaultik/main.go.
|
||||||
func Entry() int {
|
func Entry() int {
|
||||||
emitStartupBanner(os.Args[1:], os.Stdout)
|
emitStartupBanner(os.Args[1:], os.Stderr)
|
||||||
|
|
||||||
rootCmd := NewRootCommand()
|
rootCmd := NewRootCommand()
|
||||||
rootCmd.SilenceErrors = true
|
rootCmd.SilenceErrors = true
|
||||||
@@ -33,14 +41,20 @@ func Entry() int {
|
|||||||
err := rootCmd.Execute()
|
err := rootCmd.Execute()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// An operation that ran inside the fx app has already reported
|
// An operation that ran inside the fx app has already reported
|
||||||
// its own failure (and suppressed it under --json); errReported
|
// its own failure (`snapshot verify --json` puts it in the
|
||||||
// says so. Printing it again here would double the error line.
|
// document instead); errReported says so. Printing it again
|
||||||
|
// here would double the error line.
|
||||||
// Every other error — bad arguments, a config that would not
|
// Every other error — bad arguments, a config that would not
|
||||||
// load — reaches Entry unreported, so it is shown here.
|
// load, an interrupt — reaches Entry unreported, so it is shown
|
||||||
|
// here.
|
||||||
if !errors.Is(err, errReported) {
|
if !errors.Is(err, errReported) {
|
||||||
ReportErrorf("%s", err.Error())
|
ReportErrorf("%s", err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if errors.Is(err, errInterrupted) {
|
||||||
|
return exitCodeInterrupted
|
||||||
|
}
|
||||||
|
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,9 +63,8 @@ func Entry() int {
|
|||||||
|
|
||||||
// emitStartupBanner writes the startup banner to w unless args (the
|
// emitStartupBanner writes the startup banner to w unless args (the
|
||||||
// argument vector with the program name already stripped) contains a
|
// argument vector with the program name already stripped) contains a
|
||||||
// flag that suppresses it. Split out of Entry so that the decision — the
|
// flag that suppresses it. Split out of Entry so that the decision is
|
||||||
// only thing standing between a --json invocation and a parseable
|
// reachable from a test without running the whole CLI.
|
||||||
// stdout — is reachable from a test without running the whole CLI.
|
|
||||||
func emitStartupBanner(args []string, w io.Writer) {
|
func emitStartupBanner(args []string, w io.Writer) {
|
||||||
if bannerSuppressedInArgs(args) {
|
if bannerSuppressedInArgs(args) {
|
||||||
return
|
return
|
||||||
@@ -86,8 +99,7 @@ func ReportErrorf(format string, args ...any) {
|
|||||||
// --json is a subcommand flag rather than a persistent one, but so is
|
// --json is a subcommand flag rather than a persistent one, but so is
|
||||||
// --cron (it exists only on `snapshot create`), so this adds no new
|
// --cron (it exists only on `snapshot create`), so this adds no new
|
||||||
// class of imprecision. The only cost of a false positive is a missing
|
// class of imprecision. The only cost of a false positive is a missing
|
||||||
// decorative banner; the cost of a false negative is a corrupt document
|
// decorative banner.
|
||||||
// on stdout, so the scan errs deliberately in that direction.
|
|
||||||
func bannerSuppressedInArgs(args []string) bool {
|
func bannerSuppressedInArgs(args []string) bool {
|
||||||
for _, a := range args {
|
for _, a := range args {
|
||||||
if a == "--" {
|
if a == "--" {
|
||||||
|
|||||||
@@ -36,23 +36,14 @@ const (
|
|||||||
// strips it before scanning, so it has to be present.
|
// strips it before scanning, so it has to be present.
|
||||||
programName = "vaultik"
|
programName = "vaultik"
|
||||||
|
|
||||||
// someSnapshotID is any snapshot identifier: these tests never run
|
// someSnapshotID only fills the positional argument; no test needs
|
||||||
// the command, so it only has to occupy the positional argument.
|
// the snapshot to exist.
|
||||||
someSnapshotID = "host_2026-01-01T00:00:00Z"
|
someSnapshotID = "host_2026-01-01T00:00:00Z"
|
||||||
)
|
)
|
||||||
|
|
||||||
// placeholderJSONDocument stands in for whatever document a --json
|
|
||||||
// command writes to stdout. `snapshot list --json` with no snapshots
|
|
||||||
// prints exactly this; the other --json commands print an object rather
|
|
||||||
// than an array, but this test is not about their shape. It is about
|
|
||||||
// what is on stdout *before* them, which is the same for all of them
|
|
||||||
// because Entry prints the banner before cobra has parsed anything and
|
|
||||||
// therefore before it can know which command is running.
|
|
||||||
const placeholderJSONDocument = "[]\n"
|
|
||||||
|
|
||||||
// jsonArgumentVectors are the argument vectors of every --json
|
// jsonArgumentVectors are the argument vectors of every --json
|
||||||
// invocation the CLI accepts, with the program name stripped exactly as
|
// invocation the CLI accepts, with the program name stripped exactly as
|
||||||
// Entry strips it. Each one must leave stdout untouched by the banner.
|
// Entry strips it. Each one must suppress the banner.
|
||||||
//
|
//
|
||||||
//nolint:gochecknoglobals // read-only test fixture shared by two tests
|
//nolint:gochecknoglobals // read-only test fixture shared by two tests
|
||||||
var jsonArgumentVectors = map[string][]string{
|
var jsonArgumentVectors = map[string][]string{
|
||||||
@@ -74,39 +65,23 @@ var jsonArgumentVectors = map[string][]string{
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestJSONInvocationStdoutIsExactlyOneDocument is the CLI-layer
|
// TestJSONInvocationSuppressesBanner checks that every --json
|
||||||
// regression guard for issue #106: `vaultik snapshot list --json | jq`
|
// invocation suppresses the startup banner, as the README says --json
|
||||||
// must work with no other flags.
|
// does along with --quiet and --cron. The scan is over the raw argument
|
||||||
//
|
// vector, so each position and spelling of --json is listed.
|
||||||
// internal/vaultik's TestListSnapshots_JSONStdoutIsOnlyTheDocument
|
func TestJSONInvocationSuppressesBanner(t *testing.T) {
|
||||||
// guards the same contract one layer down, but it calls the library
|
|
||||||
// function directly and so cannot see Entry, which is where the
|
|
||||||
// contamination was: the startup banner is written to stdout before
|
|
||||||
// cobra parses anything, and the suppression scan did not know about
|
|
||||||
// --json. The two banner lines and the blank line landed ahead of the
|
|
||||||
// document and `jq` refused the result.
|
|
||||||
//
|
|
||||||
// The document is a constant here because this test is about the
|
|
||||||
// argument vectors, one per --json command; the one that runs a real
|
|
||||||
// command end to end is TestEntryJSONStdoutIsExactlyOneDocument below.
|
|
||||||
func TestJSONInvocationStdoutIsExactlyOneDocument(t *testing.T) {
|
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
for name, argv := range jsonArgumentVectors {
|
for name, argv := range jsonArgumentVectors {
|
||||||
t.Run(name, func(t *testing.T) {
|
t.Run(name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
var stdout bytes.Buffer
|
var banner bytes.Buffer
|
||||||
|
|
||||||
emitStartupBanner(argv, &stdout)
|
emitStartupBanner(argv, &banner)
|
||||||
|
|
||||||
require.Empty(t, stdout.String(),
|
assert.Empty(t, banner.String(),
|
||||||
"nothing may reach stdout ahead of a --json document")
|
"--json suppresses the banner")
|
||||||
|
|
||||||
_, err := stdout.WriteString(placeholderJSONDocument)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
requireExactlyOneJSONDocument(t, stdout.String())
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -127,11 +102,11 @@ func TestBannerStillPrintedWithoutSuppressingFlag(t *testing.T) {
|
|||||||
t.Run(name, func(t *testing.T) {
|
t.Run(name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
var stdout bytes.Buffer
|
var banner bytes.Buffer
|
||||||
|
|
||||||
emitStartupBanner(argv, &stdout)
|
emitStartupBanner(argv, &banner)
|
||||||
|
|
||||||
assert.Contains(t, stdout.String(), "starting up at",
|
assert.Contains(t, banner.String(), "starting up at",
|
||||||
"the banner belongs on invocations that did not opt out")
|
"the banner belongs on invocations that did not opt out")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -172,9 +147,9 @@ func TestBannerSuppressedInArgs(t *testing.T) {
|
|||||||
|
|
||||||
// hermeticConfig is a complete, valid config that needs no network and
|
// hermeticConfig is a complete, valid config that needs no network and
|
||||||
// no credentials: file:// storage is exempt from the S3 credential
|
// no credentials: file:// storage is exempt from the S3 credential
|
||||||
// checks, and FileStorer over a directory that does not exist lists
|
// checks. A test that lists the destination must create its directory
|
||||||
// zero objects without erroring. Chunk, blob and compression settings
|
// first, because listing a directory that does not exist is an error.
|
||||||
// are filled in by config.Load.
|
// Chunk, blob and compression settings are filled in by config.Load.
|
||||||
const hermeticConfig = `age_recipients:
|
const hermeticConfig = `age_recipients:
|
||||||
- age1278m9q7dp3chsh2dcy82qk27v047zywyvtxwnj4cvt0z65jw6a7q5dqhfj
|
- age1278m9q7dp3chsh2dcy82qk27v047zywyvtxwnj4cvt0z65jw6a7q5dqhfj
|
||||||
snapshots:
|
snapshots:
|
||||||
@@ -196,22 +171,25 @@ hostname: test-host
|
|||||||
// `snapshot list` is the command chosen because it is the only --json
|
// `snapshot list` is the command chosen because it is the only --json
|
||||||
// command that reaches its document without a populated destination
|
// command that reaches its document without a populated destination
|
||||||
// store: it reads the local index, streams `metadata/` (empty here),
|
// store: it reads the local index, streams `metadata/` (empty here),
|
||||||
// and treats a barren destination as an empty list rather than a
|
// and treats an empty destination directory as an empty list rather
|
||||||
// failure.
|
// than a failure.
|
||||||
//
|
//
|
||||||
// Not parallel: it replaces os.Args, os.Stdout and the xdg globals.
|
// Not parallel: it replaces os.Args, os.Stdout and the xdg globals.
|
||||||
func TestEntryJSONStdoutIsExactlyOneDocument(t *testing.T) {
|
func TestEntryJSONStdoutIsExactlyOneDocument(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
configPath := filepath.Join(dir, "config.yml")
|
configPath := filepath.Join(dir, "config.yml")
|
||||||
|
storeDir := filepath.Join(dir, "store")
|
||||||
|
|
||||||
contents := fmt.Sprintf(hermeticConfig,
|
contents := fmt.Sprintf(hermeticConfig,
|
||||||
filepath.Join(dir, "source"),
|
filepath.Join(dir, "source"),
|
||||||
filepath.Join(dir, "store"),
|
storeDir,
|
||||||
filepath.Join(dir, "index.sqlite"))
|
filepath.Join(dir, "index.sqlite"))
|
||||||
|
|
||||||
require.NoError(t,
|
require.NoError(t,
|
||||||
os.WriteFile(configPath, []byte(contents), configFileMode))
|
os.WriteFile(configPath, []byte(contents), configFileMode))
|
||||||
|
|
||||||
|
require.NoError(t, os.Mkdir(storeDir, 0o750))
|
||||||
|
|
||||||
// The PID lock lives under xdg.DataHome, which xdg resolves at
|
// The PID lock lives under xdg.DataHome, which xdg resolves at
|
||||||
// package init; point it at the temp dir so the test neither
|
// package init; point it at the temp dir so the test neither
|
||||||
// touches nor collides with the real one.
|
// touches nor collides with the real one.
|
||||||
@@ -244,9 +222,7 @@ func TestEntryJSONStdoutIsExactlyOneDocument(t *testing.T) {
|
|||||||
// captureProcessStdout redirects the process's own stdout to a pipe for
|
// captureProcessStdout redirects the process's own stdout to a pipe for
|
||||||
// the duration of fn and returns what was written to it. The redirection
|
// the duration of fn and returns what was written to it. The redirection
|
||||||
// has to be at the file-descriptor level rather than through an injected
|
// has to be at the file-descriptor level rather than through an injected
|
||||||
// writer, because the banner and the JSON encoder reach os.Stdout
|
// writer, because the commands Entry runs reach os.Stdout directly.
|
||||||
// independently and the point of the test is that both land in the same
|
|
||||||
// place.
|
|
||||||
//
|
//
|
||||||
// Not parallel-safe: os.Stdout is process-global.
|
// Not parallel-safe: os.Stdout is process-global.
|
||||||
func captureProcessStdout(t *testing.T, fn func()) string {
|
func captureProcessStdout(t *testing.T, fn func()) string {
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
package cli //nolint:testpackage // shares runEntry and the argument constants
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/adrg/xdg"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// stalledStoreConfig is hermeticConfig with an s3:// destination store
|
||||||
|
// in place of the file:// one. The server behind it accepts any
|
||||||
|
// credentials.
|
||||||
|
const stalledStoreConfig = `age_recipients:
|
||||||
|
- age1278m9q7dp3chsh2dcy82qk27v047zywyvtxwnj4cvt0z65jw6a7q5dqhfj
|
||||||
|
snapshots:
|
||||||
|
test:
|
||||||
|
paths:
|
||||||
|
- %s
|
||||||
|
storage_url: s3://bucket?endpoint=%s&ssl=false
|
||||||
|
s3:
|
||||||
|
access_key_id: key
|
||||||
|
secret_access_key: secret
|
||||||
|
index_path: %s
|
||||||
|
hostname: test-host
|
||||||
|
`
|
||||||
|
|
||||||
|
// interruptRepeat is how often interruptOnFirstRequest sends SIGINT.
|
||||||
|
const interruptRepeat = 50 * time.Millisecond
|
||||||
|
|
||||||
|
// TestEntryInterruptedRun sends SIGINT to the test process while a
|
||||||
|
// command waits on the destination store, and checks that Entry returns
|
||||||
|
// 130 and prints one line on stderr saying the run was interrupted. The
|
||||||
|
// store is a local HTTP server that holds every request open, so the
|
||||||
|
// command is always mid-operation when the signal arrives. The two
|
||||||
|
// cases cover --cron and --json, which silence other output.
|
||||||
|
//
|
||||||
|
// Not parallel: it signals the process and replaces os.Args, os.Stdout,
|
||||||
|
// os.Stderr and the xdg globals.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // signals the process and replaces process globals
|
||||||
|
func TestEntryInterruptedRun(t *testing.T) {
|
||||||
|
for _, testCase := range []struct {
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "snapshot create --cron",
|
||||||
|
args: []string{cmdSnapshot, cmdCreate, "--cron"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "snapshot verify --json",
|
||||||
|
args: []string{cmdSnapshot, cmdVerify, someSnapshotID, flagJSON},
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
|
endpoint, requestArrived := startStalledStore(t)
|
||||||
|
configPath := writeStalledStoreConfig(t, endpoint)
|
||||||
|
interruptOnFirstRequest(t, requestArrived)
|
||||||
|
|
||||||
|
code, _, stderr := runEntry(t,
|
||||||
|
append([]string{flagConfig, configPath}, testCase.args...)...)
|
||||||
|
|
||||||
|
assert.Equal(t, 130, code)
|
||||||
|
assert.Equal(t, 1,
|
||||||
|
strings.Count(stderr, errInterrupted.Error()), stderr)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// interruptOnFirstRequest sends SIGINT to the test process every
|
||||||
|
// interruptRepeat, from the first request to the destination store until
|
||||||
|
// the test ends. One signal is not enough: the command can reach the
|
||||||
|
// store before fx has started catching signals. The test catches SIGINT
|
||||||
|
// too, so that a signal fx is not catching does not kill the test
|
||||||
|
// binary.
|
||||||
|
func interruptOnFirstRequest(t *testing.T, requestArrived <-chan struct{}) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
self, err := os.FindProcess(os.Getpid())
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
caught := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(caught, os.Interrupt)
|
||||||
|
|
||||||
|
testEnded := make(chan struct{})
|
||||||
|
senderDone := make(chan struct{})
|
||||||
|
|
||||||
|
// Stop catching SIGINT only after the sender has returned. The sender
|
||||||
|
// waits for each SIGINT it sends to arrive on caught; one still on
|
||||||
|
// its way after signal.Stop would kill the test binary.
|
||||||
|
t.Cleanup(func() {
|
||||||
|
close(testEnded)
|
||||||
|
<-senderDone
|
||||||
|
signal.Stop(caught)
|
||||||
|
})
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer close(senderDone)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-requestArrived:
|
||||||
|
case <-testEnded:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ticker := time.NewTicker(interruptRepeat)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
// Empty caught, so that the receive below waits for this
|
||||||
|
// SIGINT rather than an earlier one.
|
||||||
|
select {
|
||||||
|
case <-caught:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
sendErr := self.Signal(os.Interrupt)
|
||||||
|
if sendErr != nil {
|
||||||
|
t.Errorf("sending SIGINT: %v", sendErr)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
<-caught
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-testEnded:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// startStalledStore starts an HTTP server that never answers: each
|
||||||
|
// request is held until the client gives up on it or the test ends.
|
||||||
|
// It returns the server's host:port and a channel that receives a value
|
||||||
|
// when the first request arrives.
|
||||||
|
func startStalledStore(t *testing.T) (string, <-chan struct{}) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
requestArrived := make(chan struct{}, 1)
|
||||||
|
release := make(chan struct{})
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(
|
||||||
|
func(_ http.ResponseWriter, r *http.Request) {
|
||||||
|
select {
|
||||||
|
case requestArrived <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-r.Context().Done():
|
||||||
|
case <-release:
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Cleanups run last-registered first, so release lets any held
|
||||||
|
// request return before Close waits for it.
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
t.Cleanup(func() { close(release) })
|
||||||
|
|
||||||
|
return server.Listener.Addr().String(), requestArrived
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeStalledStoreConfig writes a config whose destination store is the
|
||||||
|
// server at endpoint and whose snapshot source holds one small file, so
|
||||||
|
// that `snapshot create` has a blob to upload. Returns the config path.
|
||||||
|
func writeStalledStoreConfig(t *testing.T, endpoint string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
configPath := filepath.Join(dir, "config.yml")
|
||||||
|
sourceDir := filepath.Join(dir, "source")
|
||||||
|
|
||||||
|
require.NoError(t, os.Mkdir(sourceDir, 0o750))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(sourceDir, "file.txt"),
|
||||||
|
[]byte("contents"), 0o600))
|
||||||
|
|
||||||
|
contents := fmt.Sprintf(stalledStoreConfig,
|
||||||
|
sourceDir, endpoint, filepath.Join(dir, "index.sqlite"))
|
||||||
|
|
||||||
|
require.NoError(t,
|
||||||
|
os.WriteFile(configPath, []byte(contents), configFileMode))
|
||||||
|
|
||||||
|
// The PID lock lives under xdg.DataHome, which xdg resolves at
|
||||||
|
// package init; point it at the temp dir so the test neither
|
||||||
|
// touches nor collides with the real one.
|
||||||
|
t.Setenv("XDG_DATA_HOME", filepath.Join(dir, "data"))
|
||||||
|
xdg.Reload()
|
||||||
|
t.Cleanup(xdg.Reload)
|
||||||
|
|
||||||
|
return configPath
|
||||||
|
}
|
||||||
@@ -98,25 +98,31 @@ func TestEntryPruneJSONStdoutIsExactlyOneDocument(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeHermeticPruneConfig builds a config over a temp directory and, if
|
// writeHermeticPruneConfig builds a config over a temp directory with an
|
||||||
// seedStale is set, creates the index database up front with one
|
// empty destination directory and, if seedStale is set, creates the
|
||||||
// snapshot record that has no counterpart on the destination store.
|
// index database up front with one snapshot record that has no
|
||||||
// Returns the config path.
|
// counterpart on the destination store, and gives the destination the
|
||||||
|
// marker a backup writes. Returns the config path.
|
||||||
func writeHermeticPruneConfig(t *testing.T, seedStale bool) string {
|
func writeHermeticPruneConfig(t *testing.T, seedStale bool) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
configPath := filepath.Join(dir, "config.yml")
|
configPath := filepath.Join(dir, "config.yml")
|
||||||
indexPath := filepath.Join(dir, "index.sqlite")
|
indexPath := filepath.Join(dir, "index.sqlite")
|
||||||
|
storeDir := filepath.Join(dir, "store")
|
||||||
|
|
||||||
contents := fmt.Sprintf(hermeticConfig,
|
contents := fmt.Sprintf(hermeticConfig,
|
||||||
filepath.Join(dir, "source"),
|
filepath.Join(dir, "source"),
|
||||||
filepath.Join(dir, "store"),
|
storeDir,
|
||||||
indexPath)
|
indexPath)
|
||||||
|
|
||||||
require.NoError(t,
|
require.NoError(t,
|
||||||
os.WriteFile(configPath, []byte(contents), configFileMode))
|
os.WriteFile(configPath, []byte(contents), configFileMode))
|
||||||
|
|
||||||
|
// prune fails on a destination directory that does not exist, so the
|
||||||
|
// empty store is created here rather than left to a first backup.
|
||||||
|
require.NoError(t, os.Mkdir(storeDir, 0o750))
|
||||||
|
|
||||||
// The PID lock lives under xdg.DataHome, which xdg resolves at
|
// The PID lock lives under xdg.DataHome, which xdg resolves at
|
||||||
// package init; point it at the temp dir so the test neither
|
// package init; point it at the temp dir so the test neither
|
||||||
// touches nor collides with the real one.
|
// touches nor collides with the real one.
|
||||||
@@ -126,6 +132,11 @@ func writeHermeticPruneConfig(t *testing.T, seedStale bool) string {
|
|||||||
|
|
||||||
if seedStale {
|
if seedStale {
|
||||||
seedStaleSnapshotRecord(t, indexPath)
|
seedStaleSnapshotRecord(t, indexPath)
|
||||||
|
|
||||||
|
// While the local index holds a snapshot, prune fails on a
|
||||||
|
// destination without the marker a backup writes.
|
||||||
|
require.NoError(t, os.WriteFile(
|
||||||
|
filepath.Join(storeDir, "vaultik-destination.txt"), nil, 0o600))
|
||||||
}
|
}
|
||||||
|
|
||||||
return configPath
|
return configPath
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package cli //nolint:testpackage // shares runEntry and the hermetic config helpers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// flagCron is used to build the argument vectors below.
|
||||||
|
const flagCron = "--cron"
|
||||||
|
|
||||||
|
// plantUnreferencedBlob writes a blob that no snapshot references to the
|
||||||
|
// destination store at storeDir, dated long before any prune grace period,
|
||||||
|
// so that a prune deletes it. Returns its path.
|
||||||
|
func plantUnreferencedBlob(t *testing.T, storeDir string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
hash := strings.Repeat("b", 64)
|
||||||
|
path := filepath.Join(storeDir, "blobs", hash[:2], hash[2:4], hash)
|
||||||
|
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o750))
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte("unreferenced"), 0o600))
|
||||||
|
|
||||||
|
longAgo := time.Unix(0, 0)
|
||||||
|
require.NoError(t, os.Chtimes(path, longAgo, longAgo))
|
||||||
|
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEntrySnapshotCreatePruneIsSilent checks that a successful
|
||||||
|
// `snapshot create --prune` under --cron or --quiet prints nothing on
|
||||||
|
// stdout or stderr, whether or not the prune finds a blob to delete.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
|
||||||
|
func TestEntrySnapshotCreatePruneIsSilent(t *testing.T) {
|
||||||
|
for _, testCase := range []struct {
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
plantBlob bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "cron, no blob to delete",
|
||||||
|
args: []string{cmdSnapshot, cmdCreate, flagCron, flagPrune},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "cron, blob to delete",
|
||||||
|
args: []string{cmdSnapshot, cmdCreate, flagCron, flagPrune},
|
||||||
|
plantBlob: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "cron with keep-newer-than",
|
||||||
|
args: []string{
|
||||||
|
cmdSnapshot, cmdCreate, flagCron, flagPrune,
|
||||||
|
flagKeepNewerThan, "4w",
|
||||||
|
},
|
||||||
|
plantBlob: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "quiet",
|
||||||
|
args: []string{flagQuiet, cmdSnapshot, cmdCreate, flagPrune},
|
||||||
|
plantBlob: true,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
|
configPath, storeDir := writeBackupConfig(t)
|
||||||
|
|
||||||
|
var blob string
|
||||||
|
if testCase.plantBlob {
|
||||||
|
blob = plantUnreferencedBlob(t, storeDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
code, stdout, stderr := runEntry(t,
|
||||||
|
append([]string{flagConfig, configPath}, testCase.args...)...)
|
||||||
|
|
||||||
|
assert.Equal(t, 0, code)
|
||||||
|
assert.Empty(t, stdout)
|
||||||
|
assert.Empty(t, stderr)
|
||||||
|
|
||||||
|
if testCase.plantBlob {
|
||||||
|
assert.NoFileExists(t, blob, "the prune must delete the blob")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEntryPruneQuietIsSilent checks that a successful `prune --force`
|
||||||
|
// under --quiet prints nothing on stdout or stderr, both when there is
|
||||||
|
// nothing to remove and when it removes a stale local snapshot record
|
||||||
|
// and an unreferenced blob.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
|
||||||
|
func TestEntryPruneQuietIsSilent(t *testing.T) {
|
||||||
|
for _, testCase := range []struct {
|
||||||
|
name string
|
||||||
|
staleRecordAndBlob bool
|
||||||
|
}{
|
||||||
|
{name: "nothing to remove"},
|
||||||
|
{name: "stale record and blob to remove", staleRecordAndBlob: true},
|
||||||
|
} {
|
||||||
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
|
configPath := writeHermeticPruneConfig(t, testCase.staleRecordAndBlob)
|
||||||
|
|
||||||
|
var blob string
|
||||||
|
if testCase.staleRecordAndBlob {
|
||||||
|
// writeHermeticPruneConfig puts the store beside the config.
|
||||||
|
blob = plantUnreferencedBlob(t,
|
||||||
|
filepath.Join(filepath.Dir(configPath), "store"))
|
||||||
|
}
|
||||||
|
|
||||||
|
code, stdout, stderr := runEntry(t,
|
||||||
|
flagConfig, configPath, flagQuiet, cmdPrune, flagForce)
|
||||||
|
|
||||||
|
assert.Equal(t, 0, code)
|
||||||
|
assert.Empty(t, stdout)
|
||||||
|
assert.Empty(t, stderr)
|
||||||
|
|
||||||
|
if testCase.staleRecordAndBlob {
|
||||||
|
assert.NoFileExists(t, blob, "the prune must delete the blob")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package cli //nolint:testpackage // shares runEntry and the hermetic config helpers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Flags of `snapshot create` used to build argument vectors below.
|
||||||
|
const (
|
||||||
|
flagPrune = "--prune"
|
||||||
|
flagKeepNewerThan = "--keep-newer-than"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestEntrySnapshotCreateRejectsKeepNewerThanBeforeBackup checks that a
|
||||||
|
// --keep-newer-than the post-backup prune could not use stops `snapshot
|
||||||
|
// create` before the backup: the command fails and the destination
|
||||||
|
// store stays empty. It used to be read only by the prune, after the
|
||||||
|
// snapshot was uploaded, and without --prune it was ignored.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
|
||||||
|
func TestEntrySnapshotCreateRejectsKeepNewerThanBeforeBackup(t *testing.T) {
|
||||||
|
for _, testCase := range []struct {
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "decimal in a day count",
|
||||||
|
args: []string{flagPrune, flagKeepNewerThan, "1.5d"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown unit",
|
||||||
|
args: []string{flagPrune, flagKeepNewerThan, "4x"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "without --prune",
|
||||||
|
args: []string{flagKeepNewerThan, "4w"},
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
|
configPath, storeDir := writeBackupConfig(t)
|
||||||
|
|
||||||
|
code, _, _ := runEntry(t, append(
|
||||||
|
[]string{flagConfig, configPath, cmdSnapshot, cmdCreate},
|
||||||
|
testCase.args...)...)
|
||||||
|
|
||||||
|
assert.Equal(t, 1, code)
|
||||||
|
|
||||||
|
stored, err := os.ReadDir(storeDir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, stored, "nothing may be uploaded")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEntrySnapshotCreateReportsPostBackupPruneFailure makes the
|
||||||
|
// post-backup prune fail after a good backup, by planting a manifest
|
||||||
|
// that cannot be decoded, and checks that the failure is reported as the
|
||||||
|
// prune's and not as a failed snapshot creation.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
|
||||||
|
func TestEntrySnapshotCreateReportsPostBackupPruneFailure(t *testing.T) {
|
||||||
|
configPath, storeDir := writeBackupConfig(t)
|
||||||
|
|
||||||
|
// A remote key is 64 lowercase hex characters. The prune reads every
|
||||||
|
// manifest on the store and fails on one it cannot decode.
|
||||||
|
plantedDir := filepath.Join(storeDir, "metadata", strings.Repeat("a", 64))
|
||||||
|
require.NoError(t, os.MkdirAll(plantedDir, 0o750))
|
||||||
|
require.NoError(t, os.WriteFile(
|
||||||
|
filepath.Join(plantedDir, "manifest.json.zst"),
|
||||||
|
[]byte("not a valid manifest"), 0o600))
|
||||||
|
|
||||||
|
code, _, stderr := runEntry(t,
|
||||||
|
flagConfig, configPath, cmdSnapshot, cmdCreate, flagPrune)
|
||||||
|
|
||||||
|
assert.Equal(t, 1, code)
|
||||||
|
assert.Contains(t, stderr, "Backup completed, but post-backup prune failed")
|
||||||
|
assert.NotContains(t, stderr, "Snapshot creation failed")
|
||||||
|
|
||||||
|
snapshots, err := os.ReadDir(filepath.Join(storeDir, "metadata"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, snapshots, 2,
|
||||||
|
"the new snapshot is on the store beside the planted one")
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeBackupConfig builds a hermetic config with one small file in its
|
||||||
|
// source directory and an empty destination store directory. Returns the
|
||||||
|
// config path and the store directory.
|
||||||
|
func writeBackupConfig(t *testing.T) (string, string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
configPath := writeHermeticPruneConfig(t, false)
|
||||||
|
|
||||||
|
// writeHermeticPruneConfig puts the source and store directories
|
||||||
|
// beside the config file.
|
||||||
|
dir := filepath.Dir(configPath)
|
||||||
|
sourceDir := filepath.Join(dir, "source")
|
||||||
|
|
||||||
|
require.NoError(t, os.Mkdir(sourceDir, 0o750))
|
||||||
|
require.NoError(t, os.WriteFile(
|
||||||
|
filepath.Join(sourceDir, "file.txt"), []byte("hello"), 0o600))
|
||||||
|
|
||||||
|
return configPath, filepath.Join(dir, "store")
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package cli //nolint:testpackage // shares runEntry and the hermetic config helpers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestEntrySnapshotCreateSkipErrorsExitsNonZeroWhenItSkips backs up,
|
||||||
|
// under --skip-errors, a source directory that does not exist, which the
|
||||||
|
// walk skips. The command exits non-zero and reports the backup as
|
||||||
|
// completed, not as a failed snapshot creation. See
|
||||||
|
// https://git.eeqj.de/sneak/vaultik/issues/310.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
|
||||||
|
func TestEntrySnapshotCreateSkipErrorsExitsNonZeroWhenItSkips(t *testing.T) {
|
||||||
|
// writeBackupConfig would create the source directory.
|
||||||
|
configPath := writeHermeticPruneConfig(t, false)
|
||||||
|
|
||||||
|
code, stdout, stderr := runEntry(t,
|
||||||
|
flagConfig, configPath, "--skip-errors", cmdSnapshot, cmdCreate)
|
||||||
|
|
||||||
|
assert.Equal(t, 1, code)
|
||||||
|
assert.Contains(t, stdout,
|
||||||
|
"Files: 1 examined, 0 backed up, 0 unchanged, 1 failed.")
|
||||||
|
assert.NotContains(t, stdout, "Finished successfully.")
|
||||||
|
assert.Contains(t, stderr,
|
||||||
|
"Backup completed, but 1 file(s) could not be read")
|
||||||
|
assert.NotContains(t, stderr, "Snapshot creation failed")
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package cli //nolint:testpackage // shares runEntry and the hermetic config helpers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/vaultik/internal/database"
|
||||||
|
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Words and flags of `snapshot purge` used to build argument vectors
|
||||||
|
// below.
|
||||||
|
const (
|
||||||
|
cmdPurge = "purge"
|
||||||
|
flagOlderThan = "--older-than"
|
||||||
|
flagForce = "--force"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestEntrySnapshotPurgeKeepsMetadataWhenLocalIndexDeleteFails makes
|
||||||
|
// the local index refuse to delete an old snapshot and checks that
|
||||||
|
// `snapshot purge` exits non-zero and leaves that snapshot's metadata on
|
||||||
|
// the destination store. It used to log the error, delete the metadata
|
||||||
|
// and exit 0.
|
||||||
|
//
|
||||||
|
// `snapshot create --prune` runs the same purge, but cannot be driven
|
||||||
|
// this way: the refusal also breaks the backup's metadata export, which
|
||||||
|
// deletes the other snapshots from its copy of the local index.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
|
||||||
|
func TestEntrySnapshotPurgeKeepsMetadataWhenLocalIndexDeleteFails(t *testing.T) {
|
||||||
|
configPath := writeHermeticPruneConfig(t, false)
|
||||||
|
dir := filepath.Dir(configPath)
|
||||||
|
indexPath := filepath.Join(dir, "index.sqlite")
|
||||||
|
|
||||||
|
// seedStaleSnapshotRecord writes an old snapshot to the local index
|
||||||
|
// only; the manifest planted here puts it on the destination store
|
||||||
|
// too, so the purge selects it rather than dropping it as stale.
|
||||||
|
seedStaleSnapshotRecord(t, indexPath)
|
||||||
|
|
||||||
|
// While the local index holds a snapshot, purge fails on a
|
||||||
|
// destination without the marker a backup writes.
|
||||||
|
require.NoError(t, os.WriteFile(
|
||||||
|
filepath.Join(dir, "store", "vaultik-destination.txt"), nil, 0o600))
|
||||||
|
|
||||||
|
manifestDir := filepath.Join(dir, "store", "metadata",
|
||||||
|
snapshot.RemoteSnapshotKey(stalePruneSnapshotID))
|
||||||
|
require.NoError(t, os.MkdirAll(manifestDir, 0o750))
|
||||||
|
|
||||||
|
manifestPath := filepath.Join(manifestDir, "manifest.json.zst")
|
||||||
|
require.NoError(t, os.WriteFile(manifestPath, []byte("stub"), 0o600))
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
db, err := database.New(ctx, indexPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = db.Conn().ExecContext(ctx, `
|
||||||
|
CREATE TRIGGER refuse_snapshot_delete BEFORE DELETE ON snapshots
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'simulated index error'); END`)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, db.Close())
|
||||||
|
|
||||||
|
code, _, stderr := runEntry(t, flagConfig, configPath,
|
||||||
|
cmdSnapshot, cmdPurge, flagOlderThan, "30d", flagForce)
|
||||||
|
|
||||||
|
assert.Equal(t, 1, code)
|
||||||
|
assert.Contains(t, stderr, "simulated index error",
|
||||||
|
"the purge must fail at the local-index delete")
|
||||||
|
assert.FileExists(t, manifestPath,
|
||||||
|
"the snapshot is still in the local index, so its metadata must stay")
|
||||||
|
}
|
||||||
@@ -15,8 +15,8 @@ import (
|
|||||||
// run, so a failing command must come back with a non-zero code rather
|
// run, so a failing command must come back with a non-zero code rather
|
||||||
// than ending the process here.
|
// than ending the process here.
|
||||||
//
|
//
|
||||||
// Stdout is captured only to keep the banner and command output off the
|
// Stdout and stderr are captured only to keep the banner and command
|
||||||
// test log; the assertion is on the returned code.
|
// output off the test log; the assertion is on the returned code.
|
||||||
//
|
//
|
||||||
//nolint:paralleltest // replaces os.Args and rootFlags
|
//nolint:paralleltest // replaces os.Args and rootFlags
|
||||||
func TestEntryReturnsStatusCode(t *testing.T) {
|
func TestEntryReturnsStatusCode(t *testing.T) {
|
||||||
@@ -50,7 +50,7 @@ func TestEntryReturnsStatusCode(t *testing.T) {
|
|||||||
|
|
||||||
var code int
|
var code int
|
||||||
|
|
||||||
_ = captureProcessStdout(t, func() { code = Entry() })
|
_, _ = captureProcessStdoutAndStderr(t, func() { code = Entry() })
|
||||||
|
|
||||||
assert.Equal(t, testCase.want, code)
|
assert.Equal(t, testCase.want, code)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
package cli //nolint:testpackage // shares hermeticConfig and the capture helpers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/adrg/xdg"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/vaultik/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestEntryCompletionStdoutIsTheScript runs `vaultik completion bash`,
|
||||||
|
// whose stdout the README tells the user to source. The script has to
|
||||||
|
// start on the first line.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // replaces os.Args, os.Stdout and os.Stderr
|
||||||
|
func TestEntryCompletionStdoutIsTheScript(t *testing.T) {
|
||||||
|
code, stdout, _ := runEntry(t, "completion", "bash")
|
||||||
|
|
||||||
|
require.Equal(t, 0, code)
|
||||||
|
|
||||||
|
firstLine, _, _ := strings.Cut(stdout, "\n")
|
||||||
|
assert.True(t, strings.HasPrefix(firstLine, "# bash completion"),
|
||||||
|
"the first line of stdout must be the script's, got %q", firstLine)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEntryConfigGetStdoutIsTheValue runs `vaultik config get`, whose
|
||||||
|
// stdout a script reads as the value and nothing else.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // replaces os.Args, os.Stdout and os.Stderr
|
||||||
|
func TestEntryConfigGetStdoutIsTheValue(t *testing.T) {
|
||||||
|
configPath := filepath.Join(t.TempDir(), "config.yml")
|
||||||
|
require.NoError(t, os.WriteFile(configPath,
|
||||||
|
[]byte("hostname: test-host\n"), configFileMode))
|
||||||
|
|
||||||
|
code, stdout, _ := runEntry(t,
|
||||||
|
flagConfig, configPath, "config", "get", "hostname")
|
||||||
|
|
||||||
|
require.Equal(t, 0, code)
|
||||||
|
assert.Equal(t, "test-host\n", stdout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEntryJSONFailureIsReportedOnStderr runs each --json command that
|
||||||
|
// writes no document when it fails, against a destination it cannot
|
||||||
|
// use. The error must reach stderr, and stdout must stay empty.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
|
||||||
|
func TestEntryJSONFailureIsReportedOnStderr(t *testing.T) {
|
||||||
|
for _, testCase := range []struct {
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
wantOnStderr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "remote info",
|
||||||
|
args: []string{cmdRemote, cmdInfo, flagJSON},
|
||||||
|
wantOnStderr: "Failed to get remote info",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "prune",
|
||||||
|
args: []string{cmdPrune, flagJSON},
|
||||||
|
wantOnStderr: "Prune failed",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "snapshot remove",
|
||||||
|
args: []string{cmdSnapshot, cmdRemove, someSnapshotID, flagJSON},
|
||||||
|
wantOnStderr: "Failed to remove snapshot",
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
|
configPath := writeUnusableDestinationConfig(t)
|
||||||
|
|
||||||
|
code, stdout, stderr := runEntry(t,
|
||||||
|
append([]string{flagConfig, configPath}, testCase.args...)...)
|
||||||
|
|
||||||
|
assert.Equal(t, 1, code)
|
||||||
|
assert.Empty(t, stdout,
|
||||||
|
"a failed --json command has no document to write")
|
||||||
|
assert.Contains(t, stderr, testCase.wantOnStderr,
|
||||||
|
"the failure must be reported on stderr")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEntrySnapshotRemoveJSONWarningIsOnStderr runs `snapshot remove
|
||||||
|
// --json` on a snapshot in the local index, against a destination
|
||||||
|
// directory that does not exist. The command removes the snapshot from
|
||||||
|
// the local index and still exits 0. Its stdout must hold the document
|
||||||
|
// alone, with the warning about the destination store on stderr: the
|
||||||
|
// command to run again once it is reachable, and the snapshot's ID in
|
||||||
|
// the record's snapshot_id field.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
|
||||||
|
func TestEntrySnapshotRemoveJSONWarningIsOnStderr(t *testing.T) {
|
||||||
|
configPath, indexPath := writeMissingDestinationConfig(t)
|
||||||
|
seedStaleSnapshotRecord(t, indexPath)
|
||||||
|
|
||||||
|
code, stdout, stderr := runEntry(t, flagConfig, configPath,
|
||||||
|
cmdSnapshot, cmdRemove, stalePruneSnapshotID, flagJSON)
|
||||||
|
|
||||||
|
require.Equal(t, 0, code)
|
||||||
|
requireExactlyOneJSONDocument(t, stdout)
|
||||||
|
|
||||||
|
// stderr is a pipe here, so the logger writes one JSON record a line.
|
||||||
|
var warning map[string]any
|
||||||
|
|
||||||
|
for line := range strings.Lines(stderr) {
|
||||||
|
if strings.Contains(line,
|
||||||
|
"Could not remove snapshot metadata from remote storage") {
|
||||||
|
require.NoError(t, json.Unmarshal([]byte(line), &warning))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NotNil(t, warning, "the warning must reach stderr")
|
||||||
|
assert.Contains(t, warning[slog.MessageKey],
|
||||||
|
"run 'vaultik snapshot remove' with the snapshot's ID again")
|
||||||
|
assert.Equal(t, stalePruneSnapshotID, warning["snapshot_id"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeMissingDestinationConfig builds a config whose destination
|
||||||
|
// directory does not exist. Returns the config path and the path of
|
||||||
|
// its local index, which is not created here.
|
||||||
|
func writeMissingDestinationConfig(t *testing.T) (string, string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
configPath := filepath.Join(dir, "config.yml")
|
||||||
|
indexPath := filepath.Join(dir, "index.sqlite")
|
||||||
|
|
||||||
|
contents := fmt.Sprintf(hermeticConfig,
|
||||||
|
filepath.Join(dir, "source"),
|
||||||
|
filepath.Join(dir, "missing-store"),
|
||||||
|
indexPath)
|
||||||
|
|
||||||
|
require.NoError(t,
|
||||||
|
os.WriteFile(configPath, []byte(contents), configFileMode))
|
||||||
|
|
||||||
|
// The PID lock lives under xdg.DataHome, which xdg resolves at
|
||||||
|
// package init; point it at the temp dir so the test neither
|
||||||
|
// touches nor collides with the real one.
|
||||||
|
t.Setenv("XDG_DATA_HOME", filepath.Join(dir, "data"))
|
||||||
|
xdg.Reload()
|
||||||
|
t.Cleanup(xdg.Reload)
|
||||||
|
|
||||||
|
return configPath, indexPath
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeUnusableDestinationConfig builds a config whose destination
|
||||||
|
// directory does not exist, which fails `remote info`, and whose local
|
||||||
|
// index is bound to another destination, which fails `prune` and
|
||||||
|
// `snapshot remove` (a missing destination alone only makes `snapshot
|
||||||
|
// remove` warn). Returns the config path.
|
||||||
|
func writeUnusableDestinationConfig(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
configPath, indexPath := writeMissingDestinationConfig(t)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
db, err := database.New(ctx, indexPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
defer func() { require.NoError(t, db.Close()) }()
|
||||||
|
|
||||||
|
require.NoError(t, database.NewRepositories(db).LocalMeta.Set(ctx,
|
||||||
|
database.LocalMetaKeyStorageURL, "file://"+t.TempDir()))
|
||||||
|
|
||||||
|
return configPath
|
||||||
|
}
|
||||||
|
|
||||||
|
// runEntry runs Entry with args after the program name and returns its
|
||||||
|
// exit code and what it wrote to stdout and stderr.
|
||||||
|
//
|
||||||
|
// Not parallel-safe: it replaces os.Args, os.Stdout and os.Stderr.
|
||||||
|
func runEntry(t *testing.T, args ...string) (int, string, string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
previousArgs := os.Args
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
os.Args = previousArgs
|
||||||
|
rootFlags = RootFlags{}
|
||||||
|
})
|
||||||
|
|
||||||
|
os.Args = append([]string{programName}, args...)
|
||||||
|
|
||||||
|
var code int
|
||||||
|
|
||||||
|
stdout, stderr := captureProcessStdoutAndStderr(t,
|
||||||
|
func() { code = Entry() })
|
||||||
|
|
||||||
|
return code, stdout, stderr
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package cli //nolint:testpackage // shares runEntry and the command-word constants
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestEntryGroupRejectsUnknownSubcommand runs each group command with a
|
||||||
|
// mistyped subcommand, as a crontab line might carry one. It must fail
|
||||||
|
// with exit 1 and name the word, as `vaultik bogus` does at the top
|
||||||
|
// level, instead of printing the group's help and exiting 0 having done
|
||||||
|
// nothing. The group name alone still prints its help.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // replaces os.Args, os.Stdout and os.Stderr
|
||||||
|
func TestEntryGroupRejectsUnknownSubcommand(t *testing.T) {
|
||||||
|
for _, testCase := range []struct {
|
||||||
|
group string
|
||||||
|
mistyped string
|
||||||
|
}{
|
||||||
|
{group: cmdSnapshot, mistyped: "craete"},
|
||||||
|
{group: "config", mistyped: "inti"},
|
||||||
|
{group: cmdRemote, mistyped: "ifno"},
|
||||||
|
{group: "database", mistyped: "delte"},
|
||||||
|
{group: "completion", mistyped: "nosuchshell"},
|
||||||
|
} {
|
||||||
|
t.Run(testCase.group, func(t *testing.T) {
|
||||||
|
code, stdout, stderr := runEntry(t,
|
||||||
|
testCase.group, testCase.mistyped)
|
||||||
|
|
||||||
|
assert.Equal(t, 1, code)
|
||||||
|
assert.Empty(t, stdout)
|
||||||
|
assert.Contains(t, stderr,
|
||||||
|
`unknown command "`+testCase.mistyped+`"`)
|
||||||
|
|
||||||
|
code, stdout, _ = runEntry(t, testCase.group)
|
||||||
|
|
||||||
|
assert.Equal(t, 0, code)
|
||||||
|
assert.Contains(t, stdout,
|
||||||
|
programName+" "+testCase.group+" [command]")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package cli //nolint:testpackage // exercises the unexported command constructor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestJSONHelpSaysConfirmationPromptIsSkipped checks that the --json help
|
||||||
|
// of `snapshot remove` and `prune` says the flag skips the confirmation
|
||||||
|
// prompt. Both delete without asking under --json, since a prompt on
|
||||||
|
// stdout would break the JSON document.
|
||||||
|
func TestJSONHelpSaysConfirmationPromptIsSkipped(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, cmd := range []*cobra.Command{
|
||||||
|
newSnapshotRemoveCommand(),
|
||||||
|
NewPruneCommand(),
|
||||||
|
} {
|
||||||
|
assert.Contains(t, cmd.Flags().Lookup("json").Usage,
|
||||||
|
"skips the confirmation prompt", cmd.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
-9
@@ -20,11 +20,18 @@ Local cleanup drops incomplete snapshots and any files, chunks, or
|
|||||||
blobs no longer referenced by a completed snapshot. Remote cleanup
|
blobs no longer referenced by a completed snapshot. Remote cleanup
|
||||||
scans every snapshot manifest in the destination store, builds the
|
scans every snapshot manifest in the destination store, builds the
|
||||||
set of still-referenced blob hashes, and deletes any blob not in that
|
set of still-referenced blob hashes, and deletes any blob not in that
|
||||||
set.
|
set that is older than prune_grace_period (default 24h). A newer one
|
||||||
|
is kept: it may belong to a backup still running, on this host or
|
||||||
|
another sharing the destination store, that has not yet uploaded its
|
||||||
|
manifest. The grace period does not protect a blob such a backup
|
||||||
|
reuses from an earlier run that stopped before its manifest: that
|
||||||
|
blob keeps the time of its first upload, so it can be deleted.
|
||||||
|
|
||||||
Snapshot create --prune and snapshot remove run the same cleanup
|
Snapshot create --prune runs the same cleanup automatically; this
|
||||||
automatically; this command is the manual entry point for the same
|
command is the manual entry point for the same work (e.g. after a
|
||||||
work (e.g. after a crashed backup or to reclaim storage).`,
|
crashed backup or to reclaim storage). Snapshot remove leaves blobs in
|
||||||
|
place; run this command afterwards to delete the ones no longer
|
||||||
|
referenced.`,
|
||||||
Args: cobra.NoArgs,
|
Args: cobra.NoArgs,
|
||||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
// Use unified config resolution
|
// Use unified config resolution
|
||||||
@@ -48,10 +55,6 @@ work (e.g. after a crashed backup or to reclaim storage).`,
|
|||||||
}, func(v *vaultik.Vaultik) error {
|
}, func(v *vaultik.Vaultik) error {
|
||||||
return v.Prune(opts)
|
return v.Prune(opts)
|
||||||
}, func(err error) {
|
}, func(err error) {
|
||||||
if opts.JSON {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Error("Prune operation failed", "error", err)
|
log.Error("Prune operation failed", "error", err)
|
||||||
ReportErrorf("Prune failed: %v", err)
|
ReportErrorf("Prune failed: %v", err)
|
||||||
})
|
})
|
||||||
@@ -59,7 +62,8 @@ work (e.g. after a crashed backup or to reclaim storage).`,
|
|||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().BoolVar(&opts.Force, "force", false, "Skip confirmation prompt")
|
cmd.Flags().BoolVar(&opts.Force, "force", false, "Skip confirmation prompt")
|
||||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "Output pruning stats as JSON")
|
cmd.Flags().BoolVar(&opts.JSON, "json", false,
|
||||||
|
"Output pruning stats as JSON; skips the confirmation prompt, as --force does")
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ func NewRemoteCommand() *cobra.Command {
|
|||||||
Use: "remote",
|
Use: "remote",
|
||||||
Short: "Remote storage management commands",
|
Short: "Remote storage management commands",
|
||||||
Long: "Commands for inspecting and managing remote storage",
|
Long: "Commands for inspecting and managing remote storage",
|
||||||
|
// cobra checks Args only on a command that has a Run: without
|
||||||
|
// one, a mistyped subcommand printed help and exited 0.
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
Run: func(cmd *cobra.Command, _ []string) {
|
||||||
|
_ = cmd.Help()
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add subcommands
|
// Add subcommands
|
||||||
@@ -45,7 +51,7 @@ This is destructive and irreversible. Requires --force.`,
|
|||||||
return errNukeNeedsForce
|
return errNukeNeedsForce
|
||||||
}
|
}
|
||||||
|
|
||||||
return runVaultikApp(cmd, mutating, false, false, "Remote nuke failed",
|
return runVaultikApp(cmd, mutating, false, "Remote nuke failed",
|
||||||
func(v *vaultik.Vaultik) error {
|
func(v *vaultik.Vaultik) error {
|
||||||
return v.NukeRemote(true)
|
return v.NukeRemote(true)
|
||||||
})
|
})
|
||||||
@@ -92,10 +98,6 @@ func newRemoteInfoCommand() *cobra.Command {
|
|||||||
}, func(v *vaultik.Vaultik) error {
|
}, func(v *vaultik.Vaultik) error {
|
||||||
return v.RemoteInfo(jsonOutput)
|
return v.RemoteInfo(jsonOutput)
|
||||||
}, func(err error) {
|
}, func(err error) {
|
||||||
if jsonOutput {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Error("Failed to get remote info", "error", err)
|
log.Error("Failed to get remote info", "error", err)
|
||||||
ReportErrorf("Failed to get remote info: %v", err)
|
ReportErrorf("Failed to get remote info: %v", err)
|
||||||
})
|
})
|
||||||
|
|||||||
+17
-1
@@ -60,7 +60,8 @@ on the source system.`,
|
|||||||
cmd.PersistentFlags().BoolVar(&rootFlags.SkipErrors, "skip-errors", false,
|
cmd.PersistentFlags().BoolVar(&rootFlags.SkipErrors, "skip-errors", false,
|
||||||
"Skip files that cannot be read when creating a snapshot, or "+
|
"Skip files that cannot be read when creating a snapshot, or "+
|
||||||
"that cannot be restored when restoring, instead of aborting "+
|
"that cannot be restored when restoring, instead of aborting "+
|
||||||
"(packing and storage errors still abort)")
|
"(packing and storage errors while creating a snapshot still "+
|
||||||
|
"abort)")
|
||||||
|
|
||||||
// Add subcommands
|
// Add subcommands
|
||||||
cmd.AddCommand(
|
cmd.AddCommand(
|
||||||
@@ -73,6 +74,21 @@ on the source system.`,
|
|||||||
NewDatabaseCommand(),
|
NewDatabaseCommand(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// cobra adds the completion command only when Execute runs, and with
|
||||||
|
// no Run, so it skipped its own cobra.NoArgs and a mistyped shell name
|
||||||
|
// printed help and exited 0. Adding it here lets it have a Run. Its
|
||||||
|
// scripts go to os.Stdout as it is now: a later SetOut does not
|
||||||
|
// redirect them.
|
||||||
|
cmd.InitDefaultCompletionCmd()
|
||||||
|
|
||||||
|
for _, sub := range cmd.Commands() {
|
||||||
|
if sub.Name() == "completion" {
|
||||||
|
sub.Run = func(completion *cobra.Command, _ []string) {
|
||||||
|
_ = completion.Help()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+49
-13
@@ -41,6 +41,12 @@ func NewSnapshotCommand() *cobra.Command {
|
|||||||
Use: "snapshot",
|
Use: "snapshot",
|
||||||
Short: "Snapshot management commands",
|
Short: "Snapshot management commands",
|
||||||
Long: "Commands for creating, listing, and managing snapshots",
|
Long: "Commands for creating, listing, and managing snapshots",
|
||||||
|
// cobra checks Args only on a command that has a Run: without
|
||||||
|
// one, a mistyped subcommand printed help and exited 0.
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
Run: func(cmd *cobra.Command, _ []string) {
|
||||||
|
_ = cmd.Help()
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add subcommands
|
// Add subcommands
|
||||||
@@ -66,10 +72,16 @@ func newSnapshotCreateCommand() *cobra.Command {
|
|||||||
If snapshot names are provided, only those snapshots are created.
|
If snapshot names are provided, only those snapshots are created.
|
||||||
If no names are provided, all configured snapshots are created.
|
If no names are provided, all configured snapshots are created.
|
||||||
|
|
||||||
Config is located at /etc/vaultik/config.yml by default, but can be overridden by
|
The config is read from the path given by --config or VAULTIK_CONFIG;
|
||||||
specifying a path using --config or by setting VAULTIK_CONFIG to a path.`,
|
otherwise from the platform config directory (~/.config/vaultik/config.yml
|
||||||
|
on Linux), then /etc/vaultik/config.yml.`,
|
||||||
Args: cobra.ArbitraryArgs,
|
Args: cobra.ArbitraryArgs,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
err := opts.Validate()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// Pass snapshot names from args
|
// Pass snapshot names from args
|
||||||
opts.Snapshots = args
|
opts.Snapshots = args
|
||||||
// --skip-errors is a global flag on the root command.
|
// --skip-errors is a global flag on the root command.
|
||||||
@@ -96,6 +108,20 @@ specifying a path using --config or by setting VAULTIK_CONFIG to a path.`,
|
|||||||
}, func(v *vaultik.Vaultik) error {
|
}, func(v *vaultik.Vaultik) error {
|
||||||
return v.CreateSnapshot(opts)
|
return v.CreateSnapshot(opts)
|
||||||
}, func(err error) {
|
}, func(err error) {
|
||||||
|
if errors.Is(err, vaultik.ErrPostBackupPruneFailed) {
|
||||||
|
log.Error("Post-backup prune failed", "error", err)
|
||||||
|
ReportErrorf("Backup completed, but %v", err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if errors.Is(err, vaultik.ErrFilesUnreadable) {
|
||||||
|
log.Error("Backup completed with unreadable files", "error", err)
|
||||||
|
ReportErrorf("Backup completed, but %v", err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
log.Error("Snapshot creation failed", "error", err)
|
log.Error("Snapshot creation failed", "error", err)
|
||||||
ReportErrorf("Snapshot creation failed: %v", err)
|
ReportErrorf("Snapshot creation failed: %v", err)
|
||||||
})
|
})
|
||||||
@@ -106,7 +132,8 @@ specifying a path using --config or by setting VAULTIK_CONFIG to a path.`,
|
|||||||
"Run in cron mode (silent unless warning or error)")
|
"Run in cron mode (silent unless warning or error)")
|
||||||
cmd.Flags().BoolVar(&opts.Prune, "prune", false,
|
cmd.Flags().BoolVar(&opts.Prune, "prune", false,
|
||||||
"After backup, drop older snapshots of the same name and remove "+
|
"After backup, drop older snapshots of the same name and remove "+
|
||||||
"orphaned blobs")
|
"orphaned blobs older than prune_grace_period (not after a "+
|
||||||
|
"backup that skipped unreadable files)")
|
||||||
cmd.Flags().StringVar(&opts.KeepNewerThan, "keep-newer-than", "",
|
cmd.Flags().StringVar(&opts.KeepNewerThan, "keep-newer-than", "",
|
||||||
"With --prune: keep snapshots newer than this duration "+
|
"With --prune: keep snapshots newer than this duration "+
|
||||||
"(e.g. 30d, 4w, 6mo, 1y; m is minutes, mo is months) "+
|
"(e.g. 30d, 4w, 6mo, 1y; m is minutes, mo is months) "+
|
||||||
@@ -126,7 +153,7 @@ func newSnapshotListCommand() *cobra.Command {
|
|||||||
Long: "Lists all snapshots with their ID, timestamp, and compressed size",
|
Long: "Lists all snapshots with their ID, timestamp, and compressed size",
|
||||||
Args: cobra.NoArgs,
|
Args: cobra.NoArgs,
|
||||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
return runVaultikApp(cmd, readOnly, false, false,
|
return runVaultikApp(cmd, readOnly, false,
|
||||||
"Failed to list snapshots",
|
"Failed to list snapshots",
|
||||||
func(v *vaultik.Vaultik) error {
|
func(v *vaultik.Vaultik) error {
|
||||||
return v.ListSnapshots(jsonOutput)
|
return v.ListSnapshots(jsonOutput)
|
||||||
@@ -162,7 +189,7 @@ restrict the operation to specific snapshot names.`,
|
|||||||
return errPurgeCriteriaBoth
|
return errPurgeCriteriaBoth
|
||||||
}
|
}
|
||||||
|
|
||||||
return runVaultikApp(cmd, mutating, false, false,
|
return runVaultikApp(cmd, mutating, false,
|
||||||
"Failed to purge snapshots",
|
"Failed to purge snapshots",
|
||||||
func(v *vaultik.Vaultik) error {
|
func(v *vaultik.Vaultik) error {
|
||||||
return v.PurgeSnapshotsWithOptions(opts)
|
return v.PurgeSnapshotsWithOptions(opts)
|
||||||
@@ -251,21 +278,29 @@ By default, this removes the snapshot from the local index database and
|
|||||||
strips the snapshot's metadata from the backup destination store. Blobs
|
strips the snapshot's metadata from the backup destination store. Blobs
|
||||||
are NOT touched: deleting them requires reading every remaining remote
|
are NOT touched: deleting them requires reading every remaining remote
|
||||||
manifest (the destination store may hold snapshots this host doesn't
|
manifest (the destination store may hold snapshots this host doesn't
|
||||||
know about), which is what 'vaultik prune' does. On success the command
|
know about), which is what 'vaultik prune' does. The command prints the
|
||||||
prints the exact 'vaultik prune' invocation to run as a follow-up.
|
'vaultik prune' invocation to run as a follow-up only when it removed the
|
||||||
|
snapshot's metadata from the destination store, and never under --json.
|
||||||
|
|
||||||
|
An ID that names no snapshot in the local index or on the destination
|
||||||
|
store is an error.
|
||||||
|
|
||||||
Use --local-only to skip the remote half (e.g. when you want to forget a
|
Use --local-only to skip the remote half (e.g. when you want to forget a
|
||||||
snapshot locally without touching the destination store).
|
snapshot locally without touching the destination store); the snapshot
|
||||||
|
must then be in the local index.
|
||||||
|
|
||||||
If the remote is unreachable, the local-database removal still completes
|
If removing the snapshot's metadata from the destination store fails (for
|
||||||
and a warning is emitted; rerun 'vaultik prune' once the destination store
|
example because the remote is unreachable), the local-database removal
|
||||||
is reachable to finish remote cleanup.
|
still completes and a warning is emitted; run 'vaultik snapshot remove
|
||||||
|
<snapshot-id>' again once the destination store is reachable to remove
|
||||||
|
the snapshot's metadata from it ('vaultik prune' does not). For a
|
||||||
|
snapshot that is only on the destination store, that failure is an error.
|
||||||
|
|
||||||
To wipe the entire destination store and start over, use 'vaultik remote
|
To wipe the entire destination store and start over, use 'vaultik remote
|
||||||
nuke --force' — it is the single supported entry point for that.`,
|
nuke --force' — it is the single supported entry point for that.`,
|
||||||
Args: requireSnapshotIDArg,
|
Args: requireSnapshotIDArg,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
return runVaultikApp(cmd, mutating, opts.JSON, opts.JSON,
|
return runVaultikApp(cmd, mutating, opts.JSON,
|
||||||
"Failed to remove snapshot",
|
"Failed to remove snapshot",
|
||||||
func(v *vaultik.Vaultik) error {
|
func(v *vaultik.Vaultik) error {
|
||||||
_, err := v.RemoveSnapshot(args[0], opts)
|
_, err := v.RemoveSnapshot(args[0], opts)
|
||||||
@@ -278,7 +313,8 @@ nuke --force' — it is the single supported entry point for that.`,
|
|||||||
cmd.Flags().BoolVarP(&opts.Force, "force", "f", false, "Skip confirmation prompt")
|
cmd.Flags().BoolVarP(&opts.Force, "force", "f", false, "Skip confirmation prompt")
|
||||||
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false,
|
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false,
|
||||||
"Show what would be removed without removing")
|
"Show what would be removed without removing")
|
||||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "Output result as JSON")
|
cmd.Flags().BoolVar(&opts.JSON, "json", false,
|
||||||
|
"Output result as JSON; skips the confirmation prompt, as --force does")
|
||||||
cmd.Flags().BoolVar(&opts.LocalOnly, "local-only", false,
|
cmd.Flags().BoolVar(&opts.LocalOnly, "local-only", false,
|
||||||
"Skip remote cleanup; only touch the local index")
|
"Skip remote cleanup; only touch the local index")
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ This command will restore files from the specified snapshot to the
|
|||||||
target directory.
|
target directory.
|
||||||
If no paths are specified, all files are restored.
|
If no paths are specified, all files are restored.
|
||||||
If paths are specified, only matching files/directories are restored.
|
If paths are specified, only matching files/directories are restored.
|
||||||
|
A path that matches nothing in the snapshot is named in a warning, and
|
||||||
|
the command exits non-zero after restoring the paths that match.
|
||||||
|
|
||||||
The snapshot may be named by its ID or, when restoring on a host with no
|
The snapshot may be named by its ID or, when restoring on a host with no
|
||||||
local index, by the remote key that 'snapshot list' prints for a
|
local index, by the remote key that 'snapshot list' prints for a
|
||||||
|
|||||||
+105
-45
@@ -4,12 +4,14 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"filippo.io/age"
|
"filippo.io/age"
|
||||||
"git.eeqj.de/sneak/smartconfig"
|
"git.eeqj.de/sneak/smartconfig"
|
||||||
@@ -29,22 +31,26 @@ const appName = "vaultik"
|
|||||||
//nolint:gosec // G101: marker for detecting a pasted secret key, not a credential
|
//nolint:gosec // G101: marker for detecting a pasted secret key, not a credential
|
||||||
const secretKeyPrefix = "AGE-SECRET-KEY-"
|
const secretKeyPrefix = "AGE-SECRET-KEY-"
|
||||||
|
|
||||||
// Defaults and validation bounds for tunable settings.
|
// Defaults and validation bounds for tunable settings. MB and GB are
|
||||||
|
// 1000-based, as the size parser reads them; the S3 part sizes are S3's own
|
||||||
|
// limits, which are 1024-based (MiB, GiB).
|
||||||
const (
|
const (
|
||||||
defaultBlobSizeLimit = Size(10 * 1024 * 1024 * 1024) // 10GB
|
defaultBlobSizeLimit = Size(10 * 1000 * 1000 * 1000) // 10GB
|
||||||
defaultChunkSize = Size(10 * 1024 * 1024) // 10MB
|
defaultChunkSize = Size(10 * 1000 * 1000) // 10MB
|
||||||
defaultS3PartSize = Size(5 * 1024 * 1024) // 5MB
|
defaultS3PartSize = Size(5 * 1024 * 1024) // 5MiB
|
||||||
defaultCompressionLevel = 3
|
defaultCompressionLevel = 3
|
||||||
minChunkSize = 1024 * 1024 // 1MB
|
defaultPruneGracePeriod = 24 * time.Hour
|
||||||
|
minChunkSize = 1000 * 1000 // 1MB
|
||||||
minCompressionLevel = 1
|
minCompressionLevel = 1
|
||||||
maxCompressionLevel = 19
|
maxCompressionLevel = 19
|
||||||
|
// S3 accepts a multipart upload part from 5MiB to 5GiB.
|
||||||
|
minS3PartSize = 5 * 1024 * 1024
|
||||||
|
maxS3PartSize = 5 * 1024 * 1024 * 1024
|
||||||
)
|
)
|
||||||
|
|
||||||
// Sentinel validation errors.
|
// Sentinel validation errors.
|
||||||
var (
|
var (
|
||||||
errNoConfigPath = errors.New("config path not provided")
|
errNoConfigPath = errors.New("config path not provided")
|
||||||
errNoAgeRecipients = errors.New(
|
|
||||||
"at least one age_recipient is required (generate with: age-keygen)")
|
|
||||||
errRecipientIsSecretKey = errors.New(
|
errRecipientIsSecretKey = errors.New(
|
||||||
"an age secret key was given where a public key (age1...) belongs")
|
"an age secret key was given where a public key (age1...) belongs")
|
||||||
errRecipientNotX25519 = errors.New(
|
errRecipientNotX25519 = errors.New(
|
||||||
@@ -57,8 +63,14 @@ var (
|
|||||||
"blob_size_limit must be at least the largest chunk the chunker can " +
|
"blob_size_limit must be at least the largest chunk the chunker can " +
|
||||||
"emit (chunk_size times the FastCDC size spread)")
|
"emit (chunk_size times the FastCDC size spread)")
|
||||||
errBadCompression = errors.New("compression_level must be between 1 and 19")
|
errBadCompression = errors.New("compression_level must be between 1 and 19")
|
||||||
|
errBadS3PartSize = errors.New("s3.part_size must be between 5MiB and 5GiB")
|
||||||
errBadStorageScheme = errors.New(
|
errBadStorageScheme = errors.New(
|
||||||
"storage_url must start with s3://, file://, or rclone://")
|
"storage_url must start with s3://, file://, or rclone://")
|
||||||
|
errFileURLNotAbsolute = errors.New(
|
||||||
|
"a file:// storage_url needs an absolute path, " +
|
||||||
|
"as in file:///path/to/backup")
|
||||||
|
errNegativePruneGracePeriod = errors.New(
|
||||||
|
"prune_grace_period must not be negative")
|
||||||
errStorageNotConfigured = errors.New(
|
errStorageNotConfigured = errors.New(
|
||||||
"storage not configured; set storage_url or provide s3.endpoint + " +
|
"storage not configured; set storage_url or provide s3.endpoint + " +
|
||||||
"s3.bucket + credentials")
|
"s3.bucket + credentials")
|
||||||
@@ -179,6 +191,13 @@ type Config struct {
|
|||||||
Snapshots map[string]SnapshotConfig `yaml:"snapshots"`
|
Snapshots map[string]SnapshotConfig `yaml:"snapshots"`
|
||||||
CompressionLevel int `yaml:"compression_level"`
|
CompressionLevel int `yaml:"compression_level"`
|
||||||
|
|
||||||
|
// PruneGracePeriod is how old a blob no manifest lists must be before
|
||||||
|
// a prune deletes it. A backup lists its blobs in a manifest only
|
||||||
|
// once it has uploaded them all, so a younger one may belong to a
|
||||||
|
// backup still running, on this host or another sharing the
|
||||||
|
// destination store.
|
||||||
|
PruneGracePeriod time.Duration `yaml:"prune_grace_period"`
|
||||||
|
|
||||||
// StorageURL specifies the storage backend using a URL format.
|
// StorageURL specifies the storage backend using a URL format.
|
||||||
// Takes precedence over S3Config if set.
|
// Takes precedence over S3Config if set.
|
||||||
// Supported formats:
|
// Supported formats:
|
||||||
@@ -228,14 +247,48 @@ func New(path Path) (*Config, error) {
|
|||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load reads and parses the configuration file from the specified path.
|
// Load reads the configuration file at path, parses it with Parse, warns
|
||||||
// It applies default values for optional fields, performs environment variable
|
// when others can read the file, and validates the configuration.
|
||||||
// substitution using smartconfig, and validates the configuration.
|
// Returns an error if the file cannot be read, parsed, or if validation
|
||||||
// The configuration file should be in YAML format. Returns an error if the file
|
// fails.
|
||||||
// cannot be read, parsed, or if validation fails.
|
|
||||||
func Load(path string) (*Config, error) {
|
func Load(path string) (*Config, error) {
|
||||||
|
data, err := os.ReadFile(path) //nolint:gosec // G304: config path is operator-supplied
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to load config file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := Parse(data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check config file permissions (warn if world or group readable)
|
||||||
|
//nolint:gosec // G703: config path is operator-supplied by design
|
||||||
|
info, statErr := os.Stat(path)
|
||||||
|
if statErr == nil {
|
||||||
|
mode := info.Mode().Perm()
|
||||||
|
if mode&0044 != 0 { // group or world readable
|
||||||
|
log.Warn(cfg.readableByOthersWarning(),
|
||||||
|
"path", path,
|
||||||
|
"mode", fmt.Sprintf("%04o", mode),
|
||||||
|
"recommendation", "chmod 600 "+path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = cfg.Validate()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse reads YAML config data as Load does, without validating it. It
|
||||||
|
// applies default values for optional fields, fills in ${...} references
|
||||||
|
// using smartconfig, and expands ~ in paths.
|
||||||
|
func Parse(data []byte) (*Config, error) {
|
||||||
// Load config using smartconfig for interpolation
|
// Load config using smartconfig for interpolation
|
||||||
sc, err := smartconfig.NewFromConfigPath(path)
|
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)
|
||||||
}
|
}
|
||||||
@@ -246,6 +299,8 @@ func Load(path string) (*Config, error) {
|
|||||||
ChunkSize: defaultChunkSize,
|
ChunkSize: defaultChunkSize,
|
||||||
IndexPath: filepath.Join(xdg.DataHome, appName, "index.sqlite"),
|
IndexPath: filepath.Join(xdg.DataHome, appName, "index.sqlite"),
|
||||||
CompressionLevel: defaultCompressionLevel,
|
CompressionLevel: defaultCompressionLevel,
|
||||||
|
PruneGracePeriod: defaultPruneGracePeriod,
|
||||||
|
S3: S3Config{PartSize: defaultS3PartSize},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert smartconfig data to YAML then unmarshal
|
// Convert smartconfig data to YAML then unmarshal
|
||||||
@@ -296,36 +351,15 @@ func Load(path string) (*Config, error) {
|
|||||||
cfg.S3.Region = "us-east-1"
|
cfg.S3.Region = "us-east-1"
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.S3.PartSize == 0 {
|
|
||||||
cfg.S3.PartSize = defaultS3PartSize
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check config file permissions (warn if world or group readable)
|
|
||||||
//nolint:gosec // G703: config path is operator-supplied by design
|
|
||||||
info, statErr := os.Stat(path)
|
|
||||||
if statErr == nil {
|
|
||||||
mode := info.Mode().Perm()
|
|
||||||
if mode&0044 != 0 { // group or world readable
|
|
||||||
log.Warn("Config file has insecure permissions (contains S3 credentials)",
|
|
||||||
"path", path,
|
|
||||||
"mode", fmt.Sprintf("%04o", mode),
|
|
||||||
"recommendation", "chmod 600 "+path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err = cfg.Validate()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("invalid config: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate checks if the configuration is valid and complete.
|
// Validate checks if the configuration is valid and complete.
|
||||||
// It ensures all required fields are present and have valid values:
|
// It ensures all required fields are present and have valid values:
|
||||||
// - At least one age recipient must be specified, and every recipient must
|
// - Every age recipient must parse as an X25519 age1... public key (so a
|
||||||
// parse as an X25519 age1... public key (so a bad entry fails at load, not
|
// bad entry fails at load, not mid-backup); errors name the position,
|
||||||
// mid-backup); errors name the position, never the value
|
// never the value. An empty list is accepted, because only snapshot
|
||||||
|
// create needs a recipient and it checks for one itself
|
||||||
// - At least one snapshot must be configured with at least one path
|
// - At least one snapshot must be configured with at least one path
|
||||||
// - Storage must be configured (either storage_url or s3.* fields)
|
// - Storage must be configured (either storage_url or s3.* fields)
|
||||||
// - Chunk size must be at least 1MB
|
// - Chunk size must be at least 1MB
|
||||||
@@ -333,13 +367,11 @@ func Load(path string) (*Config, error) {
|
|||||||
// (chunk_size times chunker.ChunkSizeSpread), so a single-chunk blob never
|
// (chunk_size times chunker.ChunkSizeSpread), so a single-chunk blob never
|
||||||
// exceeds the configured limit
|
// exceeds the configured limit
|
||||||
// - Compression level must be between 1 and 19
|
// - Compression level must be between 1 and 19
|
||||||
|
// - S3 part size must be between 5MiB and 5GiB, the part sizes S3 accepts
|
||||||
|
// - Prune grace period must not be negative
|
||||||
//
|
//
|
||||||
// Returns an error describing the first validation failure encountered.
|
// Returns an error describing the first validation failure encountered.
|
||||||
func (c *Config) Validate() error {
|
func (c *Config) Validate() error {
|
||||||
if len(c.AgeRecipients) == 0 {
|
|
||||||
return errNoAgeRecipients
|
|
||||||
}
|
|
||||||
|
|
||||||
for i, recipient := range c.AgeRecipients {
|
for i, recipient := range c.AgeRecipients {
|
||||||
err := validateAgeRecipient(recipient)
|
err := validateAgeRecipient(recipient)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -381,6 +413,15 @@ func (c *Config) Validate() error {
|
|||||||
return errBadCompression
|
return errBadCompression
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if c.S3.PartSize.Int64() < minS3PartSize ||
|
||||||
|
c.S3.PartSize.Int64() > maxS3PartSize {
|
||||||
|
return errBadS3PartSize
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.PruneGracePeriod < 0 {
|
||||||
|
return errNegativePruneGracePeriod
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -416,6 +457,18 @@ func (c *Config) setAgeSecretKey() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// readableByOthersWarning is the warning Load logs when others can read
|
||||||
|
// the config file. It says "may contain" because the S3 credentials are
|
||||||
|
// seen only after smartconfig has replaced any ${...} reference in the
|
||||||
|
// file with its value, so a set credential need not be in the file.
|
||||||
|
func (c *Config) readableByOthersWarning() string {
|
||||||
|
if c.S3.AccessKeyID != "" || c.S3.SecretAccessKey != "" {
|
||||||
|
return "Config file is readable by others and may contain S3 credentials"
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Config file is readable by others"
|
||||||
|
}
|
||||||
|
|
||||||
// validateStorage validates storage configuration.
|
// validateStorage validates storage configuration.
|
||||||
// If StorageURL is set, it takes precedence. S3 URLs require credentials.
|
// If StorageURL is set, it takes precedence. S3 URLs require credentials.
|
||||||
// File URLs don't require any S3 configuration.
|
// File URLs don't require any S3 configuration.
|
||||||
@@ -447,11 +500,18 @@ func (c *Config) validateStorage() error {
|
|||||||
|
|
||||||
// validateStorageURL validates URL-based storage configuration. File and
|
// validateStorageURL validates URL-based storage configuration. File and
|
||||||
// rclone URLs need no credentials; S3 URLs require the legacy s3.*
|
// rclone URLs need no credentials; S3 URLs require the legacy s3.*
|
||||||
// credential fields.
|
// credential fields. A file URL must name an absolute path: a relative one
|
||||||
|
// would resolve against the working directory, so the destination would
|
||||||
|
// move with it.
|
||||||
func (c *Config) validateStorageURL() error {
|
func (c *Config) validateStorageURL() error {
|
||||||
switch {
|
switch {
|
||||||
case strings.HasPrefix(c.StorageURL, "file://"):
|
case strings.HasPrefix(c.StorageURL, "file://"):
|
||||||
// File storage doesn't need S3 credentials
|
// storage.ParseStorageURL applies the same rule, but config
|
||||||
|
// cannot import storage, which imports config.
|
||||||
|
if !strings.HasPrefix(c.StorageURL, "file:///") {
|
||||||
|
return errFileURLNotAbsolute
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
case strings.HasPrefix(c.StorageURL, "rclone://"):
|
case strings.HasPrefix(c.StorageURL, "rclone://"):
|
||||||
// Rclone storage uses rclone's own config
|
// Rclone storage uses rclone's own config
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/vaultik/internal/chunker"
|
"sneak.berlin/go/vaultik/internal/chunker"
|
||||||
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -166,6 +168,7 @@ func TestValidateBlobSizeLimit(t *testing.T) {
|
|||||||
ChunkSize: chunkSize,
|
ChunkSize: chunkSize,
|
||||||
BlobSizeLimit: blobLimit,
|
BlobSizeLimit: blobLimit,
|
||||||
CompressionLevel: 3,
|
CompressionLevel: 3,
|
||||||
|
S3: S3Config{PartSize: defaultS3PartSize},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,9 +224,295 @@ func TestValidateBlobSizeLimit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestValidateS3PartSize checks that s3.part_size is held to the part sizes
|
||||||
|
// S3 accepts, 5MiB to 5GiB, by changing only the part size of the test
|
||||||
|
// config. "5MB" in the config file is 5,000,000 bytes, below the minimum.
|
||||||
|
func TestValidateS3PartSize(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
base, err := Load(os.Getenv("VAULTIK_CONFIG"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to load config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
partSize Size
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "5MB is rejected",
|
||||||
|
partSize: 5_000_000,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "one byte below 5MiB is rejected",
|
||||||
|
partSize: minS3PartSize - 1,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "5MiB is accepted",
|
||||||
|
partSize: minS3PartSize,
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "5GiB is accepted",
|
||||||
|
partSize: maxS3PartSize,
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "one byte above 5GiB is rejected",
|
||||||
|
partSize: maxS3PartSize + 1,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cfg := *base
|
||||||
|
cfg.S3.PartSize = tt.partSize
|
||||||
|
|
||||||
|
err := cfg.Validate()
|
||||||
|
if tt.wantErr {
|
||||||
|
if !errors.Is(err, errBadS3PartSize) {
|
||||||
|
t.Fatalf("Validate() error = %v, want errBadS3PartSize", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Validate() unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoadS3PartSize checks that a config file without s3.part_size loads
|
||||||
|
// with the 5MiB default, and that an explicit 0 fails at load like any other
|
||||||
|
// part size S3 refuses.
|
||||||
|
func TestLoadS3PartSize(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const withoutPartSize = "snapshots:\n" +
|
||||||
|
" test:\n" +
|
||||||
|
" paths: [/tmp/vaultik-test-source]\n" +
|
||||||
|
"storage_url: file:///tmp/vaultik-test-storage\n"
|
||||||
|
|
||||||
|
writeConfig := func(t *testing.T, text string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), "config.yml")
|
||||||
|
|
||||||
|
err := os.WriteFile(path, []byte(text), 0o600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("absent loads as 5MiB", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cfg, err := Load(writeConfig(t, withoutPartSize))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load() unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.S3.PartSize != defaultS3PartSize {
|
||||||
|
t.Errorf("s3.part_size = %d, want %d",
|
||||||
|
cfg.S3.PartSize, defaultS3PartSize)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("0 is rejected", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, err := Load(writeConfig(t, withoutPartSize+"s3:\n part_size: 0\n"))
|
||||||
|
if !errors.Is(err, errBadS3PartSize) {
|
||||||
|
t.Fatalf("Load() error = %v, want errBadS3PartSize", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDocumentedSizeDefaults checks that the defaults are the documented
|
||||||
|
// chunk_size 10MB and blob_size_limit 10GB as the parser reads those values
|
||||||
|
// (1000-based), so writing a default out in the config file changes nothing.
|
||||||
|
func TestDocumentedSizeDefaults(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cfg, err := Parse([]byte("chunk_size: 10MB\nblob_size_limit: 10GB\n"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Parse() unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.ChunkSize != defaultChunkSize {
|
||||||
|
t.Errorf("chunk_size: 10MB is %d bytes, the default is %d",
|
||||||
|
cfg.ChunkSize, defaultChunkSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.BlobSizeLimit != defaultBlobSizeLimit {
|
||||||
|
t.Errorf("blob_size_limit: 10GB is %d bytes, the default is %d",
|
||||||
|
cfg.BlobSizeLimit, defaultBlobSizeLimit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDocumentedSizeExamples checks the 1MB minimum chunk_size and that
|
||||||
|
// every size the README, config.example.yml and the config init template
|
||||||
|
// list as an example is accepted.
|
||||||
|
func TestDocumentedSizeExamples(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const withoutSizes = "snapshots:\n" +
|
||||||
|
" test:\n" +
|
||||||
|
" paths: [/tmp/vaultik-test-source]\n" +
|
||||||
|
"storage_url: file:///tmp/vaultik-test-storage\n"
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
setting string
|
||||||
|
wantErr error
|
||||||
|
}{
|
||||||
|
{"chunk_size: 1MB", nil},
|
||||||
|
{"chunk_size: 999999", errChunkSizeTooSmall},
|
||||||
|
{"chunk_size: 5M", nil},
|
||||||
|
{"chunk_size: 10M", nil},
|
||||||
|
{"chunk_size: 10MB", nil},
|
||||||
|
{"chunk_size: 64MiB", nil},
|
||||||
|
{"chunk_size: 1GB", nil},
|
||||||
|
// Four times the default chunk_size, the documented minimum.
|
||||||
|
{"blob_size_limit: 40MB", nil},
|
||||||
|
{"blob_size_limit: 500MB", nil},
|
||||||
|
{"blob_size_limit: 1GB", nil},
|
||||||
|
{"blob_size_limit: 1GiB", nil},
|
||||||
|
{"blob_size_limit: 10G", nil},
|
||||||
|
{"blob_size_limit: 10GB", nil},
|
||||||
|
{"s3: {part_size: 5MiB}", nil},
|
||||||
|
{"s3: {part_size: 10MB}", nil},
|
||||||
|
{"s3: {part_size: 16MiB}", nil},
|
||||||
|
{"s3: {part_size: 100MiB}", nil},
|
||||||
|
{"s3: {part_size: 5GiB}", nil},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.setting, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cfg, err := Parse([]byte(withoutSizes + tt.setting + "\n"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Parse() unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = cfg.Validate()
|
||||||
|
if !errors.Is(err, tt.wantErr) {
|
||||||
|
t.Fatalf("Validate() error = %v, want %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestValidatePruneGracePeriod checks that a negative prune_grace_period
|
||||||
|
// is refused rather than acting like 0s, which deletes unreferenced blobs
|
||||||
|
// at once, and that 0s and the documented examples are accepted.
|
||||||
|
func TestValidatePruneGracePeriod(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const withoutGracePeriod = "snapshots:\n" +
|
||||||
|
" test:\n" +
|
||||||
|
" paths: [/tmp/vaultik-test-source]\n" +
|
||||||
|
"storage_url: file:///tmp/vaultik-test-storage\n"
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
setting string
|
||||||
|
wantErr error
|
||||||
|
}{
|
||||||
|
{"prune_grace_period: 0s", nil},
|
||||||
|
{"prune_grace_period: 90m", nil},
|
||||||
|
{"prune_grace_period: 36h", nil},
|
||||||
|
{"prune_grace_period: -1h", errNegativePruneGracePeriod},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.setting, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cfg, err := Parse([]byte(withoutGracePeriod + tt.setting + "\n"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Parse() unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = cfg.Validate()
|
||||||
|
if !errors.Is(err, tt.wantErr) {
|
||||||
|
t.Fatalf("Validate() error = %v, want %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoadPruneGracePeriod checks that a config file without
|
||||||
|
// prune_grace_period loads with the documented 24h. The value is written out
|
||||||
|
// here rather than taken from defaultPruneGracePeriod, so that a change of the
|
||||||
|
// default fails this test.
|
||||||
|
func TestLoadPruneGracePeriod(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const withoutGracePeriod = "snapshots:\n" +
|
||||||
|
" test:\n" +
|
||||||
|
" paths: [/tmp/vaultik-test-source]\n" +
|
||||||
|
"storage_url: file:///tmp/vaultik-test-storage\n"
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), "config.yml")
|
||||||
|
|
||||||
|
err := os.WriteFile(path, []byte(withoutGracePeriod), 0o600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load() unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.PruneGracePeriod != 24*time.Hour {
|
||||||
|
t.Errorf("prune_grace_period = %s, want 24h", cfg.PruneGracePeriod)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoadRejectsRelativeFileURL checks that a file:// storage_url with a
|
||||||
|
// relative path fails at load. Accepted, it would resolve against the
|
||||||
|
// working directory, so a backup run from another directory would write
|
||||||
|
// to a different destination and still succeed.
|
||||||
|
func TestLoadRejectsRelativeFileURL(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const relativeConfig = "snapshots:\n" +
|
||||||
|
" test:\n" +
|
||||||
|
" paths: [/tmp/vaultik-test-source]\n" +
|
||||||
|
"storage_url: file://dest\n"
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), "config.yml")
|
||||||
|
|
||||||
|
err := os.WriteFile(path, []byte(relativeConfig), 0o600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = Load(path)
|
||||||
|
if !errors.Is(err, errFileURLNotAbsolute) {
|
||||||
|
t.Fatalf("Load() error = %v, want errFileURLNotAbsolute", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(err.Error(), "absolute path") {
|
||||||
|
t.Errorf("error %q does not say to use an absolute path", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestValidateAgeRecipients checks that recipients are parsed at config load
|
// TestValidateAgeRecipients checks that recipients are parsed at config load
|
||||||
// (a bad entry fails immediately, not mid-backup) and that no invalid entry —
|
// (a bad entry fails immediately, not mid-backup) and that no invalid entry —
|
||||||
// least of all a pasted secret key — is echoed in the error.
|
// least of all a pasted secret key — is echoed in the error. An empty list
|
||||||
|
// loads, because only snapshot create needs a recipient.
|
||||||
func TestValidateAgeRecipients(t *testing.T) {
|
func TestValidateAgeRecipients(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -235,6 +524,7 @@ func TestValidateAgeRecipients(t *testing.T) {
|
|||||||
ChunkSize: Size(10 * 1024 * 1024),
|
ChunkSize: Size(10 * 1024 * 1024),
|
||||||
BlobSizeLimit: Size(10 * 1024 * 1024 * 1024),
|
BlobSizeLimit: Size(10 * 1024 * 1024 * 1024),
|
||||||
CompressionLevel: 3,
|
CompressionLevel: 3,
|
||||||
|
S3: S3Config{PartSize: defaultS3PartSize},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,7 +534,12 @@ func TestValidateAgeRecipients(t *testing.T) {
|
|||||||
wantErr bool
|
wantErr bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "config init placeholder is rejected",
|
name: "no recipients is accepted",
|
||||||
|
recipients: nil,
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "placeholder recipient is rejected",
|
||||||
recipients: []string{"age1REPLACE_WITH_YOUR_PUBLIC_KEY"},
|
recipients: []string{"age1REPLACE_WITH_YOUR_PUBLIC_KEY"},
|
||||||
wantErr: true,
|
wantErr: true,
|
||||||
},
|
},
|
||||||
@@ -337,3 +632,125 @@ func TestAgeSecretKeySourceName(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// loadReadableConfig writes configYAML to a file that others can read,
|
||||||
|
// loads it, and returns what the logger wrote to stderr meanwhile. The
|
||||||
|
// logger writes to the os.Stderr it finds when it is initialized, so
|
||||||
|
// os.Stderr is pointed at a file first. Not parallel-safe: os.Stderr and
|
||||||
|
// the logger are process-global.
|
||||||
|
func loadReadableConfig(t *testing.T, configYAML string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
configPath := filepath.Join(dir, "config.yml")
|
||||||
|
stderrPath := filepath.Join(dir, "stderr")
|
||||||
|
|
||||||
|
err := os.WriteFile(configPath, []byte(configYAML), 0o600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("writing config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
//nolint:gosec // G302: the test needs a config file others can read
|
||||||
|
err = os.Chmod(configPath, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("chmod config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stderrFile, err := os.Create(stderrPath) //nolint:gosec // G304: test temp path
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("creating stderr file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
previous := os.Stderr
|
||||||
|
os.Stderr = stderrFile
|
||||||
|
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
|
||||||
|
_, loadErr := Load(configPath)
|
||||||
|
|
||||||
|
os.Stderr = previous
|
||||||
|
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
|
||||||
|
_ = stderrFile.Close()
|
||||||
|
|
||||||
|
if loadErr != nil {
|
||||||
|
t.Fatalf("Load() error = %v", loadErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
captured, err := os.ReadFile(stderrPath) //nolint:gosec // G304: test temp path
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reading stderr file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(captured)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoadWarnsReadableConfigWithoutS3Credentials checks that a config
|
||||||
|
// file others can read, holding no S3 credentials, is warned about
|
||||||
|
// without a claim that it holds them.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // loadReadableConfig replaces os.Stderr
|
||||||
|
func TestLoadWarnsReadableConfigWithoutS3Credentials(t *testing.T) {
|
||||||
|
stderr := loadReadableConfig(t, `
|
||||||
|
storage_url: file:///var/backups/vaultik
|
||||||
|
snapshots:
|
||||||
|
home:
|
||||||
|
paths:
|
||||||
|
- /home
|
||||||
|
`)
|
||||||
|
|
||||||
|
if !strings.Contains(stderr, "Config file is readable by others") {
|
||||||
|
t.Errorf("expected a warning that the file is readable by others, got %q",
|
||||||
|
stderr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(stderr, "S3 credentials") {
|
||||||
|
t.Errorf("warning names S3 credentials the file does not set: %q", stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoadWarnsReadableConfigWithS3Credentials checks that a config file
|
||||||
|
// others can read and that sets S3 credentials, as values or as ${ENV:...}
|
||||||
|
// references, is warned about as one that may contain them.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // loadReadableConfig replaces os.Stderr
|
||||||
|
func TestLoadWarnsReadableConfigWithS3Credentials(t *testing.T) {
|
||||||
|
t.Setenv("VAULTIK_TEST_ACCESS_KEY_ID", "test-access-key")
|
||||||
|
t.Setenv("VAULTIK_TEST_SECRET_ACCESS_KEY", "test-secret-key")
|
||||||
|
|
||||||
|
configs := map[string]string{
|
||||||
|
"values": `
|
||||||
|
storage_url: s3://bucket/prefix?endpoint=s3.example.com
|
||||||
|
s3:
|
||||||
|
access_key_id: test-access-key
|
||||||
|
secret_access_key: test-secret-key
|
||||||
|
snapshots:
|
||||||
|
home:
|
||||||
|
paths:
|
||||||
|
- /home
|
||||||
|
`,
|
||||||
|
"references": `
|
||||||
|
storage_url: s3://bucket/prefix?endpoint=s3.example.com
|
||||||
|
s3:
|
||||||
|
access_key_id: ${ENV:VAULTIK_TEST_ACCESS_KEY_ID}
|
||||||
|
secret_access_key: ${ENV:VAULTIK_TEST_SECRET_ACCESS_KEY}
|
||||||
|
snapshots:
|
||||||
|
home:
|
||||||
|
paths:
|
||||||
|
- /home
|
||||||
|
`,
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, configYAML := range configs {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
stderr := loadReadableConfig(t, configYAML)
|
||||||
|
|
||||||
|
if !strings.Contains(stderr,
|
||||||
|
"Config file is readable by others and may contain S3 credentials") {
|
||||||
|
t.Errorf("expected a warning naming the S3 credentials, got %q",
|
||||||
|
stderr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ var (
|
|||||||
// Size represents a byte size that can be specified in configuration files.
|
// Size represents a byte size that can be specified in configuration files.
|
||||||
// It can unmarshal from both numeric values (interpreted as bytes) and
|
// It can unmarshal from both numeric values (interpreted as bytes) and
|
||||||
// human-readable strings like "10MB", "2.5GB", or "1TB".
|
// human-readable strings like "10MB", "2.5GB", or "1TB".
|
||||||
//
|
|
||||||
//nolint:recvcheck // UnmarshalYAML requires a pointer; String/Int64 are value reads
|
|
||||||
type Size int64
|
type Size int64
|
||||||
|
|
||||||
// UnmarshalYAML implements yaml.Unmarshaler for Size, allowing it to be
|
// UnmarshalYAML implements yaml.Unmarshaler for Size, allowing it to be
|
||||||
|
|||||||
@@ -58,12 +58,7 @@ func (r *BlobChunkRepository) GetByBlobID(
|
|||||||
return nil, fmt.Errorf("querying blob chunks: %w", err)
|
return nil, fmt.Errorf("querying blob chunks: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() {
|
defer func() { _ = rows.Close() }()
|
||||||
err := rows.Close()
|
|
||||||
if err != nil {
|
|
||||||
Fatalf("failed to close rows: %v", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
var blobChunks []*BlobChunk
|
var blobChunks []*BlobChunk
|
||||||
|
|
||||||
|
|||||||
+10
-12
@@ -83,12 +83,7 @@ func (r *BlobRepository) GetAll(ctx context.Context) (map[string]*Blob, error) {
|
|||||||
return nil, fmt.Errorf("querying blobs: %w", err)
|
return nil, fmt.Errorf("querying blobs: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() {
|
defer func() { _ = rows.Close() }()
|
||||||
err := rows.Close()
|
|
||||||
if err != nil {
|
|
||||||
Fatalf("failed to close rows: %v", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
out := make(map[string]*Blob)
|
out := make(map[string]*Blob)
|
||||||
|
|
||||||
@@ -159,13 +154,16 @@ func (r *BlobRepository) UpdateFinished(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateUploaded marks a blob as uploaded
|
// UpdateUploaded marks a blob as uploaded and records the size of the
|
||||||
|
// stored object as its compressed size. That size can differ from the
|
||||||
|
// local copy's when an identical blob was already stored with another
|
||||||
|
// compression level or other recipients.
|
||||||
func (r *BlobRepository) UpdateUploaded(
|
func (r *BlobRepository) UpdateUploaded(
|
||||||
ctx context.Context, tx *sql.Tx, id string,
|
ctx context.Context, tx *sql.Tx, id string, storedSize int64,
|
||||||
) error {
|
) error {
|
||||||
query := `
|
query := `
|
||||||
UPDATE blobs
|
UPDATE blobs
|
||||||
SET uploaded_ts = ?
|
SET uploaded_ts = ?, compressed_size = ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -173,9 +171,9 @@ func (r *BlobRepository) UpdateUploaded(
|
|||||||
|
|
||||||
var err error
|
var err error
|
||||||
if tx != nil {
|
if tx != nil {
|
||||||
_, err = tx.ExecContext(ctx, query, now, id)
|
_, err = tx.ExecContext(ctx, query, now, storedSize, id)
|
||||||
} else {
|
} else {
|
||||||
_, err = r.db.ExecWithLog(ctx, query, now, id)
|
_, err = r.db.ExecWithLog(ctx, query, now, storedSize, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -122,8 +122,8 @@ func TestBlobRepositoryUpdates(t *testing.T) {
|
|||||||
t.Errorf("expected compressed size 500, got %d", updated.CompressedSize)
|
t.Errorf("expected compressed size 500, got %d", updated.CompressedSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test UpdateUploaded
|
// Test UpdateUploaded, with a stored object larger than the local copy
|
||||||
err = repo.UpdateUploaded(ctx, nil, blob.ID.String())
|
err = repo.UpdateUploaded(ctx, nil, blob.ID.String(), 600)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to update blob as uploaded: %v", err)
|
t.Fatalf("failed to update blob as uploaded: %v", err)
|
||||||
}
|
}
|
||||||
@@ -141,6 +141,10 @@ func TestBlobRepositoryUpdates(t *testing.T) {
|
|||||||
if uploaded.UploadedTS.Before(now.Add(-1 * time.Second)) {
|
if uploaded.UploadedTS.Before(now.Add(-1 * time.Second)) {
|
||||||
t.Error("uploaded timestamp should be around test time")
|
t.Error("uploaded timestamp should be around test time")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if uploaded.CompressedSize != 600 {
|
||||||
|
t.Errorf("expected the stored size 600, got %d", uploaded.CompressedSize)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBlobRepositoryDuplicate(t *testing.T) {
|
func TestBlobRepositoryDuplicate(t *testing.T) {
|
||||||
|
|||||||
@@ -61,12 +61,7 @@ func (r *ChunkFileRepository) GetByChunkHash(
|
|||||||
return nil, fmt.Errorf("querying chunk files: %w", err)
|
return nil, fmt.Errorf("querying chunk files: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() {
|
defer func() { _ = rows.Close() }()
|
||||||
err := rows.Close()
|
|
||||||
if err != nil {
|
|
||||||
Fatalf("failed to close rows: %v", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
return r.scanChunkFiles(rows)
|
return r.scanChunkFiles(rows)
|
||||||
}
|
}
|
||||||
@@ -87,12 +82,7 @@ func (r *ChunkFileRepository) GetByFilePath(
|
|||||||
return nil, fmt.Errorf("querying chunk files: %w", err)
|
return nil, fmt.Errorf("querying chunk files: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() {
|
defer func() { _ = rows.Close() }()
|
||||||
err := rows.Close()
|
|
||||||
if err != nil {
|
|
||||||
Fatalf("failed to close rows: %v", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
return r.scanChunkFiles(rows)
|
return r.scanChunkFiles(rows)
|
||||||
}
|
}
|
||||||
@@ -112,12 +102,7 @@ func (r *ChunkFileRepository) GetByFileID(
|
|||||||
return nil, fmt.Errorf("querying chunk files: %w", err)
|
return nil, fmt.Errorf("querying chunk files: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() {
|
defer func() { _ = rows.Close() }()
|
||||||
err := rows.Close()
|
|
||||||
if err != nil {
|
|
||||||
Fatalf("failed to close rows: %v", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
return r.scanChunkFiles(rows)
|
return r.scanChunkFiles(rows)
|
||||||
}
|
}
|
||||||
@@ -220,7 +205,7 @@ func (r *ChunkFileRepository) CreateBatch(
|
|||||||
cf.ChunkHash.String(), cf.FileID.String(), cf.FileOffset, cf.Length)
|
cf.ChunkHash.String(), cf.FileID.String(), cf.FileOffset, cf.Length)
|
||||||
}
|
}
|
||||||
|
|
||||||
query += querySb183.String() //nolint:gosec // G202: appends "?" placeholders only
|
query += querySb183.String()
|
||||||
|
|
||||||
query += " ON CONFLICT(chunk_hash, file_id) DO NOTHING"
|
query += " ON CONFLICT(chunk_hash, file_id) DO NOTHING"
|
||||||
|
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ func (r *ChunkRepository) GetByHashes(
|
|||||||
args[i] = hash
|
args[i] = hash
|
||||||
}
|
}
|
||||||
|
|
||||||
query += querySb75.String() //nolint:gosec // G202: appends "?" placeholders only
|
query += querySb75.String()
|
||||||
|
|
||||||
query += ") ORDER BY chunk_hash"
|
query += ") ORDER BY chunk_hash"
|
||||||
|
|
||||||
@@ -107,12 +107,7 @@ func (r *ChunkRepository) GetByHashes(
|
|||||||
return nil, fmt.Errorf("querying chunks: %w", err)
|
return nil, fmt.Errorf("querying chunks: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() {
|
defer func() { _ = rows.Close() }()
|
||||||
err := rows.Close()
|
|
||||||
if err != nil {
|
|
||||||
Fatalf("failed to close rows: %v", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
var chunks []*Chunk
|
var chunks []*Chunk
|
||||||
|
|
||||||
@@ -152,12 +147,7 @@ func (r *ChunkRepository) ListUnpacked(
|
|||||||
return nil, fmt.Errorf("querying unpacked chunks: %w", err)
|
return nil, fmt.Errorf("querying unpacked chunks: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() {
|
defer func() { _ = rows.Close() }()
|
||||||
err := rows.Close()
|
|
||||||
if err != nil {
|
|
||||||
Fatalf("failed to close rows: %v", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
var chunks []*Chunk
|
var chunks []*Chunk
|
||||||
|
|
||||||
|
|||||||
@@ -38,12 +38,7 @@ func (r *ChunkRepository) list(ctx context.Context, query string) ([]*Chunk, err
|
|||||||
return nil, fmt.Errorf("querying chunks: %w", err)
|
return nil, fmt.Errorf("querying chunks: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() {
|
defer func() { _ = rows.Close() }()
|
||||||
err := rows.Close()
|
|
||||||
if err != nil {
|
|
||||||
Fatalf("failed to close rows: %v", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
var chunks []*Chunk
|
var chunks []*Chunk
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ var schemaFS embed.FS
|
|||||||
// table itself. It is applied before the normal migration loop.
|
// table itself. It is applied before the normal migration loop.
|
||||||
const bootstrapVersion = 0
|
const bootstrapVersion = 0
|
||||||
|
|
||||||
|
// busyTimeoutMs is how long a connection to the index waits for another
|
||||||
|
// connection's lock before failing with "database is locked".
|
||||||
|
const busyTimeoutMs = 10000
|
||||||
|
|
||||||
// DB represents the Vaultik local index database connection.
|
// DB represents the Vaultik local index database connection.
|
||||||
// It uses SQLite to track file metadata, content-defined chunks, and blob associations.
|
// It uses SQLite to track file metadata, content-defined chunks, and blob associations.
|
||||||
// The database enables incremental backups by detecting changed files and
|
// The database enables incremental backups by detecting changed files and
|
||||||
@@ -94,10 +98,27 @@ func ParseMigrationVersion(filename string) (int, error) {
|
|||||||
return version, nil
|
return version, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// indexDSN returns the driver DSN that opens the database at path. The
|
||||||
|
// driver runs each _pragma parameter on every connection it opens and drops
|
||||||
|
// any parameter it does not know without an error, so a setting written in
|
||||||
|
// another form silently does nothing. In WAL mode one connection can write
|
||||||
|
// while others read; the busy timeout makes a connection wait for a lock
|
||||||
|
// instead of failing at once.
|
||||||
|
func indexDSN(path string) string {
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"%s?_pragma=busy_timeout(%d)&_pragma=journal_mode(WAL)"+
|
||||||
|
"&_pragma=synchronous(NORMAL)&_pragma=foreign_keys(1)",
|
||||||
|
path, busyTimeoutMs)
|
||||||
|
}
|
||||||
|
|
||||||
// New creates a new database connection at the specified path.
|
// New creates a new database connection at the specified path.
|
||||||
// It creates the schema if needed and configures SQLite with WAL mode for
|
// It creates the schema if needed. Every connection runs in WAL mode with
|
||||||
// better concurrency. SQLite handles crash recovery automatically when
|
// a busy timeout and foreign keys on (see indexDSN), so a read-only command
|
||||||
// opening a database with journal/WAL files present.
|
// can read the index while a backup writes to it. Committed rows can sit in
|
||||||
|
// the -wal file beside the database until a checkpoint, so a copy of the
|
||||||
|
// database file alone may miss them.
|
||||||
|
// SQLite handles crash recovery automatically when opening a database with
|
||||||
|
// journal/WAL files present.
|
||||||
// The path parameter can be a file path for persistent storage or ":memory:"
|
// The path parameter can be a file path for persistent storage or ":memory:"
|
||||||
// for an in-memory database (useful for testing).
|
// for an in-memory database (useful for testing).
|
||||||
func New(ctx context.Context, path string) (*DB, error) {
|
func New(ctx context.Context, path string) (*DB, error) {
|
||||||
@@ -110,11 +131,7 @@ func New(ctx context.Context, path string) (*DB, error) {
|
|||||||
// First attempt with standard WAL mode
|
// First attempt with standard WAL mode
|
||||||
log.Debug("Attempting to open database with WAL mode", "path", path)
|
log.Debug("Attempting to open database with WAL mode", "path", path)
|
||||||
|
|
||||||
conn, err := sql.Open(
|
conn, err := sql.Open("sqlite", indexDSN(path))
|
||||||
"sqlite",
|
|
||||||
path+"?_journal_mode=WAL&_synchronous=NORMAL&_busy_timeout=10000"+
|
|
||||||
"&_locking_mode=NORMAL&_foreign_keys=ON",
|
|
||||||
)
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
configureConnPool(conn)
|
configureConnPool(conn)
|
||||||
|
|
||||||
@@ -134,8 +151,8 @@ func New(ctx context.Context, path string) (*DB, error) {
|
|||||||
_ = conn.Close()
|
_ = conn.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
// If first attempt failed, try with TRUNCATE mode to clear any locks
|
// If the first attempt failed, try once more
|
||||||
return openWithRecovery(ctx, path)
|
return retryOpen(ctx, path)
|
||||||
}
|
}
|
||||||
|
|
||||||
// configureConnPool serializes all database access through one connection.
|
// configureConnPool serializes all database access through one connection.
|
||||||
@@ -147,18 +164,12 @@ func configureConnPool(conn *sql.DB) {
|
|||||||
conn.SetMaxIdleConns(1)
|
conn.SetMaxIdleConns(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// finishOpen enables foreign keys, wraps the connection, and applies any
|
// finishOpen wraps the connection and applies any pending migrations. On
|
||||||
// pending migrations. On migration failure the connection is closed.
|
// migration failure the connection is closed.
|
||||||
func finishOpen(ctx context.Context, conn *sql.DB, path string) (*DB, error) {
|
func finishOpen(ctx context.Context, conn *sql.DB, path string) (*DB, error) {
|
||||||
// Enable foreign keys explicitly
|
|
||||||
_, err := conn.ExecContext(ctx, "PRAGMA foreign_keys = ON")
|
|
||||||
if err != nil {
|
|
||||||
log.Warn("Failed to enable foreign keys", "path", path, "error", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
db := &DB{conn: conn, path: path}
|
db := &DB{conn: conn, path: path}
|
||||||
|
|
||||||
err = applyMigrations(ctx, conn)
|
err := applyMigrations(ctx, conn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = conn.Close()
|
_ = conn.Close()
|
||||||
|
|
||||||
@@ -168,21 +179,15 @@ func finishOpen(ctx context.Context, conn *sql.DB, path string) (*DB, error) {
|
|||||||
return db, nil
|
return db, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// openWithRecovery retries opening the database in TRUNCATE journal mode to
|
// retryOpen makes a second attempt to open the database, with the same
|
||||||
// clear stale locks, then switches back to WAL mode.
|
// settings, after the first attempt failed, for example because another
|
||||||
func openWithRecovery(ctx context.Context, path string) (*DB, error) {
|
// process held a lock for longer than the busy timeout.
|
||||||
log.Info(
|
func retryOpen(ctx context.Context, path string) (*DB, error) {
|
||||||
"Database appears locked, attempting recovery with TRUNCATE mode",
|
log.Info("Database appears locked, retrying open", "path", path)
|
||||||
"path", path,
|
|
||||||
)
|
|
||||||
|
|
||||||
conn, err := sql.Open(
|
conn, err := sql.Open("sqlite", indexDSN(path))
|
||||||
"sqlite",
|
|
||||||
path+"?_journal_mode=TRUNCATE&_synchronous=NORMAL&_busy_timeout=10000"+
|
|
||||||
"&_foreign_keys=ON",
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("opening database in recovery mode: %w", err)
|
return nil, fmt.Errorf("opening database on retry: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
configureConnPool(conn)
|
configureConnPool(conn)
|
||||||
@@ -190,28 +195,18 @@ func openWithRecovery(ctx context.Context, path string) (*DB, error) {
|
|||||||
err = conn.PingContext(ctx)
|
err = conn.PingContext(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Debug(
|
log.Debug(
|
||||||
"Failed to ping database in recovery mode, closing",
|
"Failed to ping database on retry, closing",
|
||||||
"path", path, "error", err,
|
"path", path, "error", err,
|
||||||
)
|
)
|
||||||
|
|
||||||
_ = conn.Close()
|
_ = conn.Close()
|
||||||
|
|
||||||
return nil, fmt.Errorf(
|
return nil, fmt.Errorf(
|
||||||
"database still locked after recovery attempt: %w",
|
"database still locked on retry: %w",
|
||||||
err,
|
err,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Debug("Database opened in TRUNCATE mode", "path", path)
|
|
||||||
|
|
||||||
// Switch back to WAL mode
|
|
||||||
log.Debug("Switching database back to WAL mode", "path", path)
|
|
||||||
|
|
||||||
_, err = conn.ExecContext(ctx, "PRAGMA journal_mode=WAL")
|
|
||||||
if err != nil {
|
|
||||||
log.Warn("Failed to switch back to WAL mode", "path", path, "error", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
db, err := finishOpen(ctx, conn, path)
|
db, err := finishOpen(ctx, conn, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -120,6 +120,89 @@ func TestDatabaseConcurrentAccess(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestNewSetsJournalModeAndBusyTimeout checks that the connection settings
|
||||||
|
// New passes reach SQLite. The driver drops a setting it does not recognise
|
||||||
|
// without an error, so only reading the value back shows it took effect.
|
||||||
|
func TestNewSetsJournalModeAndBusyTimeout(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
db, err := New(ctx, filepath.Join(t.TempDir(), "index.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = db.Close() }()
|
||||||
|
|
||||||
|
var journalMode string
|
||||||
|
|
||||||
|
err = db.conn.QueryRowContext(ctx, "PRAGMA journal_mode").Scan(&journalMode)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reading journal_mode: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if journalMode != "wal" {
|
||||||
|
t.Errorf("journal_mode = %q, want %q", journalMode, "wal")
|
||||||
|
}
|
||||||
|
|
||||||
|
var busyTimeout int
|
||||||
|
|
||||||
|
err = db.conn.QueryRowContext(ctx, "PRAGMA busy_timeout").Scan(&busyTimeout)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reading busy_timeout: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if busyTimeout != busyTimeoutMs {
|
||||||
|
t.Errorf("busy_timeout = %d, want %d", busyTimeout, busyTimeoutMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewWriteSucceedsWhileAnotherHandleReads opens the same index twice,
|
||||||
|
// as a read-only command does while a backup runs, and checks that a write
|
||||||
|
// on one handle commits while the other is in the middle of a read.
|
||||||
|
func TestNewWriteSucceedsWhileAnotherHandleReads(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
dbPath := filepath.Join(t.TempDir(), "index.db")
|
||||||
|
|
||||||
|
reader, err := New(ctx, dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to open reading handle: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = reader.Close() }()
|
||||||
|
|
||||||
|
writer, err := New(ctx, dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to open writing handle: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = writer.Close() }()
|
||||||
|
|
||||||
|
// The read lock taken by the SELECT is held until the transaction ends.
|
||||||
|
readTx, err := reader.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("beginning read transaction: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = readTx.Rollback() }()
|
||||||
|
|
||||||
|
var count int
|
||||||
|
|
||||||
|
err = readTx.QueryRowContext(ctx, "SELECT COUNT(*) FROM chunks").Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reading chunks: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = writer.ExecWithLog(ctx,
|
||||||
|
"INSERT INTO chunks (chunk_hash, size) VALUES (?, ?)", "hash", 1024)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write while another handle reads: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestParseMigrationVersion(t *testing.T) {
|
func TestParseMigrationVersion(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
package database
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Fatalf prints an error message to stderr and exits with status 1
|
|
||||||
func Fatalf(format string, args ...any) {
|
|
||||||
fmt.Fprintf(os.Stderr, "FATAL: "+format+"\n", args...)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
@@ -62,12 +62,7 @@ func (r *FileChunkRepository) GetByPath(
|
|||||||
return nil, fmt.Errorf("querying file chunks: %w", err)
|
return nil, fmt.Errorf("querying file chunks: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() {
|
defer func() { _ = rows.Close() }()
|
||||||
err := rows.Close()
|
|
||||||
if err != nil {
|
|
||||||
Fatalf("failed to close rows: %v", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
return r.scanFileChunks(rows)
|
return r.scanFileChunks(rows)
|
||||||
}
|
}
|
||||||
@@ -88,12 +83,7 @@ func (r *FileChunkRepository) GetByFileID(
|
|||||||
return nil, fmt.Errorf("querying file chunks: %w", err)
|
return nil, fmt.Errorf("querying file chunks: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() {
|
defer func() { _ = rows.Close() }()
|
||||||
err := rows.Close()
|
|
||||||
if err != nil {
|
|
||||||
Fatalf("failed to close rows: %v", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
return r.scanFileChunks(rows)
|
return r.scanFileChunks(rows)
|
||||||
}
|
}
|
||||||
@@ -117,12 +107,7 @@ func (r *FileChunkRepository) GetByPathTx(
|
|||||||
return nil, fmt.Errorf("querying file chunks: %w", err)
|
return nil, fmt.Errorf("querying file chunks: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() {
|
defer func() { _ = rows.Close() }()
|
||||||
err := rows.Close()
|
|
||||||
if err != nil {
|
|
||||||
Fatalf("failed to close rows: %v", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
fileChunks, err := r.scanFileChunks(rows)
|
fileChunks, err := r.scanFileChunks(rows)
|
||||||
LogSQL("GetByPathTx", "Complete", path, "count", len(fileChunks))
|
LogSQL("GetByPathTx", "Complete", path, "count", len(fileChunks))
|
||||||
@@ -253,7 +238,7 @@ func (r *FileChunkRepository) CreateBatch(
|
|||||||
args = append(args, fc.FileID.String(), fc.Idx, fc.ChunkHash.String())
|
args = append(args, fc.FileID.String(), fc.Idx, fc.ChunkHash.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
query += querySb211.String() //nolint:gosec // G202: appends "?" placeholders only
|
query += querySb211.String()
|
||||||
|
|
||||||
query += " ON CONFLICT(file_id, idx) DO NOTHING"
|
query += " ON CONFLICT(file_id, idx) DO NOTHING"
|
||||||
|
|
||||||
|
|||||||
+127
-44
@@ -33,11 +33,13 @@ func (r *FileRepository) Create(ctx context.Context, tx *sql.Tx, file *File) err
|
|||||||
}
|
}
|
||||||
|
|
||||||
query := `
|
query := `
|
||||||
INSERT INTO files (id, path, source_path, mtime, size, mode, uid, gid, link_target)
|
INSERT INTO files
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
(id, path, source_path, mtime, mtime_nsec, size, mode, uid, gid, link_target)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(path) DO UPDATE SET
|
ON CONFLICT(path) DO UPDATE SET
|
||||||
source_path = excluded.source_path,
|
source_path = excluded.source_path,
|
||||||
mtime = excluded.mtime,
|
mtime = excluded.mtime,
|
||||||
|
mtime_nsec = excluded.mtime_nsec,
|
||||||
size = excluded.size,
|
size = excluded.size,
|
||||||
mode = excluded.mode,
|
mode = excluded.mode,
|
||||||
uid = excluded.uid,
|
uid = excluded.uid,
|
||||||
@@ -54,16 +56,19 @@ func (r *FileRepository) Create(ctx context.Context, tx *sql.Tx, file *File) err
|
|||||||
if tx != nil {
|
if tx != nil {
|
||||||
LogSQL("Execute", query,
|
LogSQL("Execute", query,
|
||||||
file.ID.String(), file.Path.String(), file.SourcePath.String(),
|
file.ID.String(), file.Path.String(), file.SourcePath.String(),
|
||||||
file.MTime.Unix(), file.Size, file.Mode, file.UID, file.GID,
|
file.MTime.Unix(), file.MTime.Nanosecond(),
|
||||||
|
file.Size, file.Mode, file.UID, file.GID,
|
||||||
file.LinkTarget.String())
|
file.LinkTarget.String())
|
||||||
err = tx.QueryRowContext(ctx, query,
|
err = tx.QueryRowContext(ctx, query,
|
||||||
file.ID.String(), file.Path.String(), file.SourcePath.String(),
|
file.ID.String(), file.Path.String(), file.SourcePath.String(),
|
||||||
file.MTime.Unix(), file.Size, file.Mode, file.UID, file.GID,
|
file.MTime.Unix(), file.MTime.Nanosecond(),
|
||||||
|
file.Size, file.Mode, file.UID, file.GID,
|
||||||
file.LinkTarget.String()).Scan(&idStr)
|
file.LinkTarget.String()).Scan(&idStr)
|
||||||
} else {
|
} else {
|
||||||
err = r.db.QueryRowWithLog(ctx, query,
|
err = r.db.QueryRowWithLog(ctx, query,
|
||||||
file.ID.String(), file.Path.String(), file.SourcePath.String(),
|
file.ID.String(), file.Path.String(), file.SourcePath.String(),
|
||||||
file.MTime.Unix(), file.Size, file.Mode, file.UID, file.GID,
|
file.MTime.Unix(), file.MTime.Nanosecond(),
|
||||||
|
file.Size, file.Mode, file.UID, file.GID,
|
||||||
file.LinkTarget.String()).Scan(&idStr)
|
file.LinkTarget.String()).Scan(&idStr)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +89,7 @@ func (r *FileRepository) Create(ctx context.Context, tx *sql.Tx, file *File) err
|
|||||||
// in the index.
|
// in the index.
|
||||||
func (r *FileRepository) GetByPath(ctx context.Context, path string) (*File, error) {
|
func (r *FileRepository) GetByPath(ctx context.Context, path string) (*File, error) {
|
||||||
query := `
|
query := `
|
||||||
SELECT id, path, source_path, mtime, size, mode, uid, gid, link_target
|
SELECT id, path, source_path, mtime, mtime_nsec, size, mode, uid, gid, link_target
|
||||||
FROM files
|
FROM files
|
||||||
WHERE path = ?
|
WHERE path = ?
|
||||||
`
|
`
|
||||||
@@ -104,7 +109,7 @@ func (r *FileRepository) GetByPath(ctx context.Context, path string) (*File, err
|
|||||||
// GetByID retrieves a file by its UUID
|
// GetByID retrieves a file by its UUID
|
||||||
func (r *FileRepository) GetByID(ctx context.Context, id types.FileID) (*File, error) {
|
func (r *FileRepository) GetByID(ctx context.Context, id types.FileID) (*File, error) {
|
||||||
query := `
|
query := `
|
||||||
SELECT id, path, source_path, mtime, size, mode, uid, gid, link_target
|
SELECT id, path, source_path, mtime, mtime_nsec, size, mode, uid, gid, link_target
|
||||||
FROM files
|
FROM files
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`
|
`
|
||||||
@@ -127,7 +132,7 @@ func (r *FileRepository) GetByPathTx(
|
|||||||
ctx context.Context, tx *sql.Tx, path string,
|
ctx context.Context, tx *sql.Tx, path string,
|
||||||
) (*File, error) {
|
) (*File, error) {
|
||||||
query := `
|
query := `
|
||||||
SELECT id, path, source_path, mtime, size, mode, uid, gid, link_target
|
SELECT id, path, source_path, mtime, mtime_nsec, size, mode, uid, gid, link_target
|
||||||
FROM files
|
FROM files
|
||||||
WHERE path = ?
|
WHERE path = ?
|
||||||
`
|
`
|
||||||
@@ -158,23 +163,19 @@ func (r *FileRepository) ListModifiedSince(
|
|||||||
ctx context.Context, since time.Time,
|
ctx context.Context, since time.Time,
|
||||||
) ([]*File, error) {
|
) ([]*File, error) {
|
||||||
query := `
|
query := `
|
||||||
SELECT id, path, source_path, mtime, size, mode, uid, gid, link_target
|
SELECT id, path, source_path, mtime, mtime_nsec, size, mode, uid, gid, link_target
|
||||||
FROM files
|
FROM files
|
||||||
WHERE mtime >= ?
|
WHERE (mtime, mtime_nsec) >= (?, ?)
|
||||||
ORDER BY path
|
ORDER BY path
|
||||||
`
|
`
|
||||||
|
|
||||||
rows, err := r.db.conn.QueryContext(ctx, query, since.Unix())
|
rows, err := r.db.conn.QueryContext(ctx, query,
|
||||||
|
since.Unix(), since.Nanosecond())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("querying files: %w", err)
|
return nil, fmt.Errorf("querying files: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() {
|
defer func() { _ = rows.Close() }()
|
||||||
err := rows.Close()
|
|
||||||
if err != nil {
|
|
||||||
Fatalf("failed to close rows: %v", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
var files []*File
|
var files []*File
|
||||||
|
|
||||||
@@ -228,29 +229,29 @@ func (r *FileRepository) DeleteByID(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListByPrefix returns all files whose path starts with prefix, ordered by
|
// ListUnderPath returns the file at path and every file beneath it,
|
||||||
// path.
|
// ordered by path. Paths are compared case-sensitively, and a trailing
|
||||||
func (r *FileRepository) ListByPrefix(
|
// slash on path is ignored, so "/" lists every file.
|
||||||
ctx context.Context, prefix string,
|
func (r *FileRepository) ListUnderPath(
|
||||||
|
ctx context.Context, path string,
|
||||||
) ([]*File, error) {
|
) ([]*File, error) {
|
||||||
|
path = strings.TrimRight(path, "/")
|
||||||
|
dirPrefix := path + "/"
|
||||||
|
|
||||||
|
// LIKE would ignore ASCII case and treat _ and % in path as wildcards.
|
||||||
query := `
|
query := `
|
||||||
SELECT id, path, source_path, mtime, size, mode, uid, gid, link_target
|
SELECT id, path, source_path, mtime, mtime_nsec, size, mode, uid, gid, link_target
|
||||||
FROM files
|
FROM files
|
||||||
WHERE path LIKE ? || '%'
|
WHERE path = ? OR substr(path, 1, length(?)) = ?
|
||||||
ORDER BY path
|
ORDER BY path
|
||||||
`
|
`
|
||||||
|
|
||||||
rows, err := r.db.conn.QueryContext(ctx, query, prefix)
|
rows, err := r.db.conn.QueryContext(ctx, query, path, dirPrefix, dirPrefix)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("querying files: %w", err)
|
return nil, fmt.Errorf("querying files: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() {
|
defer func() { _ = rows.Close() }()
|
||||||
err := rows.Close()
|
|
||||||
if err != nil {
|
|
||||||
Fatalf("failed to close rows: %v", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
var files []*File
|
var files []*File
|
||||||
|
|
||||||
@@ -266,10 +267,94 @@ func (r *FileRepository) ListByPrefix(
|
|||||||
return files, rows.Err()
|
return files, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListUnderPathInSnapshot returns the files that ListUnderPath returns
|
||||||
|
// for path and that the snapshot lists, in no particular order.
|
||||||
|
func (r *FileRepository) ListUnderPathInSnapshot(
|
||||||
|
ctx context.Context, path, snapshotID string,
|
||||||
|
) ([]*File, error) {
|
||||||
|
path = strings.TrimRight(path, "/")
|
||||||
|
dirPrefix := path + "/"
|
||||||
|
|
||||||
|
query := `
|
||||||
|
SELECT f.id, f.path, f.source_path, f.mtime, f.mtime_nsec, f.size,
|
||||||
|
f.mode, f.uid, f.gid, f.link_target
|
||||||
|
FROM files f
|
||||||
|
JOIN snapshot_files sf ON sf.file_id = f.id
|
||||||
|
WHERE sf.snapshot_id = ?
|
||||||
|
AND (f.path = ? OR substr(f.path, 1, length(?)) = ?)
|
||||||
|
`
|
||||||
|
|
||||||
|
rows, err := r.db.conn.QueryContext(ctx, query,
|
||||||
|
snapshotID, path, dirPrefix, dirPrefix)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("querying files: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
var files []*File
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
file, err := r.scanFileRows(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("scanning file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
files = append(files, file)
|
||||||
|
}
|
||||||
|
|
||||||
|
return files, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListIDsWithChunksNotInUploadedBlobs returns the IDs of the files whose
|
||||||
|
// path starts with prefix and that list at least one chunk held by no
|
||||||
|
// blob whose upload has completed (uploaded_ts set). A new snapshot
|
||||||
|
// cannot reference such a chunk, so a backup must not treat the file as
|
||||||
|
// unchanged even when its metadata matches the file on disk.
|
||||||
|
func (r *FileRepository) ListIDsWithChunksNotInUploadedBlobs(
|
||||||
|
ctx context.Context, prefix string,
|
||||||
|
) ([]types.FileID, error) {
|
||||||
|
query := `
|
||||||
|
SELECT DISTINCT f.id
|
||||||
|
FROM files f
|
||||||
|
JOIN file_chunks fc ON fc.file_id = f.id
|
||||||
|
WHERE f.path LIKE ? || '%'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM blob_chunks bc
|
||||||
|
JOIN blobs b ON bc.blob_id = b.id
|
||||||
|
WHERE bc.chunk_hash = fc.chunk_hash
|
||||||
|
AND b.uploaded_ts IS NOT NULL
|
||||||
|
)
|
||||||
|
`
|
||||||
|
|
||||||
|
rows, err := r.db.conn.QueryContext(ctx, query, prefix)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("querying files: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
var ids []types.FileID
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var id types.FileID
|
||||||
|
|
||||||
|
err := rows.Scan(&id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("scanning file ID: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ids, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// ListAll returns all files in the database
|
// ListAll returns all files in the database
|
||||||
func (r *FileRepository) ListAll(ctx context.Context) ([]*File, error) {
|
func (r *FileRepository) ListAll(ctx context.Context) ([]*File, error) {
|
||||||
query := `
|
query := `
|
||||||
SELECT id, path, source_path, mtime, size, mode, uid, gid, link_target
|
SELECT id, path, source_path, mtime, mtime_nsec, size, mode, uid, gid, link_target
|
||||||
FROM files
|
FROM files
|
||||||
ORDER BY path
|
ORDER BY path
|
||||||
`
|
`
|
||||||
@@ -279,12 +364,7 @@ func (r *FileRepository) ListAll(ctx context.Context) ([]*File, error) {
|
|||||||
return nil, fmt.Errorf("querying files: %w", err)
|
return nil, fmt.Errorf("querying files: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() {
|
defer func() { _ = rows.Close() }()
|
||||||
err := rows.Close()
|
|
||||||
if err != nil {
|
|
||||||
Fatalf("failed to close rows: %v", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
var files []*File
|
var files []*File
|
||||||
|
|
||||||
@@ -310,7 +390,7 @@ func (r *FileRepository) CreateBatch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Each files row binds this many SQL variables.
|
// Each files row binds this many SQL variables.
|
||||||
const fileCols = 9
|
const fileCols = 10
|
||||||
|
|
||||||
// Batch at 100 rows to be safe with SQLite's variable limit.
|
// Batch at 100 rows to be safe with SQLite's variable limit.
|
||||||
const batchSize = 100
|
const batchSize = 100
|
||||||
@@ -321,7 +401,7 @@ func (r *FileRepository) CreateBatch(
|
|||||||
batch := files[i:end]
|
batch := files[i:end]
|
||||||
|
|
||||||
query := `INSERT INTO files
|
query := `INSERT INTO files
|
||||||
(id, path, source_path, mtime, size, mode, uid, gid, link_target)
|
(id, path, source_path, mtime, mtime_nsec, size, mode, uid, gid, link_target)
|
||||||
VALUES `
|
VALUES `
|
||||||
|
|
||||||
args := make([]any, 0, len(batch)*fileCols)
|
args := make([]any, 0, len(batch)*fileCols)
|
||||||
@@ -333,19 +413,21 @@ func (r *FileRepository) CreateBatch(
|
|||||||
querySb325.WriteString(", ")
|
querySb325.WriteString(", ")
|
||||||
}
|
}
|
||||||
|
|
||||||
querySb325.WriteString("(?, ?, ?, ?, ?, ?, ?, ?, ?)")
|
querySb325.WriteString("(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
|
||||||
|
|
||||||
args = append(args,
|
args = append(args,
|
||||||
f.ID.String(), f.Path.String(), f.SourcePath.String(),
|
f.ID.String(), f.Path.String(), f.SourcePath.String(),
|
||||||
f.MTime.Unix(), f.Size, f.Mode, f.UID, f.GID,
|
f.MTime.Unix(), f.MTime.Nanosecond(),
|
||||||
|
f.Size, f.Mode, f.UID, f.GID,
|
||||||
f.LinkTarget.String())
|
f.LinkTarget.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
query += querySb325.String() //nolint:gosec // G202: appends "?" placeholders only
|
query += querySb325.String()
|
||||||
|
|
||||||
query += ` ON CONFLICT(path) DO UPDATE SET
|
query += ` ON CONFLICT(path) DO UPDATE SET
|
||||||
source_path = excluded.source_path,
|
source_path = excluded.source_path,
|
||||||
mtime = excluded.mtime,
|
mtime = excluded.mtime,
|
||||||
|
mtime_nsec = excluded.mtime_nsec,
|
||||||
size = excluded.size,
|
size = excluded.size,
|
||||||
mode = excluded.mode,
|
mode = excluded.mode,
|
||||||
uid = excluded.uid,
|
uid = excluded.uid,
|
||||||
@@ -405,7 +487,7 @@ func (r *FileRepository) scanFileFrom(row fileRowScanner) (*File, error) {
|
|||||||
var (
|
var (
|
||||||
file File
|
file File
|
||||||
idStr, pathStr, sourcePathStr string
|
idStr, pathStr, sourcePathStr string
|
||||||
mtimeUnix int64
|
mtimeUnix, mtimeNsec int64
|
||||||
linkTarget sql.NullString
|
linkTarget sql.NullString
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -414,6 +496,7 @@ func (r *FileRepository) scanFileFrom(row fileRowScanner) (*File, error) {
|
|||||||
&pathStr,
|
&pathStr,
|
||||||
&sourcePathStr,
|
&sourcePathStr,
|
||||||
&mtimeUnix,
|
&mtimeUnix,
|
||||||
|
&mtimeNsec,
|
||||||
&file.Size,
|
&file.Size,
|
||||||
&file.Mode,
|
&file.Mode,
|
||||||
&file.UID,
|
&file.UID,
|
||||||
@@ -432,7 +515,7 @@ func (r *FileRepository) scanFileFrom(row fileRowScanner) (*File, error) {
|
|||||||
file.Path = types.FilePath(pathStr)
|
file.Path = types.FilePath(pathStr)
|
||||||
file.SourcePath = types.SourcePath(sourcePathStr)
|
file.SourcePath = types.SourcePath(sourcePathStr)
|
||||||
|
|
||||||
file.MTime = time.Unix(mtimeUnix, 0).UTC()
|
file.MTime = time.Unix(mtimeUnix, mtimeNsec).UTC()
|
||||||
if linkTarget.Valid {
|
if linkTarget.Valid {
|
||||||
file.LinkTarget = types.FilePath(linkTarget.String)
|
file.LinkTarget = types.FilePath(linkTarget.String)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/vaultik/internal/database"
|
"sneak.berlin/go/vaultik/internal/database"
|
||||||
|
"sneak.berlin/go/vaultik/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
// errTestRollback is the sentinel returned from transaction bodies to
|
// errTestRollback is the sentinel returned from transaction bodies to
|
||||||
@@ -134,6 +136,141 @@ func TestFileRepositoryListDelete(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFileRepositoryListUnderPath(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db, cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
repo := database.NewFileRepository(db)
|
||||||
|
|
||||||
|
const (
|
||||||
|
docDir = "/home/u/doc"
|
||||||
|
docFile = "/home/u/doc/a.txt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// In path order, so the root case can expect all of them as listed.
|
||||||
|
paths := []string{
|
||||||
|
"/home/u/50%/x.txt",
|
||||||
|
"/home/u/50percent/y.txt",
|
||||||
|
"/home/u/DOC/c.txt",
|
||||||
|
"/home/u/a_b/x.txt",
|
||||||
|
"/home/u/axb/y.txt",
|
||||||
|
docDir,
|
||||||
|
"/home/u/doc.txt.bak",
|
||||||
|
docFile,
|
||||||
|
"/home/u/doc/sub/b.txt",
|
||||||
|
"/home/u/doc2/b.txt",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, path := range paths {
|
||||||
|
err := repo.Create(ctx, nil, &database.File{
|
||||||
|
Path: types.FilePath(path),
|
||||||
|
MTime: time.Now().Truncate(time.Second),
|
||||||
|
Mode: 0644,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create %s: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
docTree := []string{docDir, docFile, "/home/u/doc/sub/b.txt"}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{"directory", docDir, docTree},
|
||||||
|
{"directory with trailing slash", docDir + "/", docTree},
|
||||||
|
{"directory differing only in case", "/home/u/DOC",
|
||||||
|
[]string{"/home/u/DOC/c.txt"}},
|
||||||
|
{"file", docFile, []string{docFile}},
|
||||||
|
{"underscore is literal", "/home/u/a_b",
|
||||||
|
[]string{"/home/u/a_b/x.txt"}},
|
||||||
|
{"percent is literal", "/home/u/50%",
|
||||||
|
[]string{"/home/u/50%/x.txt"}},
|
||||||
|
{"root", "/", paths},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
files, err := repo.ListUnderPath(ctx, tt.path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: failed to list files: %v", tt.name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := make([]string, 0, len(files))
|
||||||
|
for _, f := range files {
|
||||||
|
got = append(got, f.Path.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
if !slices.Equal(got, tt.want) {
|
||||||
|
t.Errorf("%s: listing %q got %q, want %q",
|
||||||
|
tt.name, tt.path, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListUnderPathInSnapshot leaves out files outside path and files the
|
||||||
|
// snapshot does not list, though another snapshot lists them.
|
||||||
|
func TestFileRepositoryListUnderPathInSnapshot(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db, cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
files := database.NewFileRepository(db)
|
||||||
|
snapshots := database.NewSnapshotRepository(db)
|
||||||
|
|
||||||
|
listed := map[string][]string{
|
||||||
|
"one": {"/src/a.txt", "/src2/c.txt"},
|
||||||
|
"two": {"/src/b.txt"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for snapshotID, paths := range listed {
|
||||||
|
err := snapshots.Create(ctx, nil, &database.Snapshot{
|
||||||
|
ID: types.SnapshotID(snapshotID),
|
||||||
|
Hostname: "host",
|
||||||
|
StartedAt: time.Now(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create snapshot %s: %v", snapshotID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, path := range paths {
|
||||||
|
err := files.Create(ctx, nil, &database.File{
|
||||||
|
Path: types.FilePath(path),
|
||||||
|
MTime: time.Now().Truncate(time.Second),
|
||||||
|
Mode: 0644,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create %s: %v", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = snapshots.AddFile(ctx, nil, snapshotID, path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to add %s to %s: %v", path, snapshotID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
found, err := files.ListUnderPathInSnapshot(ctx, "/src", "one")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to list files: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := make([]string, 0, len(found))
|
||||||
|
for _, f := range found {
|
||||||
|
got = append(got, f.Path.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
if !slices.Equal(got, []string{"/src/a.txt"}) {
|
||||||
|
t.Errorf("got %q, want only /src/a.txt", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFileRepositorySymlink(t *testing.T) {
|
func TestFileRepositorySymlink(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -174,6 +311,115 @@ func TestFileRepositorySymlink(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An mtime after 2262 or before 1678 does not fit in int64 nanoseconds
|
||||||
|
// since the epoch, and must still come back from the database unchanged.
|
||||||
|
func TestFileRepositoryMTimeOutsideInt64NanosecondRange(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db, cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
repo := database.NewFileRepository(db)
|
||||||
|
|
||||||
|
mtimes := []time.Time{
|
||||||
|
time.Date(2300, time.January, 1, 0, 0, 0, 123456789, time.UTC),
|
||||||
|
time.Date(1601, time.January, 1, 0, 0, 0, 987654321, time.UTC),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, mtime := range mtimes {
|
||||||
|
created := &database.File{
|
||||||
|
Path: types.FilePath("/created-" + mtime.Format(time.RFC3339Nano)),
|
||||||
|
MTime: mtime,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := repo.Create(ctx, nil, created)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
batched := &database.File{
|
||||||
|
ID: types.NewFileID(),
|
||||||
|
Path: types.FilePath("/batched-" + mtime.Format(time.RFC3339Nano)),
|
||||||
|
MTime: mtime,
|
||||||
|
}
|
||||||
|
|
||||||
|
err = repo.CreateBatch(ctx, nil, []*database.File{batched})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to batch create file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, path := range []types.FilePath{created.Path, batched.Path} {
|
||||||
|
retrieved, err := repo.GetByPath(ctx, path.String())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to get file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !retrieved.MTime.Equal(mtime) {
|
||||||
|
t.Errorf("%s: mtime got %v, want %v",
|
||||||
|
path, retrieved.MTime, mtime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A file already in the index and rewritten within the same second must get
|
||||||
|
// its new nanoseconds stored, through both Create and CreateBatch.
|
||||||
|
func TestFileRepositoryUpsertMTimeInSameSecond(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db, cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
repo := database.NewFileRepository(db)
|
||||||
|
|
||||||
|
indexed := time.Date(2026, time.October, 7, 12, 0, 0, 100000000, time.UTC)
|
||||||
|
rewritten := indexed.Add(800 * time.Millisecond)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
upsert func(file *database.File) error
|
||||||
|
}{
|
||||||
|
{"Create", func(file *database.File) error {
|
||||||
|
return repo.Create(ctx, nil, file)
|
||||||
|
}},
|
||||||
|
{"CreateBatch", func(file *database.File) error {
|
||||||
|
return repo.CreateBatch(ctx, nil, []*database.File{file})
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
file := &database.File{
|
||||||
|
ID: types.NewFileID(),
|
||||||
|
Path: types.FilePath("/" + tt.name),
|
||||||
|
MTime: indexed,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := tt.upsert(file)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: failed to create file: %v", tt.name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
file.MTime = rewritten
|
||||||
|
|
||||||
|
err = tt.upsert(file)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: failed to update file: %v", tt.name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
retrieved, err := repo.GetByPath(ctx, file.Path.String())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: failed to get file: %v", tt.name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !retrieved.MTime.Equal(rewritten) {
|
||||||
|
t.Errorf("%s: mtime got %v, want %v",
|
||||||
|
tt.name, retrieved.MTime, rewritten)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFileRepositoryTransaction(t *testing.T) {
|
func TestFileRepositoryTransaction(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,7 @@ type File struct {
|
|||||||
ID types.FileID // UUID primary key
|
ID types.FileID // UUID primary key
|
||||||
Path types.FilePath // Absolute path of the file
|
Path types.FilePath // Absolute path of the file
|
||||||
|
|
||||||
// SourcePath is the source directory this file came from (used for
|
// SourcePath is the source directory this file came from.
|
||||||
// restore path stripping).
|
|
||||||
SourcePath types.SourcePath
|
SourcePath types.SourcePath
|
||||||
MTime time.Time
|
MTime time.Time
|
||||||
Size int64
|
Size int64
|
||||||
@@ -99,8 +98,8 @@ type Snapshot struct {
|
|||||||
StartedAt time.Time
|
StartedAt time.Time
|
||||||
CompletedAt *time.Time // nil if still in progress
|
CompletedAt *time.Time // nil if still in progress
|
||||||
FileCount int64
|
FileCount int64
|
||||||
ChunkCount int64
|
ChunkCount int64 // Chunks this snapshot stored that were not stored before
|
||||||
BlobCount int64
|
BlobCount int64 // Blobs this snapshot created
|
||||||
TotalSize int64 // Total size of all referenced files
|
TotalSize int64 // Total size of all referenced files
|
||||||
|
|
||||||
// BlobSize is the total size of all referenced blobs (compressed and
|
// BlobSize is the total size of all referenced blobs (compressed and
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package database
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -60,22 +61,24 @@ func (r *Repositories) WithTx(ctx context.Context, fn TxFunc) error {
|
|||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
if p := recover(); p != nil {
|
if p := recover(); p != nil {
|
||||||
rollbackErr := tx.Rollback()
|
// The panic is the failure to report, so a rollback error
|
||||||
if rollbackErr != nil {
|
// is dropped.
|
||||||
Fatalf("failed to rollback transaction: %v", rollbackErr)
|
_ = tx.Rollback()
|
||||||
}
|
|
||||||
|
|
||||||
panic(p)
|
panic(p)
|
||||||
} else if err != nil {
|
|
||||||
rollbackErr := tx.Rollback()
|
|
||||||
if rollbackErr != nil {
|
|
||||||
Fatalf("failed to rollback transaction: %v", rollbackErr)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
err = fn(ctx, tx)
|
err = fn(ctx, tx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// Once ctx is cancelled, database/sql rolls the transaction
|
||||||
|
// back itself and Rollback returns sql.ErrTxDone.
|
||||||
|
rollbackErr := tx.Rollback()
|
||||||
|
if rollbackErr != nil && !errors.Is(rollbackErr, sql.ErrTxDone) {
|
||||||
|
return fmt.Errorf("%w; rolling back transaction: %w",
|
||||||
|
err, rollbackErr)
|
||||||
|
}
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,22 +106,24 @@ func (r *Repositories) WithReadTx(ctx context.Context, fn TxFunc) error {
|
|||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
if p := recover(); p != nil {
|
if p := recover(); p != nil {
|
||||||
rollbackErr := tx.Rollback()
|
// The panic is the failure to report, so a rollback error
|
||||||
if rollbackErr != nil {
|
// is dropped.
|
||||||
Fatalf("failed to rollback transaction: %v", rollbackErr)
|
_ = tx.Rollback()
|
||||||
}
|
|
||||||
|
|
||||||
panic(p)
|
panic(p)
|
||||||
} else if err != nil {
|
|
||||||
rollbackErr := tx.Rollback()
|
|
||||||
if rollbackErr != nil {
|
|
||||||
Fatalf("failed to rollback transaction: %v", rollbackErr)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
err = fn(ctx, tx)
|
err = fn(ctx, tx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// Once ctx is cancelled, database/sql rolls the transaction
|
||||||
|
// back itself and Rollback returns sql.ErrTxDone.
|
||||||
|
rollbackErr := tx.Rollback()
|
||||||
|
if rollbackErr != nil && !errors.Is(rollbackErr, sql.ErrTxDone) {
|
||||||
|
return fmt.Errorf("%w; rolling back transaction: %w",
|
||||||
|
err, rollbackErr)
|
||||||
|
}
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -235,6 +236,125 @@ func TestRepositoriesTransactionRollback(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When the context is cancelled during the body, database/sql rolls the
|
||||||
|
// transaction back itself, so WithTx's own rollback finds it already
|
||||||
|
// done. WithTx must return the body's error alone, without that rollback.
|
||||||
|
func TestRepositoriesTransactionContextCancelled(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db, cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
repos := database.NewRepositories(db)
|
||||||
|
|
||||||
|
err := repos.WithTx(ctx, func(ctx context.Context, tx *sql.Tx) error {
|
||||||
|
file := &database.File{
|
||||||
|
Path: "/test/cancelled_file.txt",
|
||||||
|
MTime: time.Now().Truncate(time.Second),
|
||||||
|
Size: 1024,
|
||||||
|
Mode: 0644,
|
||||||
|
UID: 1000,
|
||||||
|
GID: 1000,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := repos.Files.Create(ctx, tx, file)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
// database/sql rolls back from a goroutine of its own; wait
|
||||||
|
// until it has, so the test does not depend on that race.
|
||||||
|
for {
|
||||||
|
_, err = tx.ExecContext(context.WithoutCancel(ctx), "SELECT 1")
|
||||||
|
if errors.Is(err, sql.ErrTxDone) {
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("expected context.Canceled, got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if errors.Is(err, sql.ErrTxDone) {
|
||||||
|
t.Fatalf("the already-done rollback was reported: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := repos.Files.GetByPath(
|
||||||
|
context.Background(), "/test/cancelled_file.txt",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("error checking for file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if file != nil {
|
||||||
|
t.Error("file should not exist after the cancelled transaction")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A body that ends the transaction itself with ROLLBACK makes WithTx's
|
||||||
|
// own rollback fail. WithTx must return that failure together with the
|
||||||
|
// body's error.
|
||||||
|
func TestRepositoriesTransactionRollbackFails(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db, cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
repos := database.NewRepositories(db)
|
||||||
|
|
||||||
|
err := repos.WithTx(ctx, func(ctx context.Context, tx *sql.Tx) error {
|
||||||
|
_, err := tx.ExecContext(ctx, "ROLLBACK")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return errIntentionalRollback
|
||||||
|
})
|
||||||
|
if !errors.Is(err, errIntentionalRollback) {
|
||||||
|
t.Fatalf("expected the body's error, got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(err.Error(), "rolling back transaction") {
|
||||||
|
t.Errorf("expected the failed rollback in the error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// When the body ends the transaction itself with ROLLBACK and then
|
||||||
|
// panics, WithTx's own rollback fails; the panic must still reach the
|
||||||
|
// caller.
|
||||||
|
func TestRepositoriesTransactionPanicRollbackFails(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db, cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
repos := database.NewRepositories(db)
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
if recover() == nil {
|
||||||
|
t.Error("expected the body's panic to reach the caller")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
_ = repos.WithTx(ctx, func(ctx context.Context, tx *sql.Tx) error {
|
||||||
|
_, err := tx.ExecContext(ctx, "ROLLBACK")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
panic(errIntentionalRollback)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestRepositoriesReadTransaction(t *testing.T) {
|
func TestRepositoriesReadTransaction(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -291,3 +411,41 @@ func TestRepositoriesReadTransaction(t *testing.T) {
|
|||||||
t.Error("expected to retrieve file in read transaction")
|
t.Error("expected to retrieve file in read transaction")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When the context is cancelled during a read transaction's body,
|
||||||
|
// database/sql rolls the transaction back itself, so WithReadTx's own
|
||||||
|
// rollback finds it already done. WithReadTx must return the body's error
|
||||||
|
// alone, without that rollback.
|
||||||
|
func TestRepositoriesReadTransactionContextCancelled(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db, cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
repos := database.NewRepositories(db)
|
||||||
|
|
||||||
|
err := repos.WithReadTx(ctx, func(ctx context.Context, tx *sql.Tx) error {
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
// database/sql rolls back from a goroutine of its own; wait
|
||||||
|
// until it has, so the test does not depend on that race.
|
||||||
|
for {
|
||||||
|
_, err := tx.ExecContext(context.WithoutCancel(ctx), "SELECT 1")
|
||||||
|
if errors.Is(err, sql.ErrTxDone) {
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("expected context.Canceled, got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if errors.Is(err, sql.ErrTxDone) {
|
||||||
|
t.Fatalf("the already-done rollback was reported: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -824,7 +824,7 @@ func TestTransactionIsolation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify the file was not created (transaction rolled back)
|
// Verify the file was not created (transaction rolled back)
|
||||||
files, err := repos.Files.ListByPrefix(ctx, "/tx-test")
|
files, err := repos.Files.ListUnderPath(ctx, "/tx-test.txt")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -916,7 +916,7 @@ func TestConcurrentOrphanedCleanup(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify correct files were deleted
|
// Verify correct files were deleted
|
||||||
files, err := repos.Files.ListByPrefix(ctx, "/concurrent-")
|
files, err := repos.Files.ListAll(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ func TestOrphanedFileCleanupDebug(t *testing.T) {
|
|||||||
t.Logf("Files count after cleanup: %d", count)
|
t.Logf("Files count after cleanup: %d", count)
|
||||||
|
|
||||||
// List remaining files
|
// List remaining files
|
||||||
files, err := repos.Files.ListByPrefix(ctx, "/")
|
files, err := repos.Files.ListUnderPath(ctx, "/")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package database
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -367,7 +368,7 @@ func verifyBlobNullUploadTS(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// createLargeDatasetFiles creates fileCount files and adds every other
|
// createLargeDatasetFiles creates fileCount files and adds every other
|
||||||
// one to the snapshot.
|
// one to the snapshot, in one transaction as a backup writes them.
|
||||||
func createLargeDatasetFiles(
|
func createLargeDatasetFiles(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
repos *Repositories,
|
repos *Repositories,
|
||||||
@@ -376,31 +377,38 @@ func createLargeDatasetFiles(
|
|||||||
) {
|
) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
|
|
||||||
for i := range fileCount {
|
err := repos.WithTx(context.Background(),
|
||||||
file := &File{
|
func(ctx context.Context, tx *sql.Tx) error {
|
||||||
Path: types.FilePath(fmt.Sprintf("/large/file%05d.txt", i)),
|
for i := range fileCount {
|
||||||
MTime: time.Now(),
|
file := &File{
|
||||||
Size: int64(i * 1024),
|
Path: types.FilePath(fmt.Sprintf("/large/file%05d.txt", i)),
|
||||||
Mode: 0644,
|
MTime: time.Now(),
|
||||||
UID: uint32(1000 + (i % 10)),
|
Size: int64(i * 1024),
|
||||||
GID: uint32(1000 + (i % 10)),
|
Mode: 0644,
|
||||||
}
|
UID: uint32(1000 + (i % 10)),
|
||||||
|
GID: uint32(1000 + (i % 10)),
|
||||||
|
}
|
||||||
|
|
||||||
err := repos.Files.Create(ctx, nil, file)
|
err := repos.Files.Create(ctx, tx, file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to create file %d: %v", i, err)
|
return fmt.Errorf("creating file %d: %w", i, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add half to snapshot
|
// Add half to snapshot
|
||||||
if i%2 == 0 {
|
if i%2 == 0 {
|
||||||
err = repos.Snapshots.AddFileByID(ctx, nil, snapshotID, file.ID)
|
err = repos.Snapshots.AddFileByID(ctx, tx, snapshotID, file.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Logf("Created %d files in %v", fileCount, time.Since(start))
|
t.Logf("Created %d files in %v", fileCount, time.Since(start))
|
||||||
@@ -442,12 +450,12 @@ func TestLargeDatasets(t *testing.T) {
|
|||||||
createLargeDatasetFiles(t, repos, snapshot.ID.String(), fileCount)
|
createLargeDatasetFiles(t, repos, snapshot.ID.String(), fileCount)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Test ListByPrefix performance
|
// Test ListUnderPath performance
|
||||||
//nolint:paralleltest // phases share one database and are order-dependent
|
//nolint:paralleltest // phases share one database and are order-dependent
|
||||||
t.Run("list by prefix performance", func(t *testing.T) {
|
t.Run("list under path performance", func(t *testing.T) {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
|
|
||||||
files, err := repos.Files.ListByPrefix(ctx, "/large/")
|
files, err := repos.Files.ListUnderPath(ctx, "/large/")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -472,7 +480,7 @@ func TestLargeDatasets(t *testing.T) {
|
|||||||
t.Logf("Cleaned up orphaned files in %v", time.Since(start))
|
t.Logf("Cleaned up orphaned files in %v", time.Since(start))
|
||||||
|
|
||||||
// Verify correct number remain
|
// Verify correct number remain
|
||||||
files, err := repos.Files.ListByPrefix(ctx, "/large/")
|
files, err := repos.Files.ListUnderPath(ctx, "/large/")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -606,8 +614,7 @@ func TestTimezoneHandling(t *testing.T) {
|
|||||||
t.Skip("timezone not available")
|
t.Skip("timezone not available")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use Truncate to remove sub-second precision since we store as Unix timestamps
|
nyTime := time.Now().In(loc)
|
||||||
nyTime := time.Now().In(loc).Truncate(time.Second)
|
|
||||||
file := &File{
|
file := &File{
|
||||||
Path: "/timezone-test.txt",
|
Path: "/timezone-test.txt",
|
||||||
MTime: nyTime,
|
MTime: nyTime,
|
||||||
|
|||||||
@@ -5,8 +5,9 @@
|
|||||||
CREATE TABLE IF NOT EXISTS files (
|
CREATE TABLE IF NOT EXISTS files (
|
||||||
id TEXT PRIMARY KEY, -- UUID
|
id TEXT PRIMARY KEY, -- UUID
|
||||||
path TEXT NOT NULL UNIQUE,
|
path TEXT NOT NULL UNIQUE,
|
||||||
source_path TEXT NOT NULL DEFAULT '', -- The source directory this file came from (for restore path stripping)
|
source_path TEXT NOT NULL DEFAULT '', -- The source directory this file came from
|
||||||
mtime INTEGER NOT NULL,
|
mtime INTEGER NOT NULL, -- whole seconds since the Unix epoch
|
||||||
|
mtime_nsec INTEGER NOT NULL, -- nanoseconds within that second, 0 to 999999999
|
||||||
size INTEGER NOT NULL,
|
size INTEGER NOT NULL,
|
||||||
mode INTEGER NOT NULL,
|
mode INTEGER NOT NULL,
|
||||||
uid INTEGER NOT NULL,
|
uid INTEGER NOT NULL,
|
||||||
@@ -85,8 +86,10 @@ CREATE TABLE IF NOT EXISTS snapshots (
|
|||||||
completed_at INTEGER,
|
completed_at INTEGER,
|
||||||
file_count INTEGER NOT NULL DEFAULT 0,
|
file_count INTEGER NOT NULL DEFAULT 0,
|
||||||
chunk_count INTEGER NOT NULL DEFAULT 0,
|
chunk_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
new_chunk_size INTEGER NOT NULL DEFAULT 0,
|
||||||
blob_count INTEGER NOT NULL DEFAULT 0,
|
blob_count INTEGER NOT NULL DEFAULT 0,
|
||||||
total_size INTEGER NOT NULL DEFAULT 0,
|
total_size INTEGER NOT NULL DEFAULT 0,
|
||||||
|
uncompressed_chunk_size INTEGER NOT NULL DEFAULT 0,
|
||||||
blob_size INTEGER NOT NULL DEFAULT 0,
|
blob_size INTEGER NOT NULL DEFAULT 0,
|
||||||
blob_uncompressed_size INTEGER NOT NULL DEFAULT 0,
|
blob_uncompressed_size INTEGER NOT NULL DEFAULT 0,
|
||||||
compression_ratio REAL NOT NULL DEFAULT 1.0,
|
compression_ratio REAL NOT NULL DEFAULT 1.0,
|
||||||
|
|||||||
@@ -120,14 +120,20 @@ func (r *SnapshotRepository) UpdateCounts(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateExtendedStats updates extended statistics for a snapshot
|
// UpdateExtendedStats updates extended statistics for a snapshot. It also
|
||||||
|
// records the total plaintext size of the distinct chunks the snapshot's
|
||||||
|
// files reference. Only the snapshot's own backup can take that total: a
|
||||||
|
// file's row is shared by every snapshot that lists it, and a later
|
||||||
|
// backup of a changed file rewrites the row with the new chunks.
|
||||||
func (r *SnapshotRepository) UpdateExtendedStats(
|
func (r *SnapshotRepository) UpdateExtendedStats(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
tx *sql.Tx,
|
tx *sql.Tx,
|
||||||
snapshotID string,
|
snapshotID string,
|
||||||
blobUncompressedSize int64,
|
blobUncompressedSize int64,
|
||||||
compressionLevel int,
|
compressionLevel int,
|
||||||
|
uploadBytes int64,
|
||||||
uploadDurationMs int64,
|
uploadDurationMs int64,
|
||||||
|
newChunkSize int64,
|
||||||
) error {
|
) error {
|
||||||
compressionRatio, err := r.extendedCompressionRatio(
|
compressionRatio, err := r.extendedCompressionRatio(
|
||||||
ctx, tx, snapshotID, blobUncompressedSize,
|
ctx, tx, snapshotID, blobUncompressedSize,
|
||||||
@@ -141,19 +147,30 @@ func (r *SnapshotRepository) UpdateExtendedStats(
|
|||||||
SET blob_uncompressed_size = ?,
|
SET blob_uncompressed_size = ?,
|
||||||
compression_ratio = ?,
|
compression_ratio = ?,
|
||||||
compression_level = ?,
|
compression_level = ?,
|
||||||
upload_bytes = blob_size,
|
upload_bytes = ?,
|
||||||
upload_duration_ms = ?
|
upload_duration_ms = ?,
|
||||||
|
new_chunk_size = ?,
|
||||||
|
uncompressed_chunk_size = (
|
||||||
|
SELECT COALESCE(SUM(c.size), 0)
|
||||||
|
FROM (
|
||||||
|
SELECT DISTINCT fc.chunk_hash
|
||||||
|
FROM snapshot_files sf
|
||||||
|
JOIN file_chunks fc ON sf.file_id = fc.file_id
|
||||||
|
WHERE sf.snapshot_id = snapshots.id
|
||||||
|
) sc
|
||||||
|
JOIN chunks c ON sc.chunk_hash = c.chunk_hash
|
||||||
|
)
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`
|
`
|
||||||
|
|
||||||
if tx != nil {
|
if tx != nil {
|
||||||
_, err = tx.ExecContext(ctx, query,
|
_, err = tx.ExecContext(ctx, query,
|
||||||
blobUncompressedSize, compressionRatio, compressionLevel,
|
blobUncompressedSize, compressionRatio, compressionLevel,
|
||||||
uploadDurationMs, snapshotID)
|
uploadBytes, uploadDurationMs, newChunkSize, snapshotID)
|
||||||
} else {
|
} else {
|
||||||
_, err = r.db.ExecWithLog(ctx, query,
|
_, err = r.db.ExecWithLog(ctx, query,
|
||||||
blobUncompressedSize, compressionRatio, compressionLevel,
|
blobUncompressedSize, compressionRatio, compressionLevel,
|
||||||
uploadDurationMs, snapshotID)
|
uploadBytes, uploadDurationMs, newChunkSize, snapshotID)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -238,12 +255,7 @@ func (r *SnapshotRepository) GetOnlySnapshot(ctx context.Context) (*Snapshot, er
|
|||||||
return nil, fmt.Errorf("querying snapshots: %w", err)
|
return nil, fmt.Errorf("querying snapshots: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() {
|
defer func() { _ = rows.Close() }()
|
||||||
err := rows.Close()
|
|
||||||
if err != nil {
|
|
||||||
Fatalf("failed to close rows: %v", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
snapshots, err := r.scanSnapshotRows(rows)
|
snapshots, err := r.scanSnapshotRows(rows)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -278,12 +290,7 @@ func (r *SnapshotRepository) ListRecent(
|
|||||||
return nil, fmt.Errorf("querying snapshots: %w", err)
|
return nil, fmt.Errorf("querying snapshots: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() {
|
defer func() { _ = rows.Close() }()
|
||||||
err := rows.Close()
|
|
||||||
if err != nil {
|
|
||||||
Fatalf("failed to close rows: %v", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
return r.scanSnapshotRows(rows)
|
return r.scanSnapshotRows(rows)
|
||||||
}
|
}
|
||||||
@@ -395,7 +402,7 @@ func (r *SnapshotRepository) AddFilesByIDBatch(
|
|||||||
args = append(args, snapshotID, fileID.String())
|
args = append(args, snapshotID, fileID.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
query += querySb312.String() //nolint:gosec // G202: appends "?" placeholders only
|
query += querySb312.String()
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
if tx != nil {
|
if tx != nil {
|
||||||
@@ -498,12 +505,7 @@ func (r *SnapshotRepository) GetBlobHashes(
|
|||||||
return nil, fmt.Errorf("querying blob hashes: %w", err)
|
return nil, fmt.Errorf("querying blob hashes: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() {
|
defer func() { _ = rows.Close() }()
|
||||||
err := rows.Close()
|
|
||||||
if err != nil {
|
|
||||||
Fatalf("failed to close rows: %v", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
var blobs []string
|
var blobs []string
|
||||||
|
|
||||||
@@ -543,71 +545,66 @@ func (r *SnapshotRepository) GetSnapshotTotalCompressedSize(
|
|||||||
return totalSize, nil
|
return totalSize, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSnapshotUncompressedChunkSize returns the sum of plaintext sizes of all unique
|
// GetSnapshotBlobSizes returns the total compressed and uncompressed sizes
|
||||||
// chunks referenced by a snapshot (via snapshot_files → file_chunks → chunks).
|
// of all blobs referenced by a snapshot.
|
||||||
|
func (r *SnapshotRepository) GetSnapshotBlobSizes(
|
||||||
|
ctx context.Context, snapshotID string,
|
||||||
|
) (int64, int64, error) {
|
||||||
|
query := `
|
||||||
|
SELECT COALESCE(SUM(b.compressed_size), 0),
|
||||||
|
COALESCE(SUM(b.uncompressed_size), 0)
|
||||||
|
FROM snapshot_blobs sb
|
||||||
|
JOIN blobs b ON sb.blob_hash = b.blob_hash
|
||||||
|
WHERE sb.snapshot_id = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
var compressed, uncompressed int64
|
||||||
|
|
||||||
|
err := r.db.conn.QueryRowContext(ctx, query, snapshotID).Scan(
|
||||||
|
&compressed, &uncompressed)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("querying snapshot blob sizes: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return compressed, uncompressed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSnapshotUncompressedChunkSize returns the total plaintext size of the
|
||||||
|
// distinct chunks the snapshot's files reference, as the snapshot's backup
|
||||||
|
// recorded it (see UpdateExtendedStats).
|
||||||
func (r *SnapshotRepository) GetSnapshotUncompressedChunkSize(
|
func (r *SnapshotRepository) GetSnapshotUncompressedChunkSize(
|
||||||
ctx context.Context, snapshotID string,
|
ctx context.Context, snapshotID string,
|
||||||
) (int64, error) {
|
) (int64, error) {
|
||||||
query := `
|
query := `SELECT uncompressed_chunk_size FROM snapshots WHERE id = ?`
|
||||||
SELECT COALESCE(SUM(c.size), 0)
|
|
||||||
FROM (
|
|
||||||
SELECT DISTINCT fc.chunk_hash
|
|
||||||
FROM snapshot_files sf
|
|
||||||
JOIN file_chunks fc ON sf.file_id = fc.file_id
|
|
||||||
WHERE sf.snapshot_id = ?
|
|
||||||
) sc
|
|
||||||
JOIN chunks c ON sc.chunk_hash = c.chunk_hash
|
|
||||||
`
|
|
||||||
|
|
||||||
var totalSize int64
|
var uncompressedChunkSize int64
|
||||||
|
|
||||||
err := r.db.conn.QueryRowContext(ctx, query, snapshotID).Scan(&totalSize)
|
err := r.db.conn.QueryRowContext(ctx, query, snapshotID).Scan(&uncompressedChunkSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("querying uncompressed chunk size: %w", err)
|
return 0, fmt.Errorf("querying uncompressed chunk size: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return totalSize, nil
|
return uncompressedChunkSize, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSnapshotNewChunkSize returns the sum of plaintext sizes of chunks that are
|
// GetSnapshotNewChunkSize returns the total plaintext size of the chunks the
|
||||||
// referenced by this snapshot but not by any earlier completed snapshot known to
|
// snapshot's backup stored that were not stored before, as the backup
|
||||||
// the local database. The result is the marginal uncompressed data this snapshot
|
// recorded it. It is not derived from the snapshot's files: a file's row is
|
||||||
// added to the dedup pool — i.e., the delta from prior snapshots.
|
// shared by every snapshot that lists it and is rewritten when the file
|
||||||
|
// changes, so it shows only the file's latest chunks.
|
||||||
func (r *SnapshotRepository) GetSnapshotNewChunkSize(
|
func (r *SnapshotRepository) GetSnapshotNewChunkSize(
|
||||||
ctx context.Context, snapshotID string,
|
ctx context.Context, snapshotID string,
|
||||||
) (int64, error) {
|
) (int64, error) {
|
||||||
query := `
|
query := `SELECT new_chunk_size FROM snapshots WHERE id = ?`
|
||||||
WITH this_snap_chunks AS (
|
|
||||||
SELECT DISTINCT fc.chunk_hash
|
|
||||||
FROM snapshot_files sf
|
|
||||||
JOIN file_chunks fc ON sf.file_id = fc.file_id
|
|
||||||
WHERE sf.snapshot_id = ?
|
|
||||||
),
|
|
||||||
prior_chunks AS (
|
|
||||||
SELECT DISTINCT fc.chunk_hash
|
|
||||||
FROM snapshots s
|
|
||||||
JOIN snapshot_files sf ON sf.snapshot_id = s.id
|
|
||||||
JOIN file_chunks fc ON fc.file_id = sf.file_id
|
|
||||||
WHERE s.completed_at IS NOT NULL
|
|
||||||
AND s.id != ?
|
|
||||||
AND s.started_at < (SELECT started_at FROM snapshots WHERE id = ?)
|
|
||||||
)
|
|
||||||
SELECT COALESCE(SUM(c.size), 0)
|
|
||||||
FROM chunks c
|
|
||||||
JOIN this_snap_chunks t ON c.chunk_hash = t.chunk_hash
|
|
||||||
WHERE c.chunk_hash NOT IN (SELECT chunk_hash FROM prior_chunks)
|
|
||||||
`
|
|
||||||
|
|
||||||
var totalSize int64
|
var newChunkSize int64
|
||||||
|
|
||||||
err := r.db.conn.QueryRowContext(
|
err := r.db.conn.QueryRowContext(ctx, query, snapshotID).Scan(&newChunkSize)
|
||||||
ctx, query, snapshotID, snapshotID, snapshotID,
|
|
||||||
).Scan(&totalSize)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("querying new chunk size: %w", err)
|
return 0, fmt.Errorf("querying new chunk size: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return totalSize, nil
|
return newChunkSize, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetIncompleteSnapshots returns all snapshots that haven't been completed
|
// GetIncompleteSnapshots returns all snapshots that haven't been completed
|
||||||
@@ -628,12 +625,7 @@ func (r *SnapshotRepository) GetIncompleteSnapshots(
|
|||||||
return nil, fmt.Errorf("querying incomplete snapshots: %w", err)
|
return nil, fmt.Errorf("querying incomplete snapshots: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() {
|
defer func() { _ = rows.Close() }()
|
||||||
err := rows.Close()
|
|
||||||
if err != nil {
|
|
||||||
Fatalf("failed to close rows: %v", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
return r.scanSnapshotRows(rows)
|
return r.scanSnapshotRows(rows)
|
||||||
}
|
}
|
||||||
@@ -656,12 +648,7 @@ func (r *SnapshotRepository) GetIncompleteByHostname(
|
|||||||
return nil, fmt.Errorf("querying incomplete snapshots: %w", err)
|
return nil, fmt.Errorf("querying incomplete snapshots: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() {
|
defer func() { _ = rows.Close() }()
|
||||||
err := rows.Close()
|
|
||||||
if err != nil {
|
|
||||||
Fatalf("failed to close rows: %v", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Same column set as every other multi-row snapshot query, so the
|
// Same column set as every other multi-row snapshot query, so the
|
||||||
// shared scanner applies — including its timestamp normalization.
|
// shared scanner applies — including its timestamp normalization.
|
||||||
|
|||||||
@@ -145,6 +145,65 @@ func TestSnapshotRepositoryUpdateCounts(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetSnapshotBlobSizes totals the blobs the snapshot references, and only
|
||||||
|
// those.
|
||||||
|
func TestSnapshotRepositoryGetSnapshotBlobSizes(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db, cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
repos := database.NewRepositories(db)
|
||||||
|
|
||||||
|
snapshot := &database.Snapshot{
|
||||||
|
ID: "2024-01-03T12:00:00Z",
|
||||||
|
Hostname: testHostname,
|
||||||
|
VaultikVersion: testVersion,
|
||||||
|
StartedAt: time.Now().Truncate(time.Second),
|
||||||
|
}
|
||||||
|
|
||||||
|
err := repos.Snapshots.Create(ctx, nil, snapshot)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create snapshot: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
blobs := []*database.Blob{
|
||||||
|
{Hash: "referenced-1", CompressedSize: 10, UncompressedSize: 100},
|
||||||
|
{Hash: "referenced-2", CompressedSize: 20, UncompressedSize: 200},
|
||||||
|
{Hash: "unreferenced", CompressedSize: 40, UncompressedSize: 400},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, blob := range blobs {
|
||||||
|
blob.ID = types.NewBlobID()
|
||||||
|
blob.CreatedTS = time.Now().Truncate(time.Second)
|
||||||
|
|
||||||
|
err = repos.Blobs.Create(ctx, nil, blob)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create blob %s: %v", blob.Hash, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, blob := range blobs[:2] {
|
||||||
|
err = repos.Snapshots.AddBlob(ctx, nil, snapshot.ID.String(),
|
||||||
|
blob.ID, blob.Hash)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to add blob %s to snapshot: %v", blob.Hash, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compressed, uncompressed, err := repos.Snapshots.GetSnapshotBlobSizes(
|
||||||
|
ctx, snapshot.ID.String())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to get snapshot blob sizes: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if compressed != 30 || uncompressed != 300 {
|
||||||
|
t.Errorf("blob sizes: got %d and %d, want 30 and 300",
|
||||||
|
compressed, uncompressed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSnapshotRepositoryListRecent(t *testing.T) {
|
func TestSnapshotRepositoryListRecent(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -158,19 +158,3 @@ type UploadStats struct {
|
|||||||
MinDurationMs int64
|
MinDurationMs int64
|
||||||
MaxDurationMs int64
|
MaxDurationMs int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetCountBySnapshot returns the count of uploads for a specific snapshot
|
|
||||||
func (r *UploadRepository) GetCountBySnapshot(
|
|
||||||
ctx context.Context, snapshotID string,
|
|
||||||
) (int64, error) {
|
|
||||||
query := `SELECT COUNT(*) FROM uploads WHERE snapshot_id = ?`
|
|
||||||
|
|
||||||
var count int64
|
|
||||||
|
|
||||||
err := r.conn.QueryRowContext(ctx, query, snapshotID).Scan(&count)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return count, nil
|
|
||||||
}
|
|
||||||
|
|||||||
+28
-13
@@ -3,6 +3,7 @@
|
|||||||
package globals
|
package globals
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -10,22 +11,26 @@ import (
|
|||||||
// Appname is the application name, populated from main().
|
// Appname is the application name, populated from main().
|
||||||
var Appname = "vaultik" //nolint:gochecknoglobals // set via -ldflags at build time
|
var Appname = "vaultik" //nolint:gochecknoglobals // set via -ldflags at build time
|
||||||
|
|
||||||
// DevVersion is the version a binary reports when it was not built
|
// DevVersion is the version a binary reports when it was built without
|
||||||
// from a tagged commit. script/version emits either this exact string
|
// git metadata: script/version emits it outside a git checkout, and an
|
||||||
// (outside a git checkout) or this string followed by "-" and the
|
// unstamped `go build` keeps it. goreleaser's snapshot template stamps
|
||||||
// commit it was built from, and goreleaser's snapshot template matches
|
// it followed by "-" and the commit it was built from. It is
|
||||||
// that shape. It is deliberately not a number: a build that is not a
|
// deliberately not a number.
|
||||||
// release must not name itself like one.
|
|
||||||
const DevVersion = "dev"
|
const DevVersion = "dev"
|
||||||
|
|
||||||
|
// Unknown is what Commit and CommitDate hold when the build did not
|
||||||
|
// stamp them, and the version script/docker and script/cibuild stamp
|
||||||
|
// when the host has no git checkout.
|
||||||
|
const Unknown = "unknown"
|
||||||
|
|
||||||
// Version is the application version, populated from main().
|
// Version is the application version, populated from main().
|
||||||
var Version = DevVersion //nolint:gochecknoglobals // set via -ldflags at build time
|
var Version = DevVersion //nolint:gochecknoglobals // set via -ldflags at build time
|
||||||
|
|
||||||
// Commit is the git commit hash, populated from main().
|
// Commit is the git commit hash, populated from main().
|
||||||
var Commit = "unknown" //nolint:gochecknoglobals // set via -ldflags at build time
|
var Commit = Unknown //nolint:gochecknoglobals // set via -ldflags at build time
|
||||||
|
|
||||||
// CommitDate is the ISO-8601 date of the commit, populated from main().
|
// CommitDate is the ISO-8601 date of the commit, populated from main().
|
||||||
var CommitDate = "unknown" //nolint:gochecknoglobals // set via -ldflags at build time
|
var CommitDate = Unknown //nolint:gochecknoglobals // set via -ldflags at build time
|
||||||
|
|
||||||
// Author identifies the upstream author of vaultik.
|
// Author identifies the upstream author of vaultik.
|
||||||
const Author = "Jeffrey Paul <sneak@sneak.berlin>"
|
const Author = "Jeffrey Paul <sneak@sneak.berlin>"
|
||||||
@@ -60,18 +65,28 @@ func New() (*Globals, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// IsDevVersion reports whether v names a development build rather than
|
// IsDevVersion reports whether v names a development build rather than
|
||||||
// a release. Both "dev" and "dev-<sha>" (and its "-dirty" variant)
|
// a release. "dev" and goreleaser's snapshot "dev-<sha>" count, and so
|
||||||
// count: a caller that compares against "dev" exactly would treat every
|
// does what `git describe --tags --always --dirty` gives a make or
|
||||||
// commit-stamped development build as a release.
|
// docker build of an untagged commit: the bare short commit, or
|
||||||
|
// tag-N-gHASH on a commit after a tag. Any version ending in "-dirty"
|
||||||
|
// counts, a modified checkout of a tag ("v1.0.0-dirty") included.
|
||||||
|
// A plain tag such as "v1.0.0" or "1.0.0" is a release.
|
||||||
//
|
//
|
||||||
// The empty string counts too. Nothing that knows its version reports
|
// The empty string counts too. Nothing that knows its version reports
|
||||||
// no version, so an empty Version means the stamping failed, and the
|
// no version, so an empty Version means the stamping failed, and the
|
||||||
// safe reading of "we could not establish that this is a release" is
|
// safe reading of "we could not establish that this is a release" is
|
||||||
// that it is not one. The Makefile refuses to build at all in that
|
// that it is not one. The Makefile refuses to build at all in that
|
||||||
// case; this is the second line of defence, for a binary linked by
|
// case; this is the second line of defence, for a binary linked by
|
||||||
// something other than the Makefile.
|
// something other than the Makefile. Unknown counts for the same
|
||||||
|
// reason.
|
||||||
func IsDevVersion(v string) bool {
|
func IsDevVersion(v string) bool {
|
||||||
return v == "" || v == DevVersion || strings.HasPrefix(v, DevVersion+"-")
|
if v == "" || v == Unknown || v == DevVersion ||
|
||||||
|
strings.HasPrefix(v, DevVersion+"-") ||
|
||||||
|
strings.HasSuffix(v, "-dirty") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return regexp.MustCompile(`^(.+-[0-9]+-g)?[0-9a-f]+$`).MatchString(v)
|
||||||
}
|
}
|
||||||
|
|
||||||
// shortCommitLen is the number of commit-hash characters ShortCommit keeps.
|
// shortCommitLen is the number of commit-hash characters ShortCommit keeps.
|
||||||
|
|||||||
@@ -34,10 +34,11 @@ func TestGlobalsNew(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestIsDevVersion covers the boundary that matters: everything
|
// TestIsDevVersion covers the boundary that matters: everything
|
||||||
// script/version and goreleaser's snapshot template can emit for an
|
// script/version, a plain docker build and goreleaser's snapshot
|
||||||
// untagged build must be recognised as a development build, and a real
|
// template can emit for an untagged build must be recognised as a
|
||||||
// tag must not be. A plain equality check against "dev" used to decide
|
// development build, and a real tag must not be. A plain equality check
|
||||||
// this, which classified every commit-stamped dev build as a release.
|
// against "dev" used to decide this, which classified every
|
||||||
|
// commit-stamped dev build as a release.
|
||||||
func TestIsDevVersion(t *testing.T) {
|
func TestIsDevVersion(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -49,8 +50,17 @@ func TestIsDevVersion(t *testing.T) {
|
|||||||
{"dev", true},
|
{"dev", true},
|
||||||
{"dev-b6e4a218a39e", true},
|
{"dev-b6e4a218a39e", true},
|
||||||
{"dev-b6e4a218a39e-dirty", true},
|
{"dev-b6e4a218a39e-dirty", true},
|
||||||
// What a tagged build produces (script/version strips the
|
// What `git describe --tags --always --dirty` produces with no
|
||||||
// leading "v", matching goreleaser's .Version).
|
// tag reachable, and on a commit after a tag.
|
||||||
|
{"877eb2f", true},
|
||||||
|
{"877eb2f-dirty", true},
|
||||||
|
{"v1.0.0-3-g877eb2f", true},
|
||||||
|
{"v1.0.0-3-g877eb2f-dirty", true},
|
||||||
|
{"1.0.0-rc.1-12-g877eb2f", true},
|
||||||
|
// A tagged commit with uncommitted changes is not that tag.
|
||||||
|
{"v1.0.0-dirty", true},
|
||||||
|
// What a tagged build produces (goreleaser's .Version strips
|
||||||
|
// the leading "v"; script/version keeps it).
|
||||||
{"1.0.0", false},
|
{"1.0.0", false},
|
||||||
{"0.1.0", false},
|
{"0.1.0", false},
|
||||||
{"1.0.0-rc.1", false},
|
{"1.0.0-rc.1", false},
|
||||||
@@ -64,6 +74,9 @@ func TestIsDevVersion(t *testing.T) {
|
|||||||
// as one. The Makefile refuses to build when script/version
|
// as one. The Makefile refuses to build when script/version
|
||||||
// yields nothing; this covers a binary linked some other way.
|
// yields nothing; this covers a binary linked some other way.
|
||||||
{"", true},
|
{"", true},
|
||||||
|
// What script/docker and script/cibuild stamp when the host
|
||||||
|
// has no git checkout.
|
||||||
|
{"unknown", true},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
"unicode"
|
"unicode"
|
||||||
|
|
||||||
|
"github.com/dustin/go-humanize"
|
||||||
)
|
)
|
||||||
|
|
||||||
// groupSeparator joins an open group path to an attribute key. This
|
// groupSeparator joins an open group path to an attribute key. This
|
||||||
@@ -304,18 +306,13 @@ func formatDuration(d time.Duration) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// formatBytes formats bytes in a human-readable way
|
// formatBytes formats a byte count with humanize.Bytes, as every command
|
||||||
|
// prints one. humanize.Bytes takes no negative count, so one is printed
|
||||||
|
// as it is.
|
||||||
func formatBytes(b int64) string {
|
func formatBytes(b int64) string {
|
||||||
const unit = 1024
|
if b < 0 {
|
||||||
if b < unit {
|
|
||||||
return fmt.Sprintf("%d B", b)
|
return fmt.Sprintf("%d B", b)
|
||||||
}
|
}
|
||||||
|
|
||||||
div, exp := int64(unit), 0
|
return humanize.Bytes(uint64(b))
|
||||||
for n := b / unit; n >= unit; n /= unit {
|
|
||||||
div *= unit
|
|
||||||
exp++
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "KMGTPE"[exp])
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -200,7 +200,8 @@ func TestTTYHandlerWithGroupQualifiesKeys(t *testing.T) {
|
|||||||
func TestTTYHandlerByteFormattingSurvivesGrouping(t *testing.T) {
|
func TestTTYHandlerByteFormattingSurvivesGrouping(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const oneAndAHalfKiB = 1536
|
// Printed as "1.0 MB", in powers of 1000 like every other byte count.
|
||||||
|
const oneMegabyte = 1_000_000
|
||||||
|
|
||||||
for name, testCase := range map[string]struct {
|
for name, testCase := range map[string]struct {
|
||||||
derive func(*slog.Logger) *slog.Logger
|
derive func(*slog.Logger) *slog.Logger
|
||||||
@@ -223,13 +224,13 @@ func TestTTYHandlerByteFormattingSurvivesGrouping(t *testing.T) {
|
|||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
|
|
||||||
logger := slog.New(log.NewTTYHandler(&buf, debugHandlerOptions()))
|
logger := slog.New(log.NewTTYHandler(&buf, debugHandlerOptions()))
|
||||||
testCase.derive(logger).Info("uploaded", "bytes", oneAndAHalfKiB)
|
testCase.derive(logger).Info("uploaded", "bytes", oneMegabyte)
|
||||||
|
|
||||||
line := ansiEscape.ReplaceAllString(buf.String(), "")
|
line := ansiEscape.ReplaceAllString(buf.String(), "")
|
||||||
|
|
||||||
assert.Contains(t, line, testCase.key+"=1.5 KB",
|
assert.Contains(t, line, testCase.key+"=1.0 MB",
|
||||||
"a byte count must be human-readable however it is qualified")
|
"a byte count must be human-readable however it is qualified")
|
||||||
assert.NotContains(t, line, strconv.Itoa(oneAndAHalfKiB),
|
assert.NotContains(t, line, strconv.Itoa(oneMegabyte),
|
||||||
"the raw number must not survive the formatting")
|
"the raw number must not survive the formatting")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+67
-52
@@ -10,15 +10,18 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrAlreadyRunning indicates another vaultik instance is running.
|
// ErrAlreadyRunning indicates another vaultik instance is running.
|
||||||
var ErrAlreadyRunning = errors.New("another vaultik instance is already running")
|
var ErrAlreadyRunning = errors.New("another vaultik instance is already running")
|
||||||
|
|
||||||
// Lock represents an acquired PID lock.
|
// Lock represents an acquired PID lock: an flock(2) on the PID file,
|
||||||
|
// held while the file stays open. The kernel drops it when the process
|
||||||
|
// exits, however it exits, so a crashed run never leaves the lock held.
|
||||||
type Lock struct {
|
type Lock struct {
|
||||||
path string
|
file *os.File
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -29,10 +32,9 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Acquire attempts to acquire a PID lock in the specified directory.
|
// Acquire attempts to acquire a PID lock in the specified directory.
|
||||||
// If the lock file exists and the process is still running, it returns
|
// If another process holds the lock, it returns ErrAlreadyRunning with
|
||||||
// ErrAlreadyRunning with details about the existing process.
|
// that process's PID. On success, it writes the current PID to the lock
|
||||||
// On success, it writes the current PID to the lock file and returns
|
// file and returns a Lock that must be released with Release().
|
||||||
// a Lock that must be released with Release().
|
|
||||||
func Acquire(lockDir string) (*Lock, error) {
|
func Acquire(lockDir string) (*Lock, error) {
|
||||||
// Ensure lock directory exists
|
// Ensure lock directory exists
|
||||||
err := os.MkdirAll(lockDir, lockDirPerm)
|
err := os.MkdirAll(lockDir, lockDirPerm)
|
||||||
@@ -42,56 +44,82 @@ func Acquire(lockDir string) (*Lock, error) {
|
|||||||
|
|
||||||
lockPath := filepath.Join(lockDir, "vaultik.pid")
|
lockPath := filepath.Join(lockDir, "vaultik.pid")
|
||||||
|
|
||||||
// Check for existing lock
|
// No O_TRUNC: the file may hold the PID of the process that has the
|
||||||
existingPID, err := readPIDFile(lockPath)
|
// lock, which the error below reports.
|
||||||
if err == nil {
|
file, err := os.OpenFile( //nolint:gosec // G304: path is our own lock file
|
||||||
// Lock file exists, check if process is running
|
lockPath, os.O_RDWR|os.O_CREATE, pidFilePerm)
|
||||||
if isProcessRunning(existingPID) {
|
|
||||||
return nil, fmt.Errorf("%w (PID %d)", ErrAlreadyRunning, existingPID)
|
|
||||||
}
|
|
||||||
// Process is not running, stale lock file - we can take over
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write our PID
|
|
||||||
pid := os.Getpid()
|
|
||||||
|
|
||||||
err = os.WriteFile(lockPath, []byte(strconv.Itoa(pid)), pidFilePerm)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("writing PID file: %w", err)
|
return nil, fmt.Errorf("opening PID file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &Lock{path: lockPath}, nil
|
err = unix.Flock(int(file.Fd()), unix.LOCK_EX|unix.LOCK_NB)
|
||||||
|
if err != nil {
|
||||||
|
_ = file.Close()
|
||||||
|
|
||||||
|
if errors.Is(err, unix.EWOULDBLOCK) {
|
||||||
|
return nil, alreadyRunningError(lockPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("locking PID file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = writePID(file)
|
||||||
|
if err != nil {
|
||||||
|
_ = file.Close()
|
||||||
|
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Lock{file: file}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Release removes the PID lock file.
|
// Release empties the PID file and closes it, which drops the lock.
|
||||||
// It is safe to call Release multiple times.
|
// It is safe to call Release multiple times.
|
||||||
func (l *Lock) Release() error {
|
func (l *Lock) Release() error {
|
||||||
if l == nil || l.path == "" {
|
if l == nil || l.file == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify we still own the lock (our PID is in the file)
|
file := l.file
|
||||||
existingPID, err := readPIDFile(l.path)
|
l.file = nil
|
||||||
|
|
||||||
|
// Do not remove the file here. A process that opened it a moment
|
||||||
|
// earlier could then lock the removed file while another creates and
|
||||||
|
// locks a new one, and both would run.
|
||||||
|
truncateErr := file.Truncate(0)
|
||||||
|
closeErr := file.Close()
|
||||||
|
|
||||||
|
return errors.Join(truncateErr, closeErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writePID replaces the contents of the locked PID file with the current
|
||||||
|
// PID.
|
||||||
|
func writePID(file *os.File) error {
|
||||||
|
err := file.Truncate(0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// File already gone or unreadable - that's fine
|
return fmt.Errorf("truncating PID file: %w", err)
|
||||||
return nil //nolint:nilerr // unreadable lock file means nothing to release
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if existingPID != os.Getpid() {
|
_, err = file.WriteAt([]byte(strconv.Itoa(os.Getpid())), 0)
|
||||||
// Someone else wrote to our lock file - don't remove it
|
if err != nil {
|
||||||
return nil
|
return fmt.Errorf("writing PID file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = os.Remove(l.path)
|
|
||||||
if err != nil && !os.IsNotExist(err) {
|
|
||||||
return fmt.Errorf("removing PID file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
l.path = "" // Prevent double-release
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// alreadyRunningError reports that another process holds the lock,
|
||||||
|
// naming its PID when the file holds one. The holder writes its PID just
|
||||||
|
// after it locks, so the file can briefly be empty.
|
||||||
|
func alreadyRunningError(lockPath string) error {
|
||||||
|
pid, err := readPIDFile(lockPath)
|
||||||
|
if err != nil {
|
||||||
|
return ErrAlreadyRunning
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("%w (PID %d)", ErrAlreadyRunning, pid)
|
||||||
|
}
|
||||||
|
|
||||||
// readPIDFile reads and parses the PID from a lock file.
|
// readPIDFile reads and parses the PID from a lock file.
|
||||||
func readPIDFile(path string) (int, error) {
|
func readPIDFile(path string) (int, error) {
|
||||||
data, err := os.ReadFile(path) //nolint:gosec // G304: path is our own lock file
|
data, err := os.ReadFile(path) //nolint:gosec // G304: path is our own lock file
|
||||||
@@ -106,16 +134,3 @@ func readPIDFile(path string) (int, error) {
|
|||||||
|
|
||||||
return pid, nil
|
return pid, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// isProcessRunning checks if a process with the given PID is running.
|
|
||||||
func isProcessRunning(pid int) bool {
|
|
||||||
process, err := os.FindProcess(pid)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// On Unix, FindProcess always succeeds. We need to send signal 0 to check.
|
|
||||||
err = process.Signal(syscall.Signal(0))
|
|
||||||
|
|
||||||
return err == nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -33,9 +34,10 @@ func TestAcquireAndRelease(t *testing.T) {
|
|||||||
err = lock.Release()
|
err = lock.Release()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Verify PID file is gone
|
// Verify PID file is empty
|
||||||
_, err = os.Stat(pidPath)
|
data, err = os.ReadFile(pidPath) //nolint:gosec // G304: test's own temp file
|
||||||
assert.True(t, os.IsNotExist(err))
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAcquireBlocksSecondInstance(t *testing.T) {
|
func TestAcquireBlocksSecondInstance(t *testing.T) {
|
||||||
@@ -55,6 +57,64 @@ func TestAcquireBlocksSecondInstance(t *testing.T) {
|
|||||||
lock2, err := pidlock.Acquire(tmpDir)
|
lock2, err := pidlock.Acquire(tmpDir)
|
||||||
require.ErrorIs(t, err, pidlock.ErrAlreadyRunning)
|
require.ErrorIs(t, err, pidlock.ErrAlreadyRunning)
|
||||||
assert.Nil(t, lock2)
|
assert.Nil(t, lock2)
|
||||||
|
|
||||||
|
// Once the first lock is released, the next Acquire succeeds
|
||||||
|
require.NoError(t, lock1.Release())
|
||||||
|
|
||||||
|
lock3, err := pidlock.Acquire(tmpDir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, lock3.Release())
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConcurrentAcquireAdmitsOne starts many Acquire calls at the same
|
||||||
|
// moment, as two cron entries firing together would, and checks that
|
||||||
|
// exactly one of them gets the lock.
|
||||||
|
func TestConcurrentAcquireAdmitsOne(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const callers = 50
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
start := make(chan struct{})
|
||||||
|
|
||||||
|
var (
|
||||||
|
mu sync.Mutex
|
||||||
|
acquired []*pidlock.Lock
|
||||||
|
failures []error
|
||||||
|
wg sync.WaitGroup
|
||||||
|
)
|
||||||
|
|
||||||
|
for range callers {
|
||||||
|
wg.Go(func() {
|
||||||
|
<-start
|
||||||
|
|
||||||
|
lock, err := pidlock.Acquire(tmpDir)
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
failures = append(failures, err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
acquired = append(acquired, lock)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
close(start)
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
for _, lock := range acquired {
|
||||||
|
require.NoError(t, lock.Release())
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Len(t, acquired, 1, "exactly one caller should hold the lock")
|
||||||
|
|
||||||
|
for _, err := range failures {
|
||||||
|
require.ErrorIs(t, err, pidlock.ErrAlreadyRunning)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAcquireWithStaleLock(t *testing.T) {
|
func TestAcquireWithStaleLock(t *testing.T) {
|
||||||
|
|||||||
+71
-11
@@ -6,7 +6,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/aws/aws-sdk-go-v2/aws"
|
"github.com/aws/aws-sdk-go-v2/aws"
|
||||||
"github.com/aws/aws-sdk-go-v2/config"
|
"github.com/aws/aws-sdk-go-v2/config"
|
||||||
@@ -17,6 +19,13 @@ import (
|
|||||||
"github.com/aws/smithy-go/logging"
|
"github.com/aws/smithy-go/logging"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ErrNoContinuationToken is returned by ListObjects and ListObjectsStream
|
||||||
|
// when the server marks a page of a listing truncated but sends no
|
||||||
|
// continuation token to fetch the rest with, as a server answering V2
|
||||||
|
// listings V1-style does.
|
||||||
|
var ErrNoContinuationToken = errors.New(
|
||||||
|
"server marked the listing truncated but sent no continuation token")
|
||||||
|
|
||||||
// Client wraps the AWS S3 client for vaultik operations.
|
// Client wraps the AWS S3 client for vaultik operations.
|
||||||
// It provides a simplified interface for S3 operations with automatic
|
// It provides a simplified interface for S3 operations with automatic
|
||||||
// prefix handling and connection management. All operations are performed
|
// prefix handling and connection management. All operations are performed
|
||||||
@@ -26,10 +35,14 @@ type Client struct {
|
|||||||
bucket string
|
bucket string
|
||||||
prefix string
|
prefix string
|
||||||
endpoint string
|
endpoint string
|
||||||
|
partSize int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// Config contains S3 client configuration.
|
// Config contains S3 client configuration.
|
||||||
// All fields are required except Prefix, which defaults to an empty string.
|
// All fields are required except Prefix, which defaults to an empty string,
|
||||||
|
// and PartSize, where zero means the SDK default of 5 MiB.
|
||||||
|
// A non-empty Prefix is joined to every key with one "/", whether or not
|
||||||
|
// it ends with one.
|
||||||
// The Endpoint field should include the protocol (http:// or https://).
|
// The Endpoint field should include the protocol (http:// or https://).
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Endpoint string
|
Endpoint string
|
||||||
@@ -38,6 +51,9 @@ type Config struct {
|
|||||||
AccessKeyID string
|
AccessKeyID string
|
||||||
SecretAccessKey string
|
SecretAccessKey string
|
||||||
Region string
|
Region string
|
||||||
|
// PartSize is the size in bytes of each part of a multipart upload.
|
||||||
|
// An upload too large for S3's limit of 10,000 parts gets larger parts.
|
||||||
|
PartSize int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// nopLogger is a logger that discards all output.
|
// nopLogger is a logger that discards all output.
|
||||||
@@ -75,11 +91,19 @@ func NewClient(ctx context.Context, cfg Config) (*Client, error) {
|
|||||||
|
|
||||||
s3Client := s3.NewFromConfig(awsCfg, s3Opts)
|
s3Client := s3.NewFromConfig(awsCfg, s3Opts)
|
||||||
|
|
||||||
|
// Every method below builds a key as prefix + key, so the prefix
|
||||||
|
// must carry its own trailing "/".
|
||||||
|
prefix := strings.TrimRight(cfg.Prefix, "/")
|
||||||
|
if prefix != "" {
|
||||||
|
prefix += "/"
|
||||||
|
}
|
||||||
|
|
||||||
return &Client{
|
return &Client{
|
||||||
s3Client: s3Client,
|
s3Client: s3Client,
|
||||||
bucket: cfg.Bucket,
|
bucket: cfg.Bucket,
|
||||||
prefix: cfg.Prefix,
|
prefix: prefix,
|
||||||
endpoint: cfg.Endpoint,
|
endpoint: cfg.Endpoint,
|
||||||
|
partSize: cfg.PartSize,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,12 +137,9 @@ func (c *Client) PutObjectWithProgress(
|
|||||||
) error {
|
) error {
|
||||||
fullKey := c.prefix + key
|
fullKey := c.prefix + key
|
||||||
|
|
||||||
// uploadPartSize is 10MB for better progress granularity.
|
|
||||||
const uploadPartSize = 10 * 1024 * 1024
|
|
||||||
|
|
||||||
// Create an uploader with the S3 client
|
// Create an uploader with the S3 client
|
||||||
uploader := manager.NewUploader(c.s3Client, func(u *manager.Uploader) {
|
uploader := manager.NewUploader(c.s3Client, func(u *manager.Uploader) {
|
||||||
u.PartSize = uploadPartSize
|
u.PartSize = uploadPartSize(c.partSize, size)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Create a progress reader that tracks upload progress
|
// Create a progress reader that tracks upload progress
|
||||||
@@ -139,6 +160,21 @@ func (c *Client) PutObjectWithProgress(
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// uploadPartSize returns the part size for an upload of size bytes: the
|
||||||
|
// configured part size (the SDK default when zero), raised where needed so
|
||||||
|
// the upload fits in S3's limit of 10,000 parts. The uploader cannot raise
|
||||||
|
// it itself, because it cannot seek the progress reader to learn its size.
|
||||||
|
func uploadPartSize(configured, size int64) int64 {
|
||||||
|
if configured == 0 {
|
||||||
|
configured = manager.DefaultUploadPartSize
|
||||||
|
}
|
||||||
|
|
||||||
|
maxParts := int64(manager.MaxUploadParts)
|
||||||
|
smallestThatFits := (size + maxParts - 1) / maxParts // rounded up
|
||||||
|
|
||||||
|
return max(configured, smallestThatFits)
|
||||||
|
}
|
||||||
|
|
||||||
// GetObject downloads an object from S3 with the specified key.
|
// GetObject downloads an object from S3 with the specified key.
|
||||||
// The key is automatically prefixed with the configured prefix.
|
// The key is automatically prefixed with the configured prefix.
|
||||||
// Returns a ReadCloser containing the object data. The caller must
|
// Returns a ReadCloser containing the object data. The caller must
|
||||||
@@ -186,7 +222,7 @@ func (c *Client) ListObjects(ctx context.Context, prefix string) ([]string, erro
|
|||||||
})
|
})
|
||||||
|
|
||||||
for paginator.HasMorePages() {
|
for paginator.HasMorePages() {
|
||||||
page, err := paginator.NextPage(ctx)
|
page, err := nextListPage(ctx, paginator)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -207,6 +243,26 @@ func (c *Client) ListObjects(ctx context.Context, prefix string) ([]string, erro
|
|||||||
return keys, nil
|
return keys, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// nextListPage fetches the next page of a listing. The SDK paginator takes
|
||||||
|
// a page marked truncated without a continuation token for the last page
|
||||||
|
// and stops without an error, so such a page is ErrNoContinuationToken
|
||||||
|
// here instead.
|
||||||
|
func nextListPage(
|
||||||
|
ctx context.Context, paginator *s3.ListObjectsV2Paginator,
|
||||||
|
) (*s3.ListObjectsV2Output, error) {
|
||||||
|
page, err := paginator.NextPage(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
truncated := aws.ToBool(page.IsTruncated)
|
||||||
|
if truncated && aws.ToString(page.NextContinuationToken) == "" {
|
||||||
|
return nil, ErrNoContinuationToken
|
||||||
|
}
|
||||||
|
|
||||||
|
return page, nil
|
||||||
|
}
|
||||||
|
|
||||||
// HeadObject checks if an object exists in S3.
|
// HeadObject checks if an object exists in S3.
|
||||||
// Returns true if the object exists, false otherwise.
|
// Returns true if the object exists, false otherwise.
|
||||||
// The key is automatically prefixed with the configured prefix.
|
// The key is automatically prefixed with the configured prefix.
|
||||||
@@ -247,7 +303,10 @@ func IsNotFound(err error) bool {
|
|||||||
type ObjectInfo struct {
|
type ObjectInfo struct {
|
||||||
Key string
|
Key string
|
||||||
Size int64
|
Size int64
|
||||||
Err error
|
// ModTime is the object's LastModified time. Only ListObjectsStream
|
||||||
|
// sets it.
|
||||||
|
ModTime time.Time
|
||||||
|
Err error
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListObjectsStream lists objects with the given prefix and returns a channel.
|
// ListObjectsStream lists objects with the given prefix and returns a channel.
|
||||||
@@ -272,7 +331,7 @@ func (c *Client) ListObjectsStream(
|
|||||||
})
|
})
|
||||||
|
|
||||||
for paginator.HasMorePages() {
|
for paginator.HasMorePages() {
|
||||||
page, err := paginator.NextPage(ctx)
|
page, err := nextListPage(ctx, paginator)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ch <- ObjectInfo{Err: err}
|
ch <- ObjectInfo{Err: err}
|
||||||
|
|
||||||
@@ -288,8 +347,9 @@ func (c *Client) ListObjectsStream(
|
|||||||
}
|
}
|
||||||
|
|
||||||
ch <- ObjectInfo{
|
ch <- ObjectInfo{
|
||||||
Key: key,
|
Key: key,
|
||||||
Size: *obj.Size,
|
Size: *obj.Size,
|
||||||
|
ModTime: aws.ToTime(obj.LastModified),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package s3
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// TestUploadPartSize checks that an upload too large for 10,000 parts of the
|
||||||
|
// configured size gets parts just large enough to fit in 10,000.
|
||||||
|
func TestUploadPartSize(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const mib = 1024 * 1024
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
configured int64
|
||||||
|
size int64
|
||||||
|
want int64
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "an upload that fits keeps the configured size",
|
||||||
|
configured: 5 * mib,
|
||||||
|
size: 10 * 1024 * mib,
|
||||||
|
want: 5 * mib,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "exactly 10,000 parts keeps the configured size",
|
||||||
|
configured: 6 * mib,
|
||||||
|
size: 10_000 * 6 * mib,
|
||||||
|
want: 6 * mib,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "one byte more than 10,000 parts adds a byte to each",
|
||||||
|
configured: 6 * mib,
|
||||||
|
size: 10_000*6*mib + 1,
|
||||||
|
want: 6*mib + 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "zero means the SDK default of 5MiB",
|
||||||
|
configured: 0,
|
||||||
|
size: 1,
|
||||||
|
want: 5 * mib,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got := uploadPartSize(tt.configured, tt.size)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("uploadPartSize(%d, %d) = %d, want %d",
|
||||||
|
tt.configured, tt.size, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ func provideClient(lc fx.Lifecycle, cfg *config.Config) (*Client, error) {
|
|||||||
AccessKeyID: cfg.S3.AccessKeyID,
|
AccessKeyID: cfg.S3.AccessKeyID,
|
||||||
SecretAccessKey: cfg.S3.SecretAccessKey,
|
SecretAccessKey: cfg.S3.SecretAccessKey,
|
||||||
Region: cfg.S3.Region,
|
Region: cfg.S3.Region,
|
||||||
|
PartSize: cfg.S3.PartSize.Int64(),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ func verifyBackupFiles(
|
|||||||
) {
|
) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
files, err := repos.Files.ListByPrefix(ctx, "")
|
files, err := repos.Files.ListAll(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to list files: %v", err)
|
t.Fatalf("Failed to list files: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
//nolint:testpackage // exercises the unexported cleanupBlobTempFile
|
||||||
|
package snapshot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
"sneak.berlin/go/vaultik/internal/blob"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A blob temp file that cannot be closed or removed must make the cleanup
|
||||||
|
// return an error, not end the process.
|
||||||
|
func TestCleanupBlobTempFileReturnsErrors(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
fs := afero.NewOsFs()
|
||||||
|
s := &Scanner{fs: fs}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
// prepare runs before the cleanup and makes its close or remove
|
||||||
|
// fail.
|
||||||
|
prepare func(tempFile afero.File) error
|
||||||
|
want error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "close fails",
|
||||||
|
prepare: func(tempFile afero.File) error { return tempFile.Close() },
|
||||||
|
want: os.ErrClosed,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "remove fails",
|
||||||
|
prepare: func(tempFile afero.File) error {
|
||||||
|
return fs.Remove(tempFile.Name())
|
||||||
|
},
|
||||||
|
want: os.ErrNotExist,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tempFile, err := afero.TempFile(fs, t.TempDir(), "blob-*.tmp")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = tt.prepare(tempFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = s.cleanupBlobTempFile(&blob.WithReader{TempFile: tempFile})
|
||||||
|
if !errors.Is(err, tt.want) {
|
||||||
|
t.Fatalf("expected an error wrapping %v, got: %v", tt.want, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -531,3 +531,101 @@ func TestExcludePatterns_UnanchoredPatternFile(t *testing.T) {
|
|||||||
require.Equal(t, 1, result.FilesScanned,
|
require.Equal(t, 1, result.FilesScanned,
|
||||||
"Unanchored pattern for file should match anywhere")
|
"Unanchored pattern for file should match anywhere")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExcludePatterns_DoubleStarDirectoryAtEveryDepth(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// As in gitignore, a leading **/ also matches directly under the source dir
|
||||||
|
fs := setupAnchoredTestFS(t)
|
||||||
|
|
||||||
|
scanner, repos, cleanup := createTestScanner(t, fs, []string{"**/projectname"})
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
createSnapshotRecord(ctx, t, repos, "test-snapshot")
|
||||||
|
|
||||||
|
result, err := scanner.Scan(ctx, "/backup", "test-snapshot")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// **/projectname should exclude BOTH:
|
||||||
|
// - /backup/projectname/file.txt
|
||||||
|
// - /backup/otherproject/projectname/file.txt
|
||||||
|
// Total files: 4, excluded: 2
|
||||||
|
require.Equal(t, 2, result.FilesScanned,
|
||||||
|
"**/ directory pattern should match at the top level and nested")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExcludePatterns_DoubleStarFileAtEveryDepth(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// As in gitignore, a leading **/ also matches directly under the source dir
|
||||||
|
fs := setupAnchoredTestFS(t)
|
||||||
|
|
||||||
|
scanner, repos, cleanup := createTestScanner(t, fs, []string{"**/*.txt"})
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
createSnapshotRecord(ctx, t, repos, "test-snapshot")
|
||||||
|
|
||||||
|
result, err := scanner.Scan(ctx, "/backup", "test-snapshot")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// **/*.txt should exclude ALL .txt files:
|
||||||
|
// - /backup/file.txt
|
||||||
|
// - /backup/projectname/file.txt
|
||||||
|
// - /backup/otherproject/projectname/file.txt
|
||||||
|
// Total files: 4, excluded: 3
|
||||||
|
require.Equal(t, 1, result.FilesScanned,
|
||||||
|
"**/ file pattern should match at the top level and nested")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExcludePatterns_AnchoredDoubleStarAtEveryDepth(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// As in gitignore, /**/ also matches directly under the source dir
|
||||||
|
fs := setupAnchoredTestFS(t)
|
||||||
|
|
||||||
|
scanner, repos, cleanup := createTestScanner(t, fs, []string{"/**/projectname"})
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
createSnapshotRecord(ctx, t, repos, "test-snapshot")
|
||||||
|
|
||||||
|
result, err := scanner.Scan(ctx, "/backup", "test-snapshot")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// /**/projectname should exclude BOTH:
|
||||||
|
// - /backup/projectname/file.txt
|
||||||
|
// - /backup/otherproject/projectname/file.txt
|
||||||
|
// Total files: 4, excluded: 2
|
||||||
|
require.Equal(t, 2, result.FilesScanned,
|
||||||
|
"/**/ pattern should match at the top level and nested")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExcludePatterns_DoubleStarInMiddleMatchesZeroDirectories(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// As in gitignore, a/**/b matches a/b as well as a/c/b. The last part
|
||||||
|
// is a glob: with a plain name the glob library already matched a/b.
|
||||||
|
fs := &afero.MemMapFs{}
|
||||||
|
files := []string{"/backup/a/x.log", "/backup/a/c/y.log", "/backup/a/c/kept.txt"}
|
||||||
|
|
||||||
|
for _, path := range files {
|
||||||
|
require.NoError(t, fs.MkdirAll(filepath.Dir(path), 0755))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, path, []byte(path), 0644))
|
||||||
|
}
|
||||||
|
|
||||||
|
scanner, repos, cleanup := createTestScanner(t, fs, []string{"a/**/*.log"})
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
createSnapshotRecord(ctx, t, repos, "test-snapshot")
|
||||||
|
|
||||||
|
result, err := scanner.Scan(ctx, "/backup", "test-snapshot")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// a/**/*.log should exclude /backup/a/x.log and /backup/a/c/y.log
|
||||||
|
// Total files: 3, excluded: 2
|
||||||
|
require.Equal(t, 1, result.FilesScanned,
|
||||||
|
"a/**/*.log should match a/x.log as well as a/c/y.log")
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,40 +1,48 @@
|
|||||||
//nolint:testpackage // exercises the unexported copyFile helper
|
//nolint:testpackage // exercises the unexported copyDatabase helper
|
||||||
package snapshot
|
package snapshot
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"syscall"
|
"syscall"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
|
"sneak.berlin/go/vaultik/internal/database"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestCopyFileExportCopyMode verifies that the exported snapshot database
|
// TestCopyDatabaseExportCopyMode verifies that the exported snapshot
|
||||||
// copy is created owner-only (0600), even under a lenient 022 umask that
|
// database copy is created owner-only (0600), even under a lenient 022
|
||||||
// would otherwise leave a fresh file world-readable.
|
// umask that would otherwise leave a fresh file world-readable.
|
||||||
//
|
//
|
||||||
//nolint:paralleltest // syscall.Umask is process-global; parallel tests would clash
|
//nolint:paralleltest // syscall.Umask is process-global; parallel tests would clash
|
||||||
func TestCopyFileExportCopyMode(t *testing.T) {
|
func TestCopyDatabaseExportCopyMode(t *testing.T) {
|
||||||
restore := syscall.Umask(0o022)
|
restore := syscall.Umask(0o022)
|
||||||
defer syscall.Umask(restore)
|
defer syscall.Umask(restore)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
|
|
||||||
src := filepath.Join(dir, "index.sqlite")
|
src := filepath.Join(dir, "index.sqlite")
|
||||||
|
|
||||||
err := os.WriteFile(src, []byte("index data"), 0o600)
|
db, err := database.New(ctx, src)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("creating source index: %v", err)
|
t.Fatalf("creating source index: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err = db.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("closing source index: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
dst := filepath.Join(dir, "snapshot.db")
|
dst := filepath.Join(dir, "snapshot.db")
|
||||||
|
|
||||||
sm := &SnapshotManager{fs: afero.NewOsFs()}
|
sm := &SnapshotManager{fs: afero.NewOsFs()}
|
||||||
|
|
||||||
err = sm.copyFile(src, dst)
|
err = sm.copyDatabase(ctx, src, dst)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("copyFile: %v", err)
|
t.Fatalf("copyDatabase: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
info, err := os.Stat(dst)
|
info, err := os.Stat(dst)
|
||||||
|
|||||||
+15
-11
@@ -16,6 +16,9 @@ type ScannerParams struct {
|
|||||||
Fs afero.Fs
|
Fs afero.Fs
|
||||||
Exclude []string // Exclude patterns (combined global + snapshot-specific)
|
Exclude []string // Exclude patterns (combined global + snapshot-specific)
|
||||||
SkipErrors bool // Skip file read errors (log loudly but continue)
|
SkipErrors bool // Skip file read errors (log loudly but continue)
|
||||||
|
// PreviousSnapshotID is the most recent completed snapshot of the
|
||||||
|
// same name, "" for none.
|
||||||
|
PreviousSnapshotID string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Module exports backup functionality as an fx module.
|
// Module exports backup functionality as an fx module.
|
||||||
@@ -44,17 +47,18 @@ func provideScannerFactory(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return NewScanner(ScannerConfig{
|
return NewScanner(ScannerConfig{
|
||||||
FS: params.Fs,
|
FS: params.Fs,
|
||||||
ChunkSize: cfg.ChunkSize.Int64(),
|
ChunkSize: cfg.ChunkSize.Int64(),
|
||||||
Repositories: repos,
|
Repositories: repos,
|
||||||
Storage: storer,
|
Storage: storer,
|
||||||
MaxBlobSize: cfg.BlobSizeLimit.Int64(),
|
MaxBlobSize: cfg.BlobSizeLimit.Int64(),
|
||||||
CompressionLevel: cfg.CompressionLevel,
|
CompressionLevel: cfg.CompressionLevel,
|
||||||
AgeRecipients: cfg.AgeRecipients,
|
AgeRecipients: cfg.AgeRecipients,
|
||||||
EnableProgress: params.EnableProgress,
|
EnableProgress: params.EnableProgress,
|
||||||
UI: params.UI,
|
UI: params.UI,
|
||||||
Exclude: excludes,
|
Exclude: excludes,
|
||||||
SkipErrors: params.SkipErrors,
|
SkipErrors: params.SkipErrors,
|
||||||
|
PreviousSnapshotID: params.PreviousSnapshotID,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,24 +56,27 @@ const (
|
|||||||
|
|
||||||
// ProgressStats holds atomic counters for progress tracking
|
// ProgressStats holds atomic counters for progress tracking
|
||||||
type ProgressStats struct {
|
type ProgressStats struct {
|
||||||
FilesScanned atomic.Int64 // Total files seen during scan (includes skipped)
|
FilesScanned atomic.Int64 // Total files seen during scan (includes skipped)
|
||||||
FilesProcessed atomic.Int64 // Files actually processed in phase 2
|
FilesProcessed atomic.Int64 // Files actually processed in phase 2
|
||||||
FilesSkipped atomic.Int64 // Files skipped due to no changes
|
FilesSkipped atomic.Int64 // Files skipped due to no changes
|
||||||
BytesScanned atomic.Int64 // Bytes from new/changed files only
|
BytesScanned atomic.Int64 // Bytes from new/changed files only
|
||||||
BytesSkipped atomic.Int64 // Bytes from unchanged files
|
BytesSkipped atomic.Int64 // Bytes from unchanged files
|
||||||
BytesProcessed atomic.Int64 // Actual bytes processed (for ETA calculation)
|
BytesProcessed atomic.Int64 // Actual bytes processed (for ETA calculation)
|
||||||
ChunksCreated atomic.Int64
|
ChunksCreated atomic.Int64
|
||||||
BlobsCreated atomic.Int64
|
BlobsCreated atomic.Int64
|
||||||
BlobsUploaded atomic.Int64
|
BlobsUploaded atomic.Int64
|
||||||
BytesUploaded atomic.Int64
|
BytesUploaded atomic.Int64
|
||||||
UploadDurationMs atomic.Int64 // Total milliseconds spent uploading
|
CurrentFile atomic.Value // stores string
|
||||||
CurrentFile atomic.Value // stores string
|
TotalSize atomic.Int64 // Size to process in the paths scanned so far
|
||||||
TotalSize atomic.Int64 // Total size to process (set after scan phase)
|
TotalFiles atomic.Int64 // Files to process in the paths scanned so far
|
||||||
TotalFiles atomic.Int64 // Total files to process in phase 2
|
StartTime time.Time
|
||||||
ProcessStartTime atomic.Value // stores time.Time when processing starts
|
mu sync.RWMutex
|
||||||
StartTime time.Time
|
lastDetailTime time.Time
|
||||||
mu sync.RWMutex
|
|
||||||
lastDetailTime time.Time
|
// Guarded by mu: when the last scan phase ended, and
|
||||||
|
// BytesProcessed at that moment.
|
||||||
|
processStartTime time.Time
|
||||||
|
processStartBytes int64
|
||||||
|
|
||||||
// Upload tracking
|
// Upload tracking
|
||||||
CurrentUpload atomic.Value // stores *UploadInfo
|
CurrentUpload atomic.Value // stores *UploadInfo
|
||||||
@@ -149,10 +152,36 @@ func (pr *ProgressReporter) GetStats() *ProgressStats {
|
|||||||
return pr.stats
|
return pr.stats
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetTotalSize sets the total size to process (after scan phase)
|
// AddTotalSize adds the size one path of the snapshot has to process to
|
||||||
func (pr *ProgressReporter) SetTotalSize(size int64) {
|
// the total, once that path's scan phase is done, and starts measuring
|
||||||
pr.stats.TotalSize.Store(size)
|
// the processing rate again. The processed counts run across every path,
|
||||||
pr.stats.ProcessStartTime.Store(time.Now().UTC())
|
// so the total does too. Restarting the rate keeps a path's scan phase,
|
||||||
|
// which processes nothing, out of the rate while that path is processed.
|
||||||
|
// During a later path's scan phase the rate is still the previous
|
||||||
|
// path's, and falls as that scan goes on.
|
||||||
|
func (pr *ProgressReporter) AddTotalSize(size int64) {
|
||||||
|
pr.stats.TotalSize.Add(size)
|
||||||
|
|
||||||
|
pr.stats.mu.Lock()
|
||||||
|
defer pr.stats.mu.Unlock()
|
||||||
|
|
||||||
|
pr.stats.processStartTime = time.Now().UTC()
|
||||||
|
pr.stats.processStartBytes = pr.stats.BytesProcessed.Load()
|
||||||
|
}
|
||||||
|
|
||||||
|
// processRate returns the bytes processed per second since the last scan
|
||||||
|
// phase ended, or 0 before the first path's scan phase is done.
|
||||||
|
func (s *ProgressStats) processRate() float64 {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
if s.processStartTime.IsZero() {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
processed := s.BytesProcessed.Load() - s.processStartBytes
|
||||||
|
|
||||||
|
return float64(processed) / time.Since(s.processStartTime).Seconds()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper functions
|
// Helper functions
|
||||||
@@ -231,9 +260,6 @@ func (pr *ProgressReporter) ReportUploadComplete(
|
|||||||
// Clear current upload
|
// Clear current upload
|
||||||
pr.stats.CurrentUpload.Store((*UploadInfo)(nil))
|
pr.stats.CurrentUpload.Store((*UploadInfo)(nil))
|
||||||
|
|
||||||
// Add to total upload duration
|
|
||||||
pr.stats.UploadDurationMs.Add(duration.Milliseconds())
|
|
||||||
|
|
||||||
// Calculate speed
|
// Calculate speed
|
||||||
if duration < time.Millisecond {
|
if duration < time.Millisecond {
|
||||||
duration = time.Millisecond
|
duration = time.Millisecond
|
||||||
@@ -361,19 +387,12 @@ func (pr *ProgressReporter) printSummaryStatus() {
|
|||||||
// Calculate ETA if we have total size and are processing
|
// Calculate ETA if we have total size and are processing
|
||||||
etaStr := ""
|
etaStr := ""
|
||||||
|
|
||||||
if totalSize > 0 && bytesProcessed > 0 {
|
processRate := pr.stats.processRate()
|
||||||
processStart, ok := pr.stats.ProcessStartTime.Load().(time.Time)
|
if totalSize > 0 && processRate > 0 {
|
||||||
if ok && !processStart.IsZero() {
|
remainingBytes := totalSize - bytesProcessed
|
||||||
processElapsed := time.Since(processStart)
|
remainingSeconds := float64(remainingBytes) / processRate
|
||||||
|
eta := time.Duration(remainingSeconds * float64(time.Second))
|
||||||
rate := float64(bytesProcessed) / processElapsed.Seconds()
|
etaStr = " | ETA: " + formatDuration(eta)
|
||||||
if rate > 0 {
|
|
||||||
remainingBytes := totalSize - bytesProcessed
|
|
||||||
remainingSeconds := float64(remainingBytes) / rate
|
|
||||||
eta := time.Duration(remainingSeconds * float64(time.Second))
|
|
||||||
etaStr = " | ETA: " + formatDuration(eta)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
rate := float64(bytesScanned+bytesSkipped) / elapsed.Seconds()
|
rate := float64(bytesScanned+bytesSkipped) / elapsed.Seconds()
|
||||||
@@ -425,25 +444,18 @@ func (pr *ProgressReporter) printDetailedStatus() {
|
|||||||
log.Info("Elapsed time", "duration", formatDuration(elapsed))
|
log.Info("Elapsed time", "duration", formatDuration(elapsed))
|
||||||
|
|
||||||
// Calculate and show ETA if we have data
|
// Calculate and show ETA if we have data
|
||||||
if totalSize > 0 && bytesProcessed > 0 {
|
processRate := pr.stats.processRate()
|
||||||
processStart, ok := pr.stats.ProcessStartTime.Load().(time.Time)
|
if totalSize > 0 && processRate > 0 {
|
||||||
if ok && !processStart.IsZero() {
|
remainingBytes := totalSize - bytesProcessed
|
||||||
processElapsed := time.Since(processStart)
|
remainingSeconds := float64(remainingBytes) / processRate
|
||||||
|
eta := time.Duration(remainingSeconds * float64(time.Second))
|
||||||
processRate := float64(bytesProcessed) / processElapsed.Seconds()
|
percentComplete := float64(bytesProcessed) / float64(totalSize) * percentScale
|
||||||
if processRate > 0 {
|
log.Info("Overall progress",
|
||||||
remainingBytes := totalSize - bytesProcessed
|
"percent", fmt.Sprintf("%.1f%%", percentComplete),
|
||||||
remainingSeconds := float64(remainingBytes) / processRate
|
"processed", humanize.Bytes(safeUint64(bytesProcessed)),
|
||||||
eta := time.Duration(remainingSeconds * float64(time.Second))
|
"total", humanize.Bytes(safeUint64(totalSize)),
|
||||||
percentComplete := float64(bytesProcessed) / float64(totalSize) * percentScale
|
"rate", humanize.Bytes(uint64(processRate))+"/s",
|
||||||
log.Info("Overall progress",
|
"eta", formatDuration(eta))
|
||||||
"percent", fmt.Sprintf("%.1f%%", percentComplete),
|
|
||||||
"processed", humanize.Bytes(safeUint64(bytesProcessed)),
|
|
||||||
"total", humanize.Bytes(safeUint64(totalSize)),
|
|
||||||
"rate", humanize.Bytes(uint64(processRate))+"/s",
|
|
||||||
"eta", formatDuration(eta))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Info("Files processed",
|
log.Info("Files processed",
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
//nolint:testpackage // exercises the unexported processRate helper
|
||||||
|
package snapshot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestProcessRateNotLoweredByLaterScanPhase feeds the reporter two paths
|
||||||
|
// that each take 10 seconds to process, the second after a 30-second scan
|
||||||
|
// phase. That scan phase processes nothing, so the rate while the second
|
||||||
|
// path is processed must be that path's own.
|
||||||
|
func TestProcessRateNotLoweredByLaterScanPhase(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const (
|
||||||
|
pathSize = 1000
|
||||||
|
processingTime = 10 * time.Second
|
||||||
|
scanTime = 30 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// Never started, but Stop releases its tickers and signal handler.
|
||||||
|
progress := NewProgressReporter()
|
||||||
|
defer progress.Stop()
|
||||||
|
|
||||||
|
stats := progress.GetStats()
|
||||||
|
|
||||||
|
// Moves the processing start time back instead of sleeping.
|
||||||
|
elapse := func(d time.Duration) {
|
||||||
|
stats.mu.Lock()
|
||||||
|
defer stats.mu.Unlock()
|
||||||
|
|
||||||
|
stats.processStartTime = stats.processStartTime.Add(-d)
|
||||||
|
}
|
||||||
|
|
||||||
|
progress.AddTotalSize(pathSize)
|
||||||
|
stats.BytesProcessed.Add(pathSize)
|
||||||
|
elapse(processingTime)
|
||||||
|
|
||||||
|
elapse(scanTime)
|
||||||
|
progress.AddTotalSize(pathSize)
|
||||||
|
stats.BytesProcessed.Add(pathSize)
|
||||||
|
elapse(processingTime)
|
||||||
|
|
||||||
|
want := pathSize / processingTime.Seconds()
|
||||||
|
got := stats.processRate()
|
||||||
|
|
||||||
|
// The test's own run time adds to the elapsed time, so got is a hair
|
||||||
|
// under want.
|
||||||
|
if got > want || got < want*0.99 {
|
||||||
|
t.Errorf("rate while the second path is processed is %.1f bytes/s, "+
|
||||||
|
"want %.1f", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package snapshot_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
"sneak.berlin/go/vaultik/internal/database"
|
||||||
|
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestProgressPercentWithSecondPathSmaller backs up two paths with one
|
||||||
|
// scanner, as a snapshot with two paths does, the second path smaller
|
||||||
|
// than the first. The progress line divides the bytes processed by the
|
||||||
|
// total size, so both must count both paths to stay within 100%.
|
||||||
|
func TestProgressPercentWithSecondPathSmaller(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
files := map[string]string{
|
||||||
|
"/large/one.txt": strings.Repeat("1", 4000),
|
||||||
|
"/large/two.txt": strings.Repeat("2", 4000),
|
||||||
|
"/small/three.txt": strings.Repeat("3", 1000),
|
||||||
|
}
|
||||||
|
|
||||||
|
for path, content := range files {
|
||||||
|
err := fs.MkdirAll(filepath.Dir(path), 0755)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("mkdir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = afero.WriteFile(fs, path, []byte(content), 0644)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write %s: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := database.NewTestDB()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create test db: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
cerr := db.Close()
|
||||||
|
if cerr != nil {
|
||||||
|
t.Errorf("close db: %v", cerr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
repos := database.NewRepositories(db)
|
||||||
|
|
||||||
|
scanner := snapshot.NewScanner(snapshot.ScannerConfig{
|
||||||
|
FS: fs,
|
||||||
|
ChunkSize: int64(1024 * 16),
|
||||||
|
Repositories: repos,
|
||||||
|
MaxBlobSize: int64(1024 * 1024),
|
||||||
|
CompressionLevel: 3,
|
||||||
|
AgeRecipients: []string{testAgePublicKey},
|
||||||
|
EnableProgress: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Never started, but Stop releases its tickers and signal handler.
|
||||||
|
progress := scanner.GetProgress()
|
||||||
|
defer progress.Stop()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
snapshotID := "test-snapshot-progress"
|
||||||
|
createTestSnapshotRecord(ctx, t, repos, snapshotID)
|
||||||
|
|
||||||
|
for _, path := range []string{"/large", "/small"} {
|
||||||
|
_, err := scanner.Scan(ctx, path, snapshotID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("scanning %s: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stats := progress.GetStats()
|
||||||
|
|
||||||
|
// With both paths counted, the percentage must not exceed 100%.
|
||||||
|
percent := float64(stats.BytesProcessed.Load()) /
|
||||||
|
float64(stats.TotalSize.Load()) * 100
|
||||||
|
if percent > 100 {
|
||||||
|
t.Errorf("progress after both paths is %.1f%%, want at most 100%%",
|
||||||
|
percent)
|
||||||
|
}
|
||||||
|
|
||||||
|
if stats.FilesProcessed.Load() != stats.TotalFiles.Load() {
|
||||||
|
t.Errorf("progress after both paths is %d of %d files, want all",
|
||||||
|
stats.FilesProcessed.Load(), stats.TotalFiles.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
+482
-255
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
|||||||
|
//nolint:testpackage // exercises the unexported countNonDirectories helper
|
||||||
|
package snapshot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/vaultik/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The walk counts regular files and symlinks, not directories, so the
|
||||||
|
// estimate of its count made from the local index's records leaves the
|
||||||
|
// directories out. See https://git.eeqj.de/sneak/vaultik/issues/305.
|
||||||
|
func TestCountNonDirectories(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
known := map[string]*database.File{
|
||||||
|
"/src": {Mode: uint32(os.ModeDir | 0o755)},
|
||||||
|
"/src/sub": {Mode: uint32(os.ModeDir | 0o755)},
|
||||||
|
"/src/a.txt": {Mode: 0o644},
|
||||||
|
"/src/link": {Mode: uint32(os.ModeSymlink | 0o777), LinkTarget: "a.txt"},
|
||||||
|
}
|
||||||
|
|
||||||
|
got := countNonDirectories(known)
|
||||||
|
if got != 2 {
|
||||||
|
t.Errorf("countNonDirectories = %d, want 2", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,7 +71,7 @@ func verifySimpleScanDatabase(
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
// Verify files in database - includes regular files and directories
|
// Verify files in database - includes regular files and directories
|
||||||
files, err := repos.Files.ListByPrefix(ctx, "/source")
|
files, err := repos.Files.ListUnderPath(ctx, "/source")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to list files: %v", err)
|
t.Fatalf("failed to list files: %v", err)
|
||||||
}
|
}
|
||||||
@@ -307,3 +307,80 @@ func TestScannerLargeFile(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestScannerRecordsOwnership backs up real files on disk and checks that
|
||||||
|
// the uid and gid of a file, a directory and a symlink are recorded.
|
||||||
|
// When the tests run as root both sides are 0, so only a run as another
|
||||||
|
// user can catch ownership recorded as 0.
|
||||||
|
func TestScannerRecordsOwnership(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
sourceDir := t.TempDir()
|
||||||
|
filePath := filepath.Join(sourceDir, "file.txt")
|
||||||
|
dirPath := filepath.Join(sourceDir, "subdir")
|
||||||
|
linkPath := filepath.Join(sourceDir, "link")
|
||||||
|
|
||||||
|
err := os.WriteFile(filePath, []byte("owned"), 0o600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = os.Mkdir(dirPath, 0o700)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = os.Symlink("file.txt", linkPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := database.NewTestDB()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create test database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
err := db.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("failed to close database: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
repos := database.NewRepositories(db)
|
||||||
|
|
||||||
|
scanner := snapshot.NewScanner(snapshot.ScannerConfig{
|
||||||
|
FS: afero.NewOsFs(),
|
||||||
|
ChunkSize: int64(1024 * 16),
|
||||||
|
Repositories: repos,
|
||||||
|
MaxBlobSize: int64(1024 * 1024),
|
||||||
|
CompressionLevel: 3,
|
||||||
|
AgeRecipients: []string{testAgePublicKey},
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
snapshotID := "test-snapshot-ownership"
|
||||||
|
|
||||||
|
createTestSnapshotRecord(ctx, t, repos, snapshotID)
|
||||||
|
|
||||||
|
_, err = scanner.Scan(ctx, sourceDir, snapshotID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("scan failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, path := range []string{filePath, dirPath, linkPath} {
|
||||||
|
file, err := repos.Files.GetByPath(ctx, path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to get %s: %v", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if file == nil {
|
||||||
|
t.Fatalf("%s was not recorded", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
if int(file.UID) != os.Getuid() || int(file.GID) != os.Getgid() {
|
||||||
|
t.Errorf("%s recorded as uid %d gid %d, want uid %d gid %d",
|
||||||
|
path, file.UID, file.GID, os.Getuid(), os.Getgid())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package snapshot_test
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -13,6 +14,7 @@ import (
|
|||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"sneak.berlin/go/vaultik/internal/database"
|
"sneak.berlin/go/vaultik/internal/database"
|
||||||
"sneak.berlin/go/vaultik/internal/snapshot"
|
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||||
|
"sneak.berlin/go/vaultik/internal/ui"
|
||||||
)
|
)
|
||||||
|
|
||||||
// errSimTempFail is the one-time temp-file creation failure blobTempFailFs
|
// errSimTempFail is the one-time temp-file creation failure blobTempFailFs
|
||||||
@@ -80,6 +82,56 @@ func (f *readFailFs) Open(name string) (afero.File, error) {
|
|||||||
return file, nil
|
return file, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// cancelOnOpenFs cancels the run when the scanner opens the target file to
|
||||||
|
// back it up, as Ctrl-C partway through processing would, and records each
|
||||||
|
// file opened after that.
|
||||||
|
type cancelOnOpenFs struct {
|
||||||
|
afero.Fs
|
||||||
|
|
||||||
|
target string
|
||||||
|
cancel context.CancelFunc
|
||||||
|
cancelled bool
|
||||||
|
openedAfterCancel []string
|
||||||
|
}
|
||||||
|
|
||||||
|
//nolint:ireturn // afero.Fs.Open is defined to return the interface.
|
||||||
|
func (f *cancelOnOpenFs) Open(name string) (afero.File, error) {
|
||||||
|
if f.cancelled {
|
||||||
|
f.openedAfterCancel = append(f.openedAfterCancel, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if name == f.target {
|
||||||
|
f.cancel()
|
||||||
|
f.cancelled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return f.Fs.Open(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// linkRemovedAfterLstatFs is the real filesystem, except that the symlink at
|
||||||
|
// target is removed right after the walk lstats it, as happens when a link is
|
||||||
|
// deleted during a backup. The scanner's readlink of it then fails.
|
||||||
|
type linkRemovedAfterLstatFs struct {
|
||||||
|
afero.OsFs
|
||||||
|
|
||||||
|
t *testing.T
|
||||||
|
target string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *linkRemovedAfterLstatFs) LstatIfPossible(
|
||||||
|
name string,
|
||||||
|
) (os.FileInfo, bool, error) {
|
||||||
|
info, lstatCalled, err := f.OsFs.LstatIfPossible(name)
|
||||||
|
if err == nil && name == f.target {
|
||||||
|
rmErr := os.Remove(name)
|
||||||
|
if rmErr != nil {
|
||||||
|
f.t.Errorf("removing %s: %v", name, rmErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return info, lstatCalled, err
|
||||||
|
}
|
||||||
|
|
||||||
// writeSkipErrorTestFile writes one file into fs with a fixed mtime.
|
// writeSkipErrorTestFile writes one file into fs with a fixed mtime.
|
||||||
func writeSkipErrorTestFile(t *testing.T, fs afero.Fs, path, content string) {
|
func writeSkipErrorTestFile(t *testing.T, fs afero.Fs, path, content string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -102,14 +154,16 @@ func writeSkipErrorTestFile(t *testing.T, fs afero.Fs, path, content string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// runSkipErrorScan scans /source on fs with the given skip-errors setting and
|
// runSkipErrorScan scans source on fs under ctx with the given skip-errors
|
||||||
|
// setting, printing user-facing messages to uiw (nil discards them), and
|
||||||
// returns the repositories (for inspection) and the scan error.
|
// returns the repositories (for inspection) and the scan error.
|
||||||
func runSkipErrorScan(
|
func runSkipErrorScan(
|
||||||
t *testing.T, fs afero.Fs, skipErrors bool,
|
ctx context.Context, t *testing.T, fs afero.Fs, source string,
|
||||||
|
skipErrors bool, uiw *ui.Writer,
|
||||||
) (*database.Repositories, error) {
|
) (*database.Repositories, error) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
db, err := database.NewTestDB()
|
db, err := database.New(ctx, ":memory:")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("create test db: %v", err)
|
t.Fatalf("create test db: %v", err)
|
||||||
}
|
}
|
||||||
@@ -130,14 +184,14 @@ func runSkipErrorScan(
|
|||||||
MaxBlobSize: int64(1024 * 1024),
|
MaxBlobSize: int64(1024 * 1024),
|
||||||
CompressionLevel: 3,
|
CompressionLevel: 3,
|
||||||
AgeRecipients: []string{testAgePublicKey},
|
AgeRecipients: []string{testAgePublicKey},
|
||||||
|
UI: uiw,
|
||||||
SkipErrors: skipErrors,
|
SkipErrors: skipErrors,
|
||||||
})
|
})
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
snapshotID := "test-snapshot-skip-errors"
|
snapshotID := "test-snapshot-skip-errors"
|
||||||
createTestSnapshotRecord(ctx, t, repos, snapshotID)
|
createTestSnapshotRecord(ctx, t, repos, snapshotID)
|
||||||
|
|
||||||
_, err = scanner.Scan(ctx, "/source", snapshotID)
|
_, err = scanner.Scan(ctx, source, snapshotID)
|
||||||
|
|
||||||
return repos, err
|
return repos, err
|
||||||
}
|
}
|
||||||
@@ -157,7 +211,7 @@ func TestScannerPackingFailureAbortsUnderSkipErrors(t *testing.T) {
|
|||||||
writeSkipErrorTestFile(t, fs, "/source/file1.txt", "first file content")
|
writeSkipErrorTestFile(t, fs, "/source/file1.txt", "first file content")
|
||||||
writeSkipErrorTestFile(t, fs, "/source/file2.txt", "second file content")
|
writeSkipErrorTestFile(t, fs, "/source/file2.txt", "second file content")
|
||||||
|
|
||||||
repos, err := runSkipErrorScan(t, fs, true)
|
repos, err := runSkipErrorScan(context.Background(), t, fs, "/source", true, nil)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected scan to abort on the packer error, got nil")
|
t.Fatal("expected scan to abort on the packer error, got nil")
|
||||||
}
|
}
|
||||||
@@ -184,7 +238,7 @@ func TestScannerReadErrorAbortsWithoutSkipErrors(t *testing.T) {
|
|||||||
fs := &readFailFs{Fs: afero.NewMemMapFs(), target: target}
|
fs := &readFailFs{Fs: afero.NewMemMapFs(), target: target}
|
||||||
writeSkipErrorTestFile(t, fs, target, "content that cannot be read")
|
writeSkipErrorTestFile(t, fs, target, "content that cannot be read")
|
||||||
|
|
||||||
_, err := runSkipErrorScan(t, fs, false)
|
_, err := runSkipErrorScan(context.Background(), t, fs, "/source", false, nil)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected scan to fail on the read error, got nil")
|
t.Fatal("expected scan to fail on the read error, got nil")
|
||||||
}
|
}
|
||||||
@@ -200,7 +254,7 @@ func TestScannerReadErrorSkippedWithSkipErrors(t *testing.T) {
|
|||||||
fs := &readFailFs{Fs: afero.NewMemMapFs(), target: target}
|
fs := &readFailFs{Fs: afero.NewMemMapFs(), target: target}
|
||||||
writeSkipErrorTestFile(t, fs, target, "content that cannot be read")
|
writeSkipErrorTestFile(t, fs, target, "content that cannot be read")
|
||||||
|
|
||||||
repos, err := runSkipErrorScan(t, fs, true)
|
repos, err := runSkipErrorScan(context.Background(), t, fs, "/source", true, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("expected scan to complete with --skip-errors, got %v", err)
|
t.Fatalf("expected scan to complete with --skip-errors, got %v", err)
|
||||||
}
|
}
|
||||||
@@ -214,3 +268,173 @@ func TestScannerReadErrorSkippedWithSkipErrors(t *testing.T) {
|
|||||||
t.Fatalf("expected unreadable file skipped, got %d chunks", len(chunks))
|
t.Fatalf("expected unreadable file skipped, got %d chunks", len(chunks))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// writeSymlinkSource creates a source directory on disk holding one symlink
|
||||||
|
// and returns the directory and the symlink's path.
|
||||||
|
func writeSymlinkSource(t *testing.T) (string, string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
sourceDir := t.TempDir()
|
||||||
|
linkPath := filepath.Join(sourceDir, "link")
|
||||||
|
|
||||||
|
err := os.Symlink("target.txt", linkPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("creating symlink: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return sourceDir, linkPath
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestScannerUnreadableSymlinkAbortsWithoutSkipErrors checks that a symlink
|
||||||
|
// whose target cannot be read aborts the run when --skip-errors is not set.
|
||||||
|
func TestScannerUnreadableSymlinkAbortsWithoutSkipErrors(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
sourceDir, linkPath := writeSymlinkSource(t)
|
||||||
|
fs := &linkRemovedAfterLstatFs{t: t, target: linkPath}
|
||||||
|
|
||||||
|
_, err := runSkipErrorScan(context.Background(), t, fs, sourceDir, false, nil)
|
||||||
|
if !errors.Is(err, os.ErrNotExist) {
|
||||||
|
t.Fatalf("expected scan to fail on the removed symlink, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestScannerUnreadableSymlinkSkippedWithSkipErrors checks that a symlink
|
||||||
|
// whose target cannot be read is skipped with an error line, and the run
|
||||||
|
// completes, when --skip-errors is set.
|
||||||
|
func TestScannerUnreadableSymlinkSkippedWithSkipErrors(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
sourceDir, linkPath := writeSymlinkSource(t)
|
||||||
|
fs := &linkRemovedAfterLstatFs{t: t, target: linkPath}
|
||||||
|
uiw := ui.NewWithColor(io.Discard, false)
|
||||||
|
|
||||||
|
repos, err := runSkipErrorScan(context.Background(), t, fs, sourceDir, true, uiw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected scan to complete with --skip-errors, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if uiw.ErrorCount() != 1 {
|
||||||
|
t.Fatalf("expected one error line for the symlink, got %d",
|
||||||
|
uiw.ErrorCount())
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := repos.Files.GetByPath(context.Background(), linkPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("getting %s: %v", linkPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if file != nil {
|
||||||
|
t.Fatalf("expected %s not to be recorded", linkPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestScannerCancelStopsSkipErrorsRun checks that a --skip-errors backup
|
||||||
|
// cancelled partway through processing returns the cancellation error,
|
||||||
|
// opens no further file, and reports no file as failed.
|
||||||
|
func TestScannerCancelStopsSkipErrorsRun(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
targetContent string
|
||||||
|
}{
|
||||||
|
// The cancellation lands while the target is being read.
|
||||||
|
{name: "while reading a file", targetContent: "first file content"},
|
||||||
|
// An empty target has no chunks, so the cancellation goes unnoticed
|
||||||
|
// until the run moves on to the next file.
|
||||||
|
{name: "between files", targetContent: ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const target = "/source/a.txt"
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
fs := &cancelOnOpenFs{
|
||||||
|
Fs: afero.NewMemMapFs(), target: target, cancel: cancel,
|
||||||
|
}
|
||||||
|
writeSkipErrorTestFile(t, fs, target, tt.targetContent)
|
||||||
|
writeSkipErrorTestFile(t, fs, "/source/b.txt", "second file content")
|
||||||
|
writeSkipErrorTestFile(t, fs, "/source/c.txt", "third file content")
|
||||||
|
|
||||||
|
uiw := ui.NewWithColor(io.Discard, false)
|
||||||
|
|
||||||
|
_, err := runSkipErrorScan(ctx, t, fs, "/source", true, uiw)
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("expected the cancellation error, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(fs.openedAfterCancel) != 0 {
|
||||||
|
t.Fatalf("expected no file opened after the cancellation, got %v",
|
||||||
|
fs.openedAfterCancel)
|
||||||
|
}
|
||||||
|
|
||||||
|
if uiw.ErrorCount() != 0 {
|
||||||
|
t.Fatalf("expected no file reported as failed, got %d error lines",
|
||||||
|
uiw.ErrorCount())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestScannerRemovesBlobTempFileWhenRunStops checks that a run stopped
|
||||||
|
// part-way, by a file it cannot read or by a cancellation, removes the
|
||||||
|
// temp file of the blob it was packing.
|
||||||
|
func TestScannerRemovesBlobTempFileWhenRunStops(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// a.txt is backed up first and starts a blob; the run stops at b.txt.
|
||||||
|
const target = "/source/b.txt"
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
newFs func(cancel context.CancelFunc) afero.Fs
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "file cannot be read",
|
||||||
|
newFs: func(context.CancelFunc) afero.Fs {
|
||||||
|
return &readFailFs{Fs: afero.NewMemMapFs(), target: target}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "cancelled",
|
||||||
|
newFs: func(cancel context.CancelFunc) afero.Fs {
|
||||||
|
return &cancelOnOpenFs{
|
||||||
|
Fs: afero.NewMemMapFs(), target: target, cancel: cancel,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
fs := tt.newFs(cancel)
|
||||||
|
writeSkipErrorTestFile(t, fs, "/source/a.txt", "first file content")
|
||||||
|
writeSkipErrorTestFile(t, fs, target, "second file content")
|
||||||
|
|
||||||
|
_, err := runSkipErrorScan(ctx, t, fs, "/source", false, nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected the scan to stop with an error, got nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
left, err := afero.Glob(fs, filepath.Join(os.TempDir(), "vaultik-blob-*"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("listing blob temp files: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(left) != 0 {
|
||||||
|
t.Fatalf("expected no blob temp file left, got %v", left)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -105,26 +105,47 @@ func (sm *SnapshotManager) CreateSnapshot(
|
|||||||
return sm.CreateSnapshotWithName(ctx, hostname, "", version, gitRevision)
|
return sm.CreateSnapshotWithName(ctx, hostname, "", version, gitRevision)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ShortHostname returns hostname up to its first dot. A snapshot ID starts
|
||||||
|
// with this form, while the snapshots table stores the full hostname.
|
||||||
|
func ShortHostname(hostname string) string {
|
||||||
|
short, _, _ := strings.Cut(hostname, ".")
|
||||||
|
|
||||||
|
return short
|
||||||
|
}
|
||||||
|
|
||||||
// CreateSnapshotWithName creates a new snapshot record with an optional
|
// CreateSnapshotWithName creates a new snapshot record with an optional
|
||||||
// snapshot name. The snapshot ID format is: hostname_name_timestamp or
|
// snapshot name. The snapshot ID format is: hostname_name_timestamp or
|
||||||
// hostname_timestamp if name is empty.
|
// hostname_timestamp if name is empty. The timestamp is in whole seconds.
|
||||||
|
// If the local index already has a snapshot with that ID, from a run of the
|
||||||
|
// same name that started in the same second, it waits a second and takes a
|
||||||
|
// new timestamp.
|
||||||
func (sm *SnapshotManager) CreateSnapshotWithName(
|
func (sm *SnapshotManager) CreateSnapshotWithName(
|
||||||
ctx context.Context, hostname, name, version, gitRevision string,
|
ctx context.Context, hostname, name, version, gitRevision string,
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
// Use short hostname (strip domain if present)
|
shortHostname := ShortHostname(hostname)
|
||||||
shortHostname := hostname
|
|
||||||
if before, _, ok := strings.Cut(hostname, "."); ok {
|
|
||||||
shortHostname = before
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build snapshot ID with optional name
|
|
||||||
timestamp := time.Now().UTC().Format("2006-01-02T15:04:05Z")
|
|
||||||
|
|
||||||
var snapshotID string
|
var snapshotID string
|
||||||
if name != "" {
|
|
||||||
snapshotID = fmt.Sprintf("%s_%s_%s", shortHostname, name, timestamp)
|
for {
|
||||||
} else {
|
// Build snapshot ID with optional name
|
||||||
snapshotID = fmt.Sprintf("%s_%s", shortHostname, timestamp)
|
timestamp := time.Now().UTC().Format("2006-01-02T15:04:05Z")
|
||||||
|
|
||||||
|
if name != "" {
|
||||||
|
snapshotID = fmt.Sprintf("%s_%s_%s", shortHostname, name, timestamp)
|
||||||
|
} else {
|
||||||
|
snapshotID = fmt.Sprintf("%s_%s", shortHostname, timestamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
existing, err := sm.repos.Snapshots.GetByID(ctx, snapshotID)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("looking up snapshot %s: %w", snapshotID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if existing == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(time.Second)
|
||||||
}
|
}
|
||||||
|
|
||||||
snapshot := &database.Snapshot{
|
snapshot := &database.Snapshot{
|
||||||
@@ -154,26 +175,6 @@ func (sm *SnapshotManager) CreateSnapshotWithName(
|
|||||||
return snapshotID, nil
|
return snapshotID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateSnapshotStats updates the statistics for a snapshot during backup
|
|
||||||
func (sm *SnapshotManager) UpdateSnapshotStats(
|
|
||||||
ctx context.Context, snapshotID string, stats BackupStats,
|
|
||||||
) error {
|
|
||||||
err := sm.repos.WithTx(ctx, func(ctx context.Context, tx *sql.Tx) error {
|
|
||||||
return sm.repos.Snapshots.UpdateCounts(ctx, tx, snapshotID,
|
|
||||||
int64(stats.FilesScanned),
|
|
||||||
int64(stats.ChunksCreated),
|
|
||||||
int64(stats.BlobsCreated),
|
|
||||||
stats.BytesScanned,
|
|
||||||
stats.BytesUploaded,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("updating snapshot stats: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateSnapshotStatsExtended updates snapshot statistics with extended metrics.
|
// UpdateSnapshotStatsExtended updates snapshot statistics with extended metrics.
|
||||||
// This includes compression level, uncompressed blob size, and upload duration.
|
// This includes compression level, uncompressed blob size, and upload duration.
|
||||||
func (sm *SnapshotManager) UpdateSnapshotStatsExtended(
|
func (sm *SnapshotManager) UpdateSnapshotStatsExtended(
|
||||||
@@ -185,8 +186,8 @@ func (sm *SnapshotManager) UpdateSnapshotStatsExtended(
|
|||||||
int64(stats.FilesScanned),
|
int64(stats.FilesScanned),
|
||||||
int64(stats.ChunksCreated),
|
int64(stats.ChunksCreated),
|
||||||
int64(stats.BlobsCreated),
|
int64(stats.BlobsCreated),
|
||||||
stats.BytesScanned,
|
stats.TotalSize,
|
||||||
stats.BytesUploaded,
|
stats.BlobSize,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -196,7 +197,9 @@ func (sm *SnapshotManager) UpdateSnapshotStatsExtended(
|
|||||||
return sm.repos.Snapshots.UpdateExtendedStats(ctx, tx, snapshotID,
|
return sm.repos.Snapshots.UpdateExtendedStats(ctx, tx, snapshotID,
|
||||||
stats.BlobUncompressedSize,
|
stats.BlobUncompressedSize,
|
||||||
stats.CompressionLevel,
|
stats.CompressionLevel,
|
||||||
|
stats.BytesUploaded,
|
||||||
stats.UploadDurationMs,
|
stats.UploadDurationMs,
|
||||||
|
stats.NewChunkSize,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -386,12 +389,12 @@ func (sm *SnapshotManager) prepareExportDB(
|
|||||||
ctx context.Context, dbPath, snapshotID, tempDir string,
|
ctx context.Context, dbPath, snapshotID, tempDir string,
|
||||||
) ([]byte, string, error) {
|
) ([]byte, string, error) {
|
||||||
// Step 1: Copy database to temp file
|
// Step 1: Copy database to temp file
|
||||||
// The main database should be closed at this point
|
// The main database is still open here, so it is copied through SQLite
|
||||||
tempDBPath := filepath.Join(tempDir, "snapshot.db")
|
tempDBPath := filepath.Join(tempDir, "snapshot.db")
|
||||||
log.Debug("Copying database to temporary location",
|
log.Debug("Copying database to temporary location",
|
||||||
"source", dbPath, "destination", tempDBPath)
|
"source", dbPath, "destination", tempDBPath)
|
||||||
|
|
||||||
err := sm.copyFile(dbPath, tempDBPath)
|
err := sm.copyDatabase(ctx, dbPath, tempDBPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", fmt.Errorf("copying database: %w", err)
|
return nil, "", fmt.Errorf("copying database: %w", err)
|
||||||
}
|
}
|
||||||
@@ -648,9 +651,10 @@ func (sm *SnapshotManager) collectCleanupStats(
|
|||||||
//
|
//
|
||||||
// VACUUM runs through the modernc.org/sqlite driver, on a freshly opened
|
// VACUUM runs through the modernc.org/sqlite driver, on a freshly opened
|
||||||
// connection with no transaction in flight (VACUUM cannot run inside one).
|
// connection with no transaction in flight (VACUUM cannot run inside one).
|
||||||
// The database opens in WAL mode, so VACUUM's rewrite lands in the WAL; the
|
// database.New opens the file in WAL mode, so VACUUM's rewrite lands in the
|
||||||
// checkpoint on Close flushes it into the main file, which is the file we
|
// -wal file. This is the only connection to the file, so closing it
|
||||||
// then compress and upload.
|
// checkpoints the rewrite into the main file and removes the -wal file; the
|
||||||
|
// main file is the one compressFile then compresses and uploads.
|
||||||
func (sm *SnapshotManager) vacuumDatabase(ctx context.Context, dbPath string) error {
|
func (sm *SnapshotManager) vacuumDatabase(ctx context.Context, dbPath string) error {
|
||||||
log.Debug("Running VACUUM on database", "path", dbPath)
|
log.Debug("Running VACUUM on database", "path", dbPath)
|
||||||
|
|
||||||
@@ -744,26 +748,15 @@ func (sm *SnapshotManager) compressFile(inputPath, outputPath string) error {
|
|||||||
// user; it holds the same private index data as the local index file.
|
// user; it holds the same private index data as the local index file.
|
||||||
const exportCopyPerm = 0o600
|
const exportCopyPerm = 0o600
|
||||||
|
|
||||||
// copyFile copies a file from src to dst. The destination is the exported
|
// copyDatabase copies the database at src to dst with VACUUM INTO. It reads
|
||||||
// snapshot database, so it is created owner-only rather than with the
|
// through SQLite, so the copy holds rows committed to src that are still in
|
||||||
// umask-dependent default.
|
// its -wal file, which a copy of the file alone would miss. The destination
|
||||||
func (sm *SnapshotManager) copyFile(src, dst string) error {
|
// is the exported snapshot database, so it is created empty and owner-only
|
||||||
log.Debug("Opening source file for copy", "path", src)
|
// first rather than with the umask-dependent default: VACUUM INTO writes
|
||||||
|
// into an existing empty file and keeps its mode.
|
||||||
sourceFile, err := sm.fs.Open(src)
|
func (sm *SnapshotManager) copyDatabase(
|
||||||
if err != nil {
|
ctx context.Context, src, dst string,
|
||||||
return err
|
) error {
|
||||||
}
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
log.Debug("Closing source file", "path", src)
|
|
||||||
|
|
||||||
err := sourceFile.Close()
|
|
||||||
if err != nil {
|
|
||||||
log.Debug("Failed to close source file", "path", src, "error", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
log.Debug("Creating destination file", "path", dst)
|
log.Debug("Creating destination file", "path", dst)
|
||||||
|
|
||||||
destFile, err := sm.fs.OpenFile(
|
destFile, err := sm.fs.OpenFile(
|
||||||
@@ -773,23 +766,28 @@ func (sm *SnapshotManager) copyFile(src, dst string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() {
|
err = destFile.Close()
|
||||||
log.Debug("Closing destination file", "path", dst)
|
|
||||||
|
|
||||||
err := destFile.Close()
|
|
||||||
if err != nil {
|
|
||||||
log.Debug("Failed to close destination file", "path", dst, "error", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
log.Debug("Copying file data")
|
|
||||||
|
|
||||||
n, err := io.Copy(destFile, sourceFile)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Debug("File copy complete", "bytes_copied", n)
|
db, err := database.New(ctx, src)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("opening database to copy: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
cerr := db.Close()
|
||||||
|
if cerr != nil {
|
||||||
|
log.Debug("Failed to close database after copy",
|
||||||
|
"path", src, "error", cerr)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
_, err = db.ExecWithLog(ctx, "VACUUM INTO ?", dst)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("running VACUUM INTO: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -895,7 +893,7 @@ func (sm *SnapshotManager) getFileSize(path string) int64 {
|
|||||||
// BackupStats contains statistics from a backup operation
|
// BackupStats contains statistics from a backup operation
|
||||||
type BackupStats struct {
|
type BackupStats struct {
|
||||||
FilesScanned int
|
FilesScanned int
|
||||||
BytesScanned int64
|
TotalSize int64 // Total size of the files in the snapshot
|
||||||
ChunksCreated int
|
ChunksCreated int
|
||||||
BlobsCreated int
|
BlobsCreated int
|
||||||
BytesUploaded int64
|
BytesUploaded int64
|
||||||
@@ -905,9 +903,11 @@ type BackupStats struct {
|
|||||||
type ExtendedBackupStats struct {
|
type ExtendedBackupStats struct {
|
||||||
BackupStats
|
BackupStats
|
||||||
|
|
||||||
|
BlobSize int64 // Total compressed size of all referenced blobs
|
||||||
BlobUncompressedSize int64 // Total uncompressed size of all referenced blobs
|
BlobUncompressedSize int64 // Total uncompressed size of all referenced blobs
|
||||||
CompressionLevel int // Compression level used for this snapshot
|
CompressionLevel int // Compression level used for this snapshot
|
||||||
UploadDurationMs int64 // Total milliseconds spent uploading to S3
|
UploadDurationMs int64 // Total milliseconds spent uploading to S3
|
||||||
|
NewChunkSize int64 // Total size of the chunks counted in ChunksCreated
|
||||||
}
|
}
|
||||||
|
|
||||||
// deleteOtherSnapshots deletes all snapshots except the current one
|
// deleteOtherSnapshots deletes all snapshots except the current one
|
||||||
|
|||||||
@@ -188,6 +188,77 @@ func TestVacuumDatabaseRemovesDeletedData(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestPrepareExportDBKeepsRowsCommittedToOpenIndex exports from an index
|
||||||
|
// that is still open, as a backup does. A row committed there can still be
|
||||||
|
// in the index's -wal file, and the export must hold it all the same.
|
||||||
|
func TestPrepareExportDBKeepsRowsCommittedToOpenIndex(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
fs := afero.NewOsFs()
|
||||||
|
|
||||||
|
dbPath := filepath.Join(t.TempDir(), "index.sqlite")
|
||||||
|
|
||||||
|
db, err := database.New(ctx, dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = db.Close() }()
|
||||||
|
|
||||||
|
repos := database.NewRepositories(db)
|
||||||
|
|
||||||
|
snapshot := &database.Snapshot{ID: "open-index-snapshot", Hostname: "test-host"}
|
||||||
|
|
||||||
|
err = repos.WithTx(ctx, func(ctx context.Context, tx *sql.Tx) error {
|
||||||
|
return repos.Snapshots.Create(ctx, tx, snapshot)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create snapshot: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sm := &SnapshotManager{
|
||||||
|
config: &config.Config{
|
||||||
|
CompressionLevel: 3,
|
||||||
|
AgeRecipients: []string{testAgeRecipient},
|
||||||
|
},
|
||||||
|
fs: fs,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, tempDBPath, err := sm.prepareExportDB(
|
||||||
|
ctx, dbPath, snapshot.ID.String(), t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("prepareExportDB failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only the main database file is compressed and uploaded, so open a
|
||||||
|
// copy of that file alone.
|
||||||
|
uploadedPath := filepath.Join(t.TempDir(), "uploaded.db")
|
||||||
|
|
||||||
|
err = copyFile(fs, tempDBPath, uploadedPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to copy exported database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
exported, err := database.OpenReadOnly(ctx, uploadedPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to open exported database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = exported.Close() }()
|
||||||
|
|
||||||
|
got, err := database.NewRepositories(exported).Snapshots.GetByID(
|
||||||
|
ctx, snapshot.ID.String())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read snapshot from export: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got == nil {
|
||||||
|
t.Fatal("exported database is missing the snapshot row")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCleanSnapshotDBEmptySnapshot(t *testing.T) {
|
func TestCleanSnapshotDBEmptySnapshot(t *testing.T) {
|
||||||
// Initialize logger
|
// Initialize logger
|
||||||
log.Initialize(log.Config{})
|
log.Initialize(log.Config{})
|
||||||
@@ -315,3 +386,36 @@ func TestCleanSnapshotDBNonExistentSnapshot(t *testing.T) {
|
|||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Two creates of one snapshot name back to back start within one second,
|
||||||
|
// the resolution of the timestamp in a snapshot ID. See
|
||||||
|
// https://git.eeqj.de/sneak/vaultik/issues/270.
|
||||||
|
func TestCreateSnapshotWithNameTwiceBackToBack(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
db, err := database.New(ctx, filepath.Join(t.TempDir(), "index.sqlite"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = db.Close() }()
|
||||||
|
|
||||||
|
sm := &SnapshotManager{repos: database.NewRepositories(db)}
|
||||||
|
|
||||||
|
first, err := sm.CreateSnapshotWithName(ctx, "test-host", "data", "v", "g")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first create failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
second, err := sm.CreateSnapshotWithName(ctx, "test-host", "data", "v", "g")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second create failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if first == second {
|
||||||
|
t.Fatalf("both creates returned snapshot ID %s", first)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,25 +8,29 @@ import (
|
|||||||
"reflect"
|
"reflect"
|
||||||
"sort"
|
"sort"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/vaultik/internal/storage"
|
"sneak.berlin/go/vaultik/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
// runStorerConformance is the shared Storer contract. Every backend that
|
// runStorerConformance is the shared Storer contract. Every backend that
|
||||||
// can run in-process is expected to pass it: TestFileStorer runs it against
|
// can run in-process is expected to pass it: TestFileStorer runs it against
|
||||||
// file://, TestS3Storer against s3://. A new backend inherits this coverage
|
// file://, TestS3Storer against s3://, TestRcloneStorer against rclone's
|
||||||
// by passing its own constructor, so the contract is defined once.
|
// local backend. A new backend inherits this coverage by passing its own
|
||||||
|
// constructor, so the contract is defined once.
|
||||||
//
|
//
|
||||||
// It exercises the public Storer interface: round-trip, stat, list with
|
// It exercises the public Storer interface: round-trip, stat, list with
|
||||||
// prefix filtering, overwrite, delete, delete-of-missing, and not-found on
|
// prefix filtering, the modification time ListStream reports, overwrite,
|
||||||
// Get and Stat. Each section takes its own fresh backend instance, so the
|
// delete, delete-of-missing, and not-found on Get and Stat. Each section
|
||||||
// order of sections never matters and no section sees another's objects.
|
// takes its own fresh backend instance, so the order of sections never
|
||||||
|
// matters and no section sees another's objects.
|
||||||
func runStorerConformance(t *testing.T, newStorer func(*testing.T) storage.Storer) {
|
func runStorerConformance(t *testing.T, newStorer func(*testing.T) storage.Storer) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
conformanceRoundTrip(t, newStorer(t))
|
conformanceRoundTrip(t, newStorer(t))
|
||||||
conformanceOverwrite(t, newStorer(t))
|
conformanceOverwrite(t, newStorer(t))
|
||||||
conformanceList(t, newStorer(t))
|
conformanceList(t, newStorer(t))
|
||||||
|
conformanceListStreamModTime(t, newStorer(t))
|
||||||
conformanceDelete(t, newStorer(t))
|
conformanceDelete(t, newStorer(t))
|
||||||
conformanceNotFound(t, newStorer(t))
|
conformanceNotFound(t, newStorer(t))
|
||||||
}
|
}
|
||||||
@@ -117,6 +121,35 @@ func conformanceList(t *testing.T, s storage.Storer) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// conformanceListStreamModTime checks that ListStream gives an object
|
||||||
|
// just written a modification time of about now, which prune compares
|
||||||
|
// with its grace period.
|
||||||
|
func conformanceListStreamModTime(t *testing.T, s storage.Storer) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
err := s.Put(ctx, "blobs/dd/written-now", bytes.NewReader([]byte("data")))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Put: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var listed []storage.ObjectInfo
|
||||||
|
|
||||||
|
for info := range s.ListStream(ctx, "blobs/") {
|
||||||
|
listed = append(listed, info)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(listed) != 1 || listed[0].Err != nil {
|
||||||
|
t.Fatalf("ListStream(\"blobs/\") = %v, want the one object", listed)
|
||||||
|
}
|
||||||
|
|
||||||
|
if time.Since(listed[0].ModTime).Abs() > time.Minute {
|
||||||
|
t.Errorf("ListStream ModTime = %v, want within a minute of now",
|
||||||
|
listed[0].ModTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// conformanceDelete checks that Delete removes an object and that deleting
|
// conformanceDelete checks that Delete removes an object and that deleting
|
||||||
// a missing key is not an error.
|
// a missing key is not an error.
|
||||||
func conformanceDelete(t *testing.T, s storage.Storer) {
|
func conformanceDelete(t *testing.T, s storage.Storer) {
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
// Package faultstore provides a storage.Storer wrapper that injects
|
// Package faultstore provides a storage.Storer wrapper that injects
|
||||||
// faults on demand, so tests can reproduce the failure modes a real
|
// faults on demand, so tests can reproduce the failure modes a real
|
||||||
// backend exhibits: an upload that fails partway, a backend that reports
|
// backend exhibits: an upload that fails partway, a backend that reports
|
||||||
// success while storing nothing, and reads that return corrupt or
|
// success while storing nothing, reads that return corrupt or truncated
|
||||||
// truncated bytes. It is the seam called for by the fault-injection
|
// bytes, and a delete the backend refuses. It is the seam called for by
|
||||||
// tests (sneak/vaultik issue 72) and is meant to be reused by future
|
// the fault-injection tests (sneak/vaultik issue 72) and is meant to be
|
||||||
// tests rather than re-implemented per case.
|
// reused by future tests rather than re-implemented per case.
|
||||||
//
|
//
|
||||||
// The wrapper delegates every method to the inner Storer. Two hooks
|
// The wrapper delegates every method to the inner Storer. Three hooks
|
||||||
// change that: OnPut decides the fate of each write, and OnGet decides
|
// change that: OnPut decides the fate of each write, OnGet decides how
|
||||||
// how each read's bytes are returned. Both are keyed by the object key,
|
// each read's bytes are returned, and OnDelete decides whether each
|
||||||
// so a test can fault only blobs, only metadata, or a single object.
|
// delete fails. All are keyed by the object key, so a test can fault
|
||||||
|
// only blobs, only metadata, or a single object.
|
||||||
package faultstore
|
package faultstore
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -25,6 +26,10 @@ import (
|
|||||||
// ErrInjectedUpload is returned by a Put the OnPut hook chose to fail.
|
// ErrInjectedUpload is returned by a Put the OnPut hook chose to fail.
|
||||||
var ErrInjectedUpload = errors.New("faultstore: injected upload failure")
|
var ErrInjectedUpload = errors.New("faultstore: injected upload failure")
|
||||||
|
|
||||||
|
// ErrInjectedDelete is returned by a Delete the OnDelete hook chose to
|
||||||
|
// fail.
|
||||||
|
var ErrInjectedDelete = errors.New("faultstore: injected delete failure")
|
||||||
|
|
||||||
// PutAction is the disposition OnPut assigns to a write.
|
// PutAction is the disposition OnPut assigns to a write.
|
||||||
type PutAction int
|
type PutAction int
|
||||||
|
|
||||||
@@ -66,6 +71,11 @@ type Storer struct {
|
|||||||
// OnGet, when set, is consulted for every Get with the object key
|
// OnGet, when set, is consulted for every Get with the object key
|
||||||
// and damages the returned bytes accordingly.
|
// and damages the returned bytes accordingly.
|
||||||
OnGet func(key string) GetFault
|
OnGet func(key string) GetFault
|
||||||
|
|
||||||
|
// OnDelete, when set, is consulted before every Delete with the
|
||||||
|
// object key. Returning true fails the delete and leaves the object
|
||||||
|
// in place.
|
||||||
|
OnDelete func(key string) bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// New wraps inner. inner must be non-nil.
|
// New wraps inner. inner must be non-nil.
|
||||||
@@ -154,8 +164,12 @@ func (f *Storer) Stat(ctx context.Context, key string) (*storage.ObjectInfo, err
|
|||||||
return f.inner.Stat(ctx, key)
|
return f.inner.Stat(ctx, key)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete delegates unchanged.
|
// Delete removes the object unless OnDelete fails the delete.
|
||||||
func (f *Storer) Delete(ctx context.Context, key string) error {
|
func (f *Storer) Delete(ctx context.Context, key string) error {
|
||||||
|
if f.OnDelete != nil && f.OnDelete(key) {
|
||||||
|
return fmt.Errorf("%w for %q", ErrInjectedDelete, key)
|
||||||
|
}
|
||||||
|
|
||||||
return f.inner.Delete(ctx, key)
|
return f.inner.Delete(ctx, key)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,6 +185,11 @@ func (f *Storer) ListStream(
|
|||||||
return f.inner.ListStream(ctx, prefix)
|
return f.inner.ListStream(ctx, prefix)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeletePartialUploads delegates unchanged.
|
||||||
|
func (f *Storer) DeletePartialUploads(ctx context.Context, prefix string) error {
|
||||||
|
return f.inner.DeletePartialUploads(ctx, prefix)
|
||||||
|
}
|
||||||
|
|
||||||
// Info delegates unchanged.
|
// Info delegates unchanged.
|
||||||
func (f *Storer) Info() storage.Info {
|
func (f *Storer) Info() storage.Info {
|
||||||
return f.inner.Info()
|
return f.inner.Info()
|
||||||
|
|||||||
+139
-20
@@ -21,12 +21,16 @@ type FileStorer struct {
|
|||||||
// NewFileStorer creates a new filesystem storage backend.
|
// NewFileStorer creates a new filesystem storage backend.
|
||||||
//
|
//
|
||||||
// Construction is intentionally cheap and does not touch the filesystem.
|
// Construction is intentionally cheap and does not touch the filesystem.
|
||||||
// The basePath is recorded; the directory is created lazily on first
|
// The backend never creates basePath: the user does, so that when
|
||||||
// write. Reads (Get/Stat/List) tolerate a missing basePath — a missing
|
// basePath is a directory on an unplugged volume, a write fails instead
|
||||||
// or unmounted destination during `snapshot list` should NOT block the
|
// of landing on the disk under the mount point. This does not hold when
|
||||||
// command, it should degrade to "no remote snapshots reachable" with a
|
// basePath is the mount point itself, which stays behind when its volume
|
||||||
// warning. Write operations (Put/PutWithProgress) call MkdirAll for the
|
// is unplugged.
|
||||||
// per-blob parent directory, which also covers basePath on first use.
|
// Put and PutWithProgress fail on a missing basePath and create the
|
||||||
|
// directories below it as needed.
|
||||||
|
// Get and Stat report a key under a missing basePath as ErrNotFound.
|
||||||
|
// List and ListStream fail on a missing basePath, because listing it as
|
||||||
|
// an empty store would make `prune` drop every local snapshot record.
|
||||||
//
|
//
|
||||||
// Uses the real OS filesystem by default; call SetFilesystem to
|
// Uses the real OS filesystem by default; call SetFilesystem to
|
||||||
// override for testing.
|
// override for testing.
|
||||||
@@ -50,9 +54,10 @@ const storageDirPerm = 0o755
|
|||||||
// temp file carrying this suffix and only renames it onto the real key once
|
// temp file carrying this suffix and only renames it onto the real key once
|
||||||
// the whole object is on disk, so an interrupted write can never leave a
|
// the whole object is on disk, so an interrupted write can never leave a
|
||||||
// truncated object at the key a later run would Stat and trust as a complete
|
// truncated object at the key a later run would Stat and trust as a complete
|
||||||
// blob. List and ListStream skip these files, so a leftover from an
|
// blob. The rclone backend's upload does the same on remotes with a
|
||||||
// interrupted write is never listed or trusted as a blob; it is otherwise
|
// server-side move. List and ListStream skip these files, so a leftover from
|
||||||
// harmless and is overwritten when the same key is written again.
|
// an interrupted write is never listed or trusted as a blob; it is otherwise
|
||||||
|
// harmless.
|
||||||
const tempSuffix = ".partial"
|
const tempSuffix = ".partial"
|
||||||
|
|
||||||
// Put stores data at the specified key.
|
// Put stores data at the specified key.
|
||||||
@@ -119,14 +124,21 @@ func (f *FileStorer) Delete(_ context.Context, key string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// List returns all keys with the given prefix.
|
// List returns all keys with the given prefix, reading through symlinked
|
||||||
|
// directories. It fails when the destination directory is missing, or
|
||||||
|
// when the prefix or a directory above it is a symlink whose target is
|
||||||
|
// missing; a prefix that is truly missing is an empty listing.
|
||||||
func (f *FileStorer) List(ctx context.Context, prefix string) ([]string, error) {
|
func (f *FileStorer) List(ctx context.Context, prefix string) ([]string, error) {
|
||||||
var keys []string
|
var keys []string
|
||||||
|
|
||||||
|
_, err := f.fs.Stat(f.basePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("checking destination directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
basePath := f.fullPath(prefix)
|
basePath := f.fullPath(prefix)
|
||||||
|
|
||||||
// Check if base path exists
|
exists, err := f.prefixExists(prefix)
|
||||||
exists, err := afero.Exists(f.fs, basePath)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("checking path: %w", err)
|
return nil, fmt.Errorf("checking path: %w", err)
|
||||||
}
|
}
|
||||||
@@ -135,7 +147,7 @@ func (f *FileStorer) List(ctx context.Context, prefix string) ([]string, error)
|
|||||||
return keys, nil // Empty list for non-existent prefix
|
return keys, nil // Empty list for non-existent prefix
|
||||||
}
|
}
|
||||||
|
|
||||||
err = afero.Walk(f.fs, basePath, func(path string, info os.FileInfo, err error) error {
|
err = f.walk(basePath, func(path string, info os.FileInfo, err error) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -167,17 +179,26 @@ func (f *FileStorer) List(ctx context.Context, prefix string) ([]string, error)
|
|||||||
return keys, nil
|
return keys, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListStream returns a channel of ObjectInfo for large result sets.
|
// ListStream returns a channel of ObjectInfo for large result sets. Like
|
||||||
|
// List, it reads through symlinked directories and sends an error when
|
||||||
|
// the destination directory is missing, or when the prefix or a directory
|
||||||
|
// above it is a symlink whose target is missing.
|
||||||
func (f *FileStorer) ListStream(ctx context.Context, prefix string) <-chan ObjectInfo {
|
func (f *FileStorer) ListStream(ctx context.Context, prefix string) <-chan ObjectInfo {
|
||||||
ch := make(chan ObjectInfo)
|
ch := make(chan ObjectInfo)
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
defer close(ch)
|
defer close(ch)
|
||||||
|
|
||||||
|
_, err := f.fs.Stat(f.basePath)
|
||||||
|
if err != nil {
|
||||||
|
ch <- ObjectInfo{Err: fmt.Errorf("checking destination directory: %w", err)}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
basePath := f.fullPath(prefix)
|
basePath := f.fullPath(prefix)
|
||||||
|
|
||||||
// Check if base path exists
|
exists, err := f.prefixExists(prefix)
|
||||||
exists, err := afero.Exists(f.fs, basePath)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ch <- ObjectInfo{Err: fmt.Errorf("checking path: %w", err)}
|
ch <- ObjectInfo{Err: fmt.Errorf("checking path: %w", err)}
|
||||||
|
|
||||||
@@ -188,7 +209,7 @@ func (f *FileStorer) ListStream(ctx context.Context, prefix string) <-chan Objec
|
|||||||
return // Empty channel for non-existent prefix
|
return // Empty channel for non-existent prefix
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = afero.Walk(f.fs, basePath, func(path string, info os.FileInfo, err error) error {
|
_ = f.walk(basePath, func(path string, info os.FileInfo, err error) error {
|
||||||
// Check context cancellation
|
// Check context cancellation
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
@@ -214,8 +235,9 @@ func (f *FileStorer) ListStream(ctx context.Context, prefix string) <-chan Objec
|
|||||||
// Normalize path separators
|
// Normalize path separators
|
||||||
relPath = strings.ReplaceAll(relPath, string(filepath.Separator), "/")
|
relPath = strings.ReplaceAll(relPath, string(filepath.Separator), "/")
|
||||||
ch <- ObjectInfo{
|
ch <- ObjectInfo{
|
||||||
Key: relPath,
|
Key: relPath,
|
||||||
Size: info.Size(),
|
Size: info.Size(),
|
||||||
|
ModTime: info.ModTime(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,6 +248,43 @@ func (f *FileStorer) ListStream(ctx context.Context, prefix string) <-chan Objec
|
|||||||
return ch
|
return ch
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeletePartialUploads removes every file under prefix whose name ends in
|
||||||
|
// tempSuffix. Like List, it reads through symlinked directories and fails
|
||||||
|
// on one whose target is missing. A missing prefix has none to remove.
|
||||||
|
func (f *FileStorer) DeletePartialUploads(ctx context.Context, prefix string) error {
|
||||||
|
basePath := f.fullPath(prefix)
|
||||||
|
|
||||||
|
exists, err := f.prefixExists(prefix)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("checking path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err = f.walk(basePath, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.IsDir() || !strings.HasSuffix(info.Name(), tempSuffix) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return f.fs.Remove(path)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("walking directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Info returns human-readable storage location information.
|
// Info returns human-readable storage location information.
|
||||||
func (f *FileStorer) Info() Info {
|
func (f *FileStorer) Info() Info {
|
||||||
return Info{
|
return Info{
|
||||||
@@ -245,7 +304,19 @@ func (f *FileStorer) writeAtomic(
|
|||||||
path := f.fullPath(key)
|
path := f.fullPath(key)
|
||||||
dir := filepath.Dir(path)
|
dir := filepath.Dir(path)
|
||||||
|
|
||||||
err := f.fs.MkdirAll(dir, storageDirPerm)
|
// Checked on every write, so a destination that disappears part-way
|
||||||
|
// through a backup fails it rather than being recreated by MkdirAll.
|
||||||
|
_, err := f.fs.Stat(f.basePath)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("destination directory does not exist; "+
|
||||||
|
"create it, or mount the volume it is on: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("checking destination directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = f.fs.MkdirAll(dir, storageDirPerm)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("creating directories: %w", err)
|
return fmt.Errorf("creating directories: %w", err)
|
||||||
}
|
}
|
||||||
@@ -300,6 +371,54 @@ func (f *FileStorer) writeAtomic(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// walk is afero.Walk, except that it reads through a symlinked directory
|
||||||
|
// the way Get and Stat do, where afero.Walk reports the link as one file.
|
||||||
|
// afero.Walk uses Lstat, which does not follow a link, whenever the
|
||||||
|
// filesystem has one; the struct below carries only the afero.Fs methods,
|
||||||
|
// so the walk uses Stat instead. A link back up the tree is followed until
|
||||||
|
// the operating system refuses the path for too many levels of links, and
|
||||||
|
// the walk then fails.
|
||||||
|
func (f *FileStorer) walk(root string, fn filepath.WalkFunc) error {
|
||||||
|
return afero.Walk(struct{ afero.Fs }{f.fs}, root, fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// prefixExists reports whether prefix exists under the destination
|
||||||
|
// directory. It checks each directory on the way down to prefix in turn,
|
||||||
|
// and the first one Stat cannot find decides: if it is not there at all,
|
||||||
|
// prefix is missing; if it is a symlink whose target is missing, such as
|
||||||
|
// a link to a volume that is not mounted, prefixExists fails, since
|
||||||
|
// listing it as empty would let prune delete every blob.
|
||||||
|
func (f *FileStorer) prefixExists(prefix string) (bool, error) {
|
||||||
|
lstater, canLstat := f.fs.(afero.Lstater)
|
||||||
|
path := f.basePath
|
||||||
|
|
||||||
|
for name := range strings.SplitSeq(prefix, "/") {
|
||||||
|
path = filepath.Join(path, name)
|
||||||
|
|
||||||
|
_, err := f.fs.Stat(path)
|
||||||
|
if err == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !os.IsNotExist(err) {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lstat does not follow a symlink at path, so it finds the link
|
||||||
|
// that Stat could not follow.
|
||||||
|
if canLstat {
|
||||||
|
_, _, lstatErr := lstater.LstatIfPossible(path)
|
||||||
|
if lstatErr == nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
// fullPath returns the full filesystem path for a key.
|
// fullPath returns the full filesystem path for a key.
|
||||||
func (f *FileStorer) fullPath(key string) string {
|
func (f *FileStorer) fullPath(key string) string {
|
||||||
return filepath.Join(f.basePath, key)
|
return filepath.Join(f.basePath, key)
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ import (
|
|||||||
// errStreamInterrupted stands in for an upload cut off mid-stream.
|
// errStreamInterrupted stands in for an upload cut off mid-stream.
|
||||||
var errStreamInterrupted = errors.New("connection reset mid-upload")
|
var errStreamInterrupted = errors.New("connection reset mid-upload")
|
||||||
|
|
||||||
|
// testBlobKey is a key laid out as a blob's key is.
|
||||||
|
const testBlobKey = "blobs/aa/bb/aabbccddeeff"
|
||||||
|
|
||||||
// failingReader yields its data once, then fails.
|
// failingReader yields its data once, then fails.
|
||||||
type failingReader struct {
|
type failingReader struct {
|
||||||
data []byte
|
data []byte
|
||||||
@@ -43,7 +46,7 @@ func TestFileStorer_InterruptedWriteLeavesNoTrustedObject(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
key := "blobs/aa/bb/aabbccddeeff"
|
key := testBlobKey
|
||||||
|
|
||||||
err = f.PutWithProgress(ctx, key, &failingReader{data: []byte("partial")}, 4096, nil)
|
err = f.PutWithProgress(ctx, key, &failingReader{data: []byte("partial")}, 4096, nil)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -79,7 +82,7 @@ func TestFileStorer_ListSkipsPartialFiles(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
realKey := "blobs/aa/bb/aabbccddeeff"
|
realKey := testBlobKey
|
||||||
|
|
||||||
err = f.Put(ctx, realKey, strings.NewReader("blob-bytes"))
|
err = f.Put(ctx, realKey, strings.NewReader("blob-bytes"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -117,3 +120,45 @@ func TestFileStorer_ListSkipsPartialFiles(t *testing.T) {
|
|||||||
t.Fatalf("ListStream should return only the real key, got %v", streamed)
|
t.Fatalf("ListStream should return only the real key, got %v", streamed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestFileStorer_DeletePartialUploads checks that a leftover temp file is
|
||||||
|
// removed and the object at the real key is kept.
|
||||||
|
func TestFileStorer_DeletePartialUploads(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
base := t.TempDir()
|
||||||
|
|
||||||
|
f, err := storage.NewFileStorer(base)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewFileStorer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
err = f.Put(ctx, testBlobKey, strings.NewReader("blob-bytes"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Put: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
leftover := filepath.Join(base, testBlobKey+"-123456.partial")
|
||||||
|
|
||||||
|
err = os.WriteFile(leftover, []byte("half"), 0o600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("writing leftover temp file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = f.DeletePartialUploads(ctx, "blobs/")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DeletePartialUploads: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = os.Stat(leftover)
|
||||||
|
if !os.IsNotExist(err) {
|
||||||
|
t.Errorf("leftover temp file was not removed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = f.Stat(ctx, testBlobKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Stat of the real key: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
package storage_test
|
package storage_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"sneak.berlin/go/vaultik/internal/storage"
|
"sneak.berlin/go/vaultik/internal/storage"
|
||||||
@@ -25,3 +32,303 @@ func TestFileStorer(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
runStorerConformance(t, newFileStorer)
|
runStorerConformance(t, newFileStorer)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestFileStorerListMissingDestination checks that List and ListStream
|
||||||
|
// fail when the destination directory does not exist, instead of
|
||||||
|
// reporting an empty store.
|
||||||
|
func TestFileStorerListMissingDestination(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
s, err := storage.NewFileStorer(filepath.Join(t.TempDir(), "unmounted"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewFileStorer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
keys, err := s.List(ctx, "metadata/")
|
||||||
|
if !errors.Is(err, fs.ErrNotExist) {
|
||||||
|
t.Errorf("List = %v, %v; want a not-exist error", keys, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var streamErr error
|
||||||
|
|
||||||
|
for object := range s.ListStream(ctx, "metadata/") {
|
||||||
|
if object.Err != nil {
|
||||||
|
streamErr = object.Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !errors.Is(streamErr, fs.ErrNotExist) {
|
||||||
|
t.Errorf("ListStream error = %v, want a not-exist error", streamErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The two directories at the top of a destination, either of which the
|
||||||
|
// tests below replace with a symlink.
|
||||||
|
const (
|
||||||
|
metadataDir = "metadata"
|
||||||
|
blobsDir = "blobs"
|
||||||
|
)
|
||||||
|
|
||||||
|
// moveBehindSymlink moves base/dir to a directory elsewhere and puts a
|
||||||
|
// symlink to it in its place.
|
||||||
|
func moveBehindSymlink(t *testing.T, base, dir string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
linkPath := filepath.Join(base, dir)
|
||||||
|
movedPath := filepath.Join(t.TempDir(), dir)
|
||||||
|
|
||||||
|
err := os.Rename(linkPath, movedPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("moving %s away: %v", dir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = os.Symlink(movedPath, linkPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("linking %s: %v", dir, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFileStorerListsThroughSymlinkedDirectory checks that List and
|
||||||
|
// ListStream read through a symlinked metadata/ or blobs/ directory, as Get
|
||||||
|
// and Stat do, instead of listing the link as one key. Listing the whole
|
||||||
|
// destination meets the link part-way through the walk; listing its prefix
|
||||||
|
// starts the walk at the link. A prefix missing behind the link lists as
|
||||||
|
// empty.
|
||||||
|
func TestFileStorerListsThroughSymlinkedDirectory(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, dir := range []string{metadataDir, blobsDir} {
|
||||||
|
t.Run(dir, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
base := t.TempDir()
|
||||||
|
key := dir + "/aa/bb/object"
|
||||||
|
|
||||||
|
s, err := storage.NewFileStorer(base)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewFileStorer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = s.Put(context.Background(), key, strings.NewReader("data"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Put: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
moveBehindSymlink(t, base, dir)
|
||||||
|
|
||||||
|
want := []string{key}
|
||||||
|
|
||||||
|
for _, prefix := range []string{"", dir + "/"} {
|
||||||
|
got := listSorted(t, s, prefix)
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Errorf("List(%q) = %v, want %v", prefix, got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
got = listStreamKeys(t, s, prefix)
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Errorf("ListStream(%q) = %v, want %v", prefix, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
absent := dir + "/absent/"
|
||||||
|
|
||||||
|
if got := listSorted(t, s, absent); len(got) != 0 {
|
||||||
|
t.Errorf("List(%q) = %v, want empty", absent, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := listStreamKeys(t, s, absent); len(got) != 0 {
|
||||||
|
t.Errorf("ListStream(%q) = %v, want empty", absent, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFileStorerListFailsOnSymlinkWithMissingTarget checks that List and
|
||||||
|
// ListStream fail on a symlinked metadata/ or blobs/ directory whose target
|
||||||
|
// is missing, such as a link to a volume that is not mounted, as they do on
|
||||||
|
// a missing destination directory, instead of listing it as empty. That
|
||||||
|
// holds for a prefix below the link too.
|
||||||
|
func TestFileStorerListFailsOnSymlinkWithMissingTarget(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
for _, dir := range []string{metadataDir, blobsDir} {
|
||||||
|
t.Run(dir, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
base := t.TempDir()
|
||||||
|
|
||||||
|
s, err := storage.NewFileStorer(base)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewFileStorer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
unmounted := filepath.Join(t.TempDir(), "unmounted")
|
||||||
|
|
||||||
|
err = os.Symlink(unmounted, filepath.Join(base, dir))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Symlink: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, prefix := range []string{"", dir + "/", dir + "/aa/"} {
|
||||||
|
keys, err := s.List(ctx, prefix)
|
||||||
|
if !errors.Is(err, fs.ErrNotExist) {
|
||||||
|
t.Errorf("List(%q) = %v, %v; want a not-exist error",
|
||||||
|
prefix, keys, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var streamErr error
|
||||||
|
|
||||||
|
for object := range s.ListStream(ctx, prefix) {
|
||||||
|
if object.Err != nil {
|
||||||
|
streamErr = object.Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !errors.Is(streamErr, fs.ErrNotExist) {
|
||||||
|
t.Errorf("ListStream(%q) error = %v, want a not-exist error",
|
||||||
|
prefix, streamErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFileStorerListFailsOnSymlinkLoop checks that a symlink pointing back
|
||||||
|
// at the destination makes List and ListStream fail instead of walking it
|
||||||
|
// forever.
|
||||||
|
func TestFileStorerListFailsOnSymlinkLoop(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
base := t.TempDir()
|
||||||
|
|
||||||
|
s, err := storage.NewFileStorer(base)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewFileStorer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = s.Put(ctx, testBlobKey, strings.NewReader("data"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Put: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = os.Symlink(base, filepath.Join(base, "blobs", "loop"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Symlink: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
keys, err := s.List(ctx, "blobs/")
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("List = %d keys and no error, want an error", len(keys))
|
||||||
|
}
|
||||||
|
|
||||||
|
var streamErr error
|
||||||
|
|
||||||
|
for object := range s.ListStream(ctx, "blobs/") {
|
||||||
|
if object.Err != nil {
|
||||||
|
streamErr = object.Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if streamErr == nil {
|
||||||
|
t.Error("ListStream sent no error, want one")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFileStorerDeletePartialUploadsThroughSymlinkedDirectory checks that
|
||||||
|
// DeletePartialUploads, which remote nuke uses, removes a leftover temp
|
||||||
|
// file behind a symlinked metadata/ or blobs/ directory and keeps the
|
||||||
|
// object at the real key.
|
||||||
|
func TestFileStorerDeletePartialUploadsThroughSymlinkedDirectory(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
for _, dir := range []string{metadataDir, blobsDir} {
|
||||||
|
t.Run(dir, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
base := t.TempDir()
|
||||||
|
key := dir + "/aa/bb/object"
|
||||||
|
|
||||||
|
s, err := storage.NewFileStorer(base)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewFileStorer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = s.Put(ctx, key, strings.NewReader("data"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Put: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
leftover := filepath.Join(base, key+"-123456.partial")
|
||||||
|
|
||||||
|
err = os.WriteFile(leftover, []byte("half"), 0o600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("writing leftover temp file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
moveBehindSymlink(t, base, dir)
|
||||||
|
|
||||||
|
err = s.DeletePartialUploads(ctx, dir+"/")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DeletePartialUploads: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// leftover runs through the link.
|
||||||
|
_, err = os.Stat(leftover)
|
||||||
|
if !os.IsNotExist(err) {
|
||||||
|
t.Errorf("leftover temp file was not removed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.Stat(ctx, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Stat of the real key: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFileStorerPutMissingDestination checks that a write fails when the
|
||||||
|
// destination directory has gone, even after earlier writes to it
|
||||||
|
// succeeded, and that it does not create the directory again.
|
||||||
|
func TestFileStorerPutMissingDestination(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
base := filepath.Join(t.TempDir(), "usbstick")
|
||||||
|
|
||||||
|
err := os.Mkdir(base, 0o750)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Mkdir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s, err := storage.NewFileStorer(base)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewFileStorer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = s.Put(ctx, "blobs/aa/bb/first", strings.NewReader("first"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first Put: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = os.Rename(base, base+"-unplugged")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Rename: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = s.Put(ctx, "blobs/aa/bb/second", strings.NewReader("second"))
|
||||||
|
if !errors.Is(err, fs.ErrNotExist) {
|
||||||
|
t.Errorf("Put = %v, want a not-exist error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = os.Stat(base)
|
||||||
|
if !errors.Is(err, fs.ErrNotExist) {
|
||||||
|
t.Errorf("Put created the destination directory again (Stat = %v)", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user