Compare commits
103
Commits
main
..
71a232d0f7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71a232d0f7 | ||
|
|
aae7fa17ae | ||
|
|
9236c0727c | ||
|
|
4418dc3e97 | ||
|
|
5a0da6da12 | ||
|
|
13a35a986a | ||
|
|
5abacbc5c1 | ||
|
|
ff50a137dd | ||
|
|
e9b17cc5fd | ||
|
|
850e2de25a | ||
|
|
6a57750a7b | ||
|
|
2590f49583 | ||
|
|
5b8d84e38a | ||
|
|
7a950ea2aa | ||
|
|
16b03dfda5 | ||
|
|
2125a9d451 | ||
|
|
a0de8bfdb7 | ||
|
|
291c39174f | ||
|
|
65036bba1b | ||
|
|
54ad279dd6 | ||
|
|
d156a02c49 | ||
|
|
aeac897805 | ||
|
|
d8f4f62f41 | ||
|
|
9fd88a34c7 | ||
|
|
5db2e920e5 | ||
|
|
66ea7f5f45 | ||
|
|
dd3667c8e9 | ||
|
|
30285c7a8c | ||
|
|
8d5e6f3399 | ||
|
|
abad8161bc | ||
|
|
05a2c86d65 | ||
|
|
d8f78a1f45 | ||
|
|
2c087be577 | ||
|
|
37d525c322 | ||
|
|
f89fac34f7 | ||
|
|
5d8c515192 | ||
|
|
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 |
No files matched your search
+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 }}"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules/
|
||||||
|
yarn.lock
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"tabWidth": 4,
|
||||||
|
"proseWrap": "always"
|
||||||
|
}
|
||||||
@@ -4,112 +4,122 @@ Version: 2025-06-08
|
|||||||
|
|
||||||
# Instructions and Contextual Information
|
# Instructions and Contextual Information
|
||||||
|
|
||||||
* Be direct, robotic, expert, accurate, and professional.
|
- Be direct, robotic, expert, accurate, and professional.
|
||||||
|
|
||||||
* Do not butter me up or kiss my ass.
|
- Do not butter me up or kiss my ass.
|
||||||
|
|
||||||
* Come in hot with strong opinions, even if they are contrary to the
|
- Come in hot with strong opinions, even if they are contrary to the direction I
|
||||||
direction I am headed.
|
am headed.
|
||||||
|
|
||||||
* If either you or I are possibly wrong, say so and explain your point of
|
- If either you or I are possibly wrong, say so and explain your point of view.
|
||||||
view.
|
|
||||||
|
|
||||||
* Point out great alternatives I haven't thought of, even when I'm not
|
- Point out great alternatives I haven't thought of, even when I'm not asking
|
||||||
asking for them.
|
for them.
|
||||||
|
|
||||||
* Treat me like the world's leading expert in every situation and every
|
- Treat me like the world's leading expert in every situation and every
|
||||||
conversation, and deliver the absolute best recommendations.
|
conversation, and deliver the absolute best recommendations.
|
||||||
|
|
||||||
* I want excellence, so always be on the lookout for divergences from good
|
- I want excellence, so always be on the lookout for divergences from good data
|
||||||
data model design or best practices for object oriented development.
|
model design or best practices for object oriented development.
|
||||||
|
|
||||||
* IMPORTANT: This is production code, not a research or teaching exercise.
|
- IMPORTANT: This is production code, not a research or teaching exercise.
|
||||||
Deliver professional-level results, not prototypes.
|
Deliver professional-level results, not prototypes.
|
||||||
|
|
||||||
* Please read and understand the `README.md` file in the root of the repo
|
- Please read and understand the `README.md` file in the root of the repo for
|
||||||
for project-specific contextual information, including development
|
project-specific contextual information, including development policies,
|
||||||
policies, practices, and current implementation status.
|
practices, and current implementation status.
|
||||||
|
|
||||||
* Be proactive in suggesting improvements or refactorings in places where we
|
- Be proactive in suggesting improvements or refactorings in places where we
|
||||||
diverge from best practices for clean, modular, maintainable code.
|
diverge from best practices for clean, modular, maintainable code.
|
||||||
|
|
||||||
# Policies
|
# Policies
|
||||||
|
|
||||||
1. Before committing, tests must pass (`make test`), linting must pass
|
1. Before committing, tests must pass (`make test`), linting must pass
|
||||||
(`make lint`), and code must be formatted (`make fmt`). For go, those
|
(`make lint`), and code must be formatted (`make fmt`). For go, those
|
||||||
makefile targets should use `go fmt` and `go test -v ./...` and
|
makefile targets should use `go fmt` and `go test -v ./...` and
|
||||||
`golangci-lint run`. Each Makefile target does exactly one thing — to
|
`golangci-lint run`. Each Makefile target does exactly one thing — to run
|
||||||
run lint + fmt-check + test together (the standard pre-commit gate),
|
lint + fmt-check + test together (the standard pre-commit gate), use
|
||||||
use `make check`.
|
`make check`.
|
||||||
|
|
||||||
2. Always write a `Makefile` with the default target being `test`, and with
|
2. Always write a `Makefile` with the default target being `test`, and with a
|
||||||
a `fmt` target that formats the code. The `test` target should run all
|
`fmt` target that formats the code. The `test` target should run all tests in
|
||||||
tests in the project, and the `fmt` target should format the code.
|
the project, and the `fmt` target should format the code. `test` should also
|
||||||
`test` should also have a prerequisite target `lint` that should run any
|
have a prerequisite target `lint` that should run any linters that are
|
||||||
linters that are configured for the project.
|
configured for the project.
|
||||||
|
|
||||||
3. After each completed bugfix or feature, the code must be committed. Do
|
3. After each completed bugfix or feature, the code must be committed. Do all of
|
||||||
all of the pre-commit checks (test, lint, fmt) before committing, of
|
the pre-commit checks (test, lint, fmt) before committing, of course.
|
||||||
course.
|
|
||||||
|
|
||||||
4. When creating a very simple test script for testing out a new feature,
|
4. When creating a very simple test script for testing out a new feature,
|
||||||
instead of making a throwaway to be deleted after verification, write an
|
instead of making a throwaway to be deleted after verification, write an
|
||||||
actual test file into the test suite. It doesn't need to be very big or
|
actual test file into the test suite. It doesn't need to be very big or
|
||||||
complex, but it should be a real test that can be run.
|
complex, but it should be a real test that can be run.
|
||||||
|
|
||||||
5. When you are instructed to make the tests pass, DO NOT delete tests, skip
|
5. When you are instructed to make the tests pass, DO NOT delete tests, skip
|
||||||
tests, or change the tests specifically to make them pass (unless there
|
tests, or change the tests specifically to make them pass (unless there is a
|
||||||
is a bug in the test). This is cheating, and it is bad. You should only
|
bug in the test). This is cheating, and it is bad. You should only be
|
||||||
be modifying the test if it is incorrect or if the test is no longer
|
modifying the test if it is incorrect or if the test is no longer relevant.
|
||||||
relevant. In almost all cases, you should be fixing the code that is
|
In almost all cases, you should be fixing the code that is being tested, or
|
||||||
being tested, or updating the tests to match a refactored implementation.
|
updating the tests to match a refactored implementation.
|
||||||
|
|
||||||
6. When dealing with dates and times or timestamps, always use, display, and
|
6. When dealing with dates and times or timestamps, always use, display, and
|
||||||
store UTC. Set the local timezone to UTC on startup. If the user needs
|
store UTC. Set the local timezone to UTC on startup. If the user needs to see
|
||||||
to see the time in a different timezone, store the user's timezone in a
|
the time in a different timezone, store the user's timezone in a separate
|
||||||
separate field and convert the UTC time to the user's timezone when
|
field and convert the UTC time to the user's timezone when displaying it. For
|
||||||
displaying it. For internal use and internal applications and
|
internal use and internal applications and administrative purposes, always
|
||||||
administrative purposes, always display UTC.
|
display UTC.
|
||||||
|
|
||||||
7. Always write tests, even if they are extremely simple and just check for
|
7. Always write tests, even if they are extremely simple and just check for
|
||||||
correct syntax (ability to compile/import). If you are writing a new
|
correct syntax (ability to compile/import). If you are writing a new feature,
|
||||||
feature, write a test for it. You don't need to target complete
|
write a test for it. You don't need to target complete coverage, but you
|
||||||
coverage, but you should at least test any new functionality you add. If
|
should at least test any new functionality you add. If you are fixing a bug,
|
||||||
you are fixing a bug, write a test first that reproduces the bug, and
|
write a test first that reproduces the bug, and then fix the bug in the code.
|
||||||
then fix the bug in the code.
|
|
||||||
|
|
||||||
8. When implementing new features, be aware of potential side-effects (such
|
8. When implementing new features, be aware of potential side-effects (such as
|
||||||
as state files on disk, data in the database, etc.) and ensure that it is
|
state files on disk, data in the database, etc.) and ensure that it is
|
||||||
possible to mock or stub these side-effects in tests.
|
possible to mock or stub these side-effects in tests.
|
||||||
|
|
||||||
9. Always use structured logging. Log any relevant state/context with the
|
9. Always use structured logging. Log any relevant state/context with the
|
||||||
messages (but do not log secrets). If the log stream is not a terminal,
|
messages (but do not log secrets). If the log stream is not a terminal,
|
||||||
output the structured logs in jsonl format.
|
output the structured logs in jsonl format.
|
||||||
|
|
||||||
10. Avoid using bare strings or numbers in code, especially if they appear
|
10. Avoid using bare strings or numbers in code, especially if they appear
|
||||||
anywhere more than once. Always define a constant (usually at the top
|
anywhere more than once. Always define a constant (usually at the top of the
|
||||||
of the file) and give it a descriptive name, then use that constant in
|
file) and give it a descriptive name, then use that constant in the code
|
||||||
the code instead of the bare string or number.
|
instead of the bare string or number.
|
||||||
|
|
||||||
11. You do not need to summarize your changes in the chat after making them.
|
11. You do not need to summarize your changes in the chat after making them.
|
||||||
Making the changes and committing them is sufficient. If anything out
|
Making the changes and committing them is sufficient. If anything out of the
|
||||||
of the ordinary happened, please explain it, but in the normal case
|
ordinary happened, please explain it, but in the normal case where you found
|
||||||
where you found and fixed the bug, or implemented the feature, there is
|
and fixed the bug, or implemented the feature, there is no need for the
|
||||||
no need for the end-of-change summary.
|
end-of-change summary.
|
||||||
|
|
||||||
12. Do not create additional files in the root directory of the project
|
12. Do not create additional files in the root directory of the project without
|
||||||
without asking permission first. Configuration files, documentation, and
|
asking permission first. Configuration files, documentation, and build files
|
||||||
build files are acceptable in the root, but source code and other files
|
are acceptable in the root, but source code and other files should be
|
||||||
should be organized in appropriate subdirectories.
|
organized in appropriate subdirectories.
|
||||||
|
|
||||||
13. Pre-1.0: NEVER 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 change
|
||||||
backup. To change the schema, edit `internal/database/schema/001.sql`
|
the schema, edit `internal/database/schema/001.sql` (and any code that
|
||||||
(and any code that touches the affected tables) directly; do not add new
|
touches the affected tables) directly. After 1.0, each schema change is a
|
||||||
numbered schema files. Those numbered files and the `schema_migrations`
|
new numbered file in that directory and a released file is never edited; an
|
||||||
table they populate only bootstrap a fresh database — they are not an
|
existing local database is then migrated when vaultik is updated. Before
|
||||||
upgrade path. The local index is disposable until 1.0 ships and is
|
1.0, a local database left on an older schema is deleted and re-created by a
|
||||||
tagged; once 1.0 is tagged that clause expires and the question of
|
full backup. See [`docs/DATAMODEL.md`](docs/DATAMODEL.md#schema-migrations).
|
||||||
upgrading existing indexes returns. See [`docs/DATAMODEL.md`](docs/DATAMODEL.md)
|
|
||||||
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.
|
||||||
@@ -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
|
||||||
|
|||||||
+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.
|
||||||
@@ -1,696 +0,0 @@
|
|||||||
# Workflow
|
|
||||||
|
|
||||||
* branch (from `main`)
|
|
||||||
* do the work in Next Step
|
|
||||||
* move Next Step to the top of Completed Steps
|
|
||||||
* move the top item of Future Steps into Next Step
|
|
||||||
* commit (`TODO.md` changes in the same commit as the work)
|
|
||||||
* merge to `main` if the branch is not protected, otherwise open a PR
|
|
||||||
* push
|
|
||||||
|
|
||||||
# Status
|
|
||||||
|
|
||||||
pre-1.0
|
|
||||||
|
|
||||||
# Next Step
|
|
||||||
|
|
||||||
Define the remaining scope for the first tagged release under the 1.0.0
|
|
||||||
milestone, then cut that tag. The mechanism to cut it now exists and is
|
|
||||||
exercised; what is left is the scope decision, which is the owner's.
|
|
||||||
This step deliberately names one version number: it previously said
|
|
||||||
"cut v0.1.0" while the `Makefile` baked in `1.0.0-rc.1` and the issue
|
|
||||||
milestone said 1.0.0, and three different answers to "what is the next
|
|
||||||
release" is exactly the contradiction
|
|
||||||
[issue #65](https://git.eeqj.de/sneak/vaultik/issues/65) was filed over.
|
|
||||||
|
|
||||||
# Completed Steps
|
|
||||||
|
|
||||||
- 2026-09-22: Routed the last direct-to-stdout command output through
|
|
||||||
`internal/ui`
|
|
||||||
([issue #149](https://git.eeqj.de/sneak/vaultik/issues/149)). The
|
|
||||||
`version`, `info`, `remote info`, `config`, and `database delete`
|
|
||||||
commands wrote plain text straight to stdout, so they were unstyled and
|
|
||||||
ignored `--quiet`. Output now falls in two buckets, both governed by
|
|
||||||
`internal/ui`: status lines and confirmations go through its message
|
|
||||||
methods (styled, and `--quiet` silences them), while the data a command
|
|
||||||
exists to produce — the reports, the `snapshot list` table, `config get`
|
|
||||||
values, and the `--json` documents — is written plain. `--quiet`
|
|
||||||
silences the human reports and tables but never the `config get` value
|
|
||||||
or the `--json` documents, which a script depends on, and the
|
|
||||||
`database delete` confirmation prompt is always shown. The README
|
|
||||||
output-style section now states this rule.
|
|
||||||
|
|
||||||
- 2026-09-22: Validated blob hashes, offsets and lengths read back from
|
|
||||||
the destination before using them
|
|
||||||
([issue #155](https://git.eeqj.de/sneak/vaultik/issues/155)). A blob
|
|
||||||
hash taken from the downloaded database or the store listing was
|
|
||||||
trusted unchecked, so a hostile remote could drive a decrypted blob to
|
|
||||||
be written outside the cache directory (a hash like `aa/../../etc`) or
|
|
||||||
crash a command with a short or negative value. `blobDiskCache.path`
|
|
||||||
now refuses any key containing a path separator, and `ReadAt` rejects a
|
|
||||||
negative offset or length and bounds with `length > size-offset` so a
|
|
||||||
sum cannot overflow past the check. A new `isBlobHash` helper (a plain
|
|
||||||
function, not a method — the packer stores `temp-placeholder-{uuid}` as
|
|
||||||
a hash) gates `FetchBlob`, shallow and deep verify; the `blobs/` and
|
|
||||||
`metadata/` listings skip a non-conforming name with a warning; and
|
|
||||||
short-hash prefixes in log and error text go through a `shortHash`
|
|
||||||
helper that cannot panic. `verify`'s chunk reader also rejects a
|
|
||||||
negative `blob_chunks` length and streams the chunk instead of
|
|
||||||
allocating a database-supplied size. `restore.go` and
|
|
||||||
`internal/database` were left untouched to avoid colliding with the
|
|
||||||
in-flight [issue #156](https://git.eeqj.de/sneak/vaultik/issues/156)
|
|
||||||
work; the cache-path and `FetchBlob` guards already stop the unsafe
|
|
||||||
write and fetch, so restore's own `buildBlobIndexes` early check is
|
|
||||||
deferred as fail-fast defense in depth.
|
|
||||||
|
|
||||||
- 2026-09-21: Stopped an interrupted blob upload from making a later
|
|
||||||
backup deduplicate against data that was never stored
|
|
||||||
([issue #148](https://git.eeqj.de/sneak/vaultik/issues/148)). The
|
|
||||||
packer commits a blob's `chunks`, `blob_chunks`, and `blobs` rows
|
|
||||||
before the upload is attempted, so a failed upload left chunk rows
|
|
||||||
behind and the next run skipped re-uploading them, producing a
|
|
||||||
snapshot that reported success but could not be restored. A run now
|
|
||||||
deduplicates only against chunks held by a blob whose `uploaded_ts` is
|
|
||||||
set, and at startup drops any un-uploaded blob rows (and the chunks
|
|
||||||
they orphan) so the affected data is re-chunked and re-uploaded. Blobs
|
|
||||||
recorded with no remote backend are marked uploaded so this invariant
|
|
||||||
holds uniformly.
|
|
||||||
|
|
||||||
- 2026-09-22: Made restore refuse any snapshot path that would write
|
|
||||||
outside the target directory
|
|
||||||
([issue #154](https://git.eeqj.de/sneak/vaultik/issues/154)).
|
|
||||||
`restoreFile` and `verifyRestoredFiles` joined the stored path onto the
|
|
||||||
target with no containment check, so a `..` segment or an absolute path
|
|
||||||
escaped the target and a restored symlink could redirect a later child
|
|
||||||
write anywhere on disk. Every stored path is now rejected unless
|
|
||||||
`filepath.IsLocal` accepts it with the leading separator removed, and
|
|
||||||
each existing ancestor directory below the target is `Lstat`ed to refuse
|
|
||||||
descending through a symlink; honest symlinks pointing outside the tree
|
|
||||||
are still written verbatim. age decryption proves a snapshot is
|
|
||||||
readable, not honest, and restore usually runs as root.
|
|
||||||
|
|
||||||
- 2026-09-21: Stopped `--json` from silencing stderr diagnostics
|
|
||||||
([issue #112](https://git.eeqj.de/sneak/vaultik/issues/112)). `--json`
|
|
||||||
used to be folded into `Quiet`, which pinned the log level to `WARN`,
|
|
||||||
so `prune --json` gave a machine consumer no record of the local index
|
|
||||||
rows it deleted even under `--verbose`. `--json` now quiets only the
|
|
||||||
stdout UI (the JSON document must stay clean, per
|
|
||||||
[issue #108](https://git.eeqj.de/sneak/vaultik/issues/108)); the stderr
|
|
||||||
log level follows `--verbose`/`--debug` again. The coupling was
|
|
||||||
removed the same way for `snapshot verify`, `snapshot remove`, and
|
|
||||||
`remote info`, which carried it for the same outdated reason.
|
|
||||||
|
|
||||||
- 2026-09-21: Stopped `prune` from reporting a failed row count as 0
|
|
||||||
([issue #96](https://git.eeqj.de/sneak/vaultik/issues/96)). The seven
|
|
||||||
`getTableCount` reads in `PruneDatabase` discarded their error, so a
|
|
||||||
query that could not run became a plausible `0` and the before/after
|
|
||||||
delta computed from it looked like real work. Each read now logs at
|
|
||||||
warn on failure and renders as `unknown`, never `0`, so an empty table
|
|
||||||
is distinguishable from one that could not be queried. The counts have
|
|
||||||
no `--json` representation — under `--json` the summary is suppressed
|
|
||||||
entirely — so nothing there can show a false `0`.
|
|
||||||
|
|
||||||
- 2026-09-21: Made the s3 storage backend report a missing object as
|
|
||||||
`storage.ErrNotFound`, like the `file` and `rclone` backends and as the
|
|
||||||
`Storer` interface documents. `S3Storer.Get` and `Stat` returned the raw
|
|
||||||
AWS SDK error, so `errors.Is(err, storage.ErrNotFound)` was false on s3
|
|
||||||
and callers branched differently per backend. Added a small `s3.IsNotFound`
|
|
||||||
helper (reused by `HeadObject`) and a test that a missing key maps to
|
|
||||||
`ErrNotFound`
|
|
||||||
([issue #129](https://git.eeqj.de/sneak/vaultik/issues/129)).
|
|
||||||
- 2026-09-21: Fixed `verify --deep` reporting healthy snapshots as
|
|
||||||
corrupt. Its final blob-integrity check hashed the encrypted
|
|
||||||
downloaded bytes with a single SHA256 and compared that to the blob
|
|
||||||
ID, which is the double SHA256 of the plaintext, so the two could
|
|
||||||
never match. It now hashes the decompressed plaintext and compares the
|
|
||||||
double SHA256. Added a test that backs up a real snapshot, deep-verifies
|
|
||||||
it, then flips a byte in one stored blob and confirms deep verification
|
|
||||||
then fails
|
|
||||||
([issue #131](https://git.eeqj.de/sneak/vaultik/issues/131)).
|
|
||||||
|
|
||||||
- 2026-09-21: Made `snapshot create` VACUUM the per-snapshot metadata
|
|
||||||
database through the `modernc.org/sqlite` driver instead of shelling
|
|
||||||
out to the external `sqlite` command-line binary (issue #120). A
|
|
||||||
backup no longer needs that binary on `PATH`, so `make check` passes
|
|
||||||
on a stock `go install` host; `script/bootstrap` and the `Dockerfile`
|
|
||||||
(both the test-build and the shipped runtime stage) no longer install
|
|
||||||
it, and a new test asserts the uploaded database keeps no pages from
|
|
||||||
deleted rows. Dropped the now-false note on the 2026-08-07 entry below
|
|
||||||
that said bootstrap installs it.
|
|
||||||
- 2026-09-21: Made `.gitea/workflows/check.yml` run on pushes to `main`
|
|
||||||
and `next` and on pull requests against either, so unit PRs (whose
|
|
||||||
base is `next`) and `next` itself get a CI run instead of relying on a
|
|
||||||
local `make check`
|
|
||||||
([issue #122](https://git.eeqj.de/sneak/vaultik/issues/122)).
|
|
||||||
|
|
||||||
- 2026-09-21: Hash-verified the Go toolchain in the release workflow
|
|
||||||
([issue #105](https://git.eeqj.de/sneak/vaultik/issues/105)). New
|
|
||||||
`script/install-go` downloads the exact `go.dev` archive for `go.mod`'s
|
|
||||||
`go` directive and refuses it unless its sha256 matches a value
|
|
||||||
committed in the script; `.gitea/workflows/release.yml` calls it
|
|
||||||
instead of `actions/setup-go`, which verified the downloaded toolchain
|
|
||||||
against nothing in the repo. `GOTOOLCHAIN: local` on the release step
|
|
||||||
keeps that exact compiler from auto-switching. Bumping Go now touches
|
|
||||||
`go.mod`, the checksum, and the `Dockerfile` `golang` digest together.
|
|
||||||
|
|
||||||
- 2026-09-21: Collapsed the two duration parsers into one and fixed the
|
|
||||||
`--older-than` months example
|
|
||||||
([issue #123](https://git.eeqj.de/sneak/vaultik/issues/123)). Two
|
|
||||||
functions named `parseDuration` existed with different grammars;
|
|
||||||
`snapshot purge --older-than` and `--keep-newer-than` both already went
|
|
||||||
through the one in `internal/vaultik`, while the richer copy in
|
|
||||||
`internal/cli/duration.go` was reachable only from its own test. Kept
|
|
||||||
the live-path parser and deleted the unused one, so no flag's accepted
|
|
||||||
grammar changes. The trap the issue was filed over: `README.md`
|
|
||||||
documented `6m` as the months example for `--older-than`, but `m` is
|
|
||||||
minutes, so the documented command deleted every snapshot older than
|
|
||||||
six minutes on a destructive flag. Corrected the doc to `6mo` and put
|
|
||||||
both flags' help text on one example list that states `m` is minutes
|
|
||||||
and `mo` is months. The surviving parser now rejects negatives, which
|
|
||||||
it previously accepted (`-5h`) or silently made positive (`-5d`).
|
|
||||||
Table-driven tests cover every unit, `6m` as six minutes, `6mo` as 180
|
|
||||||
days, and rejection of a bare number, an unknown unit, and a negative.
|
|
||||||
|
|
||||||
- 2026-08-10: Moved every lint run into its own container, as a build
|
|
||||||
step ([issue #113](https://git.eeqj.de/sneak/vaultik/issues/113)).
|
|
||||||
New root `Dockerfile.lint`, built by `script/lint`, runs
|
|
||||||
`golangci-lint run --config .golangci.yml ./...` as a `RUN`
|
|
||||||
instruction in the digest-pinned `golangci/golangci-lint` image: a
|
|
||||||
successful build of that file *is* a clean lint, and it works even
|
|
||||||
where the daemon is remote and bind mounts are impossible. That
|
|
||||||
`FROM` line is now the only pin of the linter version in the repo.
|
|
||||||
|
|
||||||
This supersedes the per-worktree cache isolation landed for
|
|
||||||
[issue #99](https://git.eeqj.de/sneak/vaultik/issues/99). Isolation
|
|
||||||
fixed cross-worktree contamination but not lock contention — two
|
|
||||||
concurrent runs with entirely separate cache directories still
|
|
||||||
collided. A container per run has its own cache and its own lock, so
|
|
||||||
the whole class is gone, and with it the per-worktree cache
|
|
||||||
machinery, the lock-retry loop, and `script/lint-audit`, which
|
|
||||||
existed to catch replayed findings from a cache that no longer
|
|
||||||
exists. The host lint path went too: no escape hatch, no
|
|
||||||
`VAULTIK_LINT_IN_CONTAINER`, no version detection. Nothing lints on
|
|
||||||
the host at any version.
|
|
||||||
|
|
||||||
A cached build lints nothing, so the same `CHECK_EPOCH` mechanism the
|
|
||||||
product `Dockerfile` already used is what makes the green mean
|
|
||||||
something: `ARG CHECK_EPOCH` with no default below the module layers,
|
|
||||||
a `RUN [ -n "$CHECK_EPOCH" ] || exit 1` guard, the value expanded
|
|
||||||
into each check command, and a fresh `$(date +%s%N)$$` per invocation
|
|
||||||
computed as a bare assignment. `cmd/vaultik/lintdocker_test.go`
|
|
||||||
parses both Dockerfiles and both scripts and fails if any part of
|
|
||||||
that is dropped, because every way of losing it is silent. No test
|
|
||||||
asserts that no script runs the host linter: `script/lint` is the one
|
|
||||||
lint entry point and runs `golangci-lint` only inside the container,
|
|
||||||
and keeping it that way is a review matter, not something a test
|
|
||||||
proves.
|
|
||||||
|
|
||||||
The product `Dockerfile` lost its lint stage rather than gaining a
|
|
||||||
second linter pin: `make lint` is now `docker build`, so the stage
|
|
||||||
would have been docker-in-docker with no daemon, and calling
|
|
||||||
`golangci-lint` directly there would have restored the two-pins drift
|
|
||||||
of [issue #78](https://git.eeqj.de/sneak/vaultik/issues/78).
|
|
||||||
`make fmt-check` moved beside `make test` in the builder stage, and
|
|
||||||
`script/cibuild` now builds `Dockerfile.lint` and then `Dockerfile`,
|
|
||||||
each with its own fresh epoch. Consequence, stated rather than left
|
|
||||||
to be found: `script/docker` builds the product image only and no
|
|
||||||
longer lints; the gates are `script/check` and `script/cibuild`.
|
|
||||||
|
|
||||||
`golangci-lint config verify` runs as its own epoch-keyed layer,
|
|
||||||
above the lint. `golangci-lint run` rejects a config it cannot parse
|
|
||||||
but silently ignores an unknown top-level *key*: renaming `linters:`
|
|
||||||
to `linterz:` discarded `default: all` and every threshold and still
|
|
||||||
exited 0 on a tree the real config fails. `config verify` catches
|
|
||||||
that, and it does so with the network off at this pin — checked under
|
|
||||||
`docker run --network none`, not assumed. An earlier revision omitted
|
|
||||||
it on the claim that it fetches its schema over live HTTPS; that
|
|
||||||
claim was false at v2.12.2.
|
|
||||||
|
|
||||||
`script/lint-fix` is kept, reimplemented as a
|
|
||||||
bind-mounted `docker run` against the image parsed out of
|
|
||||||
`Dockerfile.lint` — it cannot be a build step, because fixes have to
|
|
||||||
land in the worktree — and marked in its header as a developer
|
|
||||||
convenience that no gate reads.
|
|
||||||
|
|
||||||
- 2026-08-09: Finished the `--json` stdout contract and gave `make build`
|
|
||||||
a rule ([issue #108](https://git.eeqj.de/sneak/vaultik/issues/108),
|
|
||||||
[issue #110](https://git.eeqj.de/sneak/vaultik/issues/110)). Two
|
|
||||||
unrelated defects of the same shape — a command reporting something it
|
|
||||||
did not do — landed together because both are small.
|
|
||||||
|
|
||||||
`CleanupLocalSnapshots` wrote three prose lines to stdout with no
|
|
||||||
`--json` awareness, covering every branch of the function, so no input
|
|
||||||
avoided them and `vaultik prune --json | jq` failed even after
|
|
||||||
[issue #106](https://git.eeqj.de/sneak/vaultik/issues/106) removed the
|
|
||||||
banner. `-q` never helped either: `printlnStdout` and `stdoutf` write
|
|
||||||
straight to `Vaultik.Stdout` and never consult `Vaultik.UI`, which is
|
|
||||||
what `SetQuiet` affects. The issue offered three fixes and asked for a
|
|
||||||
decision. Taken: thread `*PruneOptions` into the function and gate each
|
|
||||||
write on `!opts.JSON`, matching `PruneBlobs` (its sibling phase, which
|
|
||||||
already takes the same struct), `RemoveSnapshot` and `remote info`, so
|
|
||||||
the package has one pattern rather than two. Rejected: moving the lines
|
|
||||||
to `log.Info`, because the logger's default level is `slog.LevelWarn`,
|
|
||||||
so that would not relocate them to stderr — it would delete them from a
|
|
||||||
plain `vaultik prune`, and the removal of rows from the local index is
|
|
||||||
not something to narrate only under `--verbose`. Also rejected: putting
|
|
||||||
the stale-record count into `PruneBlobsResult`, whose every field is
|
|
||||||
blob-scoped and which is produced by the later phase; a prune document
|
|
||||||
covering both phases is a reasonable thing to want, but it is a schema
|
|
||||||
design question and not a stream-hygiene fix. The narration is
|
|
||||||
duplicated as `log.Info` records, which `PruneBlobs` already does
|
|
||||||
alongside its own prints, so the events survive on stderr for anyone
|
|
||||||
running `--verbose`.
|
|
||||||
|
|
||||||
`make build` printed "Nothing to be done for 'build'" and exited 0
|
|
||||||
without producing a binary: `build` was listed in `.PHONY` with no
|
|
||||||
`build:` rule anywhere, and declaring a name phony is exactly what
|
|
||||||
converts make's "No rule to make target" error into a silent success.
|
|
||||||
Fixed with `build: vaultik`, keeping `vaultik:` as the file rule. The
|
|
||||||
audit the issue asked for covers all 19 `.PHONY` names; `build` was the
|
|
||||||
only one without a rule, and `vaultik` is correctly absent from
|
|
||||||
`.PHONY`, being a real file target.
|
|
||||||
|
|
||||||
Tests, each verified to fail with the fix reverted rather than assumed
|
|
||||||
to: `CleanupLocalSnapshots` leaves stdout untouched under `--json` in
|
|
||||||
all three branches (stale records, none, empty index) and still emits
|
|
||||||
every line without it, so the guard cannot be satisfied by deleting the
|
|
||||||
output; `prune --json` run end to end through `Entry`, cobra and fx
|
|
||||||
over the process's real stdout descriptor against a `file://` store,
|
|
||||||
asserting exactly one JSON document, in both the stale and non-stale
|
|
||||||
branches; and a parse of the `Makefile` asserting every `.PHONY` name
|
|
||||||
has a rule and that `build` reaches the rule that produces the binary,
|
|
||||||
which keeps the audit true for names added later. That last one is a
|
|
||||||
parse rather than an invocation of `make`, since `make test` is what
|
|
||||||
runs it and shelling back into `make build` would nest a build inside
|
|
||||||
the test run. The property a parse cannot establish — that the recipe
|
|
||||||
still fails when the build fails — was verified by hand against a
|
|
||||||
deliberately broken tree: `make build` exits 2 and produces nothing.
|
|
||||||
`cmd/vaultik` gains its first test file, so `make test` now reports 16
|
|
||||||
packages `ok` where it reported 15.
|
|
||||||
|
|
||||||
- 2026-08-09: Stopped the startup banner from contaminating `--json`
|
|
||||||
documents ([issue #106](https://git.eeqj.de/sneak/vaultik/issues/106)).
|
|
||||||
`Entry` writes the banner to stdout before cobra parses anything, and
|
|
||||||
the flag scan that suppresses it knew `--quiet`, `-q` and `--cron` but
|
|
||||||
not `--json`, so every `--json` document arrived behind two lines of
|
|
||||||
prose and a blank line, and `vaultik snapshot list --json | jq` failed.
|
|
||||||
With the logger already on stderr from
|
|
||||||
[issue #82](https://git.eeqj.de/sneak/vaultik/issues/82), this was the
|
|
||||||
last writer that could put something on stdout that the caller did not
|
|
||||||
ask for. The design question the issue raised — extend the raw-argv
|
|
||||||
scan, or move the banner after parsing — is answered in favour of the
|
|
||||||
scan: the banner is printed first deliberately, so that it still
|
|
||||||
appears when cobra rejects the arguments and on `--help`, and after
|
|
||||||
parsing there is no single place that covers those paths. The stated
|
|
||||||
cost of the scan, that `--json` is a subcommand flag matched anywhere
|
|
||||||
in the vector, is a cost `--cron` already carries — it exists only on
|
|
||||||
`snapshot create` — so this adds an instance of an accepted
|
|
||||||
imprecision rather than a new kind, and the two error directions are
|
|
||||||
not symmetric: a false positive loses a decorative banner, a false
|
|
||||||
negative corrupts a document. Regression tests at the CLI layer, where
|
|
||||||
`internal/vaultik`'s existing guard cannot reach: one runs `Entry`
|
|
||||||
itself over the process's real stdout descriptor, through cobra and fx
|
|
||||||
to the document, made hermetic by `file://` storage; a second covers
|
|
||||||
the argument vectors of all five `--json` commands; a third asserts the
|
|
||||||
banner is still printed without a suppressing flag, so the first
|
|
||||||
cannot be satisfied by deleting the banner. Also corrected `AGENTS.md`
|
|
||||||
policy 9, which still keyed the structured-log format on stdout's
|
|
||||||
TTY-ness after #82 moved that decision to stderr — a rules file that
|
|
||||||
misdescribes the code misleads exactly the readers who trust it most.
|
|
||||||
Two smaller findings from the same review: `bytesAttrKey`'s
|
|
||||||
human-readable byte formatting silently stopped applying under an open
|
|
||||||
group, because the key reaching the comparison is group-qualified
|
|
||||||
(`transfer.bytes`), now matched on its final segment and tested both
|
|
||||||
ways; and `listEnv.stderr` in `snapshot_list_test.go`, assigned but
|
|
||||||
never read since those tests began capturing the process's stderr, is
|
|
||||||
removed. `Vaultik.Stderr` is kept — nothing writes to it today, which
|
|
||||||
its comment now says outright.
|
|
||||||
|
|
||||||
- 2026-08-09: Moved the logger to stderr and fixed `TTYHandler`'s
|
|
||||||
discarded attributes
|
|
||||||
([issue #82](https://git.eeqj.de/sneak/vaultik/issues/82),
|
|
||||||
[issue #97](https://git.eeqj.de/sneak/vaultik/issues/97)). Two defects
|
|
||||||
in `internal/log`, fixed together because both live in the handler
|
|
||||||
construction path. The first: both handlers were built over
|
|
||||||
`os.Stdout`, and `WARN`/`ERROR` are never suppressed, so a config file
|
|
||||||
with group- or world-readable permissions was enough to put a log
|
|
||||||
record inside a `--json` document and break `jq`. Diagnostics now go
|
|
||||||
to stderr, and the TTY/JSON format choice follows stderr rather than
|
|
||||||
stdout — testing the wrong stream would colorize records on a
|
|
||||||
redirected stderr whenever stdout happened to be a terminal. This is
|
|
||||||
user-visible: `--verbose` and `--debug` output moves to stderr too,
|
|
||||||
which is documented in `README.md` under "stdout and stderr". It also
|
|
||||||
let the local workaround in `internal/vaultik/snapshot_list.go` go:
|
|
||||||
`warnWhileListing` had been hand-rolling structured-log formatting to
|
|
||||||
reach a non-stdout writer, and the `jsonOutput` parameter threaded
|
|
||||||
through the remote-listing helpers existed only to choose between the
|
|
||||||
two writers. The collect-then-emit machinery around `listingWarning`
|
|
||||||
stays, but on its remaining merit — warnings emitted in key order
|
|
||||||
after `group.Wait()` are deterministic run to run, where emitting from
|
|
||||||
the fetch workers would order them by network timing. The second
|
|
||||||
defect: `TTYHandler.WithAttrs` and `WithGroup` discarded their
|
|
||||||
arguments and returned the receiver while their doc comments claimed
|
|
||||||
otherwise, so `log.With` attributes vanished on a terminal and
|
|
||||||
appeared correctly in CI — failing precisely when someone is debugging
|
|
||||||
interactively. Both now return a new handler (the receiver is never
|
|
||||||
written to, since `slog` permits concurrent derivation), attributes
|
|
||||||
persist across records, and grouping is implemented as dotted key
|
|
||||||
prefixes, which is the only honest rendering for a format with nowhere
|
|
||||||
to nest. New tests cover both, including one that feeds the same
|
|
||||||
derivation chain to the TTY and JSON handlers and compares the
|
|
||||||
attribute sets, so the two paths cannot drift apart again. Found and
|
|
||||||
filed while verifying: the startup banner is written to stdout and
|
|
||||||
`--json` does not suppress it
|
|
||||||
([issue #106](https://git.eeqj.de/sneak/vaultik/issues/106)), which is
|
|
||||||
a separate writer on a separate path and the remaining source of
|
|
||||||
stdout contamination.
|
|
||||||
|
|
||||||
- 2026-08-09: Made the tagged-release path actually work on Gitea
|
|
||||||
([issue #65](https://git.eeqj.de/sneak/vaultik/issues/65)). Three
|
|
||||||
independent blockers, one of which was the whole
|
|
||||||
release: `.goreleaser.yaml` had no `gitea_urls:` block, so goreleaser
|
|
||||||
defaulted to the GitHub API and a `goreleaser release` from this repo
|
|
||||||
would have failed or published where nobody is looking. It now points
|
|
||||||
at `https://git.eeqj.de/api/v1`. The version is the second: it was a
|
|
||||||
hardcoded `VERSION := 1.0.0-rc.1` in the `Makefile`, so every local
|
|
||||||
build claimed to be a release candidate that had never been tagged and
|
|
||||||
did not exist, while `git tag -l` was empty and `internal/globals`
|
|
||||||
defaulted to `dev`. Version now comes from git via the new
|
|
||||||
`script/version` — the exact tag with a leading `v` stripped (so a
|
|
||||||
`make` build and a goreleaser build of one commit report the same
|
|
||||||
string, and it matches the archive names), otherwise `dev-<12-char
|
|
||||||
sha>`, with `-dirty` appended in either case when tracked files are
|
|
||||||
modified. Untracked files are deliberately not counted, matching
|
|
||||||
`git describe --dirty`. The same honesty was owed by the snapshot
|
|
||||||
path: `snapshot.version_template` was `{{ incpatch .Version }}-next`,
|
|
||||||
which manufactures a release number from the last tag and, with no
|
|
||||||
tags at all, from goreleaser's fabricated `v0.0.0`; it now emits the
|
|
||||||
same `dev-<sha>`. The one non-obvious consequence is that
|
|
||||||
`internal/cli/version.go` gated its "this is a development build"
|
|
||||||
notice on the version being exactly `dev`, so the moment untagged
|
|
||||||
builds began carrying a commit sha that notice would have gone silent
|
|
||||||
and an unreleased binary would have read as a release — the gate is
|
|
||||||
now `globals.IsDevVersion`, which is a predicate over a string rather
|
|
||||||
than a comparison against a global precisely so it can be tested, and
|
|
||||||
it is tested at the boundary (`1.0.0-dev` is a release, `dev-<sha>`
|
|
||||||
is not). Release automation is the third blocker: a tag-triggered
|
|
||||||
`.gitea/workflows/release.yml` runs the build in CI rather than from
|
|
||||||
a laptop, with `fetch-depth: 0` because a shallow checkout has no
|
|
||||||
tags and would silently mislabel the release, and with the
|
|
||||||
`RELEASE_TOKEN` repository secret passed as `GITEA_TOKEN` (documented
|
|
||||||
in `README.md`; the runner's automatic token is not used because it
|
|
||||||
is not guaranteed to carry release write scope). `script/release`
|
|
||||||
unsets any `GITHUB_TOKEN`/`GITLAB_TOKEN` it finds, since goreleaser
|
|
||||||
chooses its forge from whichever token variable is set and refuses to
|
|
||||||
run when it sees more than one — a runner-provided token must not get
|
|
||||||
to decide where these artifacts are published. `make release` and
|
|
||||||
`make release-snapshot`, the last two Makefile targets that were not
|
|
||||||
shims, now call `script/release` and `script/release-snapshot`, which
|
|
||||||
resolve goreleaser exactly the way `script/lint` resolves the linter:
|
|
||||||
a `PATH` binary is used only at the pinned version, never as a silent
|
|
||||||
fallback. `script/bootstrap` installs it, from a sha256-verified
|
|
||||||
GitHub release archive per `REPO_POLICIES.md`, via a separate
|
|
||||||
`script/install-goreleaser` — separate because `script/bootstrap`
|
|
||||||
hard-fails without a usable Docker daemon by design, and the release
|
|
||||||
runner needs goreleaser without needing Docker. Verified by running
|
|
||||||
the thing rather than reading it: `make release-snapshot` produced
|
|
||||||
four archives and `checksums.txt`, and the linux/amd64 binary from
|
|
||||||
`dist/` reports `dev-<sha>` with the development-build notice. Tag
|
|
||||||
handling was exercised in a throwaway repository rather than by
|
|
||||||
tagging this one; no tag was created here, since that is the owner's
|
|
||||||
call. Signing, SBOM, reproducible builds, completions and a man page
|
|
||||||
are out of scope by the issue.
|
|
||||||
|
|
||||||
- 2026-08-09: Isolated the lint cache per worktree and context-gated the
|
|
||||||
native lint path (issues #99, #80). One defect seen twice:
|
|
||||||
`script/lint` decided whether it could skip the pinned image by asking
|
|
||||||
what version was on `PATH` rather than where it was running, and cache
|
|
||||||
isolation is part of that same question. The cache was one directory
|
|
||||||
per repo, shared by every worktree on the host, so two checkouts with
|
|
||||||
identical Go file contents collided and golangci-lint replayed the
|
|
||||||
stored analysis — paths and all. The loud direction of that failure
|
|
||||||
(a clean tree failed by a dirty sibling) is the harmless one; the
|
|
||||||
silent direction, a dirty tree **passed** by a clean sibling, is a
|
|
||||||
sixth way for a gate here to report a green it did not earn. The cache
|
|
||||||
is now keyed on a digest of the worktree path, and every run is
|
|
||||||
audited by the new `script/lint-audit`, which rejects output citing any
|
|
||||||
file that is not in the tree being linted — a backstop that runs on
|
|
||||||
clean output too, because that is the case nobody investigates. Caches
|
|
||||||
record the worktree they belong to and are collected when it
|
|
||||||
disappears, so throwaway worktrees do not accumulate them; the whole
|
|
||||||
tree lives under `XDG_CACHE_HOME` and is disposable. The
|
|
||||||
`parallel golangci-lint is running` refusal is now a bounded retry
|
|
||||||
rather than a verdict: it is not a lint result, and exiting non-zero
|
|
||||||
on it is indistinguishable to a caller from real findings (#88 showed
|
|
||||||
a private cache does not remove that contention). The native path now
|
|
||||||
requires `VAULTIK_LINT_IN_CONTAINER=1`, set only by the `Dockerfile`
|
|
||||||
lint stage, in addition to matching the pin, so a developer's locally
|
|
||||||
installed 2.12.2 no longer bypasses the digest pin; `/.dockerenv` was
|
|
||||||
rejected as the signal because `dockerd` creates it for `docker run`
|
|
||||||
and it is not reliably present during a BuildKit `docker build`, which
|
|
||||||
is the case the exception exists for. Version detection uses
|
|
||||||
`golangci-lint version --short` with the old banner scrape kept only
|
|
||||||
as a fallback. `script/bootstrap` no longer prints `bootstrap
|
|
||||||
complete` on a machine that cannot run the gate: a missing docker, or
|
|
||||||
one whose daemon is unreachable, is a hard failure naming exactly what
|
|
||||||
breaks. Verification was by reproduction rather than inspection — two
|
|
||||||
concurrent lints from two worktrees of differing cleanliness, a real
|
|
||||||
run made to report an outside path, a matching linter shimmed onto
|
|
||||||
`PATH`, and a `PATH` with docker removed — and is recorded on the pull
|
|
||||||
request.
|
|
||||||
- 2026-08-09: Closed the fifth false-green mechanism (issues #93, #69).
|
|
||||||
`script/test` omitted `-count=1`, so Go's test result cache could
|
|
||||||
satisfy the gate outright: a second back-to-back `make test` printed
|
|
||||||
the full set of 14 `ok` lines, every one marked `(cached)`, having
|
|
||||||
executed no test at all. Since `ok <pkg> (cached)` is an `ok` line,
|
|
||||||
the "14 `ok` lines means the suite ran" signal this repo leans on was
|
|
||||||
forgeable, one level below the Docker layer cache that #85 addressed.
|
|
||||||
Fixed with `-count=1` unconditionally rather than only in the
|
|
||||||
container, because the pre-commit hook runs the same script and a
|
|
||||||
gate honest only in CI is dishonest where people rely on it most;
|
|
||||||
`test-coverage` got the same flag, and `script/check` inherits it by
|
|
||||||
calling `script/test`. In the same area, `make test-integration` was
|
|
||||||
deleted rather than made real: no file in the repo carried a build
|
|
||||||
tag, so `-tags=integration` selected nothing and the target was an
|
|
||||||
exact duplicate of `make test`. Tagging a subset was rejected because
|
|
||||||
the entire suite runs in well under a minute, and a scheme whose
|
|
||||||
failure mode is "some tests silently stopped running" is a poor trade
|
|
||||||
for those seconds in a repo with this particular history. The
|
|
||||||
`-timeout` was raised from 30s after measuring rather than after
|
|
||||||
assuming: the standing claim that cold-cache compilation is charged
|
|
||||||
against `-timeout` is **false**, disproved by a containerised run
|
|
||||||
that spent 46s compiling and still reported per-package durations
|
|
||||||
within noise of a warm host run. `-timeout` reaches the test binary
|
|
||||||
as `-test.timeout` and its clock starts inside `testing.M.Run`, after
|
|
||||||
the build. The real exposure was margin, not compilation. The 120s
|
|
||||||
landed on is a **deliberate, documented divergence** from
|
|
||||||
`REPO_POLICIES.md:192`, which mandates 30s, and from that file's
|
|
||||||
canonical recipe at `:212-214`; the divergence is recorded in
|
|
||||||
`script/test`'s comment because `REPO_POLICIES.md` is org-canonical
|
|
||||||
and not editable here, and issue #101 proposes amending the policy
|
|
||||||
text upstream. Numbers and the full verification are recorded once,
|
|
||||||
on the pull request, and are deliberately not restated here.
|
|
||||||
|
|
||||||
- 2026-08-09: Triaged all fifteen stale remote branches (issue #71) and
|
|
||||||
deleted fourteen of them; the full per-branch disposition with
|
|
||||||
evidence is recorded on that issue. Method mattered more than the
|
|
||||||
outcome here: a three-dot `git diff main...branch` diffs from the
|
|
||||||
merge base, so it replays everything that landed on `main` after the
|
|
||||||
branch diverged and makes any old branch look like it holds unlanded
|
|
||||||
work. That artifact is what made `golangci-v2.12.2` appear to carry
|
|
||||||
126 files of unpushed changes when its tree was byte-identical to
|
|
||||||
`main`'s. Every containment claim here therefore rests on two-dot tip
|
|
||||||
diffs, tree-hash equality, `git cherry`, and `git branch -r --merged`.
|
|
||||||
Nine branches were plain ancestors of `main` with zero `git cherry`
|
|
||||||
`+` commits. `golangci-v2.12.2` had landed squashed as `cc58583`,
|
|
||||||
whose tree hash equals the branch tip's exactly; note the hash
|
|
||||||
recorded in the issue had gone stale because `main` advanced, so the
|
|
||||||
check had to be redone rather than repeated.
|
|
||||||
`fix/sync-snapshot-cleanup` was redundant, its one line already on
|
|
||||||
`main` in `syncWithRemote`. `feature/restore-progress-bar` was
|
|
||||||
superseded by `printRestoreProgress` and the disk-backed blob cache,
|
|
||||||
and had become actively regressive — it would have deleted
|
|
||||||
`internal/blobgen/compress_test.go`, the #28 regression test that
|
|
||||||
landed separately. The two branches this issue was filed for both
|
|
||||||
turned out to be closed questions that `main` had already moved past
|
|
||||||
by a recorded decision, so neither was landed and no regression test
|
|
||||||
was owed: `ctime` no longer exists anywhere in the codebase after
|
|
||||||
`1c72a37` removed the column, the `File.CTime` field and every use
|
|
||||||
(#54/#55), and change detection compares size, mtime, mode, uid and
|
|
||||||
gid only, exactly as `ARCHITECTURE.md` documents — so the
|
|
||||||
silently-skipped-file data-loss risk that made this a 1.0 item does
|
|
||||||
not exist. The SQL allow-list branch would have reverted `bfd7334`,
|
|
||||||
which replaced that very allow-list with regex sanitisation on review
|
|
||||||
feedback, and would have broken `getTableCount("snapshots")` because
|
|
||||||
its allow-list omits that table. `feature/daemon-mode` is untouched
|
|
||||||
and deferred to #94 pending an owner decision, so it is the one
|
|
||||||
branch besides `main` still on the remote. The stale `TODO.md` entry
|
|
||||||
named in the issue needed no fix: `e496aa3` had already removed it.
|
|
||||||
No product code changed.
|
|
||||||
- 2026-08-09: Adopted the remaining upstream `CHECK_EPOCH` hardening
|
|
||||||
(issue #91), closing the gap #85 knowingly left open. Four changes,
|
|
||||||
all four decided as adopt upstream in `sneak/prompts` #26. (1) Each
|
|
||||||
check stage now asserts `[ -n "$CHECK_EPOCH" ] || exit 1` before
|
|
||||||
running anything, so a build that supplies no `--build-arg` fails
|
|
||||||
instead of lying. This is the item that mattered: an unset `ARG` is
|
|
||||||
an empty string and an empty string is a stable cache key, so the
|
|
||||||
second and every later bare `docker build .` on an unchanged tree
|
|
||||||
replayed all three check layers and still exited 0 — and `docker
|
|
||||||
build .` is the command `REPO_POLICIES.md` names verbatim as a thing
|
|
||||||
that must be green, so the documented command was precisely the one
|
|
||||||
that lied. Failed steps are never cached, which is what makes the
|
|
||||||
guard fire on every invocation rather than once. (2) The epoch is now
|
|
||||||
expanded into each check command rather than left as a bare
|
|
||||||
declaration, so the cache miss no longer depends on BuildKit's
|
|
||||||
unreferenced-`ARG` handling staying as it is, and the value appears
|
|
||||||
in the build log. (3) `script/cibuild` uses
|
|
||||||
`epoch="$(date +%s%N)$$"`, unique per invocation rather than per
|
|
||||||
second; `%N` alone is insufficient because busybox drops it silently
|
|
||||||
and exits 0, and `$$` is what makes the guarantee hold regardless.
|
|
||||||
The bare-assignment form is kept deliberately — inlined in an
|
|
||||||
argument, a failing substitution does not abort under `set -eu` and
|
|
||||||
would yield an empty constant epoch, restoring the exact false green
|
|
||||||
being fixed. (4) `script/docker` passes the same fresh arg, so the
|
|
||||||
two entrypoints cannot disagree about whether the tree is green;
|
|
||||||
local builds are almost always warm, which made it the likelier
|
|
||||||
fooling in practice. The `ARG` placement from #85 is unchanged, below
|
|
||||||
`apk add`, `COPY go.mod go.sum` and `go mod download`, so dependency
|
|
||||||
layers still cache and the build is not cold. Verified by negative
|
|
||||||
control rather than inspection — a bare `docker build .` run twice
|
|
||||||
back to back, plus back-to-back pairs of both scripts and a host-side
|
|
||||||
`make check`; the measurements are recorded once, in the PR
|
|
||||||
verification comment, rather than restated here. `.golangci.yml`, the
|
|
||||||
lint-stage `FROM` line and its digest, `script/lint`,
|
|
||||||
`REPO_POLICIES.md` and `.gitea/workflows/check.yml` are all
|
|
||||||
untouched.
|
|
||||||
- 2026-08-09: Stopped `script/cibuild` from reporting a green it did
|
|
||||||
not earn (issue #85). A bare `docker build .` let Docker serve the
|
|
||||||
check layers from the layer cache whenever the tree had not changed:
|
|
||||||
the checks never executed and the build still exited 0. The fix is an
|
|
||||||
`ARG CHECK_EPOCH` declared immediately above the check `RUN`s in both
|
|
||||||
the lint stage and the builder stage (`ARG` scope is per-stage, so
|
|
||||||
each declares its own), with `script/cibuild` assigning
|
|
||||||
`epoch="$(date +%s)"` and passing `--build-arg CHECK_EPOCH="$epoch"`.
|
|
||||||
The assignment is separate on purpose: under `set -eu` a command
|
|
||||||
substitution that fails inside an argument does not abort the script,
|
|
||||||
which would leave an empty constant `CHECK_EPOCH` and restore the
|
|
||||||
very false green being fixed. Placement is the rest of the point —
|
|
||||||
the `ARG` sits below the `apk add`, `COPY go.mod go.sum`, and `go mod
|
|
||||||
download` layers, so only the checks are invalidated and the
|
|
||||||
dependency layers still cache. The guarantee is conditional on a
|
|
||||||
fresh value rather than absolute: a bare `docker build .` gets an
|
|
||||||
empty `CHECK_EPOCH` and can still serve the check layers from cache,
|
|
||||||
which `README.md` and the `Dockerfile` now say plainly, with issue
|
|
||||||
#91 tracking the upstream hardening (expanded `ARG` form, unset
|
|
||||||
guard, per-invocation epoch, `script/docker`) that would close it.
|
|
||||||
Verified by re-running the reproduction plus the withheld-`--build-arg`
|
|
||||||
counterfactual; the measurements are recorded once, in the PR #89
|
|
||||||
verification comment, rather than restated here. `.golangci.yml`, the
|
|
||||||
lint-stage `FROM` line and its digest, `script/lint`, and
|
|
||||||
`.gitea/workflows/check.yml` are all untouched.
|
|
||||||
- 2026-08-09: Corrected the `Vaultik.UI` doc comment (issue #84). It
|
|
||||||
claimed the cli layer replaces the writer with a discarding one in
|
|
||||||
`--cron` mode; the actual mechanism is `UI.SetQuiet(true)` in
|
|
||||||
`setupGlobals`, which drops Begin/Complete/Info/Notice/Detail/
|
|
||||||
Progress/Banner but still emits Warning and Error. The `--cron` line
|
|
||||||
in `README.md` said "Silent unless error", which understated what
|
|
||||||
survives, and now names warnings too. The other `--cron` comments
|
|
||||||
(`internal/log/log.go`, `internal/cli/snapshot.go`,
|
|
||||||
`internal/vaultik/snapshot.go`) were audited and already accurate.
|
|
||||||
Comments and docs only, no behavior change.
|
|
||||||
- 2026-08-09: Made `snapshot list` list the destination store without
|
|
||||||
the private key (issue #64). The listing is now the union of the
|
|
||||||
local index and a single streamed listing of the `metadata/` prefix,
|
|
||||||
with no `age_secret_key` gate — the manifest is unencrypted, so a
|
|
||||||
host holding only the public key can enumerate its own backups and a
|
|
||||||
host that lost its local index can still see them. A remote-only
|
|
||||||
snapshot's hostname and name are deliberately not recovered (they are
|
|
||||||
not recoverable without the private key, and making them so would
|
|
||||||
undo the privacy property tracked in issue #81); such rows are
|
|
||||||
labelled by an abbreviation of their remote key and carry the real
|
|
||||||
timestamp and compressed size from the manifest, with `<remote only>`
|
|
||||||
in the two columns that require the local index. Local-only snapshots
|
|
||||||
are reported as drift, and the hint now names `vaultik prune`, which
|
|
||||||
exists, instead of `vaultik snapshot cleanup`, which does not.
|
|
||||||
`reportRemoteDrift` collapsed into the merged view. Every remote
|
|
||||||
manifest read in the codebase now goes through
|
|
||||||
`downloadManifestByKey`, so issue #81 has one call site to change.
|
|
||||||
Review rework: snapshot timestamps now normalize to UTC in
|
|
||||||
`scanSnapshotRows`, the one place they enter the domain, so the merged
|
|
||||||
TIMESTAMP column cannot show local time for a locally tracked row and
|
|
||||||
UTC for a remote-only row on a non-UTC host; `GetIncompleteByHostname`
|
|
||||||
was folded onto that same scanner. `--json` now reports the
|
|
||||||
unreadable-manifest count and the 1000-row truncation on stderr
|
|
||||||
instead of returning a silently short document (the document's shape
|
|
||||||
is unchanged). The two per-snapshot `log.Warn` calls on the listing
|
|
||||||
path now route through the same JSON-aware writer as the existing
|
|
||||||
workaround, so one corrupt manifest can no longer put a log line on
|
|
||||||
stdout ahead of the document and break `| jq` — still a local
|
|
||||||
workaround pending issue #82. Verified with `script/cibuild` and with
|
|
||||||
an uncached `make check` (`0 issues.`, no cached test packages), plus
|
|
||||||
end to end against a `file://` destination with no secret key present.
|
|
||||||
- 2026-08-09: Closed the gap between `make lint` and CI (issue #78).
|
|
||||||
`script/lint` now runs the digest-pinned `golangci-lint` image taken
|
|
||||||
from the `Dockerfile` lint stage, which is the single source of truth
|
|
||||||
for the linter version; the duplicate pin in the `Makefile` `deps`
|
|
||||||
target and the unpinned `golangci-lint` install in `script/bootstrap`
|
|
||||||
are gone. A `golangci-lint` on `PATH` is used only when its version is
|
|
||||||
exactly the pinned one (which is how the lint stage runs it inside the
|
|
||||||
container); anything else goes through Docker, and a missing or
|
|
||||||
unreachable Docker daemon is a hard error rather than a silent
|
|
||||||
fallback. Only the **lint** leg of `make check` became equivalent to
|
|
||||||
`script/cibuild`; its tests and `gofmt` still run on the host against
|
|
||||||
the host toolchain, as `README.md` states. An earlier version of this
|
|
||||||
entry claimed `make check` was "as trustworthy as `script/cibuild`"
|
|
||||||
outright, which overstated it; corrected under issue #80.
|
|
||||||
- 2026-08-09: Finished the lint remediation under the canonical
|
|
||||||
`.golangci.yml` (issue #61, which also unblocks issue #59). The
|
|
||||||
remaining findings were fixed behavior-preservingly: `wsl_v5`
|
|
||||||
whitespace, `sqlclosecheck`, and `prealloc`. The `sqlclosecheck` sites
|
|
||||||
now close `sql.Rows` in a deferred closure instead of via the
|
|
||||||
`CloseRows` helper, which the linter could not see through. Only the
|
|
||||||
`revive` package-name findings remain suppressed, with per-site
|
|
||||||
`//nolint` directives; the package-rename question behind them is
|
|
||||||
tracked in issue #76. Verified with `script/cibuild`, which exits 0 —
|
|
||||||
that is the only trustworthy gate, because `script/lint` runs whatever
|
|
||||||
`golangci-lint` happens to be on `PATH` rather than the pinned
|
|
||||||
v2.12.2 that CI and the `Dockerfile` use, so `make check` can report
|
|
||||||
green on findings CI still fails. That tooling gap is tracked in issue
|
|
||||||
#78.
|
|
||||||
- 2026-08-09: The earlier next step "reconcile the uncommitted
|
|
||||||
`ARCHITECTURE.md` edits on `main`" needed no work: the working tree is
|
|
||||||
clean and `ARCHITECTURE.md` is committed on `main`.
|
|
||||||
- 2026-08-07: Updated golangci-lint to v2.12.2 everywhere it is pinned
|
|
||||||
(`Dockerfile` lint stage, `Makefile` deps target), replaced
|
|
||||||
`.golangci.yml` with the canonical config (v2 schema, `default: all`),
|
|
||||||
and remediated the bulk of the lint findings it surfaced (issue #61):
|
|
||||||
behavior-preserving fixes across every package, 2,990 findings down to
|
|
||||||
80. `make test` and `make fmt-check` were green at that point but
|
|
||||||
`make lint` was still red; the commit message claiming `make check`
|
|
||||||
was green was wrong.
|
|
||||||
- 2026-08-07: Added the standard `.golangci.yml` and `.editorconfig`
|
|
||||||
(issue #59); lint findings under the new config are tracked in issue
|
|
||||||
#61.
|
|
||||||
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints,
|
|
||||||
Makefile shims, README Entrypoints section
|
|
||||||
- 2026-07-02: Consolidated CLI verbs, retired overlapping commands; bound
|
|
||||||
the local index to its backup destination URL.
|
|
||||||
- 2026-06-28: snapshot rm now removes metadata only and prints the prune
|
|
||||||
command; restore skips chown when running as non-root.
|
|
||||||
- 2026-06-26: Snapshot IDs hashed at the storage boundary; snapshot list
|
|
||||||
made resilient to bad remote entries.
|
|
||||||
- 2026-06-24: Collapsed snapshot prune into vaultik prune; restore streams
|
|
||||||
blobs to disk and restores files in blob-locality order; cron output
|
|
||||||
fixes.
|
|
||||||
- 2026-06-17: Restore overhaul: ReadAt chunk reads from cached blobs,
|
|
||||||
reference-counted blob sweeper, integration tests; new internal/ui
|
|
||||||
output layer, banner, and progress lines.
|
|
||||||
- 2025-12-18: Added ARCHITECTURE.md and godoc coverage for exported API.
|
|
||||||
- 2025-07-26: End-to-end integration tests; manifest format refactor;
|
|
||||||
renamed backup to snapshot; afero filesystem abstraction.
|
|
||||||
- 2025-07-20: Initial design and implementation: cobra + fx CLI skeleton,
|
|
||||||
SQLite index database, UUID blob storage with streaming chunking.
|
|
||||||
|
|
||||||
# Future Steps
|
|
||||||
|
|
||||||
None queued; the release-scoping item is now the Next Step.
|
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,20 @@ storage_url: "rclone://myremote/path/to/backups"
|
|||||||
# Higher = better compression but slower
|
# Higher = better compression but slower
|
||||||
# Default: 3
|
# Default: 3
|
||||||
compression_level: 5
|
compression_level: 5
|
||||||
|
|
||||||
|
# How old a blob that no snapshot lists must be before prune deletes it, and
|
||||||
|
# how old every object in a directory under metadata/ with no manifest must
|
||||||
|
# be before prune deletes that directory
|
||||||
|
# A backup uploads its blobs and its db.zst.age before its manifest, so on a
|
||||||
|
# destination shared between hosts or users this must be longer than the
|
||||||
|
# longest backup run
|
||||||
|
# It does not protect a blob a backup reuses from an earlier run that stopped
|
||||||
|
# before its manifest: that blob keeps the time of its first upload, so a
|
||||||
|
# prune can delete it
|
||||||
|
# Supports: 36h, 90m, etc.; 0s deletes such blobs and directories at once
|
||||||
|
# Default: 24h
|
||||||
|
#prune_grace_period: 24h
|
||||||
|
|
||||||
# Hostname to use in backup metadata
|
# Hostname to use in backup metadata
|
||||||
# Default: system hostname
|
# Default: system hostname
|
||||||
#hostname: myserver
|
#hostname: myserver
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
# Vaultik Architecture
|
# Vaultik Architecture
|
||||||
|
|
||||||
This document describes the internal architecture of Vaultik, focusing on the data model, type instantiation, and the relationships between core modules.
|
This document describes the internal architecture of Vaultik, focusing on the
|
||||||
|
data model, type instantiation, and the relationships between core modules.
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
Vaultik is a backup system that uses content-defined chunking for deduplication and packs chunks into large, compressed, encrypted blobs for efficient cloud storage. The system is built around dependency injection using [uber-go/fx](https://github.com/uber-go/fx).
|
Vaultik is a backup system that uses content-defined chunking for deduplication
|
||||||
|
and packs chunks into large, compressed, encrypted blobs for efficient cloud
|
||||||
|
storage. The system is built around dependency injection using
|
||||||
|
[uber-go/fx](https://github.com/uber-go/fx).
|
||||||
|
|
||||||
## Data Flow
|
## Data Flow
|
||||||
|
|
||||||
@@ -53,53 +57,88 @@ The database tracks five primary entities and their relationships:
|
|||||||
### Entity Descriptions
|
### Entity Descriptions
|
||||||
|
|
||||||
#### File (`database.File`)
|
#### File (`database.File`)
|
||||||
Represents a file, directory, or symlink in the backup system. Stores metadata needed for restoration:
|
|
||||||
- Path, source_path (for restore path stripping), mtime
|
Represents a file, directory, or symlink in the backup system. Stores metadata
|
||||||
|
needed for restoration:
|
||||||
|
|
||||||
|
- 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)
|
||||||
- `Size`: Chunk size in bytes
|
- `Size`: Chunk size in bytes
|
||||||
|
|
||||||
Chunk sizes vary between `avgChunkSize/4` and `avgChunkSize*4` (2.5MB-40MB for the 10MB default average).
|
Chunk sizes vary between `avgChunkSize/4` and `avgChunkSize*4` (2.5MB-40MB for
|
||||||
|
the 10MB default average).
|
||||||
|
|
||||||
#### FileChunk (`database.FileChunk`)
|
#### FileChunk (`database.FileChunk`)
|
||||||
|
|
||||||
Maps files to their constituent chunks:
|
Maps files to their constituent chunks:
|
||||||
|
|
||||||
- `FileID`: Reference to the file
|
- `FileID`: Reference to the file
|
||||||
- `Idx`: Position of this chunk within the file (0-indexed)
|
- `Idx`: Position of this chunk within the file (0-indexed)
|
||||||
- `ChunkHash`: Reference to the chunk
|
- `ChunkHash`: Reference to the chunk
|
||||||
|
|
||||||
#### Blob (`database.Blob`)
|
#### Blob (`database.Blob`)
|
||||||
The final storage unit uploaded to S3. Contains many compressed and encrypted chunks:
|
|
||||||
|
The final storage unit uploaded to S3. Contains many compressed and encrypted
|
||||||
|
chunks:
|
||||||
|
|
||||||
- `ID`: UUID assigned at creation
|
- `ID`: UUID assigned at creation
|
||||||
- `Hash`: `hex(SHA256(SHA256(uncompressed blob contents)))`, computed before compression and encryption (see [docs/REPOSTRUCTURE.md](docs/REPOSTRUCTURE.md#blobs-directory-blobs))
|
- `Hash`: `hex(SHA256(SHA256(uncompressed blob contents)))`, computed before
|
||||||
|
compression and encryption (see
|
||||||
|
[docs/REPOSTRUCTURE.md](REPOSTRUCTURE.md#blobs-directory-blobs))
|
||||||
- `UncompressedSize`: Total raw chunk data before compression
|
- `UncompressedSize`: Total raw chunk data before compression
|
||||||
- `CompressedSize`: Size after zstd compression and age encryption
|
- `CompressedSize`: Size after zstd compression and age encryption
|
||||||
- `CreatedTS`, `FinishedTS`, `UploadedTS`: Lifecycle timestamps
|
- `CreatedTS`, `FinishedTS`, `UploadedTS`: Lifecycle timestamps
|
||||||
|
|
||||||
Blob creation process:
|
Blob creation process:
|
||||||
|
|
||||||
1. Chunks are accumulated (up to MaxBlobSize, typically 10GB)
|
1. Chunks are accumulated (up to MaxBlobSize, typically 10GB)
|
||||||
2. As each chunk is added, its uncompressed bytes are fed to a running SHA-256
|
2. As each chunk is added, its uncompressed bytes are fed to a running SHA-256
|
||||||
3. Concurrently, the same bytes are compressed with zstd, then encrypted with age (recipients configured in config), and streamed to storage
|
3. Concurrently, the same bytes are compressed with zstd, then encrypted with
|
||||||
4. On finalize, the blob's name is the double SHA-256 of the uncompressed contents — `hex(SHA256(SHA256(...)))` — not a hash of the compressed, encrypted bytes
|
age (recipients configured in config), and written to a temporary file
|
||||||
5. Uploaded to `blobs/{hash[0:2]}/{hash[2:4]}/{hash}`
|
4. On finalize, the blob's name is the double SHA-256 of the uncompressed
|
||||||
|
contents — `hex(SHA256(SHA256(...)))` — not a hash of the compressed,
|
||||||
|
encrypted bytes
|
||||||
|
5. The finished file is uploaded to `blobs/{hash[0:2]}/{hash[2:4]}/{hash}` and
|
||||||
|
then deleted
|
||||||
|
|
||||||
|
A backup needs free temporary space, because each blob is written whole to a
|
||||||
|
temporary file before it is uploaded (up to about `blob_size_limit`; an rclone
|
||||||
|
destination that cannot stream uploads needs about twice that) and the metadata
|
||||||
|
export writes copies of the local index. Temporary files go to `$TMPDIR`
|
||||||
|
(default `/tmp`); with `TMPDIR` unset, SQLite writes one of those copies to
|
||||||
|
`/var/tmp`.
|
||||||
|
|
||||||
#### BlobChunk (`database.BlobChunk`)
|
#### BlobChunk (`database.BlobChunk`)
|
||||||
|
|
||||||
Maps chunks to their position within blobs:
|
Maps chunks to their position within blobs:
|
||||||
|
|
||||||
- `BlobID`: Reference to the blob
|
- `BlobID`: Reference to the blob
|
||||||
- `ChunkHash`: Reference to the chunk
|
- `ChunkHash`: Reference to the chunk
|
||||||
- `Offset`: Byte offset within the uncompressed blob
|
- `Offset`: Byte offset within the uncompressed blob
|
||||||
- `Length`: Chunk size
|
- `Length`: Chunk size
|
||||||
|
|
||||||
#### Snapshot (`database.Snapshot`)
|
#### Snapshot (`database.Snapshot`)
|
||||||
|
|
||||||
Represents a point-in-time backup:
|
Represents a point-in-time backup:
|
||||||
- `ID`: Format is `{hostname}_{snapshot-name}_{RFC3339}` (e.g. `server1_home_2025-06-01T12:00:00Z`)
|
|
||||||
|
- `ID`: Format is `{hostname}_{snapshot-name}_{RFC3339}` (e.g.
|
||||||
|
`server1_home_2025-06-01T12:00:00Z`)
|
||||||
- Tracks file count, chunk count, blob count, sizes, compression ratio
|
- Tracks file count, chunk count, blob count, sizes, compression ratio
|
||||||
- `CompletedAt`: Null until snapshot finishes successfully
|
- `CompletedAt`: Null until snapshot finishes successfully
|
||||||
|
|
||||||
#### SnapshotFile / SnapshotBlob
|
#### SnapshotFile / SnapshotBlob
|
||||||
|
|
||||||
Join tables linking snapshots to their files and blobs.
|
Join tables linking snapshots to their files and blobs.
|
||||||
|
|
||||||
### Relationship Summary
|
### Relationship Summary
|
||||||
@@ -136,21 +175,27 @@ fx.New(
|
|||||||
### Key Type Instantiation Points
|
### Key Type Instantiation Points
|
||||||
|
|
||||||
#### 1. Config (`config.Config`)
|
#### 1. Config (`config.Config`)
|
||||||
|
|
||||||
- **Created by**: `config.Module` via `config.LoadConfig()`
|
- **Created by**: `config.Module` via `config.LoadConfig()`
|
||||||
- **When**: Application startup (fx DI)
|
- **When**: Application startup (fx DI)
|
||||||
- **Contains**: All configuration from YAML file (S3 credentials, encryption keys, paths, etc.)
|
- **Contains**: All configuration from YAML file (S3 credentials, encryption
|
||||||
|
keys, paths, etc.)
|
||||||
|
|
||||||
#### 2. Database (`database.DB`)
|
#### 2. Database (`database.DB`)
|
||||||
|
|
||||||
- **Created by**: `database.Module` via `database.New()`
|
- **Created by**: `database.Module` via `database.New()`
|
||||||
- **When**: Application startup (fx DI)
|
- **When**: Application startup (fx DI)
|
||||||
- **Contains**: SQLite connection, path reference
|
- **Contains**: SQLite connection, path reference
|
||||||
|
|
||||||
#### 3. Repositories (`database.Repositories`)
|
#### 3. Repositories (`database.Repositories`)
|
||||||
|
|
||||||
- **Created by**: `database.Module` via `database.NewRepositories()`
|
- **Created by**: `database.Module` via `database.NewRepositories()`
|
||||||
- **When**: Application startup (fx DI)
|
- **When**: Application startup (fx DI)
|
||||||
- **Contains**: All repository interfaces (Files, Chunks, Blobs, Snapshots, etc.)
|
- **Contains**: All repository interfaces (Files, Chunks, Blobs, Snapshots,
|
||||||
|
etc.)
|
||||||
|
|
||||||
#### 4. Vaultik (`vaultik.Vaultik`)
|
#### 4. Vaultik (`vaultik.Vaultik`)
|
||||||
|
|
||||||
- **Created by**: `vaultik.New(VaultikParams)`
|
- **Created by**: `vaultik.New(VaultikParams)`
|
||||||
- **When**: Application startup (fx DI)
|
- **When**: Application startup (fx DI)
|
||||||
- **Contains**: All dependencies for backup operations
|
- **Contains**: All dependencies for backup operations
|
||||||
@@ -172,11 +217,13 @@ type Vaultik struct {
|
|||||||
```
|
```
|
||||||
|
|
||||||
#### 5. SnapshotManager (`snapshot.SnapshotManager`)
|
#### 5. SnapshotManager (`snapshot.SnapshotManager`)
|
||||||
|
|
||||||
- **Created by**: `snapshot.Module` via `snapshot.NewSnapshotManager()`
|
- **Created by**: `snapshot.Module` via `snapshot.NewSnapshotManager()`
|
||||||
- **When**: Application startup (fx DI)
|
- **When**: Application startup (fx DI)
|
||||||
- **Responsibility**: Creates/completes snapshots, exports metadata to S3
|
- **Responsibility**: Creates/completes snapshots, exports metadata to S3
|
||||||
|
|
||||||
#### 6. Scanner (`snapshot.Scanner`)
|
#### 6. Scanner (`snapshot.Scanner`)
|
||||||
|
|
||||||
- **Created by**: `ScannerFactory(ScannerParams)`
|
- **Created by**: `ScannerFactory(ScannerParams)`
|
||||||
- **When**: Each `CreateSnapshot()` call
|
- **When**: Each `CreateSnapshot()` call
|
||||||
- **Contains**: Chunker, Packer, progress reporter
|
- **Contains**: Chunker, Packer, progress reporter
|
||||||
@@ -190,20 +237,22 @@ scanner := v.ScannerFactory(snapshot.ScannerParams{
|
|||||||
```
|
```
|
||||||
|
|
||||||
#### 7. Chunker (`chunker.Chunker`)
|
#### 7. Chunker (`chunker.Chunker`)
|
||||||
|
|
||||||
- **Created by**: `chunker.NewChunker(avgChunkSize)`
|
- **Created by**: `chunker.NewChunker(avgChunkSize)`
|
||||||
- **When**: Inside `snapshot.NewScanner()`
|
- **When**: Inside `snapshot.NewScanner()`
|
||||||
- **Configuration**:
|
- **Configuration**:
|
||||||
- `avgChunkSize`: From config (default 10MB)
|
- `avgChunkSize`: From config (default 10MB)
|
||||||
- `minChunkSize`: avgChunkSize / 4
|
- `minChunkSize`: avgChunkSize / 4
|
||||||
- `maxChunkSize`: avgChunkSize * 4
|
- `maxChunkSize`: avgChunkSize \* 4
|
||||||
|
|
||||||
#### 8. Packer (`blob.Packer`)
|
#### 8. Packer (`blob.Packer`)
|
||||||
|
|
||||||
- **Created by**: `blob.NewPacker(PackerConfig)`
|
- **Created by**: `blob.NewPacker(PackerConfig)`
|
||||||
- **When**: Inside `snapshot.NewScanner()`
|
- **When**: Inside `snapshot.NewScanner()`
|
||||||
- **Configuration**:
|
- **Configuration**:
|
||||||
- `MaxBlobSize`: Maximum blob size before finalization (typically 10GB)
|
- `MaxBlobSize`: Maximum blob size before finalization (typically 10GB)
|
||||||
- `CompressionLevel`: zstd level (1-19)
|
- `CompressionLevel`: zstd level (1-19)
|
||||||
- `Recipients`: age public keys for encryption
|
- `Recipients`: age public keys for encryption
|
||||||
|
|
||||||
```go
|
```go
|
||||||
// snapshot/scanner.go: NewScanner()
|
// snapshot/scanner.go: NewScanner()
|
||||||
@@ -220,17 +269,22 @@ packer, err := blob.NewPacker(packerCfg)
|
|||||||
## Module Responsibilities
|
## Module Responsibilities
|
||||||
|
|
||||||
### `internal/cli`
|
### `internal/cli`
|
||||||
Entry point for fx application. Combines all modules and handles signal interrupts.
|
|
||||||
|
Entry point for fx application. Combines all modules and handles signal
|
||||||
|
interrupts.
|
||||||
|
|
||||||
Key functions:
|
Key functions:
|
||||||
|
|
||||||
- `NewApp(AppOptions)` → Creates fx.App with all modules
|
- `NewApp(AppOptions)` → Creates fx.App with all modules
|
||||||
- `RunApp(ctx, app)` → Starts app, handles graceful shutdown
|
- `RunApp(ctx, app)` → Starts app, handles graceful shutdown
|
||||||
- `RunWithApp(ctx, opts)` → Convenience wrapper
|
- `RunWithApp(ctx, opts)` → Convenience wrapper
|
||||||
|
|
||||||
### `internal/vaultik`
|
### `internal/vaultik`
|
||||||
|
|
||||||
Main orchestrator containing all dependencies and command implementations.
|
Main orchestrator containing all dependencies and command implementations.
|
||||||
|
|
||||||
Key methods:
|
Key methods:
|
||||||
|
|
||||||
- `New(VaultikParams)` → Constructor (fx DI)
|
- `New(VaultikParams)` → Constructor (fx DI)
|
||||||
- `CreateSnapshot(opts)` → Main backup operation
|
- `CreateSnapshot(opts)` → Main backup operation
|
||||||
- `ListSnapshots(jsonOutput)` → List available snapshots
|
- `ListSnapshots(jsonOutput)` → List available snapshots
|
||||||
@@ -238,27 +292,34 @@ Key methods:
|
|||||||
- `PurgeSnapshots(...)` → Remove old snapshots
|
- `PurgeSnapshots(...)` → Remove old snapshots
|
||||||
|
|
||||||
### `internal/chunker`
|
### `internal/chunker`
|
||||||
|
|
||||||
Content-defined chunking using FastCDC algorithm.
|
Content-defined chunking using FastCDC algorithm.
|
||||||
|
|
||||||
Key types:
|
Key types:
|
||||||
|
|
||||||
- `Chunk` → Hash, Data, Offset, Size
|
- `Chunk` → Hash, Data, Offset, Size
|
||||||
- `Chunker` → avgChunkSize, minChunkSize, maxChunkSize
|
- `Chunker` → avgChunkSize, minChunkSize, maxChunkSize
|
||||||
|
|
||||||
Key methods:
|
Key methods:
|
||||||
|
|
||||||
- `NewChunker(avgChunkSize)` → Constructor
|
- `NewChunker(avgChunkSize)` → Constructor
|
||||||
- `ChunkReaderStreaming(reader, callback)` → Stream chunks with callback (preferred)
|
- `ChunkReaderStreaming(reader, callback)` → Stream chunks with callback
|
||||||
|
(preferred)
|
||||||
- `ChunkReader(reader)` → Return all chunks at once (memory-intensive)
|
- `ChunkReader(reader)` → Return all chunks at once (memory-intensive)
|
||||||
|
|
||||||
### `internal/blob`
|
### `internal/blob`
|
||||||
|
|
||||||
Blob packing: accumulates chunks, compresses, encrypts, tracks metadata.
|
Blob packing: accumulates chunks, compresses, encrypts, tracks metadata.
|
||||||
|
|
||||||
Key types:
|
Key types:
|
||||||
|
|
||||||
- `Packer` → Thread-safe blob accumulator
|
- `Packer` → Thread-safe blob accumulator
|
||||||
- `ChunkRef` → Hash + Data for adding to packer
|
- `ChunkRef` → Hash + Data for adding to packer
|
||||||
- `FinishedBlob` → Completed blob ready for upload
|
- `FinishedBlob` → Completed blob ready for upload
|
||||||
- `BlobWithReader` → FinishedBlob + io.Reader for streaming upload
|
- `BlobWithReader` → FinishedBlob + io.Reader for streaming upload
|
||||||
|
|
||||||
Key methods:
|
Key methods:
|
||||||
|
|
||||||
- `NewPacker(PackerConfig)` → Constructor
|
- `NewPacker(PackerConfig)` → Constructor
|
||||||
- `AddChunk(ChunkRef)` → Add chunk to current blob
|
- `AddChunk(ChunkRef)` → Add chunk to current blob
|
||||||
- `FinalizeBlob()` → Compress, encrypt, hash current blob
|
- `FinalizeBlob()` → Compress, encrypt, hash current blob
|
||||||
@@ -268,35 +329,48 @@ Key methods:
|
|||||||
### `internal/snapshot`
|
### `internal/snapshot`
|
||||||
|
|
||||||
#### Scanner
|
#### Scanner
|
||||||
|
|
||||||
Orchestrates the backup process for a directory.
|
Orchestrates the backup process for a directory.
|
||||||
|
|
||||||
Key methods:
|
Key methods:
|
||||||
|
|
||||||
- `NewScanner(ScannerConfig)` → Constructor (creates Chunker + Packer)
|
- `NewScanner(ScannerConfig)` → Constructor (creates Chunker + Packer)
|
||||||
- `Scan(ctx, path, snapshotID)` → Main scan operation
|
- `Scan(ctx, path, snapshotID)` → Main scan operation
|
||||||
|
|
||||||
Scan phases:
|
Scan phases:
|
||||||
1. **Phase 0**: Detect deleted files from previous snapshots
|
|
||||||
2. **Phase 1**: Walk directory, identify files needing processing
|
1. **Phase 1**: Walk directory, identify files needing processing
|
||||||
|
2. **Phase 1b**: Count as deleted the files that the previous snapshot of the
|
||||||
|
same name lists, the walk did not find, and are gone from disk; a file that
|
||||||
|
is excluded, or inside an excluded directory, is not counted
|
||||||
3. **Phase 2**: Process files (chunk → pack → upload)
|
3. **Phase 2**: Process files (chunk → pack → upload)
|
||||||
|
|
||||||
#### SnapshotManager
|
#### SnapshotManager
|
||||||
|
|
||||||
Manages snapshot lifecycle and metadata export.
|
Manages snapshot lifecycle and metadata export.
|
||||||
|
|
||||||
Key methods:
|
Key methods:
|
||||||
|
|
||||||
- `CreateSnapshot(ctx, hostname, version, commit)` → Create snapshot record
|
- `CreateSnapshot(ctx, hostname, version, commit)` → Create snapshot record
|
||||||
- `PopulateSnapshotBlobs(ctx, snapshotID)` → Record every blob the snapshot references
|
- `PopulateSnapshotBlobs(ctx, snapshotID)` → Record every blob the snapshot
|
||||||
|
references
|
||||||
- `ExportSnapshotMetadata(ctx, dbPath, snapshotID)` → Export to S3
|
- `ExportSnapshotMetadata(ctx, dbPath, snapshotID)` → Export to S3
|
||||||
- `MarkSnapshotComplete(ctx, snapshotID)` → Record completion, only after a successful export
|
- `MarkSnapshotComplete(ctx, snapshotID)` → Record completion, only after a
|
||||||
- `CompleteSnapshot(ctx, snapshotID)` → Convenience: populate blobs, then mark complete (no export between)
|
successful export
|
||||||
|
- `CompleteSnapshot(ctx, snapshotID)` → Convenience: populate blobs, then mark
|
||||||
|
complete (no export between)
|
||||||
|
|
||||||
### `internal/database`
|
### `internal/database`
|
||||||
|
|
||||||
SQLite database for local index. Single-writer mode for thread safety.
|
SQLite database for local index. Single-writer mode for thread safety.
|
||||||
|
|
||||||
Key types:
|
Key types:
|
||||||
|
|
||||||
- `DB` → Database connection wrapper
|
- `DB` → Database connection wrapper
|
||||||
- `Repositories` → Collection of all repository interfaces
|
- `Repositories` → Collection of all repository interfaces
|
||||||
|
|
||||||
Repository interfaces:
|
Repository interfaces:
|
||||||
|
|
||||||
- `FilesRepository` → CRUD for File records
|
- `FilesRepository` → CRUD for File records
|
||||||
- `ChunksRepository` → CRUD for Chunk records
|
- `ChunksRepository` → CRUD for Chunk records
|
||||||
- `BlobsRepository` → CRUD for Blob records
|
- `BlobsRepository` → CRUD for Blob records
|
||||||
@@ -307,6 +381,8 @@ Repository interfaces:
|
|||||||
|
|
||||||
```
|
```
|
||||||
CreateSnapshot(opts)
|
CreateSnapshot(opts)
|
||||||
|
│
|
||||||
|
├─► markDestination() // Check the marker (see Storage Layout in S3), then write it
|
||||||
│
|
│
|
||||||
├─► PruneDatabase() // Critical: avoid dedup errors
|
├─► PruneDatabase() // Critical: avoid dedup errors
|
||||||
│
|
│
|
||||||
@@ -316,13 +392,13 @@ CreateSnapshot(opts)
|
|||||||
│ │
|
│ │
|
||||||
│ ├─► scanner.Scan(ctx, path, snapshotID)
|
│ ├─► scanner.Scan(ctx, path, snapshotID)
|
||||||
│ │ │
|
│ │ │
|
||||||
│ │ ├─► Phase 0: detectDeletedFiles()
|
|
||||||
│ │ │
|
|
||||||
│ │ ├─► Phase 1: scanPhase()
|
│ │ ├─► Phase 1: scanPhase()
|
||||||
│ │ │ Walk directory
|
│ │ │ Walk directory
|
||||||
│ │ │ Check file metadata changes
|
│ │ │ Check file metadata changes
|
||||||
│ │ │ Build list of files to process
|
│ │ │ Build list of files to process
|
||||||
│ │ │
|
│ │ │
|
||||||
|
│ │ ├─► Phase 1b: detectDeletedFiles()
|
||||||
|
│ │ │
|
||||||
│ │ └─► Phase 2: processPhase()
|
│ │ └─► Phase 2: processPhase()
|
||||||
│ │ For each file:
|
│ │ For each file:
|
||||||
│ │ chunker.ChunkReaderStreaming()
|
│ │ chunker.ChunkReaderStreaming()
|
||||||
@@ -335,10 +411,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,16 +429,23 @@ CreateSnapshot(opts)
|
|||||||
|
|
||||||
## Deduplication Strategy
|
## Deduplication Strategy
|
||||||
|
|
||||||
1. **File-level**: Files unchanged since last backup are skipped (metadata comparison: size, mtime, mode, uid, gid)
|
1. **File-level**: Files and symlinks unchanged since last backup are skipped
|
||||||
|
(metadata comparison: size, mtime, mode, uid, gid, and a symlink's target),
|
||||||
|
unless the file lists a chunk that no uploaded blob holds; such a file is
|
||||||
|
re-chunked
|
||||||
|
|
||||||
2. **Chunk-level**: Chunks are content-addressed by SHA256 hash. If a chunk hash already exists in the database, the chunk data is not re-uploaded.
|
2. **Chunk-level**: Chunks are content-addressed by SHA256 hash. If a chunk hash
|
||||||
|
already exists in the database, the chunk data is not re-uploaded.
|
||||||
|
|
||||||
3. **Blob-level**: Blobs contain only unique chunks. Duplicate chunks within a blob are skipped.
|
3. **Blob-level**: Blobs contain only unique chunks. Duplicate chunks within a
|
||||||
|
blob are skipped.
|
||||||
|
|
||||||
## Storage Layout in S3
|
## Storage Layout in S3
|
||||||
|
|
||||||
```
|
```
|
||||||
bucket/
|
bucket/
|
||||||
|
├── vaultik-destination.txt # Marker written by every backup
|
||||||
|
│
|
||||||
├── blobs/
|
├── blobs/
|
||||||
│ └── {hash[0:2]}/
|
│ └── {hash[0:2]}/
|
||||||
│ └── {hash[2:4]}/
|
│ └── {hash[2:4]}/
|
||||||
@@ -374,15 +457,30 @@ bucket/
|
|||||||
└── manifest.json.zst # Blob list (for pruning/verification)
|
└── manifest.json.zst # Blob list (for pruning/verification)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
A destination that answers a look-up of `vaultik-destination.txt` with not found
|
||||||
|
is treated as not present while the local index holds snapshots stored there
|
||||||
|
(`checkDestinationMarker` in `internal/vaultik/destination_marker.go`):
|
||||||
|
`snapshot create` refuses to write to it, `prune`, `snapshot purge` and
|
||||||
|
`remote nuke` fail, and `snapshot list` and `snapshot remove` behave as for a
|
||||||
|
destination that cannot be listed. This keeps those commands from taking an
|
||||||
|
unplugged volume's empty mount point for a destination whose snapshots are all
|
||||||
|
gone. When the look-up fails for any reason other than not found, for example
|
||||||
|
when S3 refuses it to credentials that allow only PUT, the check is skipped, so
|
||||||
|
a host with such credentials does not notice an emptied destination; see
|
||||||
|
[docs/REPOSTRUCTURE.md](REPOSTRUCTURE.md#marker-file-vaultik-destinationtxt).
|
||||||
|
The `file://` backend never creates its destination directory, so a write to a
|
||||||
|
missing one fails.
|
||||||
|
|
||||||
The `{remote-key}` directory name is a one-way double SHA-256 hash of the human
|
The `{remote-key}` directory name is a one-way double SHA-256 hash of the human
|
||||||
snapshot ID, so the human ID (hostname, snapshot name, timestamp) is never
|
snapshot ID, so the human ID (hostname, snapshot name, timestamp) is never
|
||||||
written to the store as a directory name. See
|
written to the store as a directory name. See
|
||||||
[docs/REPOSTRUCTURE.md](docs/REPOSTRUCTURE.md#remote-key-derivation) for the
|
[docs/REPOSTRUCTURE.md](REPOSTRUCTURE.md#remote-key-derivation) for the
|
||||||
derivation and a worked example.
|
derivation and a worked example.
|
||||||
|
|
||||||
## Thread Safety
|
## Thread Safety
|
||||||
|
|
||||||
- `Packer`: Thread-safe via mutex. Multiple goroutines can call `AddChunk()`.
|
- `Packer`: Thread-safe via mutex. Multiple goroutines can call `AddChunk()`.
|
||||||
- `Scanner`: Uses `packerMu` mutex to coordinate blob finalization.
|
- `Scanner`: Uses `packerMu` mutex to coordinate blob finalization.
|
||||||
- `Database`: Single-writer mode (`MaxOpenConns=1`) ensures SQLite thread safety.
|
- `Database`: Single-writer mode (`MaxOpenConns=1`) ensures SQLite thread
|
||||||
|
safety.
|
||||||
- `Repositories.WithTx()`: Handles transaction lifecycle automatically.
|
- `Repositories.WithTx()`: Handles transaction lifecycle automatically.
|
||||||
+174
-79
@@ -2,46 +2,48 @@
|
|||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
Vaultik uses a local SQLite database to track file metadata, chunk mappings, and blob associations during the backup process. This database serves as an index for incremental backups and enables efficient deduplication.
|
Vaultik uses a local SQLite database to track file metadata, chunk mappings, and
|
||||||
|
blob associations during the backup process. This database serves as an index
|
||||||
|
for incremental backups and enables efficient deduplication.
|
||||||
|
|
||||||
**Important Notes:**
|
**Important Notes:**
|
||||||
|
|
||||||
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`
|
||||||
|
|
||||||
Stores metadata about files in the filesystem being backed up.
|
Stores metadata about files in the filesystem being backed up.
|
||||||
|
|
||||||
**Columns:**
|
**Columns:**
|
||||||
|
|
||||||
- `id` (TEXT PRIMARY KEY) - UUID for the file record
|
- `id` (TEXT PRIMARY KEY) - UUID for the file record
|
||||||
- `path` (TEXT NOT NULL UNIQUE) - Absolute file path
|
- `path` (TEXT NOT NULL UNIQUE) - Absolute file path
|
||||||
- `mtime` (INTEGER NOT NULL) - Modification time 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
|
||||||
@@ -49,23 +51,30 @@ Stores metadata about files in the filesystem being backed up.
|
|||||||
- `link_target` (TEXT) - Symlink target path (NULL for regular files)
|
- `link_target` (TEXT) - Symlink target path (NULL for regular files)
|
||||||
|
|
||||||
**Indexes:**
|
**Indexes:**
|
||||||
|
|
||||||
- `idx_files_path` on `path` for efficient lookups
|
- `idx_files_path` on `path` for efficient lookups
|
||||||
|
|
||||||
**Purpose:** Tracks file metadata to detect changes between backup runs. Used for incremental backup decisions. The UUID primary key provides stable references that don't change if files are moved.
|
**Purpose:** Tracks file metadata to detect changes between backup runs. Used
|
||||||
|
for incremental backup decisions. The UUID primary key provides stable
|
||||||
|
references that don't change if files are moved.
|
||||||
|
|
||||||
### 2. `chunks`
|
### 2. `chunks`
|
||||||
|
|
||||||
Stores information about content-defined chunks created from files.
|
Stores information about content-defined chunks created from files.
|
||||||
|
|
||||||
**Columns:**
|
**Columns:**
|
||||||
|
|
||||||
- `chunk_hash` (TEXT PRIMARY KEY) - SHA256 hash of chunk content
|
- `chunk_hash` (TEXT PRIMARY KEY) - SHA256 hash of chunk content
|
||||||
- `size` (INTEGER NOT NULL) - Chunk size in bytes
|
- `size` (INTEGER NOT NULL) - Chunk size in bytes
|
||||||
|
|
||||||
**Purpose:** Enables deduplication by tracking unique chunks across all files.
|
**Purpose:** Enables deduplication by tracking unique chunks across all files.
|
||||||
|
|
||||||
### 3. `file_chunks`
|
### 3. `file_chunks`
|
||||||
|
|
||||||
Maps files to their constituent chunks in order.
|
Maps files to their constituent chunks in order.
|
||||||
|
|
||||||
**Columns:**
|
**Columns:**
|
||||||
|
|
||||||
- `file_id` (TEXT) - File ID (FK to files.id)
|
- `file_id` (TEXT) - File ID (FK to files.id)
|
||||||
- `idx` (INTEGER) - Chunk index within file (0-based)
|
- `idx` (INTEGER) - Chunk index within file (0-based)
|
||||||
- `chunk_hash` (TEXT) - Chunk hash (FK to chunks.chunk_hash)
|
- `chunk_hash` (TEXT) - Chunk hash (FK to chunks.chunk_hash)
|
||||||
@@ -74,35 +83,49 @@ Maps files to their constituent chunks in order.
|
|||||||
**Purpose:** Allows reconstruction of files from chunks during restore.
|
**Purpose:** Allows reconstruction of files from chunks during restore.
|
||||||
|
|
||||||
### 4. `chunk_files`
|
### 4. `chunk_files`
|
||||||
|
|
||||||
Reverse mapping showing which files contain each chunk.
|
Reverse mapping showing which files contain each chunk.
|
||||||
|
|
||||||
**Columns:**
|
**Columns:**
|
||||||
|
|
||||||
- `chunk_hash` (TEXT) - Chunk hash (FK to chunks.chunk_hash)
|
- `chunk_hash` (TEXT) - Chunk hash (FK to chunks.chunk_hash)
|
||||||
- `file_id` (TEXT) - File ID (FK to files.id)
|
- `file_id` (TEXT) - File ID (FK to files.id)
|
||||||
- `file_offset` (INTEGER) - Byte offset of chunk within file
|
- `file_offset` (INTEGER) - Byte offset of chunk within file
|
||||||
- `length` (INTEGER) - Length of chunk in bytes
|
- `length` (INTEGER) - Length of chunk in bytes
|
||||||
- PRIMARY KEY (`chunk_hash`, `file_id`)
|
- PRIMARY KEY (`chunk_hash`, `file_id`)
|
||||||
|
|
||||||
**Purpose:** Supports efficient queries for chunk usage and deduplication statistics.
|
**Purpose:** Supports efficient queries for chunk usage and deduplication
|
||||||
|
statistics.
|
||||||
|
|
||||||
### 5. `blobs`
|
### 5. `blobs`
|
||||||
|
|
||||||
Stores information about packed, compressed, and encrypted blob files.
|
Stores information about packed, compressed, and encrypted blob files.
|
||||||
|
|
||||||
**Columns:**
|
**Columns:**
|
||||||
|
|
||||||
- `id` (TEXT PRIMARY KEY) - UUID assigned when blob creation starts
|
- `id` (TEXT PRIMARY KEY) - UUID assigned when blob creation starts
|
||||||
- `blob_hash` (TEXT UNIQUE) - `hex(SHA256(SHA256(uncompressed blob contents)))`, computed before compression and encryption (NULL until finalized); see [REPOSTRUCTURE.md](REPOSTRUCTURE.md#blobs-directory-blobs)
|
- `blob_hash` (TEXT UNIQUE) - `hex(SHA256(SHA256(uncompressed blob contents)))`,
|
||||||
|
computed before compression and encryption (NULL until finalized); see
|
||||||
|
[REPOSTRUCTURE.md](REPOSTRUCTURE.md#blobs-directory-blobs)
|
||||||
- `created_ts` (INTEGER NOT NULL) - Creation timestamp
|
- `created_ts` (INTEGER NOT NULL) - Creation timestamp
|
||||||
- `finished_ts` (INTEGER) - Finalization timestamp (NULL if in progress)
|
- `finished_ts` (INTEGER) - Finalization timestamp (NULL if in progress)
|
||||||
- `uncompressed_size` (INTEGER NOT NULL DEFAULT 0) - Total size of chunks before compression
|
- `uncompressed_size` (INTEGER NOT NULL DEFAULT 0) - Total size of chunks before
|
||||||
- `compressed_size` (INTEGER NOT NULL DEFAULT 0) - Size after compression and encryption
|
compression
|
||||||
|
- `compressed_size` (INTEGER NOT NULL DEFAULT 0) - Size after compression and
|
||||||
|
encryption. Once uploaded, the size of the object in storage: when the blob
|
||||||
|
was already stored, under another compression level or other recipients, this
|
||||||
|
can differ from the size of the local copy
|
||||||
- `uploaded_ts` (INTEGER) - Upload completion timestamp (NULL if not uploaded)
|
- `uploaded_ts` (INTEGER) - Upload completion timestamp (NULL if not uploaded)
|
||||||
|
|
||||||
**Purpose:** Tracks blob lifecycle from creation through upload. The UUID primary key allows immediate association of chunks with blobs.
|
**Purpose:** Tracks blob lifecycle from creation through upload. The UUID
|
||||||
|
primary key allows immediate association of chunks with blobs.
|
||||||
|
|
||||||
### 6. `blob_chunks`
|
### 6. `blob_chunks`
|
||||||
|
|
||||||
Maps chunks to the blobs that contain them.
|
Maps chunks to the blobs that contain them.
|
||||||
|
|
||||||
**Columns:**
|
**Columns:**
|
||||||
|
|
||||||
- `blob_id` (TEXT) - Blob ID (FK to blobs.id)
|
- `blob_id` (TEXT) - Blob ID (FK to blobs.id)
|
||||||
- `chunk_hash` (TEXT) - Chunk hash (FK to chunks.chunk_hash)
|
- `chunk_hash` (TEXT) - Chunk hash (FK to chunks.chunk_hash)
|
||||||
- `offset` (INTEGER) - Byte offset of chunk within blob (before compression)
|
- `offset` (INTEGER) - Byte offset of chunk within blob (before compression)
|
||||||
@@ -112,32 +135,46 @@ Maps chunks to the blobs that contain them.
|
|||||||
**Purpose:** Enables chunk retrieval from blobs during restore operations.
|
**Purpose:** Enables chunk retrieval from blobs during restore operations.
|
||||||
|
|
||||||
### 7. `snapshots`
|
### 7. `snapshots`
|
||||||
|
|
||||||
Tracks backup snapshots.
|
Tracks backup snapshots.
|
||||||
|
|
||||||
**Columns:**
|
**Columns:**
|
||||||
- `id` (TEXT PRIMARY KEY) - Snapshot ID (format: hostname-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
|
||||||
- `blob_count` (INTEGER) - Number of blobs referenced
|
stored before
|
||||||
|
- `new_chunk_size` (INTEGER) - Total size of those chunks before compression;
|
||||||
|
`snapshot list` shows it as NEW CHUNK SIZE
|
||||||
|
- `blob_count` (INTEGER) - Number of blobs this snapshot created
|
||||||
- `total_size` (INTEGER) - Total size of all files
|
- `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
|
||||||
- `blob_uncompressed_size` (INTEGER) - Total uncompressed size of all referenced blobs
|
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
|
||||||
- `compression_ratio` (REAL) - Compression ratio achieved
|
- `compression_ratio` (REAL) - Compression ratio achieved
|
||||||
- `compression_level` (INTEGER) - Compression level used for this snapshot
|
- `compression_level` (INTEGER) - Compression level used for this snapshot
|
||||||
- `upload_bytes` (INTEGER) - Total bytes uploaded during this snapshot
|
- `upload_bytes` (INTEGER) - Total bytes uploaded during this snapshot
|
||||||
- `upload_duration_ms` (INTEGER) - Total milliseconds spent uploading to S3
|
- `upload_duration_ms` (INTEGER) - Total milliseconds spent uploading to S3
|
||||||
|
|
||||||
**Purpose:** Provides snapshot metadata and statistics including version tracking for compatibility.
|
**Purpose:** Provides snapshot metadata and statistics including version
|
||||||
|
tracking for compatibility.
|
||||||
|
|
||||||
### 8. `snapshot_files`
|
### 8. `snapshot_files`
|
||||||
|
|
||||||
Maps snapshots to the files they contain.
|
Maps snapshots to the files they contain.
|
||||||
|
|
||||||
**Columns:**
|
**Columns:**
|
||||||
|
|
||||||
- `snapshot_id` (TEXT) - Snapshot ID (FK to snapshots.id)
|
- `snapshot_id` (TEXT) - Snapshot ID (FK to snapshots.id)
|
||||||
- `file_id` (TEXT) - File ID (FK to files.id)
|
- `file_id` (TEXT) - File ID (FK to files.id)
|
||||||
- PRIMARY KEY (`snapshot_id`, `file_id`)
|
- PRIMARY KEY (`snapshot_id`, `file_id`)
|
||||||
@@ -145,99 +182,147 @@ Maps snapshots to the files they contain.
|
|||||||
**Purpose:** Records which files are included in each snapshot.
|
**Purpose:** Records which files are included in each snapshot.
|
||||||
|
|
||||||
### 9. `snapshot_blobs`
|
### 9. `snapshot_blobs`
|
||||||
|
|
||||||
Maps snapshots to the blobs they reference.
|
Maps snapshots to the blobs they reference.
|
||||||
|
|
||||||
**Columns:**
|
**Columns:**
|
||||||
|
|
||||||
- `snapshot_id` (TEXT) - Snapshot ID (FK to snapshots.id)
|
- `snapshot_id` (TEXT) - Snapshot ID (FK to snapshots.id)
|
||||||
- `blob_id` (TEXT) - Blob ID (FK to blobs.id)
|
- `blob_id` (TEXT) - Blob ID (FK to blobs.id)
|
||||||
- `blob_hash` (TEXT) - Denormalized blob hash for manifest generation
|
- `blob_hash` (TEXT) - Denormalized blob hash for manifest generation
|
||||||
- PRIMARY KEY (`snapshot_id`, `blob_id`)
|
- PRIMARY KEY (`snapshot_id`, `blob_id`)
|
||||||
|
|
||||||
**Purpose:** Tracks blob dependencies for snapshots and enables manifest generation.
|
**Purpose:** Tracks blob dependencies for snapshots and enables manifest
|
||||||
|
generation.
|
||||||
|
|
||||||
|
### 10. `snapshot_mount_points`
|
||||||
|
|
||||||
|
The directories at or below a configured path, the configured path itself
|
||||||
|
included, that a snapshot's backup found an entry in and, once it had listed
|
||||||
|
them, found on a different filesystem from their parent directory (a different
|
||||||
|
device number), as a mount point is. An automount point whose volume cannot be
|
||||||
|
mounted is not recorded, because the backup finds no entry in it.
|
||||||
|
|
||||||
|
**Columns:**
|
||||||
|
|
||||||
|
- `snapshot_id` (TEXT) - Snapshot ID (FK to snapshots.id)
|
||||||
|
- `path` (TEXT) - Absolute path of the directory
|
||||||
|
- PRIMARY KEY (`snapshot_id`, `path`)
|
||||||
|
|
||||||
|
**Purpose:** Lets `snapshot create --prune` skip the prune when a backup does
|
||||||
|
not find a mount point that an earlier snapshot of the same name found, as when
|
||||||
|
the volume is not mounted.
|
||||||
|
|
||||||
|
### 11. `uploads`
|
||||||
|
|
||||||
### 10. `uploads`
|
|
||||||
Tracks blob upload metrics.
|
Tracks blob upload metrics.
|
||||||
|
|
||||||
**Columns:**
|
**Columns:**
|
||||||
|
|
||||||
- `blob_hash` (TEXT PRIMARY KEY) - Hash of uploaded blob
|
- `blob_hash` (TEXT PRIMARY KEY) - Hash of uploaded blob
|
||||||
- `snapshot_id` (TEXT NOT NULL) - The snapshot that triggered this upload (FK to snapshots.id)
|
- `snapshot_id` (TEXT NOT NULL) - The snapshot that triggered this upload (FK to
|
||||||
|
snapshots.id)
|
||||||
- `uploaded_at` (INTEGER) - Upload timestamp
|
- `uploaded_at` (INTEGER) - Upload timestamp
|
||||||
- `size` (INTEGER) - Size of uploaded blob
|
- `size` (INTEGER) - Size of uploaded blob
|
||||||
- `duration_ms` (INTEGER) - Upload duration in milliseconds
|
- `duration_ms` (INTEGER) - Upload duration in milliseconds
|
||||||
|
|
||||||
**Purpose:** Performance monitoring and tracking which blobs were newly created (uploaded) during each snapshot.
|
**Purpose:** Performance monitoring and tracking which blobs were newly created
|
||||||
|
(uploaded) during each snapshot.
|
||||||
|
|
||||||
## Data Flow and Operations
|
## Data Flow and Operations
|
||||||
|
|
||||||
### 1. Backup Process
|
### 1. Backup Process
|
||||||
|
|
||||||
1. **File Scanning**
|
1. **File Scanning**
|
||||||
- `INSERT OR REPLACE INTO files` - Update file metadata
|
- `INSERT OR REPLACE INTO files` - Update file metadata
|
||||||
- `SELECT * FROM files WHERE path = ?` - Check if file has changed
|
- `SELECT * FROM files WHERE path = ?` - Check if file has changed
|
||||||
- `INSERT INTO snapshot_files` - Add file to current snapshot
|
- `INSERT INTO snapshot_files` - Add file to current snapshot
|
||||||
|
- `INSERT OR IGNORE INTO snapshot_mount_points` - Record each directory on a
|
||||||
|
different filesystem from its parent that the walk found an entry in
|
||||||
|
|
||||||
2. **Chunking** (for changed files)
|
2. **Chunking** (for changed files)
|
||||||
- `INSERT OR IGNORE INTO chunks` - Store new chunks
|
- `INSERT OR IGNORE INTO chunks` - Store new chunks
|
||||||
- `INSERT INTO file_chunks` - Map chunks to file
|
- `INSERT INTO file_chunks` - Map chunks to file
|
||||||
- `INSERT INTO chunk_files` - Create reverse mapping
|
- `INSERT INTO chunk_files` - Create reverse mapping
|
||||||
|
|
||||||
3. **Blob Packing**
|
3. **Blob Packing**
|
||||||
- `INSERT INTO blobs` - Create blob record with UUID (blob_hash NULL)
|
- `INSERT INTO blobs` - Create blob record with UUID (blob_hash NULL)
|
||||||
- `INSERT INTO blob_chunks` - Associate chunks with blob immediately
|
- `INSERT INTO blob_chunks` - Associate chunks with blob immediately
|
||||||
- `UPDATE blobs SET blob_hash = ?, finished_ts = ?` - Finalize blob after packing
|
- `UPDATE blobs SET blob_hash = ?, finished_ts = ?` - Finalize blob after
|
||||||
|
packing
|
||||||
|
|
||||||
4. **Upload**
|
4. **Upload**
|
||||||
- `UPDATE blobs SET uploaded_ts = ?` - Mark blob as uploaded
|
- `UPDATE blobs SET uploaded_ts = ?, compressed_size = ?` - Mark blob as
|
||||||
- `INSERT INTO uploads` - Record upload metrics with snapshot_id
|
uploaded, with the size of the object in storage (the upload is skipped
|
||||||
- `INSERT INTO snapshot_blobs` - Associate blob with snapshot
|
when the blob is already stored)
|
||||||
|
- `INSERT INTO uploads` - Record upload metrics with snapshot_id
|
||||||
|
- `INSERT INTO snapshot_blobs` - Associate blob with snapshot
|
||||||
|
|
||||||
5. **Snapshot Completion**
|
5. **Snapshot Completion**
|
||||||
- `UPDATE snapshots SET completed_at = ?, stats...` - Finalize snapshot
|
- `UPDATE snapshots SET completed_at = ?, stats...` - Finalize snapshot
|
||||||
- Generate and upload blob manifest from `snapshot_blobs`
|
- Generate and upload blob manifest from `snapshot_blobs`
|
||||||
|
|
||||||
### 2. Incremental Backup
|
### 2. Incremental Backup
|
||||||
|
|
||||||
1. **Change Detection**
|
1. **Change Detection**
|
||||||
- `SELECT * FROM files WHERE path = ?` - Get previous file metadata
|
- `SELECT * FROM files WHERE path = ?` - Get previous file metadata
|
||||||
- Compare mtime, size, mode to detect changes
|
- Compare mtime, size, mode to detect changes
|
||||||
- Skip unchanged files but still add to `snapshot_files`
|
- Re-chunk a file that lists a chunk no uploaded blob holds, even when its
|
||||||
|
metadata is unchanged
|
||||||
|
- Skip unchanged files but still add to `snapshot_files`
|
||||||
|
|
||||||
2. **Chunk Reuse**
|
2. **Chunk Reuse**
|
||||||
- `SELECT * FROM blob_chunks WHERE chunk_hash = ?` - Find existing chunks
|
- `SELECT * FROM blob_chunks WHERE chunk_hash = ?` - Find existing chunks
|
||||||
- `INSERT INTO snapshot_blobs` - Reference existing blobs for unchanged files
|
- `INSERT INTO snapshot_blobs` - Reference existing blobs for unchanged
|
||||||
|
files
|
||||||
|
|
||||||
### 3. Snapshot Metadata Export
|
### 3. Snapshot Metadata Export
|
||||||
|
|
||||||
After a snapshot is completed:
|
After a snapshot is completed:
|
||||||
|
|
||||||
1. Copy database to temporary file
|
1. Copy database to temporary file
|
||||||
2. Clean temporary database to contain only current snapshot data
|
2. Clean temporary database to contain only current snapshot data
|
||||||
3. VACUUM the trimmed database so deleted rows leave no pages behind
|
3. VACUUM the trimmed database so deleted rows leave no pages behind
|
||||||
4. Compress with zstd and encrypt with age
|
4. Compress with zstd and encrypt with age
|
||||||
5. Upload to S3 as `metadata/{remote-key}/db.zst.age`
|
5. Upload to S3 as `metadata/{remote-key}/db.zst.age`
|
||||||
6. Generate blob manifest and upload as `metadata/{remote-key}/manifest.json.zst`
|
6. Generate blob manifest and upload as
|
||||||
|
`metadata/{remote-key}/manifest.json.zst`
|
||||||
|
|
||||||
The `{remote-key}` directory name is a one-way hash of the human snapshot ID, so the ID is never written to the store in plaintext. The hash uses no secret, so a guessed hostname and snapshot name can still be confirmed against a listing; see [REPOSTRUCTURE.md](REPOSTRUCTURE.md#remote-key-derivation) and its [Accepted Risks](REPOSTRUCTURE.md#accepted-risks).
|
The `{remote-key}` directory name is a one-way hash of the human snapshot ID, so
|
||||||
|
the ID is never written to the store in plaintext. The hash uses no secret, so a
|
||||||
|
guessed hostname and snapshot name can still be confirmed against a listing; see
|
||||||
|
[REPOSTRUCTURE.md](REPOSTRUCTURE.md#remote-key-derivation) and its
|
||||||
|
[Accepted Risks](REPOSTRUCTURE.md#accepted-risks).
|
||||||
|
|
||||||
### 4. Restore Process
|
### 4. Restore Process
|
||||||
|
|
||||||
The restore process doesn't use the local database. Instead:
|
The restore process doesn't use the local database. Instead:
|
||||||
1. Downloads snapshot metadata from S3
|
|
||||||
2. Downloads required blobs based on manifest
|
1. Downloads and decrypts the snapshot's metadata database (`db.zst.age`) from
|
||||||
|
S3
|
||||||
|
2. Downloads the blobs holding the chunks of the files being restored, found
|
||||||
|
through that database's `blob_chunks` table; the manifest is not read
|
||||||
3. Reconstructs files from decrypted and decompressed chunks
|
3. Reconstructs files from decrypted and decompressed chunks
|
||||||
|
|
||||||
### 5. Pruning
|
### 5. Pruning
|
||||||
|
|
||||||
1. **Identify Unreferenced Blobs**
|
1. **Identify Unreferenced Blobs**
|
||||||
- Query blobs not referenced by any remaining snapshot
|
- Query blobs not referenced by any remaining snapshot
|
||||||
- Delete from S3 and local database
|
- Delete from S3, once older than `prune_grace_period`, and from the local
|
||||||
|
database
|
||||||
|
2. **Delete Metadata Directories With No Manifest**
|
||||||
|
- Delete each directory under `metadata/` that has no `manifest.json.zst`
|
||||||
|
and no local snapshot record, once every object in it is older than
|
||||||
|
`prune_grace_period`
|
||||||
|
|
||||||
### 6. Incomplete Snapshot Cleanup
|
### 6. Incomplete Snapshot Cleanup
|
||||||
|
|
||||||
Before each backup:
|
Before each backup:
|
||||||
|
|
||||||
1. Query incomplete snapshots (where `completed_at IS NULL`)
|
1. Query incomplete snapshots (where `completed_at IS NULL`)
|
||||||
2. Check if metadata exists in S3
|
2. Delete each one and all its associations, without checking S3 for its
|
||||||
3. If no metadata, delete snapshot and all associations
|
metadata
|
||||||
4. Clean up orphaned files, chunks, and blobs
|
3. Clean up orphaned files, chunks, and blobs
|
||||||
|
|
||||||
## Repository Pattern
|
## Repository Pattern
|
||||||
|
|
||||||
@@ -246,18 +331,20 @@ Vaultik uses a repository pattern for database access:
|
|||||||
- `FileRepository` - CRUD operations for files and file metadata
|
- `FileRepository` - CRUD operations for files and file metadata
|
||||||
- `ChunkRepository` - CRUD operations for content chunks
|
- `ChunkRepository` - CRUD operations for content chunks
|
||||||
- `FileChunkRepository` - Manage file-to-chunk mappings
|
- `FileChunkRepository` - Manage file-to-chunk mappings
|
||||||
- `ChunkFileRepository` - Manage chunk-to-file reverse mappings
|
- `ChunkFileRepository` - Manage chunk-to-file reverse mappings
|
||||||
- `BlobRepository` - Manage blob lifecycle (creation, finalization, upload)
|
- `BlobRepository` - Manage blob lifecycle (creation, finalization, upload)
|
||||||
- `BlobChunkRepository` - Manage blob-to-chunk associations
|
- `BlobChunkRepository` - Manage blob-to-chunk associations
|
||||||
- `SnapshotRepository` - Manage snapshots and their relationships
|
- `SnapshotRepository` - Manage snapshots and their relationships
|
||||||
- `UploadRepository` - Track blob upload metrics
|
- `UploadRepository` - Track blob upload metrics
|
||||||
|
|
||||||
Each repository provides methods like:
|
Each repository provides methods like:
|
||||||
|
|
||||||
- `Create()` - Insert new record
|
- `Create()` - Insert new record
|
||||||
- `GetByID()` / `GetByPath()` / `GetByHash()` - Retrieve records
|
- `GetByID()` / `GetByPath()` / `GetByHash()` - Retrieve records
|
||||||
- `Update()` - Update existing records
|
- `Update()` - Update existing records
|
||||||
- `Delete()` - Remove records
|
- `Delete()` - Remove records
|
||||||
- Specialized queries for each entity type (e.g., `DeleteOrphaned()`, `GetIncompleteByHostname()`)
|
- Specialized queries for each entity type (e.g., `DeleteOrphaned()`,
|
||||||
|
`GetIncompleteByHostname()`)
|
||||||
|
|
||||||
## Transaction Management
|
## Transaction Management
|
||||||
|
|
||||||
@@ -270,25 +357,33 @@ err := repos.WithTx(ctx, func(ctx context.Context, tx *sql.Tx) error {
|
|||||||
```
|
```
|
||||||
|
|
||||||
This ensures consistency, especially important for operations like:
|
This ensures consistency, especially important for operations like:
|
||||||
|
|
||||||
- Creating file-chunk mappings
|
- Creating file-chunk mappings
|
||||||
- Associating chunks with blobs
|
- Associating chunks with blobs
|
||||||
- Updating snapshot statistics
|
- Updating snapshot statistics
|
||||||
|
|
||||||
## Performance Considerations
|
## Performance Considerations
|
||||||
|
|
||||||
1. **Indexes**:
|
1. **Indexes**:
|
||||||
- Primary keys are automatically indexed
|
- Primary keys are automatically indexed
|
||||||
- `idx_files_path` on `files(path)` for efficient file lookups
|
- `idx_files_path` on `files(path)` for efficient file lookups
|
||||||
|
|
||||||
2. **Prepared Statements**: All queries use prepared statements for performance and security
|
2. **Prepared Statements**: All queries use prepared statements for performance
|
||||||
|
and security
|
||||||
|
|
||||||
3. **Batch Operations**: Where possible, operations are batched within transactions
|
3. **Batch Operations**: Where possible, operations are batched within
|
||||||
|
transactions
|
||||||
|
|
||||||
4. **Write-Ahead Logging**: 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
|
||||||
|
|
||||||
1. **Foreign Keys**: Enforced through CASCADE DELETE and application-level repository methods
|
1. **Foreign Keys**: Enforced through CASCADE DELETE and application-level
|
||||||
|
repository methods
|
||||||
2. **Unique Constraints**: Chunk hashes, file paths, and blob hashes are unique
|
2. **Unique Constraints**: Chunk hashes, file paths, and blob hashes are unique
|
||||||
3. **Null Handling**: Nullable fields clearly indicate in-progress operations
|
3. **Null Handling**: Nullable fields clearly indicate in-progress operations
|
||||||
4. **Timestamp Tracking**: All major operations record timestamps for auditing
|
4. **Timestamp Tracking**: All major operations record timestamps for auditing
|
||||||
+232
-55
@@ -1,10 +1,13 @@
|
|||||||
# Vaultik S3 Repository Structure
|
# Vaultik S3 Repository Structure
|
||||||
|
|
||||||
This document describes the structure and organization of data stored in the S3 bucket by Vaultik.
|
This document describes the structure and organization of data stored in the S3
|
||||||
|
bucket by Vaultik.
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
Vaultik stores all backup data in an S3-compatible object store. The repository consists of two main components:
|
Vaultik stores all backup data in an S3-compatible object store. The repository
|
||||||
|
consists of two main components, plus a marker file:
|
||||||
|
|
||||||
1. **Blobs** - The actual backup data (content-addressed, encrypted)
|
1. **Blobs** - The actual backup data (content-addressed, encrypted)
|
||||||
2. **Metadata** - Snapshot information and manifests (partially encrypted)
|
2. **Metadata** - Snapshot information and manifests (partially encrypted)
|
||||||
|
|
||||||
@@ -12,6 +15,7 @@ Vaultik stores all backup data in an S3-compatible object store. The repository
|
|||||||
|
|
||||||
```
|
```
|
||||||
<bucket>/<prefix>/
|
<bucket>/<prefix>/
|
||||||
|
├── vaultik-destination.txt
|
||||||
├── blobs/
|
├── blobs/
|
||||||
│ └── <hash[0:2]>/
|
│ └── <hash[0:2]>/
|
||||||
│ └── <hash[2:4]>/
|
│ └── <hash[2:4]>/
|
||||||
@@ -22,114 +26,208 @@ Vaultik stores all backup data in an S3-compatible object store. The repository
|
|||||||
└── manifest.json.zst
|
└── manifest.json.zst
|
||||||
```
|
```
|
||||||
|
|
||||||
The metadata subdirectory is named with the **remote key**, a one-way hash of the snapshot ID, not with the human-readable snapshot ID itself. See [Remote Key Derivation](#remote-key-derivation).
|
The metadata subdirectory is named with the **remote key**, a one-way hash of
|
||||||
|
the snapshot ID, not with the human-readable snapshot ID itself. See
|
||||||
|
[Remote Key Derivation](#remote-key-derivation).
|
||||||
|
|
||||||
|
## Marker File (`vaultik-destination.txt`)
|
||||||
|
|
||||||
|
Every backup writes this file at the root before it uploads anything. It holds
|
||||||
|
one fixed line of plain text and names no host, path or time. While the local
|
||||||
|
index holds snapshots stored there, a destination that answers a look-up of the
|
||||||
|
marker with not found is treated as not present: `snapshot create` refuses to
|
||||||
|
write to it, and `prune`, `snapshot purge`, `snapshot remove`, `snapshot list`
|
||||||
|
and `remote nuke` behave as for a destination that cannot be listed. An
|
||||||
|
unplugged volume leaves an empty directory at its mount point, and without the
|
||||||
|
marker that directory would read as a destination whose snapshots are all gone.
|
||||||
|
`remote nuke` deletes the marker last.
|
||||||
|
|
||||||
|
When the look-up of the marker fails for any reason other than not found, the
|
||||||
|
check is skipped and the command goes on. S3 refuses it on every run to
|
||||||
|
credentials that allow only PUT, so a host with such credentials does not notice
|
||||||
|
a destination emptied since its last backup, and its next backup would leave out
|
||||||
|
the data its local index records as already stored. After the destination is
|
||||||
|
emptied, run `vaultik database delete` on such a host before it backs up again.
|
||||||
|
|
||||||
## Blobs Directory (`blobs/`)
|
## Blobs Directory (`blobs/`)
|
||||||
|
|
||||||
### Structure
|
### Structure
|
||||||
|
|
||||||
- **Path format**: `blobs/<first-2-chars>/<next-2-chars>/<full-hash>`
|
- **Path format**: `blobs/<first-2-chars>/<next-2-chars>/<full-hash>`
|
||||||
- **Example**: `blobs/ca/fe/cafebabe1234567890abcdef1234567890abcdef1234567890abcdef12345678`
|
- **Example**:
|
||||||
- **Sharding**: The two-level directory structure (using the first 4 characters of the hash) prevents any single directory from containing too many objects
|
`blobs/ca/fe/cafebabe1234567890abcdef1234567890abcdef1234567890abcdef12345678`
|
||||||
|
- **Sharding**: The two-level directory structure (using the first 4 characters
|
||||||
|
of the hash) prevents any single directory from containing too many objects
|
||||||
|
|
||||||
### Content
|
### Content
|
||||||
|
|
||||||
- **What it contains**: Packed collections of content-defined chunks from files
|
- **What it contains**: Packed collections of content-defined chunks from files
|
||||||
- **Format**: Zstandard compressed, then Age encrypted
|
- **Format**: Zstandard compressed, then Age encrypted
|
||||||
- **Encryption**: Always encrypted with Age using the configured recipients
|
- **Encryption**: Always encrypted with Age using the configured recipients
|
||||||
- **Naming**: Content-addressed. The blob's name is `hex(SHA256(SHA256(uncompressed blob contents)))` — the double SHA-256 of the concatenated chunk data, computed before compression and encryption, not a hash of the stored (compressed, encrypted) bytes. One consequence: only a holder of the age private key can check a stored blob's integrity, because matching a blob to its name means decrypting and decompressing it first — which is what `restore` and `verify --deep` do. Implemented in `internal/blobgen` (`DoubleSHA256`). This is the canonical description of blob naming; other documents and comments point here.
|
- **Naming**: Content-addressed. The blob's name is
|
||||||
|
`hex(SHA256(SHA256(uncompressed blob contents)))` — the double SHA-256 of the
|
||||||
|
concatenated chunk data, computed before compression and encryption, not a
|
||||||
|
hash of the stored (compressed, encrypted) bytes. One consequence: only a
|
||||||
|
holder of the age private key can check a stored blob's integrity, because
|
||||||
|
matching a blob to its name means decrypting and decompressing it first —
|
||||||
|
which is what `restore` and `verify --deep` do. Implemented in
|
||||||
|
`internal/blobgen` (`DoubleSHA256`). This is the canonical description of blob
|
||||||
|
naming; other documents and comments point here.
|
||||||
|
|
||||||
### Why Encrypted
|
### Why Encrypted
|
||||||
Blobs contain the actual file data from backups and must be encrypted for security. The content-addressing ensures deduplication while the encryption ensures privacy.
|
|
||||||
|
Blobs contain the actual file data from backups and must be encrypted for
|
||||||
|
security. The content-addressing ensures deduplication while the encryption
|
||||||
|
ensures privacy.
|
||||||
|
|
||||||
## Metadata Directory (`metadata/`)
|
## Metadata Directory (`metadata/`)
|
||||||
|
|
||||||
Each snapshot has its own subdirectory. The directory is **not** named with the human-readable snapshot ID; it is named with the remote key — a one-way hash of that ID. The human ID is never written to the destination store as a directory name (see [Remote Key Derivation](#remote-key-derivation)).
|
Each snapshot has its own subdirectory. The directory is **not** named with the
|
||||||
|
human-readable snapshot ID; it is named with the remote key — a one-way hash of
|
||||||
|
that ID. The human ID is never written to the destination store as a directory
|
||||||
|
name (see [Remote Key Derivation](#remote-key-derivation)).
|
||||||
|
|
||||||
### Snapshot ID Format
|
### Snapshot ID Format
|
||||||
|
|
||||||
The human-readable snapshot ID is used in CLI arguments, log lines, and the local database. It is not written to the destination store.
|
The human-readable snapshot ID is used in CLI arguments, log lines, and the
|
||||||
- **Format**: `<hostname>_<snapshot-name>_<RFC3339>` (or `<hostname>_<RFC3339>` if no
|
local database. It is not written to the destination store.
|
||||||
name was specified)
|
|
||||||
|
- **Format**: `<hostname>_<snapshot-name>_<RFC3339>` (or `<hostname>_<RFC3339>`
|
||||||
|
if no name was specified)
|
||||||
- **Example**: `laptop_home_2024-01-15T14:30:52Z`
|
- **Example**: `laptop_home_2024-01-15T14:30:52Z`
|
||||||
- **Components**:
|
- **Components**:
|
||||||
- Short hostname (everything before the first dot is stripped from the FQDN)
|
- Short hostname (everything before the first dot is stripped from the FQDN)
|
||||||
- Snapshot name from the configured `snapshots:` map (optional)
|
- Snapshot name from the configured `snapshots:` map (optional)
|
||||||
- RFC3339 UTC timestamp
|
- RFC3339 UTC timestamp
|
||||||
|
|
||||||
This ID reveals the hostname, the configured snapshot name, and the backup time, so it is never used as the on-disk directory name — the remote key is used instead.
|
This ID reveals the hostname, the configured snapshot name, and the backup time,
|
||||||
|
so it is never used as the on-disk directory name — the remote key is used
|
||||||
|
instead.
|
||||||
|
|
||||||
### Remote Key Derivation
|
### Remote Key Derivation
|
||||||
|
|
||||||
The remote key is `hex(SHA256(SHA256("vaultik|" + snapshot-id)))`: a double SHA-256 over the snapshot ID, with a `vaultik|` domain-separation prefix. The result is a 64-character hex string. The hash is not reversible, but it uses no secret: an observer who guesses a candidate hostname and snapshot name can hash it the same way and confirm whether that snapshot is present. The remote key keeps names out of a plain listing; it does not hide them from a guess. Implemented in `internal/snapshot/remotekey.go`.
|
The remote key is `hex(SHA256(SHA256("vaultik|" + snapshot-id)))`: a double
|
||||||
|
SHA-256 over the snapshot ID, with a `vaultik|` domain-separation prefix. The
|
||||||
|
result is a 64-character hex string. The hash is not reversible, but it uses no
|
||||||
|
secret: an observer who guesses a candidate hostname and snapshot name can hash
|
||||||
|
it the same way and confirm whether that snapshot is present. The remote key
|
||||||
|
keeps names out of a plain listing; it does not hide them from a guess.
|
||||||
|
Implemented in `internal/snapshot/remotekey.go`.
|
||||||
|
|
||||||
Worked example:
|
Worked example:
|
||||||
|
|
||||||
- Snapshot ID: `server1_home_2025-06-01T12:00:00Z`
|
- Snapshot ID: `server1_home_2025-06-01T12:00:00Z`
|
||||||
- Remote key: `17f97bcde958748af076b926af59823943db59e80ce7170b40f124dfa28f64aa`
|
- Remote key: `17f97bcde958748af076b926af59823943db59e80ce7170b40f124dfa28f64aa`
|
||||||
- Directory: `metadata/17f97bcde958748af076b926af59823943db59e80ce7170b40f124dfa28f64aa/`
|
- Directory:
|
||||||
|
`metadata/17f97bcde958748af076b926af59823943db59e80ce7170b40f124dfa28f64aa/`
|
||||||
|
|
||||||
A plain listing of the destination store therefore shows only these hashes, not the hostname or snapshot name of any backup — but because the hash uses no secret, a guessed hostname and snapshot name can be hashed and confirmed against the listing. The same remote key is stored in the manifest's `snapshot_id` field.
|
A plain listing of the destination store therefore shows only these hashes, not
|
||||||
|
the hostname or snapshot name of any backup — but because the hash uses no
|
||||||
|
secret, a guessed hostname and snapshot name can be hashed and confirmed against
|
||||||
|
the listing. The same remote key is stored in the manifest's `snapshot_id`
|
||||||
|
field.
|
||||||
|
|
||||||
### Files in Each Snapshot Directory
|
### Files in Each Snapshot Directory
|
||||||
|
|
||||||
#### `db.zst.age` - Encrypted Database
|
#### `db.zst.age` - Encrypted Database
|
||||||
|
|
||||||
- **What it contains**: Pruned binary SQLite database for this snapshot
|
- **What it contains**: Pruned binary SQLite database for this snapshot
|
||||||
- **Format**: Binary SQLite → Zstandard compressed → Age encrypted
|
- **Format**: Binary SQLite → Zstandard compressed → Age encrypted
|
||||||
- **Encryption**: Encrypted with Age
|
- **Encryption**: Encrypted with Age
|
||||||
- **Purpose**: Contains full file metadata, chunk mappings, and all relationships
|
- **Purpose**: Contains full file metadata, chunk mappings, and all
|
||||||
- **Why encrypted**: Contains sensitive metadata like file paths, permissions, and ownership
|
relationships
|
||||||
|
- **Why encrypted**: Contains sensitive metadata like file paths, permissions,
|
||||||
|
and ownership
|
||||||
|
|
||||||
#### `manifest.json.zst` - Unencrypted Blob Manifest
|
#### `manifest.json.zst` - Unencrypted Blob Manifest
|
||||||
|
|
||||||
- **What it contains**: JSON list of all blob hashes referenced by this snapshot
|
- **What it contains**: JSON list of all blob hashes referenced by this snapshot
|
||||||
- **Format**: JSON → Zstandard compressed (NOT encrypted)
|
- **Format**: JSON → Zstandard compressed (NOT encrypted)
|
||||||
- **Encryption**: NOT encrypted
|
- **Encryption**: NOT encrypted
|
||||||
- **Purpose**: Enables pruning operations without requiring decryption keys
|
- **Purpose**: Enables pruning operations without requiring decryption keys
|
||||||
- **Structure**:
|
- **Structure**:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"snapshot_id": "17f97bcde958748af076b926af59823943db59e80ce7170b40f124dfa28f64aa",
|
"snapshot_id": "17f97bcde958748af076b926af59823943db59e80ce7170b40f124dfa28f64aa",
|
||||||
"timestamp": "2025-06-01T12:00:00Z",
|
"timestamp": "2025-06-01T12:00:00Z",
|
||||||
"blob_count": 42,
|
"blob_count": 42,
|
||||||
"total_compressed_size": 1048576,
|
"total_compressed_size": 1048576,
|
||||||
"blobs": [
|
"blobs": [
|
||||||
{ "hash": "cafebabe1234567890abcdef1234567890abcdef1234567890abcdef12345678", "compressed_size": 24576 },
|
{
|
||||||
{ "hash": "deadbeef1234567890abcdef1234567890abcdef1234567890abcdef12345678", "compressed_size": 32768 }
|
"hash": "cafebabe1234567890abcdef1234567890abcdef1234567890abcdef12345678",
|
||||||
]
|
"compressed_size": 24576
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hash": "deadbeef1234567890abcdef1234567890abcdef1234567890abcdef12345678",
|
||||||
|
"compressed_size": 32768
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
`snapshot_id` is the remote key (a hash), not the human ID; `timestamp` is written in the clear.
|
|
||||||
|
`snapshot_id` is the remote key (a hash), not the human ID; `timestamp` is
|
||||||
|
written in the clear.
|
||||||
|
|
||||||
### Why Manifest is Unencrypted
|
### Why Manifest is Unencrypted
|
||||||
|
|
||||||
The manifest must be readable without the private key to enable:
|
The manifest must be readable without the private key to enable:
|
||||||
|
|
||||||
1. **Pruning operations** - Identifying unreferenced blobs for deletion
|
1. **Pruning operations** - Identifying unreferenced blobs for deletion
|
||||||
2. **Storage analysis** - Understanding space usage without decryption
|
2. **Storage analysis** - Understanding space usage without decryption
|
||||||
3. **Verification** - Checking blob existence without decryption
|
3. **Verification** - Checking blob existence without decryption
|
||||||
4. **Cross-snapshot deduplication analysis** - Finding shared blobs between snapshots
|
4. **Cross-snapshot deduplication analysis** - Finding shared blobs between
|
||||||
|
snapshots
|
||||||
|
|
||||||
The manifest contains the remote key, the backup timestamp, the blob count and total compressed size, and each blob's hash and compressed size. It contains no file names, paths, or other decrypted metadata.
|
The manifest contains the remote key, the backup timestamp, the blob count and
|
||||||
|
total compressed size, and each blob's hash and compressed size. It contains no
|
||||||
|
file names, paths, or other decrypted metadata.
|
||||||
|
|
||||||
## Security Considerations
|
## Security Considerations
|
||||||
|
|
||||||
### What's Encrypted
|
### What's Encrypted
|
||||||
|
|
||||||
- **All file content** (in blobs)
|
- **All file content** (in blobs)
|
||||||
- **All file metadata** (paths, permissions, timestamps, ownership in db.zst.age)
|
- **All file metadata** (paths, permissions, timestamps, ownership in
|
||||||
|
db.zst.age)
|
||||||
- **File-to-chunk mappings** (in db.zst.age)
|
- **File-to-chunk mappings** (in db.zst.age)
|
||||||
|
|
||||||
### What's Not Encrypted
|
### What's Not Encrypted
|
||||||
- **The remote key** — directory names and the manifest `snapshot_id`, a one-way hash of the snapshot ID (see [Remote Key Derivation](#remote-key-derivation))
|
|
||||||
|
- **The remote key** — directory names and the manifest `snapshot_id`, a one-way
|
||||||
|
hash of the snapshot ID (see [Remote Key Derivation](#remote-key-derivation))
|
||||||
- **The backup timestamp** (in manifest.json.zst)
|
- **The backup timestamp** (in manifest.json.zst)
|
||||||
- **Blob hashes and their compressed sizes** (in manifest.json.zst)
|
- **Blob hashes and their compressed sizes** (in manifest.json.zst)
|
||||||
- **Blob count and total compressed size per snapshot** (in manifest.json.zst)
|
- **Blob count and total compressed size per snapshot** (in manifest.json.zst)
|
||||||
|
|
||||||
### Privacy Implications
|
### Privacy Implications
|
||||||
From the unencrypted data, an observer of the destination store can determine:
|
|
||||||
- **When each backup was taken** — not from the directory name, which is a one-way hash, but from the plaintext `timestamp` field in manifest.json.zst, which is published in the clear
|
|
||||||
- How many blobs each snapshot references, and the total compressed size
|
|
||||||
- The compressed size of each blob, and which blobs are shared between snapshots (deduplication patterns)
|
|
||||||
- **Whether a guessed hostname and snapshot name are present** — the remote key is an unkeyed hash, so an observer holding candidate names can hash each one and match it against the directory listing. The human ID is never published, so it cannot be read off directly, but it can be confirmed by guessing.
|
|
||||||
|
|
||||||
Together these give an observer a timing-and-size profile of every snapshot. This is an accepted, documented property of the format, not a defect: the manifest is unencrypted so that pruning can run without the private key, and the timing channel could not be closed by encrypting it anyway — object creation times and per-object sizes stay visible at the storage layer on both `s3://` and `file://` destinations regardless.
|
From the unencrypted data, an observer of the destination store can determine:
|
||||||
|
|
||||||
|
- **When each backup was taken** — not from the directory name, which is a
|
||||||
|
one-way hash, but from the plaintext `timestamp` field in manifest.json.zst,
|
||||||
|
which is published in the clear
|
||||||
|
- How many blobs each snapshot references, and the total compressed size
|
||||||
|
- The compressed size of each blob, and which blobs are shared between snapshots
|
||||||
|
(deduplication patterns)
|
||||||
|
- **Whether a guessed hostname and snapshot name are present** — the remote key
|
||||||
|
is an unkeyed hash, so an observer holding candidate names can hash each one
|
||||||
|
and match it against the directory listing. The human ID is never published,
|
||||||
|
so it cannot be read off directly, but it can be confirmed by guessing.
|
||||||
|
|
||||||
|
Together these give an observer a timing-and-size profile of every snapshot.
|
||||||
|
This is an accepted, documented property of the format, not a defect: the
|
||||||
|
manifest is unencrypted so that pruning can run without the private key, and the
|
||||||
|
timing channel could not be closed by encrypting it anyway — object creation
|
||||||
|
times and per-object sizes stay visible at the storage layer on both `s3://` and
|
||||||
|
`file://` destinations regardless.
|
||||||
|
|
||||||
An observer cannot determine:
|
An observer cannot determine:
|
||||||
- The hostname or snapshot name of any backup by reading it off the store — the directory name and the manifest `snapshot_id` are unkeyed hashes of the human ID, so the text is never published (though a guessed name can be confirmed, as above)
|
|
||||||
|
- The hostname or snapshot name of any backup by reading it off the store — the
|
||||||
|
directory name and the manifest `snapshot_id` are unkeyed hashes of the human
|
||||||
|
ID, so the text is never published (though a guessed name can be confirmed, as
|
||||||
|
above)
|
||||||
- File names or paths
|
- File names or paths
|
||||||
- File contents
|
- File contents
|
||||||
- File permissions or ownership
|
- File permissions or ownership
|
||||||
@@ -138,40 +236,119 @@ An observer cannot determine:
|
|||||||
|
|
||||||
### Accepted Risks
|
### Accepted Risks
|
||||||
|
|
||||||
These are known, deliberate properties of the format and the tooling, recorded so an operator can weigh them rather than discover them.
|
These are known, deliberate properties of the format and the tooling, recorded
|
||||||
|
so an operator can weigh them rather than discover them.
|
||||||
|
|
||||||
1. **No proof of authorship.** Restore and `verify --deep` prove that data decrypts with the age private key and matches its unkeyed content hashes. They do not prove who wrote it: anyone who knows a recipient public key and can replace objects on the destination can substitute a snapshot they built. The recipient string is not stored at the destination, but a compromised backed-up host has it. Defences live on the destination side — bucket versioning or object lock, credentials for the source host that cannot delete or overwrite existing versions, and pruning from a trusted host. Note that S3 `PutObject` overwrites an existing key, so PUT permission alone is not append-only.
|
1. **No proof of authorship.** Restore and `verify --deep` prove that data
|
||||||
2. **Compression reveals sizes.** Blobs and `db.zst.age` are zstd-compressed then age-encrypted; the manifest is compressed only. age does not pad, so an object's size is the exact compressed length of its contents. All new chunks packed into one blob share a single zstd stream (8 MiB window, 4 MiB at compression levels 1-2), and a blob is closed at `blob_size_limit` and at the end of each configured path. Because a stored chunk is never packed again, someone who can write into a backed-up file and watch blob sizes learns something only when their controlled data and a secret land in the same chunk of a file that keeps changing. Advice: back up any outsider-writable directory as its own snapshot.
|
decrypts with the age private key and matches its unkeyed content hashes.
|
||||||
3. **Chunking uses no secret.** The FastCDC parameters are fixed and public. The default 10 MB average yields chunks between 2.5 MB and 40 MB, and any file of 2.5 MB or less is a single chunk. At the default 10 GB `blob_size_limit` a blob holds hundreds of chunks, so individual chunk lengths are not visible in the blob's size; lowering the limit toward the chunk size begins to expose them.
|
They do not prove who wrote it: anyone who knows a recipient public key and
|
||||||
4. **Decrypted data on local disk.** Several commands stage plaintext under `$TMPDIR`: `snapshot restore` writes decrypted blobs under `vaultik-blobcache-*/` (no size cap) and the decrypted metadata database at `vaultik-restore-*/snapshot.db`; `verify --deep` writes that database at `vaultik-verify-*/snapshot.db`; `snapshot create` keeps a plaintext copy of the index at `vaultik-snapshot-*/snapshot.db`. These files are created `0600` and removed on success, but a `kill -9` or a power loss leaves them behind — delete any leftover `vaultik-*` directory under `$TMPDIR` by hand. `$TMPDIR` should be trusted to the same degree as the restore target.
|
can replace objects on the destination can substitute a snapshot they built.
|
||||||
5. **Store permissions differ per command.** The backed-up host needs only PUT to run `snapshot create`: it writes blobs and metadata and neither reads nor deletes them. Other commands need more — `snapshot verify`, `snapshot restore`, and `prune` list and read; `prune`, `snapshot purge`, `snapshot remove`, and `remote nuke` also delete. The recommended cron line uses `--prune`, which runs `prune` on the backed-up host, so granting that host `--prune` gives it credentials that can delete its own backups. To keep the source host to PUT only, prune from a separate trusted host instead.
|
The recipient string is not stored at the destination, but a compromised
|
||||||
6. **Changing recipients does not re-encrypt existing data.** Deduplicated chunks and same-named blobs already on the destination stay encrypted to the recipients in force when they were written. A new snapshot that reuses them cannot be restored with a newly added recipient's key alone, because those reused objects were never encrypted to it. To make everything readable by a new key, run `vaultik database delete` and take a full backup to a fresh destination or prefix.
|
backed-up host has it. Defences live on the destination side — bucket
|
||||||
7. **X25519 recipients only.** vaultik rejects age ssh and plugin recipients. Long-lived ciphertext held by a third party (the destination operator) has no fallback if X25519 is ever broken: there is no second recipient type and no post-quantum option.
|
versioning or object lock, credentials for the source host that cannot delete
|
||||||
|
or overwrite existing versions, and pruning from a trusted host. Note that S3
|
||||||
|
`PutObject` overwrites an existing key, so PUT permission alone is not
|
||||||
|
append-only.
|
||||||
|
2. **Compression reveals sizes.** Blobs and `db.zst.age` are zstd-compressed
|
||||||
|
then age-encrypted; the manifest is compressed only. age does not pad, so an
|
||||||
|
object's size is the exact compressed length of its contents. All new chunks
|
||||||
|
packed into one blob share a single zstd stream (8 MiB window, 4 MiB at
|
||||||
|
compression levels 1-2), and a blob is closed at `blob_size_limit` and at the
|
||||||
|
end of each configured path. Because a stored chunk is never packed again,
|
||||||
|
someone who can write into a backed-up file and watch blob sizes learns
|
||||||
|
something only when their controlled data and a secret land in the same chunk
|
||||||
|
of a file that keeps changing. Advice: back up any outsider-writable
|
||||||
|
directory as its own snapshot.
|
||||||
|
3. **Chunking uses no secret.** The FastCDC parameters are fixed and public. The
|
||||||
|
default 10 MB average yields chunks between 2.5 MB and 40 MB, and any file of
|
||||||
|
2.5 MB or less is a single chunk. At the default 10 GB `blob_size_limit` a
|
||||||
|
blob holds hundreds of chunks, so individual chunk lengths are not visible in
|
||||||
|
the blob's size; lowering the limit toward the chunk size begins to expose
|
||||||
|
them.
|
||||||
|
4. **Decrypted data on local disk.** Several commands stage plaintext under
|
||||||
|
`$TMPDIR`: `snapshot restore` writes decrypted blobs under
|
||||||
|
`vaultik-blobcache-*/` (no size cap) and the decrypted metadata database at
|
||||||
|
`vaultik-restore-*/snapshot.db`; `verify --deep` writes that database at
|
||||||
|
`vaultik-verify-*/snapshot.db`; `snapshot create` keeps a plaintext copy of
|
||||||
|
the index at `vaultik-snapshot-*/snapshot.db`. These files are created `0600`
|
||||||
|
and removed on success, but a `kill -9` or a power loss leaves them behind —
|
||||||
|
delete any leftover `vaultik-*` directory under `$TMPDIR` by hand. `$TMPDIR`
|
||||||
|
should be trusted to the same degree as the restore target.
|
||||||
|
5. **Store permissions differ per command.** The backed-up host needs only PUT
|
||||||
|
to run `snapshot create`: it writes blobs and metadata and neither reads nor
|
||||||
|
deletes them. Other commands need more — `snapshot verify`,
|
||||||
|
`snapshot restore`, and `prune` list and read; `prune`, `snapshot purge`,
|
||||||
|
`snapshot remove`, and `remote nuke` also delete. The recommended cron line
|
||||||
|
uses `--prune`, which runs `prune` on the backed-up host, so granting that
|
||||||
|
host `--prune` gives it credentials that can delete its own backups. To keep
|
||||||
|
the source host to PUT only, prune from a separate trusted host instead.
|
||||||
|
6. **Changing recipients does not re-encrypt existing data.** Deduplicated
|
||||||
|
chunks and same-named blobs already on the destination stay encrypted to the
|
||||||
|
recipients in force when they were written. A new snapshot that reuses them
|
||||||
|
cannot be restored with a newly added recipient's key alone, because those
|
||||||
|
reused objects were never encrypted to it. To make everything readable by a
|
||||||
|
new key, run `vaultik database delete` and take a full backup to a fresh
|
||||||
|
destination or prefix.
|
||||||
|
7. **X25519 recipients only.** vaultik rejects age ssh and plugin recipients.
|
||||||
|
Long-lived ciphertext held by a third party (the destination operator) has no
|
||||||
|
fallback if X25519 is ever broken: there is no second recipient type and no
|
||||||
|
post-quantum option.
|
||||||
|
|
||||||
## Consistency Guarantees
|
## Consistency Guarantees
|
||||||
|
|
||||||
1. **Blobs are immutable** - Once written, a blob is never modified
|
1. **Blobs are immutable** - Once written, a blob is never modified
|
||||||
2. **Blobs are written before metadata** - A snapshot's metadata is only written after all its blobs are successfully uploaded
|
2. **Blobs are written before metadata** - A snapshot's metadata is only written
|
||||||
3. **Metadata is written atomically** - Both db.zst.age and manifest.json.zst are written as complete files
|
after all its blobs are successfully uploaded
|
||||||
4. **A snapshot is marked complete in the local DB only after its metadata is uploaded** - `finalizeSnapshotMetadata` runs `ExportSnapshotMetadata` first and records completion (`MarkSnapshotComplete`) only once the export succeeds (see the backup data flow in [ARCHITECTURE.md](../ARCHITECTURE.md)). A crash during the export therefore leaves the snapshot incomplete, so the next backup's `PruneDatabase` drops it and re-backs-up its data, rather than leaving a completed-looking row in the local index with no matching metadata on the destination store. (A crash in the brief moment after the export succeeds but before completion is recorded leaves a fully-restorable snapshot on the destination that the local index drops as incomplete on the next run; `snapshot list` then reports it honestly as remote-only, which is the safe direction: the destination copy stays restorable.)
|
3. **Metadata is written atomically** - Both db.zst.age and manifest.json.zst
|
||||||
|
are written as complete files
|
||||||
|
4. **A snapshot is marked complete in the local DB only after its metadata is
|
||||||
|
uploaded** - `finalizeSnapshotMetadata` runs `ExportSnapshotMetadata` first
|
||||||
|
and records completion (`MarkSnapshotComplete`) only once the export succeeds
|
||||||
|
(see the backup data flow in [ARCHITECTURE.md](ARCHITECTURE.md)). A crash
|
||||||
|
during the export therefore leaves the snapshot incomplete, so the next
|
||||||
|
backup's `PruneDatabase` drops it and re-backs-up its data, rather than
|
||||||
|
leaving a completed-looking row in the local index with no matching metadata
|
||||||
|
on the destination store. (A crash in the brief moment after the export
|
||||||
|
succeeds but before completion is recorded leaves a fully-restorable snapshot
|
||||||
|
on the destination that the local index drops as incomplete on the next run;
|
||||||
|
`snapshot list` then reports it honestly as remote-only, which is the safe
|
||||||
|
direction: the destination copy stays restorable.)
|
||||||
|
|
||||||
## Pruning Safety
|
## Pruning Safety
|
||||||
|
|
||||||
The prune operation is safe because:
|
The prune operation is safe because:
|
||||||
1. It keeps every blob listed in any snapshot's manifest and deletes only blobs that no manifest references
|
|
||||||
|
1. It keeps every blob listed in any snapshot's manifest and deletes only blobs
|
||||||
|
that no manifest references. Under `metadata/` it deletes only a directory
|
||||||
|
that has no `manifest.json.zst`, whose name is a remote key, and that no
|
||||||
|
local snapshot record claims
|
||||||
2. Manifests are unencrypted and can be read without keys
|
2. Manifests are unencrypted and can be read without keys
|
||||||
3. If any manifest cannot be downloaded or decoded, prune deletes nothing and exits with an error, rather than treating that snapshot's blobs as unreferenced
|
3. If any manifest cannot be downloaded or decoded, prune deletes nothing and
|
||||||
4. Prune requires exclusive access to the destination: running it during a concurrent backup can race a snapshot whose manifest is not yet written, so do not prune while a backup is in progress
|
exits with an error, rather than treating that snapshot's blobs as
|
||||||
|
unreferenced
|
||||||
|
4. It deletes an unreferenced blob only once the blob is older than
|
||||||
|
`prune_grace_period` (default `24h`), and a directory under `metadata/` with
|
||||||
|
no manifest only once every object in it is. A backup uploads its blobs and
|
||||||
|
its `db.zst.age` before its manifest, so a prune running meanwhile, on this
|
||||||
|
host or another sharing the destination, leaves them as long as no backup run
|
||||||
|
takes longer than the grace period. The grace period does not protect a blob
|
||||||
|
a backup reuses from an earlier run that stopped before its manifest: the
|
||||||
|
backup skips the upload of a blob already on the destination, so that blob
|
||||||
|
keeps the time of its first upload, and a prune before the backup's manifest
|
||||||
|
is written can delete it; the new snapshot then cannot be restored.
|
||||||
|
`remote nuke` has no such check
|
||||||
|
|
||||||
## Restoration Requirements
|
## Restoration Requirements
|
||||||
|
|
||||||
To restore from a backup, you need:
|
To restore from a backup, you need:
|
||||||
|
|
||||||
1. **The Age private key** - To decrypt blobs and database
|
1. **The Age private key** - To decrypt blobs and database
|
||||||
2. **The snapshot metadata** - Both files from the snapshot's metadata directory
|
2. **The snapshot metadata** - Both files from the snapshot's metadata directory
|
||||||
3. **All referenced blobs** - As listed in the manifest
|
3. **All referenced blobs** - As listed in the manifest
|
||||||
|
|
||||||
The restoration process:
|
The restoration process:
|
||||||
|
|
||||||
1. Download and decrypt the database dump to understand file structure
|
1. Download and decrypt the database dump to understand file structure
|
||||||
2. Download and decrypt the required blobs
|
2. Download and decrypt the required blobs
|
||||||
3. Reconstruct files from their chunks
|
3. Reconstruct files from their chunks
|
||||||
4. Restore file metadata (permissions, timestamps, etc.)
|
4. Restore file metadata (permissions, timestamps, etc.)
|
||||||
+1224
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||||
|
|||||||
+55
-39
@@ -8,12 +8,12 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/adrg/xdg"
|
"github.com/adrg/xdg"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"go.uber.org/dig"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
"sneak.berlin/go/vaultik/internal/config"
|
"sneak.berlin/go/vaultik/internal/config"
|
||||||
"sneak.berlin/go/vaultik/internal/database"
|
"sneak.berlin/go/vaultik/internal/database"
|
||||||
@@ -90,7 +90,7 @@ func setupGlobals(
|
|||||||
// blank line. Used both from the fx hook (for subcommand invocations) and
|
// blank line. Used both from the fx hook (for subcommand invocations) and
|
||||||
// from the root cobra Run handler (for `vaultik` with no subcommand).
|
// from the root cobra Run handler (for `vaultik` with no subcommand).
|
||||||
func writeStartupBanner(w *ui.Writer, startTime time.Time, shortCommit string) {
|
func writeStartupBanner(w *ui.Writer, startTime time.Time, shortCommit string) {
|
||||||
w.Bannerf("%s %s by %s (commit %s, built on %s) starting up at %s.",
|
w.Bannerf("%s %s by %s (commit %s, commit date %s) starting up at %s.",
|
||||||
globals.Appname, globals.Version, globals.Author,
|
globals.Appname, globals.Version, globals.Author,
|
||||||
shortCommit, globals.CommitDate,
|
shortCommit, globals.CommitDate,
|
||||||
startTime.Format(time.RFC3339))
|
startTime.Format(time.RFC3339))
|
||||||
@@ -146,16 +146,12 @@ func (e *startupError) Error() string {
|
|||||||
// could not build arguments for function "Y" (file:line): failed to build U:
|
// could not build arguments for function "Y" (file:line): failed to build U:
|
||||||
// received non-nil error from function "Z" (file:line): <real error>
|
// received non-nil error from function "Z" (file:line): <real error>
|
||||||
//
|
//
|
||||||
// Users care about the real error, not the DI plumbing. We strip everything
|
// Users care about the real error, not the DI plumbing. dig.RootCause,
|
||||||
// up through the last "): " (which is always the close-paren of an fx
|
// from the library fx is built on, unwraps those layers and returns the
|
||||||
// function-location annotation followed by the wrapped error).
|
// real error whole. Cutting the text at "): " instead would also cut a
|
||||||
|
// real error that contains it, such as the blob_size_limit one.
|
||||||
func cleanStartupError(err error) error {
|
func cleanStartupError(err error) error {
|
||||||
msg := err.Error()
|
return &startupError{msg: dig.RootCause(err).Error()}
|
||||||
if idx := strings.LastIndex(msg, "): "); idx >= 0 {
|
|
||||||
msg = msg[idx+3:]
|
|
||||||
}
|
|
||||||
|
|
||||||
return &startupError{msg: msg}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunApp starts the fx application, blocks until it is asked to stop, and
|
// RunApp starts the fx application, blocks until it is asked to stop, and
|
||||||
@@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+237
-36
@@ -7,11 +7,16 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"runtime"
|
||||||
|
"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"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -26,11 +31,14 @@ const configSetArgs = 2
|
|||||||
// parent config dirs (e.g. ~/.config) are conventionally traversable.
|
// parent config dirs (e.g. ~/.config) are conventionally traversable.
|
||||||
const configDirMode = 0o755
|
const configDirMode = 0o755
|
||||||
|
|
||||||
// configYAMLIndent matches the 2-space indentation of defaultConfigTemplate,
|
// configYAMLIndent matches the 2-space indentation of the default config,
|
||||||
// so `config set` writes the file back with the same indentation rather than
|
// so `config set` writes the file back with the same indentation rather than
|
||||||
// yaml.Marshal's 4-space default.
|
// yaml.Marshal's 4-space default.
|
||||||
const configYAMLIndent = 2
|
const configYAMLIndent = 2
|
||||||
|
|
||||||
|
// 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,23 +46,44 @@ var (
|
|||||||
errNeedNumericIndex = errors.New("key is a list; use a numeric index")
|
errNeedNumericIndex = errors.New("key is a list; use a numeric index")
|
||||||
errIndexOutOfRange = errors.New("index out of range")
|
errIndexOutOfRange = errors.New("index out of range")
|
||||||
errNotMapOrList = errors.New("key is not a map or list")
|
errNotMapOrList = errors.New("key is not a map or list")
|
||||||
|
errWouldNotLoad = errors.New(
|
||||||
|
"vaultik would not load the config after this change, " +
|
||||||
|
"so the file was not changed")
|
||||||
)
|
)
|
||||||
|
|
||||||
const defaultConfigTemplate = `# vaultik configuration
|
// macOS is the value of runtime.GOOS on macOS.
|
||||||
|
const macOS = "darwin"
|
||||||
|
|
||||||
|
// defaultConfigText returns the file `config init` writes on the operating
|
||||||
|
// system goos, named as runtime.GOOS names it. Only macOS gets the apps
|
||||||
|
// snapshot, since /Applications exists nowhere else.
|
||||||
|
func defaultConfigText(goos string) string {
|
||||||
|
if goos == macOS {
|
||||||
|
return defaultConfigHead + appsSnapshotConfig + defaultConfigTail
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultConfigHead + defaultConfigTail
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultConfigHead is the default config up to the end of the home snapshot.
|
||||||
|
const defaultConfigHead = `# vaultik configuration
|
||||||
# Documentation: https://sneak.berlin/go/vaultik
|
# Documentation: https://sneak.berlin/go/vaultik
|
||||||
|
|
||||||
# ─── REQUIRED ────────────────────────────────────────────────────────────────
|
# ─── REQUIRED ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
# 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.
|
||||||
@@ -155,7 +184,10 @@ snapshots:
|
|||||||
- "/Movies/ProxyMedia"
|
- "/Movies/ProxyMedia"
|
||||||
- "/Music/iTunes/Album Artwork"
|
- "/Music/iTunes/Album Artwork"
|
||||||
- "/Pictures/iPod Photo Cache"
|
- "/Pictures/iPod Photo Cache"
|
||||||
|
`
|
||||||
|
|
||||||
|
// appsSnapshotConfig is the apps snapshot of the default config.
|
||||||
|
const appsSnapshotConfig = `
|
||||||
# Third-party applications. OS-provided apps live in /System/Applications
|
# Third-party applications. OS-provided apps live in /System/Applications
|
||||||
# on modern macOS and are never in /Applications, but Apple-installed
|
# on modern macOS and are never in /Applications, but Apple-installed
|
||||||
# App Store apps (Safari, GarageBand, iWork, iMovie) are excluded since
|
# App Store apps (Safari, GarageBand, iWork, iMovie) are excluded since
|
||||||
@@ -176,7 +208,10 @@ snapshots:
|
|||||||
- "/Steam.app"
|
- "/Steam.app"
|
||||||
- "/VirtualBox.app"
|
- "/VirtualBox.app"
|
||||||
- "/Utilities/Adobe Installers"
|
- "/Utilities/Adobe Installers"
|
||||||
|
`
|
||||||
|
|
||||||
|
// defaultConfigTail is the default config after the snapshots.
|
||||||
|
const defaultConfigTail = `
|
||||||
# Storage backend (pick ONE of the three forms below).
|
# Storage backend (pick ONE of the three forms below).
|
||||||
#
|
#
|
||||||
# S3-compatible:
|
# S3-compatible:
|
||||||
@@ -196,7 +231,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 +246,7 @@ storage_url: ""
|
|||||||
|
|
||||||
# Average chunk size for content-defined chunking (FastCDC).
|
# Average chunk size for content-defined chunking (FastCDC).
|
||||||
# Smaller = better deduplication but more metadata overhead.
|
# Smaller = better deduplication but more metadata overhead.
|
||||||
# Accepts: 1MB, 10M, 64KB, etc.
|
# Minimum 1MB. Accepts: 1MB, 10M, 64MiB, etc.
|
||||||
# Default: 10MB
|
# Default: 10MB
|
||||||
# chunk_size: 10MB
|
# chunk_size: 10MB
|
||||||
|
|
||||||
@@ -226,6 +261,18 @@ storage_url: ""
|
|||||||
# Default: 3
|
# Default: 3
|
||||||
# compression_level: 3
|
# compression_level: 3
|
||||||
|
|
||||||
|
# How old a blob that no snapshot lists must be before prune deletes it,
|
||||||
|
# and how old every object in a directory under metadata/ with no manifest
|
||||||
|
# must be before prune deletes that directory. A backup uploads its blobs
|
||||||
|
# and its db.zst.age before its manifest, so on a destination shared
|
||||||
|
# between hosts or users this must be longer than the longest backup run.
|
||||||
|
# It does not protect a blob a backup reuses from an earlier run that
|
||||||
|
# stopped before its manifest: that blob keeps the time of its first
|
||||||
|
# upload, so a prune can delete it.
|
||||||
|
# Accepts: 36h, 90m, etc. 0s deletes such blobs and directories at once.
|
||||||
|
# Default: 24h
|
||||||
|
# prune_grace_period: 24h
|
||||||
|
|
||||||
# Hostname used in snapshot IDs. Default: system hostname.
|
# Hostname used in snapshot IDs. Default: system hostname.
|
||||||
# hostname: myserver
|
# hostname: myserver
|
||||||
|
|
||||||
@@ -242,6 +289,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())
|
||||||
@@ -280,7 +333,8 @@ on macOS, ~/.config/ on Linux, /etc/vaultik/ as root).`,
|
|||||||
return fmt.Errorf("creating config directory %s: %w", dir, err)
|
return fmt.Errorf("creating config directory %s: %w", dir, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = os.WriteFile(path, []byte(defaultConfigTemplate), configFileMode)
|
err = os.WriteFile(path, []byte(defaultConfigText(runtime.GOOS)),
|
||||||
|
configFileMode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("writing config file: %w", err)
|
return fmt.Errorf("writing config file: %w", err)
|
||||||
}
|
}
|
||||||
@@ -314,8 +368,15 @@ func newConfigEditCommand() *cobra.Command {
|
|||||||
editor = "vi"
|
editor = "vi"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Run $EDITOR through the shell, as git does, so a value
|
||||||
|
// with arguments such as "code --wait" works. The path
|
||||||
|
// goes in as an argument ("$@"), not into the command
|
||||||
|
// text, so a path with spaces or quotes arrives whole.
|
||||||
|
// sh -c takes the word after the command as $0, which
|
||||||
|
// is why editor appears twice.
|
||||||
//nolint:gosec // G204: launching the operator's own $EDITOR is the point
|
//nolint:gosec // G204: launching the operator's own $EDITOR is the point
|
||||||
ed := exec.CommandContext(cmd.Context(), editor, path)
|
ed := exec.CommandContext(cmd.Context(),
|
||||||
|
"sh", "-c", editor+` "$@"`, editor, path)
|
||||||
ed.Stdin = os.Stdin
|
ed.Stdin = os.Stdin
|
||||||
ed.Stdout = os.Stdout
|
ed.Stdout = os.Stdout
|
||||||
ed.Stderr = os.Stderr
|
ed.Stderr = os.Stderr
|
||||||
@@ -376,8 +437,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 +477,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 +494,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,8 +530,60 @@ func writeConfigSet(out *ui.Writer, path, key, value string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkConfigChange returns the reason vaultik would not load newData, the
|
||||||
|
// config after a change, if the change brings that failure in. If oldData,
|
||||||
|
// the config before it, loads, the new one must load too. A config that
|
||||||
|
// does not load, such as one still being filled in or one with a ${...}
|
||||||
|
// reference that cannot be filled in here, is checked one setting at a
|
||||||
|
// time: settingData, the changed setting on its own, must decode into the
|
||||||
|
// config type.
|
||||||
|
func checkConfigChange(oldData, newData, settingData []byte) error {
|
||||||
|
oldCfg, err := config.Parse(oldData)
|
||||||
|
if err == nil && oldCfg.Validate() == nil {
|
||||||
|
newCfg, err := config.Parse(newData)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return newCfg.Validate()
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = config.Parse(settingData)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// yamlPathOnly returns a document holding only the value at the dotted key
|
||||||
|
// path in root, inside the maps and lists that lead to it there.
|
||||||
|
func yamlPathOnly(root *yaml.Node, keys []string) (*yaml.Node, error) {
|
||||||
|
node, err := yamlPathGet(root, keys)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, key := range slices.Backward(keys) {
|
||||||
|
parent, err := yamlPathGet(root, keys[:i])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if parent.Kind == yaml.SequenceNode {
|
||||||
|
node = &yaml.Node{
|
||||||
|
Kind: yaml.SequenceNode, Content: []*yaml.Node{node},
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
keyNode := &yaml.Node{Kind: yaml.ScalarNode, Value: key}
|
||||||
|
node = &yaml.Node{
|
||||||
|
Kind: yaml.MappingNode, Content: []*yaml.Node{keyNode, node},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &yaml.Node{Kind: yaml.DocumentNode, Content: []*yaml.Node{node}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// marshalConfigYAML renders a config document tree with 2-space indentation,
|
// marshalConfigYAML renders a config document tree with 2-space indentation,
|
||||||
// matching defaultConfigTemplate. yaml.Marshal defaults to 4 spaces, which
|
// matching the default config. yaml.Marshal defaults to 4 spaces, which
|
||||||
// would reindent the whole file on the first `config set` despite the promise
|
// would reindent the whole file on the first `config set` despite the promise
|
||||||
// to preserve formatting.
|
// to preserve formatting.
|
||||||
func marshalConfigYAML(root *yaml.Node) ([]byte, error) {
|
func marshalConfigYAML(root *yaml.Node) ([]byte, error) {
|
||||||
@@ -544,8 +688,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 +702,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 +724,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 +793,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 +822,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 +830,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
|
||||||
|
|||||||
+414
-6
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -12,20 +13,23 @@ import (
|
|||||||
"sneak.berlin/go/vaultik/internal/ui"
|
"sneak.berlin/go/vaultik/internal/ui"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestDefaultConfigTemplateParses ensures the init template is valid YAML
|
// TestDefaultConfigParsesOnMacOS ensures the config `config init` writes on
|
||||||
// that unmarshals into the Config struct with the expected snapshots.
|
// macOS is valid YAML that unmarshals into the Config struct with the
|
||||||
func TestDefaultConfigTemplateParses(t *testing.T) {
|
// expected snapshots.
|
||||||
|
func TestDefaultConfigParsesOnMacOS(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
var cfg config.Config
|
var cfg config.Config
|
||||||
|
|
||||||
err := yaml.Unmarshal([]byte(defaultConfigTemplate), &cfg)
|
err := yaml.Unmarshal([]byte(defaultConfigText("darwin")), &cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("default config template is not valid YAML: %v", err)
|
t.Fatalf("default config template is not valid YAML: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
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 +59,386 @@ func TestDefaultConfigTemplateParses(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestDefaultConfigOnLinux checks that the config `config init` writes on
|
||||||
|
// Linux loads with its home snapshot and without the apps snapshot, whose
|
||||||
|
// /Applications exists only on macOS.
|
||||||
|
func TestDefaultConfigOnLinux(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), "config.yml")
|
||||||
|
|
||||||
|
err := os.WriteFile(path, []byte(defaultConfigText("linux")), configFileMode)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = writeConfigSet(ui.NewWithColor(&bytes.Buffer{}, false),
|
||||||
|
path, "storage_url", "file:///mnt/backups")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("config set storage_url: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("config.Load: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := cfg.Snapshots["home"]; !ok {
|
||||||
|
t.Error("expected 'home' snapshot in the Linux default config")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := cfg.Snapshots["apps"]; ok {
|
||||||
|
t.Error("the Linux default config should have no 'apps' snapshot")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// testRecipient is a valid age public key.
|
||||||
|
const testRecipient = "age1278m9q7dp3chsh2dcy82qk27v047zywyvtxwnj4cvt0z65jw6a7q5dqhfj"
|
||||||
|
|
||||||
|
// TestConfigSetRecipientOnFreshConfig follows the README quickstart: on the
|
||||||
|
// file `config init` writes, `config set age_recipients.0` and
|
||||||
|
// `config set storage_url` give a config that loads with that recipient.
|
||||||
|
// Neither is refused, although the config does not load until both are set;
|
||||||
|
// once it loads, a recipient that is not a valid key is.
|
||||||
|
func TestConfigSetRecipientOnFreshConfig(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), "config.yml")
|
||||||
|
|
||||||
|
err := os.WriteFile(path, []byte(defaultConfigText(macOS)), configFileMode)
|
||||||
|
if err != nil {
|
||||||
|
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.
|
||||||
|
{defaultConfigText(macOS), "snapshots.apps.paths", "/srv",
|
||||||
|
"into []string"},
|
||||||
|
// The type check on a config that cannot be read here.
|
||||||
|
{configWithUnsetReference, "snapshots.home.paths", "/x",
|
||||||
|
"cannot unmarshal !!str `/x`"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.key+" "+tt.value, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), "config.yml")
|
||||||
|
|
||||||
|
err := os.WriteFile(path, []byte(tt.config), configFileMode)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = writeConfigSet(ui.NewWithColor(&bytes.Buffer{}, false),
|
||||||
|
path, tt.key, tt.value)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), tt.reason) {
|
||||||
|
t.Errorf("config set %s %s gives error %v, want one saying %q",
|
||||||
|
tt.key, tt.value, err, tt.reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path) //nolint:gosec // G304: test-controlled path
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if string(data) != tt.config {
|
||||||
|
t.Errorf("config set %s changed the file to:\n%s",
|
||||||
|
tt.key, data)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfigSetRefusesOnlyItsOwnFailure checks that setting another key
|
||||||
|
// works on a config with a ${...} reference on compression_level, which
|
||||||
|
// loads because loading fills the reference in, on one whose
|
||||||
|
// compression_level already fails to load, and on one with a reference
|
||||||
|
// that cannot be filled in here.
|
||||||
|
func TestConfigSetRefusesOnlyItsOwnFailure(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
configs := map[string]string{
|
||||||
|
"reference": configWithLevelReference(t),
|
||||||
|
"failing": configWithoutRecipients + "compression_level: fast\n",
|
||||||
|
"unset reference": configWithUnsetReference,
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, configYAML := range configs {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), "config.yml")
|
||||||
|
|
||||||
|
err := os.WriteFile(path, []byte(configYAML), configFileMode)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = writeConfigSet(ui.NewWithColor(&bytes.Buffer{}, false),
|
||||||
|
path, "hostname", "myhost")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("config set hostname: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path) //nolint:gosec // G304: test-controlled path
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(string(data), "hostname: myhost") {
|
||||||
|
t.Errorf("config set hostname wrote:\n%s", data)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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(defaultConfigText(macOS)), 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 +681,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, ".")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"sneak.berlin/go/vaultik/internal/config"
|
"sneak.berlin/go/vaultik/internal/config"
|
||||||
"sneak.berlin/go/vaultik/internal/log"
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
|
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewDatabaseCommand creates the database command group
|
// NewDatabaseCommand creates the database command group
|
||||||
@@ -15,6 +16,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(
|
||||||
@@ -84,12 +91,12 @@ Use --force to skip the confirmation prompt.`,
|
|||||||
|
|
||||||
var confirm string
|
var confirm string
|
||||||
|
|
||||||
_, err = fmt.Scanln(&confirm)
|
// A failure to read the answer means no confirmation.
|
||||||
|
_, err = fmt.Fscanln(cmd.InOrStdin(), &confirm)
|
||||||
if err != nil || confirm != "yes" {
|
if err != nil || confirm != "yes" {
|
||||||
_, _ = fmt.Fprintln(w, "Aborted.")
|
_, _ = fmt.Fprintln(w, "Aborted.")
|
||||||
|
|
||||||
//nolint:nilerr // a failed/aborted confirmation is a clean abort
|
return vaultik.ErrNotConfirmed
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package cli //nolint:testpackage // sets the unexported rootFlags directly
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestDatabaseDeleteDeclinedIsError checks that answering anything but
|
||||||
|
// "yes" keeps the database and returns ErrNotConfirmed, so the command
|
||||||
|
// exits non-zero.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // mutates the process-global rootFlags
|
||||||
|
func TestDatabaseDeleteDeclinedIsError(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
dbPath, cfgPath := seedDatabaseDeleteConfig(t, dir)
|
||||||
|
|
||||||
|
setRootFlags(t, RootFlags{ConfigPath: cfgPath})
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
|
||||||
|
cmd := newDatabaseDeleteCommand()
|
||||||
|
cmd.SetOut(&out)
|
||||||
|
cmd.SetErr(io.Discard)
|
||||||
|
cmd.SetIn(strings.NewReader("no\n"))
|
||||||
|
cmd.SetArgs(nil)
|
||||||
|
|
||||||
|
err := cmd.Execute()
|
||||||
|
if !errors.Is(err, vaultik.ErrNotConfirmed) {
|
||||||
|
t.Errorf("declined database delete returned %v, want %v",
|
||||||
|
err, vaultik.ErrNotConfirmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = os.Stat(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("declined database delete removed the file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(out.String(), "Aborted.") {
|
||||||
|
t.Errorf("declined database delete printed %q, want Aborted.",
|
||||||
|
out.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
+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 == "--" {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"github.com/adrg/xdg"
|
"github.com/adrg/xdg"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/vaultik/internal/globals"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Command words and flags used to build argument vectors below. They are
|
// Command words and flags used to build argument vectors below. They are
|
||||||
@@ -36,23 +37,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 +66,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,16 +103,45 @@ 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")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestBannerLabelsCommitDate checks that the banner prints the commit
|
||||||
|
// and the commit date, each after its own label. Commit and CommitDate
|
||||||
|
// get different values, so printing the wrong one fails.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // sets globals.Commit and globals.CommitDate
|
||||||
|
func TestBannerLabelsCommitDate(t *testing.T) {
|
||||||
|
const (
|
||||||
|
commit = "abc1234"
|
||||||
|
commitDate = "2026-11-09"
|
||||||
|
)
|
||||||
|
|
||||||
|
previousCommit, previousCommitDate := globals.Commit, globals.CommitDate
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
globals.Commit = previousCommit
|
||||||
|
globals.CommitDate = previousCommitDate
|
||||||
|
})
|
||||||
|
|
||||||
|
globals.Commit = commit
|
||||||
|
globals.CommitDate = commitDate
|
||||||
|
|
||||||
|
var banner bytes.Buffer
|
||||||
|
|
||||||
|
emitStartupBanner(nil, &banner)
|
||||||
|
|
||||||
|
assert.Contains(t, banner.String(),
|
||||||
|
"(commit "+commit+", commit date "+commitDate+")")
|
||||||
|
}
|
||||||
|
|
||||||
// TestBannerSuppressedInArgs covers the suppression scan directly,
|
// TestBannerSuppressedInArgs covers the suppression scan directly,
|
||||||
// including the flags that suppressed the banner before --json joined
|
// including the flags that suppressed the banner before --json joined
|
||||||
// them, so that adding --json cannot regress them.
|
// them, so that adding --json cannot regress them.
|
||||||
@@ -172,9 +177,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 +201,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 +252,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 that answers that it has no marker.
|
||||||
|
require.NoError(t, os.WriteFile(
|
||||||
|
filepath.Join(storeDir, "vaultik-destination.txt"), nil, 0o600))
|
||||||
}
|
}
|
||||||
|
|
||||||
return configPath
|
return configPath
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package cli //nolint:testpackage // shares runEntry and the hermetic config helpers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// flagCron is used to build the argument vectors below.
|
||||||
|
const flagCron = "--cron"
|
||||||
|
|
||||||
|
// plantUnreferencedBlob writes a blob that no snapshot references to the
|
||||||
|
// destination store at storeDir, dated long before any prune grace period,
|
||||||
|
// so that a prune deletes it. Returns its path.
|
||||||
|
func plantUnreferencedBlob(t *testing.T, storeDir string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
hash := strings.Repeat("b", 64)
|
||||||
|
path := filepath.Join(storeDir, "blobs", hash[:2], hash[2:4], hash)
|
||||||
|
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o750))
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte("unreferenced"), 0o600))
|
||||||
|
|
||||||
|
longAgo := time.Unix(0, 0)
|
||||||
|
require.NoError(t, os.Chtimes(path, longAgo, longAgo))
|
||||||
|
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEntrySnapshotCreatePruneIsSilent checks that a successful
|
||||||
|
// `snapshot create --prune` under --cron or --quiet prints nothing on
|
||||||
|
// stdout or stderr, whether or not the prune finds a blob to delete.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
|
||||||
|
func TestEntrySnapshotCreatePruneIsSilent(t *testing.T) {
|
||||||
|
for _, testCase := range []struct {
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
plantBlob bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "cron, no blob to delete",
|
||||||
|
args: []string{cmdSnapshot, cmdCreate, flagCron, flagPrune},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "cron, blob to delete",
|
||||||
|
args: []string{cmdSnapshot, cmdCreate, flagCron, flagPrune},
|
||||||
|
plantBlob: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "cron with keep-newer-than",
|
||||||
|
args: []string{
|
||||||
|
cmdSnapshot, cmdCreate, flagCron, flagPrune,
|
||||||
|
flagKeepNewerThan, "4w",
|
||||||
|
},
|
||||||
|
plantBlob: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "quiet",
|
||||||
|
args: []string{flagQuiet, cmdSnapshot, cmdCreate, flagPrune},
|
||||||
|
plantBlob: true,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
|
configPath, storeDir := writeBackupConfig(t)
|
||||||
|
|
||||||
|
var blob string
|
||||||
|
if testCase.plantBlob {
|
||||||
|
blob = plantUnreferencedBlob(t, storeDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
code, stdout, stderr := runEntry(t,
|
||||||
|
append([]string{flagConfig, configPath}, testCase.args...)...)
|
||||||
|
|
||||||
|
assert.Equal(t, 0, code)
|
||||||
|
assert.Empty(t, stdout)
|
||||||
|
assert.Empty(t, stderr)
|
||||||
|
|
||||||
|
if testCase.plantBlob {
|
||||||
|
assert.NoFileExists(t, blob, "the prune must delete the blob")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEntryPruneQuietIsSilent checks that a successful `prune --force`
|
||||||
|
// under --quiet prints nothing on stdout or stderr, both when there is
|
||||||
|
// nothing to remove and when it removes a stale local snapshot record
|
||||||
|
// and an unreferenced blob.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
|
||||||
|
func TestEntryPruneQuietIsSilent(t *testing.T) {
|
||||||
|
for _, testCase := range []struct {
|
||||||
|
name string
|
||||||
|
staleRecordAndBlob bool
|
||||||
|
}{
|
||||||
|
{name: "nothing to remove"},
|
||||||
|
{name: "stale record and blob to remove", staleRecordAndBlob: true},
|
||||||
|
} {
|
||||||
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
|
configPath := writeHermeticPruneConfig(t, testCase.staleRecordAndBlob)
|
||||||
|
|
||||||
|
var blob string
|
||||||
|
if testCase.staleRecordAndBlob {
|
||||||
|
// writeHermeticPruneConfig puts the store beside the config.
|
||||||
|
blob = plantUnreferencedBlob(t,
|
||||||
|
filepath.Join(filepath.Dir(configPath), "store"))
|
||||||
|
}
|
||||||
|
|
||||||
|
code, stdout, stderr := runEntry(t,
|
||||||
|
flagConfig, configPath, flagQuiet, cmdPrune, flagForce)
|
||||||
|
|
||||||
|
assert.Equal(t, 0, code)
|
||||||
|
assert.Empty(t, stdout)
|
||||||
|
assert.Empty(t, stderr)
|
||||||
|
|
||||||
|
if testCase.staleRecordAndBlob {
|
||||||
|
assert.NoFileExists(t, blob, "the prune must delete the blob")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
package cli //nolint:testpackage // shares runEntry and the hermetic config helpers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/vaultik/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Flags of `snapshot create` used to build argument vectors below.
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEntrySnapshotCreatePruneExitsNonZeroWhenSourceIsEmpty backs up,
|
||||||
|
// empties the source directory, as a volume that is not mounted leaves
|
||||||
|
// its mount point, and runs `snapshot create --prune`. The command exits
|
||||||
|
// non-zero and reports the backup as completed, not as a failed snapshot
|
||||||
|
// creation. See https://git.eeqj.de/sneak/vaultik/issues/388.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
|
||||||
|
func TestEntrySnapshotCreatePruneExitsNonZeroWhenSourceIsEmpty(t *testing.T) {
|
||||||
|
configPath, storeDir := writeBackupConfig(t)
|
||||||
|
|
||||||
|
code, _, _ := runEntry(t, flagConfig, configPath, cmdSnapshot, cmdCreate)
|
||||||
|
require.Equal(t, 0, code)
|
||||||
|
|
||||||
|
// writeBackupConfig puts the source directory beside the config file.
|
||||||
|
require.NoError(t, os.Remove(
|
||||||
|
filepath.Join(filepath.Dir(configPath), "source", "file.txt")))
|
||||||
|
|
||||||
|
code, _, stderr := runEntry(t,
|
||||||
|
flagConfig, configPath, cmdSnapshot, cmdCreate, flagPrune)
|
||||||
|
|
||||||
|
assert.Equal(t, 1, code)
|
||||||
|
assert.Contains(t, stderr,
|
||||||
|
"Backup completed, but 1 source path(s) were empty")
|
||||||
|
assert.NotContains(t, stderr, "Snapshot creation failed")
|
||||||
|
|
||||||
|
snapshots, err := os.ReadDir(filepath.Join(storeDir, "metadata"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, snapshots, 2, "the older snapshot is kept")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEntrySnapshotCreatePruneExitsNonZeroWhenMountPointIsMissing backs
|
||||||
|
// up, records in the local index that this backup found the source
|
||||||
|
// directory as a mount point, and runs `snapshot create --prune`, which
|
||||||
|
// does not find it as one. The command exits non-zero, reports the
|
||||||
|
// backup as completed, not as a failed snapshot creation, and keeps the
|
||||||
|
// older snapshot. See https://git.eeqj.de/sneak/vaultik/issues/395.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
|
||||||
|
func TestEntrySnapshotCreatePruneExitsNonZeroWhenMountPointIsMissing(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
configPath, storeDir := writeBackupConfig(t)
|
||||||
|
|
||||||
|
code, _, _ := runEntry(t, flagConfig, configPath, cmdSnapshot, cmdCreate)
|
||||||
|
require.Equal(t, 0, code)
|
||||||
|
|
||||||
|
// writeBackupConfig puts the source directory and the local index
|
||||||
|
// beside the config file.
|
||||||
|
dir := filepath.Dir(configPath)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
db, err := database.New(ctx, filepath.Join(dir, "index.sqlite"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
repos := database.NewRepositories(db)
|
||||||
|
|
||||||
|
backedUp, err := repos.Snapshots.GetOnlySnapshot(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, repos.Snapshots.AddMountPoint(ctx, nil,
|
||||||
|
backedUp.ID.String(), filepath.Join(dir, "source")))
|
||||||
|
require.NoError(t, db.Close())
|
||||||
|
|
||||||
|
code, _, stderr := runEntry(t,
|
||||||
|
flagConfig, configPath, cmdSnapshot, cmdCreate, flagPrune)
|
||||||
|
|
||||||
|
assert.Equal(t, 1, code)
|
||||||
|
assert.Contains(t, stderr, "Backup completed, but 1 mount point(s) "+
|
||||||
|
"an earlier snapshot recorded were not found")
|
||||||
|
assert.NotContains(t, stderr, "Snapshot creation failed")
|
||||||
|
|
||||||
|
snapshots, err := os.ReadDir(filepath.Join(storeDir, "metadata"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, snapshots, 2, "the older snapshot is kept")
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeBackupConfig builds a hermetic config with one small file in its
|
||||||
|
// 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 that answers that it has no marker.
|
||||||
|
require.NoError(t, os.WriteFile(
|
||||||
|
filepath.Join(dir, "store", "vaultik-destination.txt"), nil, 0o600))
|
||||||
|
|
||||||
|
manifestDir := filepath.Join(dir, "store", "metadata",
|
||||||
|
snapshot.RemoteSnapshotKey(stalePruneSnapshotID))
|
||||||
|
require.NoError(t, os.MkdirAll(manifestDir, 0o750))
|
||||||
|
|
||||||
|
manifestPath := filepath.Join(manifestDir, "manifest.json.zst")
|
||||||
|
require.NoError(t, os.WriteFile(manifestPath, []byte("stub"), 0o600))
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
db, err := database.New(ctx, indexPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = db.Conn().ExecContext(ctx, `
|
||||||
|
CREATE TRIGGER refuse_snapshot_delete BEFORE DELETE ON snapshots
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'simulated index error'); END`)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, db.Close())
|
||||||
|
|
||||||
|
code, _, stderr := runEntry(t, flagConfig, configPath,
|
||||||
|
cmdSnapshot, cmdPurge, flagOlderThan, "30d", flagForce)
|
||||||
|
|
||||||
|
assert.Equal(t, 1, code)
|
||||||
|
assert.Contains(t, stderr, "simulated index error",
|
||||||
|
"the purge must fail at the local-index delete")
|
||||||
|
assert.FileExists(t, manifestPath,
|
||||||
|
"the snapshot is still in the local index, so its metadata must stay")
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
-11
@@ -13,18 +13,30 @@ func NewPruneCommand() *cobra.Command {
|
|||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "prune",
|
Use: "prune",
|
||||||
Short: "Tidy local database and remote storage",
|
Short: "Tidy local database and remote storage",
|
||||||
Long: `Removes orphaned data from both the local index database and
|
Long: `Removes orphaned data from the local index database, and
|
||||||
unreferenced blobs from the backup destination store.
|
unreferenced blobs and directories under metadata/ with no manifest
|
||||||
|
from the backup destination store.
|
||||||
|
|
||||||
Local cleanup drops incomplete snapshots and any files, chunks, or
|
Local cleanup drops incomplete snapshots and any files, chunks, or
|
||||||
blobs no longer referenced by a completed snapshot. Remote cleanup
|
blobs no longer referenced by a completed snapshot. Remote cleanup
|
||||||
scans every snapshot manifest in the destination store, builds the
|
scans every snapshot manifest in the destination store, builds the
|
||||||
set of still-referenced blob hashes, and deletes any blob not in that
|
set of still-referenced blob hashes, and deletes any blob not in that
|
||||||
set.
|
set that is older than prune_grace_period (default 24h). A newer one
|
||||||
|
is kept: it may belong to a backup still running, on this host or
|
||||||
|
another sharing the destination store, that has not yet uploaded its
|
||||||
|
manifest. The grace period does not protect a blob such a backup
|
||||||
|
reuses from an earlier run that stopped before its manifest: that
|
||||||
|
blob keeps the time of its first upload, so it can be deleted.
|
||||||
|
Remote cleanup also deletes each directory under metadata/ with no
|
||||||
|
manifest, as a backup that stopped before its manifest leaves, once
|
||||||
|
all its objects are older than prune_grace_period and its snapshot is
|
||||||
|
not in the local index.
|
||||||
|
|
||||||
Snapshot create --prune 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 +60,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 +67,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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+56
-14
@@ -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,22 @@ 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) ||
|
||||||
|
errors.Is(err, vaultik.ErrSourcePathEmpty) ||
|
||||||
|
errors.Is(err, vaultik.ErrVolumeNotMounted) {
|
||||||
|
log.Error("Backup completed with an error", "error", err)
|
||||||
|
ReportErrorf("Backup completed, but %v", err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
log.Error("Snapshot creation failed", "error", err)
|
log.Error("Snapshot creation failed", "error", err)
|
||||||
ReportErrorf("Snapshot creation failed: %v", err)
|
ReportErrorf("Snapshot creation failed: %v", err)
|
||||||
})
|
})
|
||||||
@@ -105,8 +133,13 @@ specifying a path using --config or by setting VAULTIK_CONFIG to a path.`,
|
|||||||
cmd.Flags().BoolVar(&opts.Cron, "cron", false,
|
cmd.Flags().BoolVar(&opts.Cron, "cron", false,
|
||||||
"Run in cron mode (silent unless warning or error)")
|
"Run in cron mode (silent unless warning or error)")
|
||||||
cmd.Flags().BoolVar(&opts.Prune, "prune", false,
|
cmd.Flags().BoolVar(&opts.Prune, "prune", false,
|
||||||
"After backup, drop older snapshots of the same name and remove "+
|
"After backup, drop older snapshots of the same name, remove "+
|
||||||
"orphaned blobs")
|
"orphaned blobs older than prune_grace_period, and delete each "+
|
||||||
|
"directory under metadata/ with no manifest once all its "+
|
||||||
|
"objects are older than prune_grace_period and its snapshot is "+
|
||||||
|
"not in the local index (not after a backup that skipped "+
|
||||||
|
"unreadable files, found a configured path empty, or did not "+
|
||||||
|
"find a mount point an earlier snapshot found)")
|
||||||
cmd.Flags().StringVar(&opts.KeepNewerThan, "keep-newer-than", "",
|
cmd.Flags().StringVar(&opts.KeepNewerThan, "keep-newer-than", "",
|
||||||
"With --prune: keep snapshots newer than this duration "+
|
"With --prune: keep snapshots newer than this duration "+
|
||||||
"(e.g. 30d, 4w, 6mo, 1y; m is minutes, mo is months) "+
|
"(e.g. 30d, 4w, 6mo, 1y; m is minutes, mo is months) "+
|
||||||
@@ -126,7 +159,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 +195,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 +284,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 +319,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
|
||||||
|
|||||||
@@ -37,13 +37,13 @@ func writeVersion(out *ui.Writer) {
|
|||||||
w := out.Out()
|
w := out.Out()
|
||||||
|
|
||||||
_, _ = fmt.Fprintf(w, "vaultik %s\n", globals.Version)
|
_, _ = fmt.Fprintf(w, "vaultik %s\n", globals.Version)
|
||||||
_, _ = fmt.Fprintf(w, " commit: %s\n", globals.Commit)
|
_, _ = fmt.Fprintf(w, " commit: %s\n", globals.Commit)
|
||||||
_, _ = fmt.Fprintf(w, " build date: %s\n", globals.CommitDate)
|
_, _ = fmt.Fprintf(w, " commit date: %s\n", globals.CommitDate)
|
||||||
_, _ = fmt.Fprintf(w, " go: %s\n", runtime.Version())
|
_, _ = fmt.Fprintf(w, " go: %s\n", runtime.Version())
|
||||||
_, _ = fmt.Fprintf(w, " os/arch: %s/%s\n", runtime.GOOS, runtime.GOARCH)
|
_, _ = fmt.Fprintf(w, " os/arch: %s/%s\n", runtime.GOOS, runtime.GOARCH)
|
||||||
_, _ = fmt.Fprintf(w, " author: %s\n", globals.Author)
|
_, _ = fmt.Fprintf(w, " author: %s\n", globals.Author)
|
||||||
_, _ = fmt.Fprintf(w, " homepage: %s\n", globals.Homepage)
|
_, _ = fmt.Fprintf(w, " homepage: %s\n", globals.Homepage)
|
||||||
_, _ = fmt.Fprintf(w, " license: %s\n", globals.License)
|
_, _ = fmt.Fprintf(w, " license: %s\n", globals.License)
|
||||||
|
|
||||||
if globals.IsDevVersion(globals.Version) {
|
if globals.IsDevVersion(globals.Version) {
|
||||||
_, _ = fmt.Fprintln(w)
|
_, _ = fmt.Fprintln(w)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package cli_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -49,6 +50,42 @@ func TestVersionCommandReportsBuildVersion(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestVersionCommandReportLines checks the label, padding and value of
|
||||||
|
// every line of the report. Commit and CommitDate get different values,
|
||||||
|
// so a line that prints the wrong one fails.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // sets globals.Commit and globals.CommitDate
|
||||||
|
func TestVersionCommandReportLines(t *testing.T) {
|
||||||
|
const (
|
||||||
|
commit = "abc1234"
|
||||||
|
commitDate = "2026-11-09"
|
||||||
|
)
|
||||||
|
|
||||||
|
previousCommit, previousCommitDate := globals.Commit, globals.CommitDate
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
globals.Commit = previousCommit
|
||||||
|
globals.CommitDate = previousCommitDate
|
||||||
|
})
|
||||||
|
|
||||||
|
globals.Commit = commit
|
||||||
|
globals.CommitDate = commitDate
|
||||||
|
|
||||||
|
want := "vaultik " + globals.Version + "\n" +
|
||||||
|
" commit: " + commit + "\n" +
|
||||||
|
" commit date: " + commitDate + "\n" +
|
||||||
|
" go: " + runtime.Version() + "\n" +
|
||||||
|
" os/arch: " + runtime.GOOS + "/" + runtime.GOARCH + "\n" +
|
||||||
|
" author: " + globals.Author + "\n" +
|
||||||
|
" homepage: " + globals.Homepage + "\n" +
|
||||||
|
" license: " + globals.License + "\n"
|
||||||
|
|
||||||
|
out := runVersionCommand(t)
|
||||||
|
if !strings.HasPrefix(out, want) {
|
||||||
|
t.Errorf("report:\n%s\nwant it to start with:\n%s", out, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestVersionCommandFlagsDevelopmentBuild is the regression test for
|
// TestVersionCommandFlagsDevelopmentBuild is the regression test for
|
||||||
// the thing this command exists to prevent: a build that is not a
|
// the thing this command exists to prevent: a build that is not a
|
||||||
// release must say so. The notice used to be gated on the version
|
// release must say so. The notice used to be gated on the version
|
||||||
|
|||||||
+108
-46
@@ -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,36 +31,46 @@ 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(
|
||||||
"not a valid recipient; only X25519 age1... public keys are supported")
|
"not a valid recipient; only X25519 age1... public keys are supported")
|
||||||
errNoSnapshots = errors.New(
|
errNoSnapshots = errors.New(
|
||||||
"at least one snapshot must be configured (see config.example.yml)")
|
"at least one snapshot must be configured (see configs/config.example.yml)")
|
||||||
errSnapshotNoPaths = errors.New("snapshot must have at least one path")
|
errSnapshotNoPaths = errors.New("snapshot must have at least one path")
|
||||||
errChunkSizeTooSmall = errors.New("chunk_size must be at least 1MB")
|
errChunkSizeTooSmall = errors.New("chunk_size must be at least 1MB")
|
||||||
errBlobSizeTooSmall = errors.New(
|
errBlobSizeTooSmall = errors.New(
|
||||||
"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,15 @@ type Config struct {
|
|||||||
Snapshots map[string]SnapshotConfig `yaml:"snapshots"`
|
Snapshots map[string]SnapshotConfig `yaml:"snapshots"`
|
||||||
CompressionLevel int `yaml:"compression_level"`
|
CompressionLevel int `yaml:"compression_level"`
|
||||||
|
|
||||||
|
// PruneGracePeriod is how old a blob no manifest lists must be before
|
||||||
|
// a prune deletes it, and how old every object in a directory under
|
||||||
|
// metadata/ with no manifest must be before a prune deletes that
|
||||||
|
// directory. A backup writes its manifest only once it has uploaded
|
||||||
|
// all its blobs and its db.zst.age, so a younger blob or directory may
|
||||||
|
// belong to a backup still running, on this host or another sharing
|
||||||
|
// the destination store.
|
||||||
|
PruneGracePeriod time.Duration `yaml:"prune_grace_period"`
|
||||||
|
|
||||||
// StorageURL specifies the storage backend using a URL format.
|
// StorageURL specifies the storage backend using a URL format.
|
||||||
// Takes precedence over S3Config if set.
|
// Takes precedence over S3Config if set.
|
||||||
// Supported formats:
|
// Supported formats:
|
||||||
@@ -228,14 +249,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 +301,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 +353,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 +369,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 +415,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 +459,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 +502,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 (
|
||||||
@@ -88,12 +90,12 @@ func TestConfigLoad(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestExampleConfigIsScrubbedAndLoads checks that the shipped
|
// TestExampleConfigIsScrubbedAndLoads checks that the shipped
|
||||||
// config.example.yml carries only neutral placeholders (no real credentials,
|
// configs/config.example.yml carries only neutral placeholders (no real
|
||||||
// private addresses, or internal host names) and still parses.
|
// credentials, private addresses, or internal host names) and still parses.
|
||||||
func TestExampleConfigIsScrubbedAndLoads(t *testing.T) {
|
func TestExampleConfigIsScrubbedAndLoads(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
examplePath := filepath.Join("..", "..", "config.example.yml")
|
examplePath := filepath.Join("..", "..", "configs", "config.example.yml")
|
||||||
|
|
||||||
cfg, err := Load(examplePath)
|
cfg, err := Load(examplePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -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,296 @@ 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, configs/config.example.yml and the config init template
|
||||||
|
// list as an example is accepted.
|
||||||
|
func TestDocumentedSizeExamples(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const withoutSizes = "snapshots:\n" +
|
||||||
|
" test:\n" +
|
||||||
|
" paths: [/tmp/vaultik-test-source]\n" +
|
||||||
|
"storage_url: file:///tmp/vaultik-test-storage\n"
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
setting string
|
||||||
|
wantErr error
|
||||||
|
}{
|
||||||
|
{"chunk_size: 1MB", nil},
|
||||||
|
{"chunk_size: 999999", errChunkSizeTooSmall},
|
||||||
|
{"chunk_size: 5M", nil},
|
||||||
|
{"chunk_size: 10M", nil},
|
||||||
|
{"chunk_size: 10MB", nil},
|
||||||
|
{"chunk_size: 64MiB", nil},
|
||||||
|
{"chunk_size: 1GB", nil},
|
||||||
|
// Four times the default chunk_size, the documented minimum.
|
||||||
|
{"blob_size_limit: 40MB", nil},
|
||||||
|
{"blob_size_limit: 500MB", nil},
|
||||||
|
{"blob_size_limit: 1GB", nil},
|
||||||
|
{"blob_size_limit: 1GiB", nil},
|
||||||
|
{"blob_size_limit: 10G", nil},
|
||||||
|
{"blob_size_limit: 10GB", nil},
|
||||||
|
{"s3: {part_size: 5MiB}", nil},
|
||||||
|
{"s3: {part_size: 10MB}", nil},
|
||||||
|
{"s3: {part_size: 16MiB}", nil},
|
||||||
|
{"s3: {part_size: 100MiB}", nil},
|
||||||
|
{"s3: {part_size: 5GiB}", nil},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.setting, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cfg, err := Parse([]byte(withoutSizes + tt.setting + "\n"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Parse() unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = cfg.Validate()
|
||||||
|
if !errors.Is(err, tt.wantErr) {
|
||||||
|
t.Fatalf("Validate() error = %v, want %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestValidatePruneGracePeriod checks that a negative prune_grace_period
|
||||||
|
// is refused rather than acting like 0s, which deletes unreferenced blobs
|
||||||
|
// and directories under metadata/ with no manifest at once, and that 0s
|
||||||
|
// and the documented examples are accepted.
|
||||||
|
func TestValidatePruneGracePeriod(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const withoutGracePeriod = "snapshots:\n" +
|
||||||
|
" test:\n" +
|
||||||
|
" paths: [/tmp/vaultik-test-source]\n" +
|
||||||
|
"storage_url: file:///tmp/vaultik-test-storage\n"
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
setting string
|
||||||
|
wantErr error
|
||||||
|
}{
|
||||||
|
{"prune_grace_period: 0s", nil},
|
||||||
|
{"prune_grace_period: 90m", nil},
|
||||||
|
{"prune_grace_period: 36h", nil},
|
||||||
|
{"prune_grace_period: -1h", errNegativePruneGracePeriod},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.setting, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cfg, err := Parse([]byte(withoutGracePeriod + tt.setting + "\n"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Parse() unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = cfg.Validate()
|
||||||
|
if !errors.Is(err, tt.wantErr) {
|
||||||
|
t.Fatalf("Validate() error = %v, want %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoadPruneGracePeriod checks that a config file without
|
||||||
|
// prune_grace_period loads with the documented 24h. The value is written out
|
||||||
|
// here rather than taken from defaultPruneGracePeriod, so that a change of the
|
||||||
|
// default fails this test.
|
||||||
|
func TestLoadPruneGracePeriod(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const withoutGracePeriod = "snapshots:\n" +
|
||||||
|
" test:\n" +
|
||||||
|
" paths: [/tmp/vaultik-test-source]\n" +
|
||||||
|
"storage_url: file:///tmp/vaultik-test-storage\n"
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), "config.yml")
|
||||||
|
|
||||||
|
err := os.WriteFile(path, []byte(withoutGracePeriod), 0o600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load() unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.PruneGracePeriod != 24*time.Hour {
|
||||||
|
t.Errorf("prune_grace_period = %s, want 24h", cfg.PruneGracePeriod)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoadRejectsRelativeFileURL checks that a file:// storage_url with a
|
||||||
|
// 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 +525,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 +535,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 +633,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,6 +123,16 @@ CREATE TABLE IF NOT EXISTS snapshot_blobs (
|
|||||||
-- Index for efficient blob lookups (used in orphan detection)
|
-- Index for efficient blob lookups (used in orphan detection)
|
||||||
CREATE INDEX IF NOT EXISTS idx_snapshot_blobs_blob_id ON snapshot_blobs(blob_id);
|
CREATE INDEX IF NOT EXISTS idx_snapshot_blobs_blob_id ON snapshot_blobs(blob_id);
|
||||||
|
|
||||||
|
-- Snapshot mount points table: the directories at or below a configured
|
||||||
|
-- path that the snapshot's backup found on a different filesystem from
|
||||||
|
-- their parent directory, as a mount point is, and found an entry in
|
||||||
|
CREATE TABLE IF NOT EXISTS snapshot_mount_points (
|
||||||
|
snapshot_id TEXT NOT NULL,
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (snapshot_id, path),
|
||||||
|
FOREIGN KEY (snapshot_id) REFERENCES snapshots(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
-- Uploads table: tracks blob upload metrics
|
-- Uploads table: tracks blob upload metrics
|
||||||
CREATE TABLE IF NOT EXISTS uploads (
|
CREATE TABLE IF NOT EXISTS uploads (
|
||||||
blob_hash TEXT PRIMARY KEY,
|
blob_hash TEXT PRIMARY KEY,
|
||||||
|
|||||||
+141
-95
@@ -24,7 +24,8 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// SnapshotRepository provides access to the snapshots table and its
|
// SnapshotRepository provides access to the snapshots table and its
|
||||||
// snapshot_files / snapshot_blobs association tables.
|
// snapshot_files / snapshot_blobs association tables, and to the mount
|
||||||
|
// points each snapshot's backup found (snapshot_mount_points).
|
||||||
type SnapshotRepository struct {
|
type SnapshotRepository struct {
|
||||||
db *DB
|
db *DB
|
||||||
}
|
}
|
||||||
@@ -120,14 +121,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 +148,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 +256,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 +291,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 +403,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 {
|
||||||
@@ -487,38 +495,63 @@ func (r *SnapshotRepository) GetBlobHashes(
|
|||||||
ctx context.Context, snapshotID string,
|
ctx context.Context, snapshotID string,
|
||||||
) ([]string, error) {
|
) ([]string, error) {
|
||||||
query := `
|
query := `
|
||||||
SELECT sb.blob_hash
|
SELECT sb.blob_hash
|
||||||
FROM snapshot_blobs sb
|
FROM snapshot_blobs sb
|
||||||
WHERE sb.snapshot_id = ?
|
WHERE sb.snapshot_id = ?
|
||||||
ORDER BY sb.blob_hash
|
ORDER BY sb.blob_hash
|
||||||
`
|
`
|
||||||
|
|
||||||
rows, err := r.db.conn.QueryContext(ctx, query, snapshotID)
|
blobs, err := r.queryStrings(ctx, query, snapshotID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("querying blob hashes: %w", err)
|
return nil, fmt.Errorf("querying blob hashes: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() {
|
return blobs, nil
|
||||||
err := rows.Close()
|
}
|
||||||
if err != nil {
|
|
||||||
Fatalf("failed to close rows: %v", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
var blobs []string
|
// AddMountPoint records dir as a directory that the backup of a snapshot
|
||||||
|
// found on a different filesystem from its parent directory, as a mount
|
||||||
|
// point is, using tx when non-nil.
|
||||||
|
func (r *SnapshotRepository) AddMountPoint(
|
||||||
|
ctx context.Context, tx *sql.Tx, snapshotID string, dir string,
|
||||||
|
) error {
|
||||||
|
query := `
|
||||||
|
INSERT OR IGNORE INTO snapshot_mount_points (snapshot_id, path)
|
||||||
|
VALUES (?, ?)
|
||||||
|
`
|
||||||
|
|
||||||
for rows.Next() {
|
var err error
|
||||||
var blobHash string
|
if tx != nil {
|
||||||
|
_, err = tx.ExecContext(ctx, query, snapshotID, dir)
|
||||||
err := rows.Scan(&blobHash)
|
} else {
|
||||||
if err != nil {
|
_, err = r.db.ExecWithLog(ctx, query, snapshotID, dir)
|
||||||
return nil, fmt.Errorf("scanning blob hash: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
blobs = append(blobs, blobHash)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return blobs, rows.Err()
|
if err != nil {
|
||||||
|
return fmt.Errorf("adding mount point to snapshot: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMountPoints returns the directories AddMountPoint recorded for a
|
||||||
|
// snapshot, sorted.
|
||||||
|
func (r *SnapshotRepository) GetMountPoints(
|
||||||
|
ctx context.Context, snapshotID string,
|
||||||
|
) ([]string, error) {
|
||||||
|
query := `
|
||||||
|
SELECT path
|
||||||
|
FROM snapshot_mount_points
|
||||||
|
WHERE snapshot_id = ?
|
||||||
|
ORDER BY path
|
||||||
|
`
|
||||||
|
|
||||||
|
dirs, err := r.queryStrings(ctx, query, snapshotID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("querying mount points: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return dirs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSnapshotTotalCompressedSize returns the total compressed size of all
|
// GetSnapshotTotalCompressedSize returns the total compressed size of all
|
||||||
@@ -543,71 +576,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 +656,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 +679,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.
|
||||||
@@ -722,6 +740,34 @@ func (r *SnapshotRepository) DeleteSnapshotUploads(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// queryStrings runs query, which selects one text column, and returns
|
||||||
|
// its values in the order of the rows.
|
||||||
|
func (r *SnapshotRepository) queryStrings(
|
||||||
|
ctx context.Context, query string, args ...any,
|
||||||
|
) ([]string, error) {
|
||||||
|
rows, err := r.db.conn.QueryContext(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
var values []string
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var value string
|
||||||
|
|
||||||
|
err := rows.Scan(&value)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
values = append(values, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return values, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// extendedCompressionRatio computes the compression ratio for a snapshot
|
// extendedCompressionRatio computes the compression ratio for a snapshot
|
||||||
// from its stored blob_size and the given uncompressed size. Returns 1.0
|
// from its stored blob_size and the given uncompressed size. Returns 1.0
|
||||||
// when the uncompressed size is zero.
|
// when the uncompressed size is zero.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -145,6 +146,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()
|
||||||
|
|
||||||
@@ -363,3 +423,52 @@ func TestSnapshotRepositoryDuplicate(t *testing.T) {
|
|||||||
t.Error("expected error for duplicate snapshot")
|
t.Error("expected error for duplicate snapshot")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deleting a snapshot from the local index deletes the mount points its
|
||||||
|
// backup recorded, and no other snapshot's. See
|
||||||
|
// https://git.eeqj.de/sneak/vaultik/issues/395.
|
||||||
|
func TestSnapshotRepositoryDeleteRemovesMountPoints(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db, cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
repo := database.NewSnapshotRepository(db)
|
||||||
|
|
||||||
|
for _, id := range []types.SnapshotID{"kept", "deleted"} {
|
||||||
|
err := repo.Create(ctx, nil, &database.Snapshot{
|
||||||
|
ID: id,
|
||||||
|
Hostname: testHostname,
|
||||||
|
VaultikVersion: testVersion,
|
||||||
|
StartedAt: time.Now().Truncate(time.Second),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create snapshot: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = repo.AddMountPoint(ctx, nil, id.String(), "/data/nas")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to add mount point: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err := repo.Delete(ctx, "deleted")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to delete snapshot: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for id, want := range map[string][]string{
|
||||||
|
"kept": {"/data/nas"},
|
||||||
|
"deleted": nil,
|
||||||
|
} {
|
||||||
|
got, err := repo.GetMountPoints(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read mount points: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !slices.Equal(got, want) {
|
||||||
|
t.Errorf("mount points of %s: got %v, want %v", id, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
//nolint:testpackage // exercises the unexported deviceNumberFromStat
|
||||||
|
package snapshot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The device number a real backup reads is the same for a directory and
|
||||||
|
// a file in it, and differs between the temp directory and /dev.
|
||||||
|
// Without it no mount point is ever found. See
|
||||||
|
// https://git.eeqj.de/sneak/vaultik/issues/395.
|
||||||
|
func TestDeviceNumberFromStat(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
file := filepath.Join(dir, "file.txt")
|
||||||
|
require.NoError(t, os.WriteFile(file, nil, 0o600))
|
||||||
|
|
||||||
|
deviceOf := func(path string) uint64 {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
device, ok := deviceNumberFromStat(info)
|
||||||
|
require.True(t, ok, "no device number read for %s", path)
|
||||||
|
|
||||||
|
return device
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, deviceOf(dir), deviceOf(file))
|
||||||
|
|
||||||
|
// /dev is a different filesystem from the temp dir on Linux, macOS and in Docker.
|
||||||
|
assert.NotEqual(t, deviceOf(dir), deviceOf("/dev"))
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
+21
-11
@@ -1,6 +1,8 @@
|
|||||||
package snapshot
|
package snapshot
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
"sneak.berlin/go/vaultik/internal/config"
|
"sneak.berlin/go/vaultik/internal/config"
|
||||||
@@ -16,6 +18,12 @@ type ScannerParams struct {
|
|||||||
Fs afero.Fs
|
Fs afero.Fs
|
||||||
Exclude []string // Exclude patterns (combined global + snapshot-specific)
|
Exclude []string // Exclude patterns (combined global + snapshot-specific)
|
||||||
SkipErrors bool // Skip file read errors (log loudly but continue)
|
SkipErrors bool // Skip file read errors (log loudly but continue)
|
||||||
|
// PreviousSnapshotID is the most recent completed snapshot of the
|
||||||
|
// same name, "" for none.
|
||||||
|
PreviousSnapshotID string
|
||||||
|
// DeviceNumber is ScannerConfig.DeviceNumber; nil reads it from the
|
||||||
|
// file's stat.
|
||||||
|
DeviceNumber func(info os.FileInfo) (uint64, bool)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Module exports backup functionality as an fx module.
|
// Module exports backup functionality as an fx module.
|
||||||
@@ -44,17 +52,19 @@ func provideScannerFactory(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return NewScanner(ScannerConfig{
|
return NewScanner(ScannerConfig{
|
||||||
FS: params.Fs,
|
FS: params.Fs,
|
||||||
ChunkSize: cfg.ChunkSize.Int64(),
|
ChunkSize: cfg.ChunkSize.Int64(),
|
||||||
Repositories: repos,
|
Repositories: repos,
|
||||||
Storage: storer,
|
Storage: storer,
|
||||||
MaxBlobSize: cfg.BlobSizeLimit.Int64(),
|
MaxBlobSize: cfg.BlobSizeLimit.Int64(),
|
||||||
CompressionLevel: cfg.CompressionLevel,
|
CompressionLevel: cfg.CompressionLevel,
|
||||||
AgeRecipients: cfg.AgeRecipients,
|
AgeRecipients: cfg.AgeRecipients,
|
||||||
EnableProgress: params.EnableProgress,
|
EnableProgress: params.EnableProgress,
|
||||||
UI: params.UI,
|
UI: params.UI,
|
||||||
Exclude: excludes,
|
Exclude: excludes,
|
||||||
SkipErrors: params.SkipErrors,
|
SkipErrors: params.SkipErrors,
|
||||||
|
PreviousSnapshotID: params.PreviousSnapshotID,
|
||||||
|
DeviceNumber: params.DeviceNumber,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
package snapshot_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/vaultik/internal/database"
|
||||||
|
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A scan records with the snapshot each directory on a different device
|
||||||
|
// from its parent directory that the walk found an entry in, as a mount
|
||||||
|
// point is, the scanned path included. A filesystem that gives no device
|
||||||
|
// numbers, as the in-memory one, records none. See
|
||||||
|
// https://git.eeqj.de/sneak/vaultik/issues/395.
|
||||||
|
func TestScanRecordsMountPoints(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const (
|
||||||
|
scannedPath = "/data/source"
|
||||||
|
snapshotID = "mount-points"
|
||||||
|
)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
// onVolume names the directories on the volume's device; every
|
||||||
|
// other directory is on another one. nil leaves the device
|
||||||
|
// numbers to the in-memory filesystem, which has none.
|
||||||
|
onVolume []string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "volume below the scanned path",
|
||||||
|
onVolume: []string{"nas"},
|
||||||
|
want: []string{scannedPath + "/nas"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "volume at the scanned path",
|
||||||
|
onVolume: []string{"source", "nas"},
|
||||||
|
want: []string{scannedPath},
|
||||||
|
},
|
||||||
|
{name: "no device numbers", want: nil},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
|
for _, path := range []string{
|
||||||
|
scannedPath + "/notes.txt", scannedPath + "/nas/a.txt",
|
||||||
|
} {
|
||||||
|
require.NoError(t, fs.MkdirAll(filepath.Dir(path), 0o755))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, path, []byte(path), 0o644))
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := database.NewTestDB()
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
|
||||||
|
repos := database.NewRepositories(db)
|
||||||
|
createTestSnapshotRecord(ctx, t, repos, snapshotID)
|
||||||
|
|
||||||
|
cfg := snapshot.ScannerConfig{
|
||||||
|
FS: fs,
|
||||||
|
ChunkSize: int64(1024 * 16),
|
||||||
|
Repositories: repos,
|
||||||
|
MaxBlobSize: int64(1024 * 1024),
|
||||||
|
CompressionLevel: 3,
|
||||||
|
AgeRecipients: []string{testAgePublicKey},
|
||||||
|
}
|
||||||
|
if tt.onVolume != nil {
|
||||||
|
cfg.DeviceNumber = func(info os.FileInfo) (uint64, bool) {
|
||||||
|
if slices.Contains(tt.onVolume, info.Name()) {
|
||||||
|
return 2, true
|
||||||
|
}
|
||||||
|
|
||||||
|
return 1, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = snapshot.NewScanner(cfg).Scan(ctx, scannedPath, snapshotID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
got, err := repos.Snapshots.GetMountPoints(ctx, snapshotID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tt.want, got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// automountFs is an in-memory filesystem with a volume at mountPoint
|
||||||
|
// that is mounted on first access: opening mountPoint to list it mounts
|
||||||
|
// the volume or, if it cannot be mounted, fails with "no such file or
|
||||||
|
// directory", as with the autofs daemon. Stat records the device an
|
||||||
|
// entry is on at that moment, as a real stat does: unmountedDevice for
|
||||||
|
// mountPoint until the volume is mounted; the volume's, 2, for
|
||||||
|
// mountPoint once it is and for everything in it; 1 for everything else.
|
||||||
|
type automountFs struct {
|
||||||
|
afero.Fs
|
||||||
|
|
||||||
|
mountPoint string
|
||||||
|
unmountedDevice uint64
|
||||||
|
canMount bool
|
||||||
|
mounted bool
|
||||||
|
}
|
||||||
|
|
||||||
|
//nolint:ireturn // afero.Fs.Open is defined to return the interface.
|
||||||
|
func (fs *automountFs) Open(name string) (afero.File, error) {
|
||||||
|
if name == fs.mountPoint && !fs.canMount {
|
||||||
|
return nil, &os.PathError{Op: "open", Path: name, Err: syscall.ENOENT}
|
||||||
|
}
|
||||||
|
|
||||||
|
if name == fs.mountPoint {
|
||||||
|
fs.mounted = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return fs.Fs.Open(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fs *automountFs) Stat(name string) (os.FileInfo, error) {
|
||||||
|
info, err := fs.Fs.Stat(name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
device := uint64(1)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case name == fs.mountPoint && !fs.mounted:
|
||||||
|
device = fs.unmountedDevice
|
||||||
|
case name == fs.mountPoint || strings.HasPrefix(name, fs.mountPoint+"/"):
|
||||||
|
device = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
return statWithDevice{FileInfo: info, device: device}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// statWithDevice is an entry automountFs.Stat looked up, with the device
|
||||||
|
// it was on then.
|
||||||
|
type statWithDevice struct {
|
||||||
|
os.FileInfo
|
||||||
|
|
||||||
|
device uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
// deviceAtLookup returns the device automountFs.Stat recorded for info.
|
||||||
|
func deviceAtLookup(info os.FileInfo) (uint64, bool) {
|
||||||
|
stat, ok := info.(statWithDevice)
|
||||||
|
|
||||||
|
return stat.device, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// A volume mounted on first access is mounted when the walk lists its
|
||||||
|
// mount point. Until then the mount point is on the automounter's own
|
||||||
|
// device (a systemd automount) or on its parent's (an autofs map that
|
||||||
|
// shows its mount points before mounting them). Either way only the
|
||||||
|
// mount point is recorded, by the backup that mounts the volume and by a
|
||||||
|
// later one that finds it mounted. Listing an automount point whose
|
||||||
|
// volume cannot be mounted fails; it is not recorded, because the walk
|
||||||
|
// finds no entry in it. See https://git.eeqj.de/sneak/vaultik/issues/395.
|
||||||
|
func TestScanRecordsOnlyTheMountPointOfAnAutomountedVolume(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const scannedPath = "/data/source"
|
||||||
|
|
||||||
|
mountPoint := scannedPath + "/nas"
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
unmountedDevice uint64
|
||||||
|
canMount bool
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "on the automounter's device until listed",
|
||||||
|
unmountedDevice: 3, canMount: true, want: []string{mountPoint},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "on its parent's device until listed",
|
||||||
|
unmountedDevice: 1, canMount: true, want: []string{mountPoint},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "volume that cannot be mounted",
|
||||||
|
unmountedDevice: 1, canMount: false, want: nil,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
fs := &automountFs{
|
||||||
|
Fs: afero.NewMemMapFs(), mountPoint: mountPoint,
|
||||||
|
unmountedDevice: tt.unmountedDevice, canMount: tt.canMount,
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, fs.MkdirAll(mountPoint+"/photos", 0o755))
|
||||||
|
|
||||||
|
for _, path := range []string{
|
||||||
|
scannedPath + "/notes.txt", mountPoint + "/photos/a.jpg",
|
||||||
|
} {
|
||||||
|
require.NoError(t, afero.WriteFile(fs, path, []byte(path), 0o644))
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := database.NewTestDB()
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
|
||||||
|
repos := database.NewRepositories(db)
|
||||||
|
|
||||||
|
for _, snapshotID := range []string{"first", "later"} {
|
||||||
|
createTestSnapshotRecord(ctx, t, repos, snapshotID)
|
||||||
|
|
||||||
|
scanner := snapshot.NewScanner(snapshot.ScannerConfig{
|
||||||
|
FS: fs,
|
||||||
|
ChunkSize: int64(1024 * 16),
|
||||||
|
Repositories: repos,
|
||||||
|
MaxBlobSize: int64(1024 * 1024),
|
||||||
|
CompressionLevel: 3,
|
||||||
|
AgeRecipients: []string{testAgePublicKey},
|
||||||
|
DeviceNumber: deviceAtLookup,
|
||||||
|
})
|
||||||
|
|
||||||
|
_, err = scanner.Scan(ctx, scannedPath, snapshotID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
got, err := repos.Snapshots.GetMountPoints(ctx, snapshotID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tt.want, got, "mount points of the %s backup", snapshotID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
+639
-263
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loaded 100 of 195 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user