Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6fcd8e1668 |
+24
-3
@@ -20,8 +20,6 @@
|
|||||||
# 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
|
||||||
|
|
||||||
ARG VERSION=dev
|
|
||||||
|
|
||||||
# Build tooling: make, plus a C toolchain because `go test -race` needs cgo.
|
# Build tooling: make, plus a C toolchain because `go test -race` needs cgo.
|
||||||
# The sqlite driver is pure Go (modernc.org/sqlite), so no sqlite library or
|
# The sqlite driver is pure Go (modernc.org/sqlite), so no sqlite library or
|
||||||
# CLI is required.
|
# CLI is required.
|
||||||
@@ -66,8 +64,31 @@ RUN [ -n "$CHECK_EPOCH" ] || exit 1
|
|||||||
RUN echo "check epoch: ${CHECK_EPOCH}" && make fmt-check
|
RUN echo "check epoch: ${CHECK_EPOCH}" && make fmt-check
|
||||||
RUN echo "check epoch: ${CHECK_EPOCH}" && make test
|
RUN echo "check epoch: ${CHECK_EPOCH}" && make test
|
||||||
|
|
||||||
|
# Version, commit and build date are computed on the host by
|
||||||
|
# script/docker and script/cibuild (where .git exists) and passed in as
|
||||||
|
# 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)
|
# 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=$(git rev-parse HEAD 2>/dev/null || echo unknown)' -X 'sneak.berlin/go/vaultik/internal/globals.CommitDate=$(git show -s --format=%cs HEAD 2>/dev/null || echo unknown)'" -o /vaultik ./cmd/vaultik
|
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
|
# Runtime stage
|
||||||
# alpine:3.21, 2026-02-25
|
# alpine:3.21, 2026-02-25
|
||||||
|
|||||||
@@ -84,57 +84,6 @@ VAULTIK_AGE_SECRET_KEY='AGE-SECRET-KEY-...' vaultik snapshot restore <snapshot-i
|
|||||||
# 0 3 * * * vaultik snapshot create --cron --prune --keep-newer-than 4w
|
# 0 3 * * * vaultik snapshot create --cron --prune --keep-newer-than 4w
|
||||||
```
|
```
|
||||||
|
|
||||||
## restoring on another machine
|
|
||||||
|
|
||||||
Restoring on a host that never ran the backup — a replacement machine
|
|
||||||
after the original is gone — is the case vaultik is built for. That host
|
|
||||||
needs only three things: the `vaultik` binary, the age **private** key,
|
|
||||||
and the storage credentials for the destination. It does **not** need the
|
|
||||||
local index, the original config file, or the original hostname.
|
|
||||||
|
|
||||||
```sh
|
|
||||||
# install
|
|
||||||
go install sneak.berlin/go/vaultik/cmd/vaultik@latest
|
|
||||||
|
|
||||||
# create a config and point it at the ORIGINAL backup destination
|
|
||||||
vaultik config init
|
|
||||||
vaultik config set storage_url "s3://bucket/prefix?endpoint=https://s3.example.com"
|
|
||||||
vaultik config set s3.access_key_id "..."
|
|
||||||
vaultik config set s3.secret_access_key "..."
|
|
||||||
|
|
||||||
# see what is on the destination store
|
|
||||||
vaultik snapshot list
|
|
||||||
```
|
|
||||||
|
|
||||||
`snapshot list` reads the destination store without the private key. A
|
|
||||||
snapshot that is not in this host's (empty) local index is shown as
|
|
||||||
remote-only: its row is identified by `<remote only:...>` rather than by
|
|
||||||
a `hostname_name_timestamp` name, because the name lives only in the
|
|
||||||
local index and the encrypted database and cannot be recovered from the
|
|
||||||
store. Its timestamp and compressed size are real. (See the `snapshot
|
|
||||||
list` description under [command details](#command-details) for the full
|
|
||||||
explanation.)
|
|
||||||
|
|
||||||
Use that remote key — the hex printed inside `<remote only:...>`, or the
|
|
||||||
full `remote_key` from `snapshot list --json` — to restore and verify:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
# restore everything to /tmp/restored, then check every restored file's
|
|
||||||
# chunk hashes
|
|
||||||
VAULTIK_AGE_SECRET_KEY='AGE-SECRET-KEY-...' \
|
|
||||||
vaultik snapshot restore --verify <remote-key> /tmp/restored
|
|
||||||
|
|
||||||
# optionally, deep-verify the snapshot against the store (downloads and
|
|
||||||
# cryptographically checks every blob)
|
|
||||||
VAULTIK_AGE_SECRET_KEY='AGE-SECRET-KEY-...' \
|
|
||||||
vaultik snapshot verify --deep <remote-key>
|
|
||||||
```
|
|
||||||
|
|
||||||
`age_recipients` (the public key) is not needed to restore — only the
|
|
||||||
private key in `VAULTIK_AGE_SECRET_KEY`. Both the abbreviated key printed
|
|
||||||
in the table and the full 64-character key from `--json` are accepted; a
|
|
||||||
leading part of the key is enough as long as it is unambiguous.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## cli
|
## cli
|
||||||
@@ -296,8 +245,6 @@ local index alone, and still exits zero.
|
|||||||
* Default (shallow): checks that all blobs referenced in the manifest exist in storage
|
* Default (shallow): checks that all blobs referenced in the manifest exist in storage
|
||||||
* `--deep`: Downloads and decrypts each blob, verifies chunk hashes against the
|
* `--deep`: Downloads and decrypts each blob, verifies chunk hashes against the
|
||||||
encrypted metadata database
|
encrypted metadata database
|
||||||
* Accepts the same identifiers as `snapshot restore`: a snapshot ID, or a
|
|
||||||
remote-only snapshot's remote key (or an unambiguous leading part of it)
|
|
||||||
* `--json`: Output results as JSON
|
* `--json`: Output results as JSON
|
||||||
|
|
||||||
**`snapshot purge`**: Remove old snapshots based on criteria. Retention is
|
**`snapshot purge`**: Remove old snapshots based on criteria. Retention is
|
||||||
@@ -328,10 +275,6 @@ on the destination in one go, use `vaultik remote nuke --force`.
|
|||||||
|
|
||||||
**`snapshot restore`**: Restore files from a backup snapshot.
|
**`snapshot restore`**: Restore files from a backup snapshot.
|
||||||
* Requires `VAULTIK_AGE_SECRET_KEY` environment variable
|
* Requires `VAULTIK_AGE_SECRET_KEY` environment variable
|
||||||
* Accepts a snapshot ID, or — for a snapshot only on the destination
|
|
||||||
store — its remote key (or an unambiguous leading part of it) as shown
|
|
||||||
by `snapshot list`. See
|
|
||||||
[restoring on another machine](#restoring-on-another-machine).
|
|
||||||
* Optional path arguments to restore specific files/directories (default: all)
|
* Optional path arguments to restore specific files/directories (default: all)
|
||||||
* Preserves file permissions, timestamps, ownership (ownership requires root),
|
* Preserves file permissions, timestamps, ownership (ownership requires root),
|
||||||
symlinks, and empty directories
|
symlinks, and empty directories
|
||||||
@@ -586,6 +529,10 @@ priority.
|
|||||||
|
|
||||||
### infrastructure
|
### infrastructure
|
||||||
|
|
||||||
|
* **Cross-machine restore documentation.** The "restore from
|
||||||
|
another host" workflow works but isn't documented as a
|
||||||
|
first-class operation in this README. Worth a dedicated section
|
||||||
|
once it's settled.
|
||||||
* **Cross-version schema upgrades.** There is no upgrade path between
|
* **Cross-version schema upgrades.** There is no upgrade path between
|
||||||
released versions — pre-1.0 schema changes are handled by `vaultik
|
released versions — pre-1.0 schema changes are handled by `vaultik
|
||||||
database delete` plus a full re-scan (see
|
database delete` plus a full re-scan (see
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package main_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// This file guards the version stamping of the product image (issue
|
||||||
|
// #75). The failure it protects against is silent: the image still
|
||||||
|
// builds and runs, but `vaultik version` inside it reports "commit:
|
||||||
|
// unknown", so an operator cannot tell which source produced a given
|
||||||
|
// backup. .dockerignore excludes .git, so the build cannot derive the
|
||||||
|
// commit itself; the values must be computed on the host and passed in.
|
||||||
|
//
|
||||||
|
// These are parses of the committed files, for the same reason the lint
|
||||||
|
// guards next door are: shelling out to docker would nest a build
|
||||||
|
// inside `make test`. That `vaultik version` in the built image really
|
||||||
|
// prints the host's version is verified by hand and recorded on the
|
||||||
|
// pull request.
|
||||||
|
|
||||||
|
// dockerScript is script/docker, relative to the repository root.
|
||||||
|
const dockerScript = "script/docker"
|
||||||
|
|
||||||
|
// versionArgs are the ldflag targets the build stamps and, matching
|
||||||
|
// them, the build args the host must supply. The names line up so the
|
||||||
|
// same list checks both files.
|
||||||
|
func versionArgs() []string {
|
||||||
|
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()
|
||||||
|
|
||||||
|
found := instructions(t, productDockerfile)
|
||||||
|
|
||||||
|
for _, arg := range versionArgs() {
|
||||||
|
require.GreaterOrEqual(t, indexOf(found, "ARG "+arg), 0,
|
||||||
|
"%s must declare `ARG %s` so the host can pass it in",
|
||||||
|
productDockerfile, arg)
|
||||||
|
|
||||||
|
assertLdflagReferences(t, found, arg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProductDockerfileDoesNotDeriveVersionItself is the anti-regression
|
||||||
|
// for the original defect: the container ran `git rev-parse`, but .git
|
||||||
|
// is not in the build context, so it always resolved to "unknown". No
|
||||||
|
// git command may reach into a build that cannot see the history.
|
||||||
|
func TestProductDockerfileDoesNotDeriveVersionItself(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
text := instructionText(readRepoFile(t, productDockerfile))
|
||||||
|
|
||||||
|
assert.NotContains(t, text, "git ",
|
||||||
|
"%s must not run git: .git is excluded from the build context, so"+
|
||||||
|
" any value it derives is wrong. Pass version, commit and date"+
|
||||||
|
" in as build args instead.", productDockerfile)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDockerScriptComputesVersionOnTheHost fails unless script/docker
|
||||||
|
// derives each value where .git exists and passes it 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) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
script := readRepoFile(t, dockerScript)
|
||||||
|
|
||||||
|
for _, arg := range versionArgs() {
|
||||||
|
assert.Contains(t, script, "--build-arg "+arg+"=",
|
||||||
|
"%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
|
||||||
|
// named variable from the ARG (a ${arg} reference), not from a value
|
||||||
|
// computed inside the container.
|
||||||
|
func assertLdflagReferences(t *testing.T, found []string, arg string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
for _, instruction := range found {
|
||||||
|
if strings.HasPrefix(instruction, "RUN ") &&
|
||||||
|
strings.Contains(instruction, "go build") &&
|
||||||
|
strings.Contains(instruction, "${"+arg+"}") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Fail(t, "version arg is declared but never stamped",
|
||||||
|
"the go build in %s must reference ${%s} in its ldflags, or the"+
|
||||||
|
" arg is passed and discarded", productDockerfile, arg)
|
||||||
|
}
|
||||||
@@ -304,10 +304,14 @@ func instructionText(contents string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// indexOf returns the position of the first instruction equal to, or
|
// indexOf returns the position of the first instruction equal to, or
|
||||||
// beginning with, want; -1 if there is none.
|
// 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 {
|
func indexOf(found []string, want string) int {
|
||||||
for i, instruction := range found {
|
for i, instruction := range found {
|
||||||
if instruction == want || strings.HasPrefix(instruction, want+" ") {
|
if instruction == want ||
|
||||||
|
strings.HasPrefix(instruction, want+" ") ||
|
||||||
|
strings.HasPrefix(instruction, want+"=") {
|
||||||
return i
|
return i
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-1
@@ -10,6 +10,16 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
os.Exit(run())
|
||||||
|
}
|
||||||
|
|
||||||
|
// run sets up optional profiling, runs the CLI, and returns the process
|
||||||
|
// exit code. os.Exit lives in main so it fires only after run's deferred
|
||||||
|
// profile writers have flushed. cli.Entry returns a status code rather
|
||||||
|
// than calling os.Exit itself: an os.Exit from inside it would skip
|
||||||
|
// these defers and truncate the profile of a failing command -- exactly
|
||||||
|
// the command one most often wants to profile.
|
||||||
|
func run() int {
|
||||||
// CPU profiling: set VAULTIK_CPUPROFILE=/path/to/cpu.prof
|
// CPU profiling: set VAULTIK_CPUPROFILE=/path/to/cpu.prof
|
||||||
if cpuProfile := os.Getenv("VAULTIK_CPUPROFILE"); cpuProfile != "" {
|
if cpuProfile := os.Getenv("VAULTIK_CPUPROFILE"); cpuProfile != "" {
|
||||||
f, err := os.Create(cpuProfile) //nolint:gosec // G304: operator-set path
|
f, err := os.Create(cpuProfile) //nolint:gosec // G304: operator-set path
|
||||||
@@ -46,5 +56,5 @@ func main() {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
cli.Entry()
|
return cli.Entry()
|
||||||
}
|
}
|
||||||
|
|||||||
+90
-39
@@ -11,6 +11,7 @@ import (
|
|||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -196,13 +197,90 @@ func RunApp(ctx context.Context, app *fx.App) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// errReported marks a failure the operation has already shown the user
|
||||||
|
// (and deliberately withheld under --json). Entry turns it into a
|
||||||
|
// non-zero exit status without printing anything further, so the error
|
||||||
|
// line is not doubled. It flows up from RunOperation through cobra to
|
||||||
|
// Entry.
|
||||||
|
var errReported = errors.New("operation failed")
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// within the goroutine. An os.Exit there skipped main's deferred
|
||||||
|
// profile writers -- so profiling a failing command yielded a truncated
|
||||||
|
// profile (issue #75) -- and RunWithApp's PID-lock release, and denied
|
||||||
|
// the app any graceful shutdown; returning the error to the top runs
|
||||||
|
// all three.
|
||||||
|
//
|
||||||
|
// op runs in a goroutine so OnStart returns promptly and an interrupt
|
||||||
|
// can still cancel through OnStop; when it finishes, success or failure,
|
||||||
|
// it triggers shutdown, which is what lets RunWithApp return. report is
|
||||||
|
// called with a non-canceled failure so the caller can log it (and
|
||||||
|
// suppress it under --json) before it becomes errReported. A context
|
||||||
|
// cancellation is the interrupt path, not a failure: it is neither
|
||||||
|
// reported nor counted as one.
|
||||||
|
func RunOperation(
|
||||||
|
ctx context.Context, opts AppOptions,
|
||||||
|
op func(v *vaultik.Vaultik) error, report func(err error),
|
||||||
|
) error {
|
||||||
|
var (
|
||||||
|
mu sync.Mutex
|
||||||
|
failed bool
|
||||||
|
)
|
||||||
|
|
||||||
|
opts.Invokes = append(opts.Invokes,
|
||||||
|
fx.Invoke(func(v *vaultik.Vaultik, lc fx.Lifecycle) {
|
||||||
|
lc.Append(fx.Hook{
|
||||||
|
OnStart: func(_ context.Context) error {
|
||||||
|
go func() {
|
||||||
|
err := op(v)
|
||||||
|
if err != nil && !errors.Is(err, context.Canceled) {
|
||||||
|
report(err)
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
failed = true
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
stopErr := v.Shutdowner.Shutdown()
|
||||||
|
if stopErr != nil {
|
||||||
|
log.Error("Failed to shutdown", "error", stopErr)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
OnStop: func(_ context.Context) error {
|
||||||
|
v.Cancel()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
err := RunWithApp(ctx, opts)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// The goroutine sets failed before triggering the shutdown that lets
|
||||||
|
// RunWithApp return, so the write is in place by the time we read it.
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
if failed {
|
||||||
|
return errReported
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// runVaultikApp runs the standard single-operation command lifecycle
|
// runVaultikApp runs the standard single-operation command lifecycle
|
||||||
// shared by the list/purge/verify/remove/remote-info subcommands:
|
// shared by the list/purge/verify/remove/remote-info subcommands:
|
||||||
// resolve the config, start the fx app, run op against the Vaultik
|
// resolve the config, then run op against the Vaultik instance through
|
||||||
// instance in a goroutine, report a failure prefixed with failMsg
|
// RunOperation, reporting a failure prefixed with failMsg (suppressed
|
||||||
// (suppressed while suppressErrors is true, e.g. under --json), then
|
// while suppressErrors is true, e.g. under --json). extraQuiet is OR-ed
|
||||||
// trigger shutdown. The operation is cancelled when the app stops.
|
// into LogOptions.Quiet (e.g. --json output modes).
|
||||||
// extraQuiet is OR-ed into LogOptions.Quiet (e.g. --json output modes).
|
|
||||||
func runVaultikApp(
|
func runVaultikApp(
|
||||||
cmd *cobra.Command, extraQuiet, suppressErrors bool,
|
cmd *cobra.Command, extraQuiet, suppressErrors bool,
|
||||||
failMsg string, op func(v *vaultik.Vaultik) error,
|
failMsg string, op func(v *vaultik.Vaultik) error,
|
||||||
@@ -214,47 +292,20 @@ func runVaultikApp(
|
|||||||
|
|
||||||
rootFlags := GetRootFlags()
|
rootFlags := GetRootFlags()
|
||||||
|
|
||||||
return RunWithApp(cmd.Context(), AppOptions{
|
return RunOperation(cmd.Context(), AppOptions{
|
||||||
ConfigPath: configPath,
|
ConfigPath: configPath,
|
||||||
LogOptions: log.Options{
|
LogOptions: log.Options{
|
||||||
Verbose: rootFlags.Verbose,
|
Verbose: rootFlags.Verbose,
|
||||||
Debug: rootFlags.Debug,
|
Debug: rootFlags.Debug,
|
||||||
Quiet: rootFlags.Quiet || extraQuiet,
|
Quiet: rootFlags.Quiet || extraQuiet,
|
||||||
},
|
},
|
||||||
Modules: []fx.Option{},
|
}, op, func(err error) {
|
||||||
Invokes: []fx.Option{
|
if suppressErrors {
|
||||||
fx.Invoke(func(v *vaultik.Vaultik, lc fx.Lifecycle) {
|
return
|
||||||
lc.Append(fx.Hook{
|
}
|
||||||
OnStart: func(_ context.Context) error {
|
|
||||||
go func() {
|
|
||||||
err := op(v)
|
|
||||||
if err != nil {
|
|
||||||
if !errors.Is(err, context.Canceled) {
|
|
||||||
if !suppressErrors {
|
|
||||||
log.Error(failMsg, "error", err)
|
|
||||||
ReportErrorf("%s: %v", failMsg, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
os.Exit(1)
|
log.Error(failMsg, "error", err)
|
||||||
}
|
ReportErrorf("%s: %v", failMsg, err)
|
||||||
}
|
|
||||||
|
|
||||||
err = v.Shutdowner.Shutdown()
|
|
||||||
if err != nil {
|
|
||||||
log.Error("Failed to shutdown", "error", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
OnStop: func(_ context.Context) error {
|
|
||||||
v.Cancel()
|
|
||||||
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+18
-3
@@ -1,6 +1,7 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -19,7 +20,11 @@ const shortCommitLen = 12
|
|||||||
// 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.
|
||||||
func Entry() {
|
//
|
||||||
|
// It returns the process exit code (0 on success, 1 on error) rather
|
||||||
|
// than calling os.Exit, so that main's deferred profile writers run
|
||||||
|
// before the process ends. See run in cmd/vaultik/main.go.
|
||||||
|
func Entry() int {
|
||||||
emitStartupBanner(os.Args[1:], os.Stdout)
|
emitStartupBanner(os.Args[1:], os.Stdout)
|
||||||
|
|
||||||
rootCmd := NewRootCommand()
|
rootCmd := NewRootCommand()
|
||||||
@@ -27,9 +32,19 @@ func Entry() {
|
|||||||
|
|
||||||
err := rootCmd.Execute()
|
err := rootCmd.Execute()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ReportErrorf("%s", err.Error())
|
// An operation that ran inside the fx app has already reported
|
||||||
os.Exit(1)
|
// its own failure (and suppressed it under --json); errReported
|
||||||
|
// says so. Printing it again here would double the error line.
|
||||||
|
// Every other error — bad arguments, a config that would not
|
||||||
|
// load — reaches Entry unreported, so it is shown here.
|
||||||
|
if !errors.Is(err, errReported) {
|
||||||
|
ReportErrorf("%s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// emitStartupBanner writes the startup banner to w unless args (the
|
// emitStartupBanner writes the startup banner to w unless args (the
|
||||||
|
|||||||
@@ -230,7 +230,7 @@ func TestEntryJSONStdoutIsExactlyOneDocument(t *testing.T) {
|
|||||||
programName, flagConfig, configPath, cmdSnapshot, cmdList, flagJSON,
|
programName, flagConfig, configPath, cmdSnapshot, cmdList, flagJSON,
|
||||||
}
|
}
|
||||||
|
|
||||||
stdout := captureProcessStdout(t, Entry)
|
stdout := captureProcessStdout(t, func() { _ = Entry() })
|
||||||
|
|
||||||
requireExactlyOneJSONDocument(t, stdout)
|
requireExactlyOneJSONDocument(t, stdout)
|
||||||
|
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ func TestEntryPruneJSONStdoutIsExactlyOneDocument(t *testing.T) {
|
|||||||
programName, flagConfig, configPath, cmdPrune, flagJSON,
|
programName, flagConfig, configPath, cmdPrune, flagJSON,
|
||||||
}
|
}
|
||||||
|
|
||||||
stdout := captureProcessStdout(t, Entry)
|
stdout := captureProcessStdout(t, func() { _ = Entry() })
|
||||||
|
|
||||||
requireExactlyOneJSONDocument(t, stdout)
|
requireExactlyOneJSONDocument(t, stdout)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package cli //nolint:testpackage // shares programName and the capture helpers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestEntryReturnsStatusCode pins the contract main() relies on for
|
||||||
|
// issue #75: Entry reports success or failure through its return value
|
||||||
|
// and never calls os.Exit. An os.Exit from inside Entry would skip
|
||||||
|
// main's deferred profile writers and truncate the profile of a failing
|
||||||
|
// command. main turns this code into os.Exit only after those defers
|
||||||
|
// run, so a failing command must come back with a non-zero code rather
|
||||||
|
// than ending the process here.
|
||||||
|
//
|
||||||
|
// Stdout is captured only to keep the banner and command output off the
|
||||||
|
// test log; the assertion is on the returned code.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // replaces os.Args and rootFlags
|
||||||
|
func TestEntryReturnsStatusCode(t *testing.T) {
|
||||||
|
for _, testCase := range []struct {
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
// version is self-contained: it needs no config and no
|
||||||
|
// destination store, so it exercises the success path.
|
||||||
|
name: "successful command returns zero",
|
||||||
|
args: []string{programName, "version"},
|
||||||
|
want: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown command returns one",
|
||||||
|
args: []string{programName, "no-such-command"},
|
||||||
|
want: 1,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
|
previousArgs := os.Args
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
os.Args = previousArgs
|
||||||
|
rootFlags = RootFlags{}
|
||||||
|
})
|
||||||
|
|
||||||
|
os.Args = testCase.args
|
||||||
|
|
||||||
|
var code int
|
||||||
|
|
||||||
|
_ = captureProcessStdout(t, func() { code = Entry() })
|
||||||
|
|
||||||
|
assert.Equal(t, testCase.want, code)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
-37
@@ -1,12 +1,7 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"go.uber.org/fx"
|
|
||||||
"sneak.berlin/go/vaultik/internal/log"
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
"sneak.berlin/go/vaultik/internal/vaultik"
|
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||||
)
|
)
|
||||||
@@ -33,44 +28,18 @@ func NewInfoCommand() *cobra.Command {
|
|||||||
// Use the app framework
|
// Use the app framework
|
||||||
rootFlags := GetRootFlags()
|
rootFlags := GetRootFlags()
|
||||||
|
|
||||||
return RunWithApp(cmd.Context(), AppOptions{
|
return RunOperation(cmd.Context(), AppOptions{
|
||||||
ConfigPath: configPath,
|
ConfigPath: configPath,
|
||||||
LogOptions: log.Options{
|
LogOptions: log.Options{
|
||||||
Verbose: rootFlags.Verbose,
|
Verbose: rootFlags.Verbose,
|
||||||
Debug: rootFlags.Debug,
|
Debug: rootFlags.Debug,
|
||||||
Quiet: rootFlags.Quiet,
|
Quiet: rootFlags.Quiet,
|
||||||
},
|
},
|
||||||
Modules: []fx.Option{},
|
}, func(v *vaultik.Vaultik) error {
|
||||||
Invokes: []fx.Option{
|
return v.ShowInfo()
|
||||||
fx.Invoke(func(v *vaultik.Vaultik, lc fx.Lifecycle) {
|
}, func(err error) {
|
||||||
lc.Append(fx.Hook{
|
log.Error("Failed to show info", "error", err)
|
||||||
OnStart: func(_ context.Context) error {
|
ReportErrorf("Failed to show info: %v", err)
|
||||||
go func() {
|
|
||||||
err := v.ShowInfo()
|
|
||||||
if err != nil {
|
|
||||||
if !errors.Is(err, context.Canceled) {
|
|
||||||
log.Error("Failed to show info", "error", err)
|
|
||||||
ReportErrorf("Failed to show info: %v", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err = v.Shutdowner.Shutdown()
|
|
||||||
if err != nil {
|
|
||||||
log.Error("Failed to shutdown", "error", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
OnStop: func(_ context.Context) error {
|
|
||||||
v.Cancel()
|
|
||||||
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-43
@@ -1,12 +1,7 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"go.uber.org/fx"
|
|
||||||
"sneak.berlin/go/vaultik/internal/log"
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
"sneak.berlin/go/vaultik/internal/vaultik"
|
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||||
)
|
)
|
||||||
@@ -41,51 +36,22 @@ work (e.g. after a crashed backup or to reclaim storage).`,
|
|||||||
// Use the app framework like other commands
|
// Use the app framework like other commands
|
||||||
rootFlags := GetRootFlags()
|
rootFlags := GetRootFlags()
|
||||||
|
|
||||||
return RunWithApp(cmd.Context(), AppOptions{
|
return RunOperation(cmd.Context(), AppOptions{
|
||||||
ConfigPath: configPath,
|
ConfigPath: configPath,
|
||||||
LogOptions: log.Options{
|
LogOptions: log.Options{
|
||||||
Verbose: rootFlags.Verbose,
|
Verbose: rootFlags.Verbose,
|
||||||
Debug: rootFlags.Debug,
|
Debug: rootFlags.Debug,
|
||||||
Quiet: rootFlags.Quiet || opts.JSON,
|
Quiet: rootFlags.Quiet || opts.JSON,
|
||||||
},
|
},
|
||||||
Modules: []fx.Option{},
|
}, func(v *vaultik.Vaultik) error {
|
||||||
Invokes: []fx.Option{
|
return v.Prune(opts)
|
||||||
fx.Invoke(func(v *vaultik.Vaultik, lc fx.Lifecycle) {
|
}, func(err error) {
|
||||||
lc.Append(fx.Hook{
|
if opts.JSON {
|
||||||
OnStart: func(_ context.Context) error {
|
return
|
||||||
// Start the prune operation in a goroutine
|
}
|
||||||
go func() {
|
|
||||||
// Run the prune operation
|
|
||||||
err := v.Prune(opts)
|
|
||||||
if err != nil {
|
|
||||||
if !errors.Is(err, context.Canceled) {
|
|
||||||
if !opts.JSON {
|
|
||||||
log.Error("Prune operation failed", "error", err)
|
|
||||||
ReportErrorf("Prune failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
os.Exit(1)
|
log.Error("Prune operation failed", "error", err)
|
||||||
}
|
ReportErrorf("Prune failed: %v", err)
|
||||||
}
|
|
||||||
|
|
||||||
// Shutdown the app when prune completes
|
|
||||||
err = v.Shutdowner.Shutdown()
|
|
||||||
if err != nil {
|
|
||||||
log.Error("Failed to shutdown", "error", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
OnStop: func(_ context.Context) error {
|
|
||||||
log.Debug("Stopping prune operation")
|
|
||||||
v.Cancel()
|
|
||||||
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-37
@@ -1,12 +1,9 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"errors"
|
"errors"
|
||||||
"os"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"go.uber.org/fx"
|
|
||||||
"sneak.berlin/go/vaultik/internal/log"
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
"sneak.berlin/go/vaultik/internal/vaultik"
|
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||||
)
|
)
|
||||||
@@ -83,47 +80,22 @@ func newRemoteInfoCommand() *cobra.Command {
|
|||||||
|
|
||||||
rootFlags := GetRootFlags()
|
rootFlags := GetRootFlags()
|
||||||
|
|
||||||
return RunWithApp(cmd.Context(), AppOptions{
|
return RunOperation(cmd.Context(), AppOptions{
|
||||||
ConfigPath: configPath,
|
ConfigPath: configPath,
|
||||||
LogOptions: log.Options{
|
LogOptions: log.Options{
|
||||||
Verbose: rootFlags.Verbose,
|
Verbose: rootFlags.Verbose,
|
||||||
Debug: rootFlags.Debug,
|
Debug: rootFlags.Debug,
|
||||||
Quiet: rootFlags.Quiet || jsonOutput,
|
Quiet: rootFlags.Quiet || jsonOutput,
|
||||||
},
|
},
|
||||||
Modules: []fx.Option{},
|
}, func(v *vaultik.Vaultik) error {
|
||||||
Invokes: []fx.Option{
|
return v.RemoteInfo(jsonOutput)
|
||||||
fx.Invoke(func(v *vaultik.Vaultik, lc fx.Lifecycle) {
|
}, func(err error) {
|
||||||
lc.Append(fx.Hook{
|
if jsonOutput {
|
||||||
OnStart: func(_ context.Context) error {
|
return
|
||||||
go func() {
|
}
|
||||||
err := v.RemoteInfo(jsonOutput)
|
|
||||||
if err != nil {
|
|
||||||
if !errors.Is(err, context.Canceled) {
|
|
||||||
if !jsonOutput {
|
|
||||||
log.Error("Failed to get remote info", "error", err)
|
|
||||||
ReportErrorf("Failed to get remote info: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
os.Exit(1)
|
log.Error("Failed to get remote info", "error", err)
|
||||||
}
|
ReportErrorf("Failed to get remote info: %v", err)
|
||||||
}
|
|
||||||
|
|
||||||
err = v.Shutdowner.Shutdown()
|
|
||||||
if err != nil {
|
|
||||||
log.Error("Failed to shutdown", "error", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
OnStop: func(_ context.Context) error {
|
|
||||||
v.Cancel()
|
|
||||||
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-79
@@ -1,13 +1,10 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"go.uber.org/fx"
|
|
||||||
"sneak.berlin/go/vaultik/internal/log"
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
"sneak.berlin/go/vaultik/internal/vaultik"
|
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||||
)
|
)
|
||||||
@@ -86,7 +83,8 @@ specifying a path using --config or by setting VAULTIK_CONFIG to a path.`,
|
|||||||
// Use the backup functionality from cli package
|
// Use the backup functionality from cli package
|
||||||
rootFlags := GetRootFlags()
|
rootFlags := GetRootFlags()
|
||||||
|
|
||||||
return RunWithApp(cmd.Context(), AppOptions{
|
// --cron suppression is wired through v.UI by setupGlobals.
|
||||||
|
return RunOperation(cmd.Context(), AppOptions{
|
||||||
ConfigPath: configPath,
|
ConfigPath: configPath,
|
||||||
LogOptions: log.Options{
|
LogOptions: log.Options{
|
||||||
Verbose: rootFlags.Verbose,
|
Verbose: rootFlags.Verbose,
|
||||||
@@ -94,42 +92,11 @@ specifying a path using --config or by setting VAULTIK_CONFIG to a path.`,
|
|||||||
Cron: opts.Cron,
|
Cron: opts.Cron,
|
||||||
Quiet: rootFlags.Quiet,
|
Quiet: rootFlags.Quiet,
|
||||||
},
|
},
|
||||||
Modules: []fx.Option{},
|
}, func(v *vaultik.Vaultik) error {
|
||||||
Invokes: []fx.Option{
|
return v.CreateSnapshot(opts)
|
||||||
fx.Invoke(func(v *vaultik.Vaultik, lc fx.Lifecycle) {
|
}, func(err error) {
|
||||||
lc.Append(fx.Hook{
|
log.Error("Snapshot creation failed", "error", err)
|
||||||
OnStart: func(_ context.Context) error {
|
ReportErrorf("Snapshot creation failed: %v", err)
|
||||||
// Start the snapshot creation in a goroutine
|
|
||||||
go func() {
|
|
||||||
// --cron suppression is wired through v.UI by setupGlobals.
|
|
||||||
err := v.CreateSnapshot(opts)
|
|
||||||
if err != nil {
|
|
||||||
if !errors.Is(err, context.Canceled) {
|
|
||||||
log.Error("Snapshot creation failed", "error", err)
|
|
||||||
ReportErrorf("Snapshot creation failed: %v", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shutdown the app when snapshot completes
|
|
||||||
err = v.Shutdowner.Shutdown()
|
|
||||||
if err != nil {
|
|
||||||
log.Error("Failed to shutdown", "error", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
OnStop: func(_ context.Context) error {
|
|
||||||
log.Debug("Stopping snapshot creation")
|
|
||||||
// Cancel the Vaultik context
|
|
||||||
v.Cancel()
|
|
||||||
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -221,11 +188,8 @@ func newSnapshotVerifyCommand() *cobra.Command {
|
|||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "verify <snapshot-id>",
|
Use: "verify <snapshot-id>",
|
||||||
Short: "Verify snapshot integrity",
|
Short: "Verify snapshot integrity",
|
||||||
Long: "Verifies that all blobs referenced in a snapshot exist.\n\n" +
|
Long: "Verifies that all blobs referenced in a snapshot exist",
|
||||||
"The snapshot may be named by its ID or, on a host with no local\n" +
|
Args: requireSnapshotIDArg,
|
||||||
"index, by the remote key that 'snapshot list' prints for a\n" +
|
|
||||||
"remote-only snapshot (an unambiguous leading part is enough).",
|
|
||||||
Args: requireSnapshotIDArg,
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
snapshotID := args[0]
|
snapshotID := args[0]
|
||||||
|
|
||||||
@@ -237,47 +201,22 @@ func newSnapshotVerifyCommand() *cobra.Command {
|
|||||||
|
|
||||||
rootFlags := GetRootFlags()
|
rootFlags := GetRootFlags()
|
||||||
|
|
||||||
return RunWithApp(cmd.Context(), AppOptions{
|
return RunOperation(cmd.Context(), AppOptions{
|
||||||
ConfigPath: configPath,
|
ConfigPath: configPath,
|
||||||
LogOptions: log.Options{
|
LogOptions: log.Options{
|
||||||
Verbose: rootFlags.Verbose,
|
Verbose: rootFlags.Verbose,
|
||||||
Debug: rootFlags.Debug,
|
Debug: rootFlags.Debug,
|
||||||
Quiet: rootFlags.Quiet || opts.JSON,
|
Quiet: rootFlags.Quiet || opts.JSON,
|
||||||
},
|
},
|
||||||
Modules: []fx.Option{},
|
}, func(v *vaultik.Vaultik) error {
|
||||||
Invokes: []fx.Option{
|
return v.VerifySnapshotWithOptions(snapshotID, opts)
|
||||||
fx.Invoke(func(v *vaultik.Vaultik, lc fx.Lifecycle) {
|
}, func(err error) {
|
||||||
lc.Append(fx.Hook{
|
if opts.JSON {
|
||||||
OnStart: func(_ context.Context) error {
|
return
|
||||||
go func() {
|
}
|
||||||
err := v.VerifySnapshotWithOptions(snapshotID, opts)
|
|
||||||
if err != nil {
|
|
||||||
if !errors.Is(err, context.Canceled) {
|
|
||||||
if !opts.JSON {
|
|
||||||
log.Error("Verification failed", "error", err)
|
|
||||||
ReportErrorf("Verification failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
os.Exit(1)
|
log.Error("Verification failed", "error", err)
|
||||||
}
|
ReportErrorf("Verification failed: %v", err)
|
||||||
}
|
|
||||||
|
|
||||||
err = v.Shutdowner.Shutdown()
|
|
||||||
if err != nil {
|
|
||||||
log.Error("Failed to shutdown", "error", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
OnStop: func(_ context.Context) error {
|
|
||||||
v.Cancel()
|
|
||||||
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,8 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"go.uber.org/fx"
|
|
||||||
"sneak.berlin/go/vaultik/internal/config"
|
|
||||||
"sneak.berlin/go/vaultik/internal/globals"
|
|
||||||
"sneak.berlin/go/vaultik/internal/log"
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
"sneak.berlin/go/vaultik/internal/storage"
|
|
||||||
"sneak.berlin/go/vaultik/internal/vaultik"
|
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -25,15 +17,6 @@ type RestoreOptions struct {
|
|||||||
Verify bool // Verify restored files after restore
|
Verify bool // Verify restored files after restore
|
||||||
}
|
}
|
||||||
|
|
||||||
// RestoreApp contains all dependencies needed for restore
|
|
||||||
type RestoreApp struct {
|
|
||||||
Globals *globals.Globals
|
|
||||||
Config *config.Config
|
|
||||||
Storage storage.Storer
|
|
||||||
Vaultik *vaultik.Vaultik
|
|
||||||
Shutdowner fx.Shutdowner
|
|
||||||
}
|
|
||||||
|
|
||||||
// newSnapshotRestoreCommand creates the 'snapshot restore' subcommand
|
// newSnapshotRestoreCommand creates the 'snapshot restore' subcommand
|
||||||
func newSnapshotRestoreCommand() *cobra.Command {
|
func newSnapshotRestoreCommand() *cobra.Command {
|
||||||
opts := &RestoreOptions{}
|
opts := &RestoreOptions{}
|
||||||
@@ -48,10 +31,6 @@ 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.
|
||||||
|
|
||||||
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
|
|
||||||
remote-only snapshot (an unambiguous leading part is enough).
|
|
||||||
|
|
||||||
Requires the VAULTIK_AGE_SECRET_KEY environment variable to be set with
|
Requires the VAULTIK_AGE_SECRET_KEY environment variable to be set with
|
||||||
the age private key.
|
the age private key.
|
||||||
|
|
||||||
@@ -81,7 +60,8 @@ Examples:
|
|||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
// runRestore parses arguments and runs the restore operation through the app framework
|
// runRestore parses arguments and runs the restore operation through the
|
||||||
|
// app framework.
|
||||||
func runRestore(cmd *cobra.Command, args []string, opts *RestoreOptions) error {
|
func runRestore(cmd *cobra.Command, args []string, opts *RestoreOptions) error {
|
||||||
snapshotID := args[0]
|
snapshotID := args[0]
|
||||||
|
|
||||||
@@ -90,87 +70,30 @@ func runRestore(cmd *cobra.Command, args []string, opts *RestoreOptions) error {
|
|||||||
opts.Paths = args[restoreMinArgs:]
|
opts.Paths = args[restoreMinArgs:]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use unified config resolution
|
|
||||||
configPath, err := ResolveConfigPath()
|
configPath, err := ResolveConfigPath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the app framework like other commands
|
|
||||||
rootFlags := GetRootFlags()
|
rootFlags := GetRootFlags()
|
||||||
|
|
||||||
return RunWithApp(cmd.Context(), AppOptions{
|
return RunOperation(cmd.Context(), AppOptions{
|
||||||
ConfigPath: configPath,
|
ConfigPath: configPath,
|
||||||
LogOptions: log.Options{
|
LogOptions: log.Options{
|
||||||
Verbose: rootFlags.Verbose,
|
Verbose: rootFlags.Verbose,
|
||||||
Debug: rootFlags.Debug,
|
Debug: rootFlags.Debug,
|
||||||
Quiet: rootFlags.Quiet,
|
Quiet: rootFlags.Quiet,
|
||||||
},
|
},
|
||||||
Modules: buildRestoreModules(),
|
}, func(v *vaultik.Vaultik) error {
|
||||||
Invokes: buildRestoreInvokes(snapshotID, opts),
|
return v.Restore(&vaultik.RestoreOptions{
|
||||||
|
SnapshotID: snapshotID,
|
||||||
|
TargetDir: opts.TargetDir,
|
||||||
|
Paths: opts.Paths,
|
||||||
|
Verify: opts.Verify,
|
||||||
|
SkipErrors: rootFlags.SkipErrors,
|
||||||
|
})
|
||||||
|
}, func(err error) {
|
||||||
|
log.Error("Restore operation failed", "error", err)
|
||||||
|
ReportErrorf("Restore failed: %v", err)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildRestoreModules returns the fx.Options for dependency injection in restore
|
|
||||||
func buildRestoreModules() []fx.Option {
|
|
||||||
return []fx.Option{
|
|
||||||
fx.Provide(fx.Annotate(
|
|
||||||
func(g *globals.Globals, cfg *config.Config,
|
|
||||||
storer storage.Storer, v *vaultik.Vaultik, shutdowner fx.Shutdowner) *RestoreApp {
|
|
||||||
return &RestoreApp{
|
|
||||||
Globals: g,
|
|
||||||
Config: cfg,
|
|
||||||
Storage: storer,
|
|
||||||
Vaultik: v,
|
|
||||||
Shutdowner: shutdowner,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildRestoreInvokes returns the fx.Options that wire up the restore lifecycle
|
|
||||||
func buildRestoreInvokes(snapshotID string, opts *RestoreOptions) []fx.Option {
|
|
||||||
return []fx.Option{
|
|
||||||
fx.Invoke(func(app *RestoreApp, lc fx.Lifecycle) {
|
|
||||||
lc.Append(fx.Hook{
|
|
||||||
OnStart: func(_ context.Context) error {
|
|
||||||
// Start the restore operation in a goroutine
|
|
||||||
go func() {
|
|
||||||
// Run the restore operation
|
|
||||||
restoreOpts := &vaultik.RestoreOptions{
|
|
||||||
SnapshotID: snapshotID,
|
|
||||||
TargetDir: opts.TargetDir,
|
|
||||||
Paths: opts.Paths,
|
|
||||||
Verify: opts.Verify,
|
|
||||||
SkipErrors: GetRootFlags().SkipErrors,
|
|
||||||
}
|
|
||||||
|
|
||||||
err := app.Vaultik.Restore(restoreOpts)
|
|
||||||
if err != nil {
|
|
||||||
if !errors.Is(err, context.Canceled) {
|
|
||||||
log.Error("Restore operation failed", "error", err)
|
|
||||||
ReportErrorf("Restore failed: %v", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shutdown the app when restore completes
|
|
||||||
err = app.Shutdowner.Shutdown()
|
|
||||||
if err != nil {
|
|
||||||
log.Error("Failed to shutdown", "error", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
OnStop: func(_ context.Context) error {
|
|
||||||
log.Debug("Stopping restore operation")
|
|
||||||
app.Vaultik.Cancel()
|
|
||||||
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
"sneak.berlin/go/vaultik/internal/blobgen"
|
"sneak.berlin/go/vaultik/internal/blobgen"
|
||||||
"sneak.berlin/go/vaultik/internal/database"
|
"sneak.berlin/go/vaultik/internal/database"
|
||||||
"sneak.berlin/go/vaultik/internal/log"
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
|
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||||
"sneak.berlin/go/vaultik/internal/types"
|
"sneak.berlin/go/vaultik/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -576,20 +577,14 @@ func (v *Vaultik) handleRestoreVerification(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// downloadSnapshotDB downloads and decrypts the snapshot metadata
|
// downloadSnapshotDB downloads and decrypts the snapshot metadata
|
||||||
// database. The identifier is resolved to the snapshot's remote key: a
|
// database. The snapshotID is the human ID; we hash it to the remote
|
||||||
// human ID is hashed, and a remote key (or its abbreviation, as printed
|
// key for the storage path.
|
||||||
// for a remote-only snapshot) is used as-is, so a host with no local
|
|
||||||
// index can restore the snapshots it can only see on the store.
|
|
||||||
func (v *Vaultik) downloadSnapshotDB(
|
func (v *Vaultik) downloadSnapshotDB(
|
||||||
snapshotID string, identity age.Identity,
|
snapshotID string, identity age.Identity,
|
||||||
) (*database.DB, error) {
|
) (*database.DB, error) {
|
||||||
remoteKey, err := v.resolveSnapshotRemoteKey(snapshotID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Download encrypted database from storage
|
// Download encrypted database from storage
|
||||||
dbKey := fmt.Sprintf("metadata/%s/db.zst.age", remoteKey)
|
dbKey := fmt.Sprintf("metadata/%s/db.zst.age",
|
||||||
|
snapshot.RemoteSnapshotKey(snapshotID))
|
||||||
|
|
||||||
reader, err := v.Storage.Get(v.ctx, dbKey)
|
reader, err := v.Storage.Get(v.ctx, dbKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1,167 +0,0 @@
|
|||||||
package vaultik_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"io"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/spf13/afero"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"sneak.berlin/go/vaultik/internal/config"
|
|
||||||
"sneak.berlin/go/vaultik/internal/database"
|
|
||||||
"sneak.berlin/go/vaultik/internal/log"
|
|
||||||
"sneak.berlin/go/vaultik/internal/snapshot"
|
|
||||||
"sneak.berlin/go/vaultik/internal/storage"
|
|
||||||
"sneak.berlin/go/vaultik/internal/ui"
|
|
||||||
"sneak.berlin/go/vaultik/internal/vaultik"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestRestoreOnAnotherMachine proves the disaster-recovery path: a host
|
|
||||||
// that has only the vaultik binary, the age secret key, and the storage
|
|
||||||
// credentials — no local index, a different hostname, and no
|
|
||||||
// age_recipients configured — can list, restore, and verify a snapshot
|
|
||||||
// straight from the destination store.
|
|
||||||
//
|
|
||||||
// The backup half writes a snapshot with one index and hostname. The
|
|
||||||
// restore half throws that index away entirely: a fresh, empty index and
|
|
||||||
// a config that shares nothing with the original but the storage location
|
|
||||||
// and the secret key. If restore or verify needed the original local
|
|
||||||
// index — or the human snapshot ID that only that index holds — this test
|
|
||||||
// could not run, because the recovery host can know neither.
|
|
||||||
func TestRestoreOnAnotherMachine(t *testing.T) {
|
|
||||||
log.Initialize(log.Config{})
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewOsFs()
|
|
||||||
tempDir := t.TempDir()
|
|
||||||
|
|
||||||
dataDir := filepath.Join(tempDir, "source")
|
|
||||||
storeDir := filepath.Join(tempDir, "remote")
|
|
||||||
restoreDir := filepath.Join(tempDir, "restored")
|
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
|
||||||
|
|
||||||
chunkSize := int64(64 * 1024)
|
|
||||||
maxBlobSize := int64(512 * 1024)
|
|
||||||
|
|
||||||
sourceFiles := writeRecoverySourceTree(t, fs, dataDir, chunkSize)
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// Backup host: one index, hostname test-host, age_recipients set.
|
|
||||||
// runFileStorageBackup closes the index before returning, so nothing
|
|
||||||
// below can lean on it.
|
|
||||||
_, storer, originalID := runFileStorageBackup(
|
|
||||||
ctx, t, fs, dataDir, storeDir, dbPath, chunkSize, maxBlobSize)
|
|
||||||
|
|
||||||
// Recovery host: a fresh empty index, a different hostname, and no
|
|
||||||
// age_recipients — only the secret key and the same storage location.
|
|
||||||
recovery, stdout := newRecoveryHost(ctx, t, fs, storer)
|
|
||||||
|
|
||||||
// The recovery index really is empty. This is the assertion that makes
|
|
||||||
// the test a guard against restore quietly depending on the original
|
|
||||||
// index: if it did, an empty index would make restore fail.
|
|
||||||
localSnaps, err := recovery.Repositories.Snapshots.ListRecent(ctx, 100)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Empty(t, localSnaps, "recovery host must start with no local index")
|
|
||||||
|
|
||||||
// List: the snapshot shows up as remote-only, identified by its remote
|
|
||||||
// key, with no recoverable human ID.
|
|
||||||
require.NoError(t, recovery.ListSnapshots(true))
|
|
||||||
|
|
||||||
rows := decodeListJSON(t, stdout.String())
|
|
||||||
require.Len(t, rows, 1)
|
|
||||||
|
|
||||||
remote := rows[0]
|
|
||||||
assert.False(t, remote.LocallyTracked, "snapshot must be remote-only here")
|
|
||||||
assert.Empty(t, remote.ID, "the human ID is unknown to the recovery host")
|
|
||||||
require.Len(t, remote.RemoteKey, 64)
|
|
||||||
assert.Equal(t, snapshot.RemoteSnapshotKey(originalID), remote.RemoteKey,
|
|
||||||
"the listed key is the hashed snapshot ID")
|
|
||||||
|
|
||||||
// Restore driven by the abbreviated identifier the table prints (the
|
|
||||||
// first 12 hex of the remote key), then deep-verify from the store
|
|
||||||
// keyed by the full remote key. Both are what a recovery host can know.
|
|
||||||
require.NoError(t, recovery.Restore(&vaultik.RestoreOptions{
|
|
||||||
SnapshotID: remote.RemoteKey[:12],
|
|
||||||
TargetDir: restoreDir,
|
|
||||||
Verify: true,
|
|
||||||
}))
|
|
||||||
require.NoError(t, recovery.RunDeepVerify(
|
|
||||||
remote.RemoteKey, &vaultik.VerifyOptions{Deep: true}))
|
|
||||||
|
|
||||||
assertRestoredTreeMatches(t, fs, restoreDir, sourceFiles)
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeRecoverySourceTree writes a small source tree spanning several
|
|
||||||
// chunks (so restore reassembles real multi-chunk files) and returns the
|
|
||||||
// content keyed by absolute path.
|
|
||||||
func writeRecoverySourceTree(
|
|
||||||
t *testing.T, fs afero.Fs, dataDir string, chunkSize int64,
|
|
||||||
) map[string][]byte {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
sourceFiles := map[string][]byte{
|
|
||||||
filepath.Join(dataDir, "notes.txt"): []byte("recover me"),
|
|
||||||
filepath.Join(dataDir, "sub", "big.bin"): bytesPattern("big-", int(chunkSize*3)),
|
|
||||||
filepath.Join(dataDir, "sub", "small.bin"): bytesPattern("small-", 128),
|
|
||||||
}
|
|
||||||
|
|
||||||
for path, content := range sourceFiles {
|
|
||||||
require.NoError(t, fs.MkdirAll(filepath.Dir(path), 0o755))
|
|
||||||
require.NoError(t, afero.WriteFile(fs, path, content, 0o644))
|
|
||||||
}
|
|
||||||
|
|
||||||
return sourceFiles
|
|
||||||
}
|
|
||||||
|
|
||||||
// newRecoveryHost builds the Vaultik a replacement machine would run: an
|
|
||||||
// empty in-memory index, a hostname different from the backup host, no
|
|
||||||
// age_recipients, and only the secret key plus the shared storer. It
|
|
||||||
// returns the instance and the buffer its stdout is wired to.
|
|
||||||
func newRecoveryHost(
|
|
||||||
ctx context.Context, t *testing.T, fs afero.Fs, storer storage.Storer,
|
|
||||||
) (*vaultik.Vaultik, *bytes.Buffer) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
recoveryDB, err := database.New(ctx, ":memory:")
|
|
||||||
require.NoError(t, err)
|
|
||||||
t.Cleanup(func() { _ = recoveryDB.Close() })
|
|
||||||
|
|
||||||
stdout := &bytes.Buffer{}
|
|
||||||
|
|
||||||
recovery := &vaultik.Vaultik{
|
|
||||||
Config: &config.Config{
|
|
||||||
AgeSecretKey: testAgeSecretKey,
|
|
||||||
Hostname: "recovery-host",
|
|
||||||
},
|
|
||||||
Storage: storer,
|
|
||||||
Fs: fs,
|
|
||||||
Repositories: database.NewRepositories(recoveryDB),
|
|
||||||
DB: recoveryDB,
|
|
||||||
Stdout: stdout,
|
|
||||||
Stderr: io.Discard,
|
|
||||||
UI: ui.NewWithColor(io.Discard, false),
|
|
||||||
}
|
|
||||||
recovery.SetContext(ctx)
|
|
||||||
|
|
||||||
return recovery, stdout
|
|
||||||
}
|
|
||||||
|
|
||||||
// assertRestoredTreeMatches byte-compares every restored file against its
|
|
||||||
// source content.
|
|
||||||
func assertRestoredTreeMatches(
|
|
||||||
t *testing.T, fs afero.Fs, restoreDir string, sourceFiles map[string][]byte,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
for origPath, expected := range sourceFiles {
|
|
||||||
restored := filepath.Join(restoreDir, origPath)
|
|
||||||
got, err := afero.ReadFile(fs, restored)
|
|
||||||
require.NoErrorf(t, err, "restored file missing: %s", restored)
|
|
||||||
require.Truef(t, bytes.Equal(got, expected),
|
|
||||||
"byte mismatch for %s", origPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -670,11 +670,9 @@ func (v *Vaultik) VerifySnapshotWithOptions(
|
|||||||
|
|
||||||
v.printVerifyHeader(snapshotID, opts)
|
v.printVerifyHeader(snapshotID, opts)
|
||||||
|
|
||||||
// Resolve the identifier to the snapshot's remote key and download the
|
// Download and parse manifest. The caller supplies a human
|
||||||
// manifest. A human ID is hashed; a remote key (or its abbreviation,
|
// snapshot ID; we hash it to address remote storage.
|
||||||
// as printed for a remote-only snapshot) is used as-is, so a host with
|
manifest, err := v.downloadManifestByKey(snapshot.RemoteSnapshotKey(snapshotID))
|
||||||
// no local index can verify a snapshot it can only see on the store.
|
|
||||||
manifest, err := v.resolveAndDownloadManifest(snapshotID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if opts.JSON {
|
if opts.JSON {
|
||||||
result.Status = verifyStatusFailed
|
result.Status = verifyStatusFailed
|
||||||
|
|||||||
@@ -1,101 +0,0 @@
|
|||||||
package vaultik
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"sneak.berlin/go/vaultik/internal/snapshot"
|
|
||||||
)
|
|
||||||
|
|
||||||
// remoteKeyHexLen is the length of a full remote snapshot key: a SHA256
|
|
||||||
// digest rendered as lowercase hex.
|
|
||||||
const remoteKeyHexLen = 64
|
|
||||||
|
|
||||||
// Sentinel errors for resolving a snapshot identifier against the store.
|
|
||||||
var (
|
|
||||||
errSnapshotKeyNotFound = errors.New(
|
|
||||||
"no snapshot on the destination store matches this identifier")
|
|
||||||
errSnapshotKeyAmbiguous = errors.New(
|
|
||||||
"identifier matches more than one snapshot on the destination store")
|
|
||||||
)
|
|
||||||
|
|
||||||
// resolveSnapshotRemoteKey turns a snapshot identifier supplied on the
|
|
||||||
// command line into the remote key that names the snapshot's metadata
|
|
||||||
// directory on the destination store. Every remote path a restore or
|
|
||||||
// verify reads is built from that key.
|
|
||||||
//
|
|
||||||
// Two forms are accepted, matching the two things a host can know:
|
|
||||||
//
|
|
||||||
// - A human snapshot ID (hostname_name_timestamp), which a host holding
|
|
||||||
// the local index has. It is hashed to its remote key; the store is
|
|
||||||
// not consulted.
|
|
||||||
// - A remote key, or the leading part of one, which is all a host with
|
|
||||||
// no local index can know — it is exactly what `snapshot list` prints
|
|
||||||
// for a remote-only snapshot (see formatRemoteOnlyID). It is resolved
|
|
||||||
// against the destination store's metadata listing; an identifier that
|
|
||||||
// matches no snapshot, or more than one, is an error.
|
|
||||||
//
|
|
||||||
// The two are told apart by shape: a remote key is lowercase hex, and a
|
|
||||||
// human snapshot ID never is (it carries a hostname, underscores, and an
|
|
||||||
// RFC3339 timestamp).
|
|
||||||
func (v *Vaultik) resolveSnapshotRemoteKey(identifier string) (string, error) {
|
|
||||||
if !isRemoteKeyOrPrefix(identifier) {
|
|
||||||
return snapshot.RemoteSnapshotKey(identifier), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
keys, err := v.listAllRemoteSnapshotKeys()
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf(
|
|
||||||
"listing destination store to resolve %q: %w", identifier, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var matches []string
|
|
||||||
|
|
||||||
for _, key := range keys {
|
|
||||||
if strings.HasPrefix(key, identifier) {
|
|
||||||
matches = append(matches, key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
switch len(matches) {
|
|
||||||
case 1:
|
|
||||||
return matches[0], nil
|
|
||||||
case 0:
|
|
||||||
return "", fmt.Errorf("%w: %s", errSnapshotKeyNotFound, identifier)
|
|
||||||
default:
|
|
||||||
return "", fmt.Errorf("%w: %s (%d matches)",
|
|
||||||
errSnapshotKeyAmbiguous, identifier, len(matches))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// resolveAndDownloadManifest resolves a snapshot identifier to its remote
|
|
||||||
// key (see resolveSnapshotRemoteKey) and downloads that snapshot's
|
|
||||||
// manifest.
|
|
||||||
func (v *Vaultik) resolveAndDownloadManifest(
|
|
||||||
identifier string,
|
|
||||||
) (*snapshot.Manifest, error) {
|
|
||||||
remoteKey, err := v.resolveSnapshotRemoteKey(identifier)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return v.downloadManifestByKey(remoteKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
// isRemoteKeyOrPrefix reports whether s is a full remote key or the
|
|
||||||
// leading part of one: 1 to 64 lowercase hex characters. A human snapshot
|
|
||||||
// ID is never all hex, so this shape test is enough to tell the two apart.
|
|
||||||
func isRemoteKeyOrPrefix(s string) bool {
|
|
||||||
if s == "" || len(s) > remoteKeyHexLen {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, r := range s {
|
|
||||||
if (r < '0' || r > '9') && (r < 'a' || r > 'f') {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
@@ -138,15 +138,8 @@ func (v *Vaultik) RunDeepVerify(snapshotID string, opts *VerifyOptions) error {
|
|||||||
func (v *Vaultik) loadVerificationData(
|
func (v *Vaultik) loadVerificationData(
|
||||||
snapshotID string, opts *VerifyOptions, result *VerifyResult,
|
snapshotID string, opts *VerifyOptions, result *VerifyResult,
|
||||||
) (*snapshot.Manifest, *tempDB, []snapshot.BlobInfo, error) {
|
) (*snapshot.Manifest, *tempDB, []snapshot.BlobInfo, error) {
|
||||||
// Resolve the identifier to the snapshot's remote key. A human ID is
|
// All remote paths use the hashed key derived from the human ID.
|
||||||
// hashed; a remote key (or its abbreviation, as printed for a
|
remoteKey := snapshot.RemoteSnapshotKey(snapshotID)
|
||||||
// remote-only snapshot) is used as-is, so a host with no local index
|
|
||||||
// can verify a snapshot it can only see on the store.
|
|
||||||
remoteKey, err := v.resolveSnapshotRemoteKey(snapshotID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, nil, v.deepVerifyFailure(result, opts,
|
|
||||||
fmt.Sprintf("resolving snapshot identifier: %v", err), err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Download manifest. downloadManifestByKey is the single reader for
|
// Download manifest. downloadManifestByKey is the single reader for
|
||||||
// remote manifests; see its doc comment.
|
// remote manifests; see its doc comment.
|
||||||
@@ -193,7 +186,7 @@ func (v *Vaultik) loadVerificationData(
|
|||||||
fmt.Errorf("failed to decrypt database: %w", err))
|
fmt.Errorf("failed to decrypt database: %w", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
dbBlobs, err := v.getBlobsFromDatabase(tdb.DB)
|
dbBlobs, err := v.getBlobsFromDatabase(snapshotID, tdb.DB)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = tdb.Close()
|
_ = tdb.Close()
|
||||||
|
|
||||||
@@ -508,21 +501,19 @@ func (v *Vaultik) verifyBlobFinalIntegrity(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getBlobsFromDatabase gets all blobs for the snapshot from the database.
|
// getBlobsFromDatabase gets all blobs for the snapshot from the database
|
||||||
//
|
func (v *Vaultik) getBlobsFromDatabase(
|
||||||
// The exported per-snapshot database holds exactly one snapshot's data
|
snapshotID string, db *sql.DB,
|
||||||
// (see cleanSnapshotDB), so every row in snapshot_blobs belongs to it.
|
) ([]snapshot.BlobInfo, error) {
|
||||||
// We select them directly rather than filtering by the human snapshot ID,
|
|
||||||
// which a host restoring from the store alone does not have.
|
|
||||||
func (v *Vaultik) getBlobsFromDatabase(db *sql.DB) ([]snapshot.BlobInfo, error) {
|
|
||||||
query := `
|
query := `
|
||||||
SELECT b.blob_hash, b.compressed_size
|
SELECT b.blob_hash, b.compressed_size
|
||||||
FROM snapshot_blobs sb
|
FROM snapshot_blobs sb
|
||||||
JOIN blobs b ON sb.blob_hash = b.blob_hash
|
JOIN blobs b ON sb.blob_hash = b.blob_hash
|
||||||
|
WHERE sb.snapshot_id = ?
|
||||||
ORDER BY b.blob_hash
|
ORDER BY b.blob_hash
|
||||||
`
|
`
|
||||||
|
|
||||||
rows, err := db.QueryContext(v.ctx, query)
|
rows, err := db.QueryContext(v.ctx, query, snapshotID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to query snapshot blobs: %w", err)
|
return nil, fmt.Errorf("failed to query snapshot blobs: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-1
@@ -56,8 +56,23 @@ main() {
|
|||||||
docker build --output=type=cacheonly \
|
docker build --output=type=cacheonly \
|
||||||
--build-arg CHECK_EPOCH="$epoch" -f Dockerfile.lint .
|
--build-arg CHECK_EPOCH="$epoch" -f Dockerfile.lint .
|
||||||
|
|
||||||
|
# Version, commit and build date are computed here on the host, the
|
||||||
|
# same way script/docker does, and passed into the product build so
|
||||||
|
# the CI-built image reports its real source. The build context
|
||||||
|
# excludes .git (see .dockerignore), so the build cannot derive them
|
||||||
|
# itself; without these it would stamp the Dockerfile's dev/unknown
|
||||||
|
# fallbacks. VERSION comes from script/version, the source of truth
|
||||||
|
# shared with the Makefile.
|
||||||
|
version="$("$ROOT/script/version")"
|
||||||
|
commit="$(git rev-parse HEAD 2>/dev/null || echo unknown)"
|
||||||
|
commit_date="$(git show -s --format=%cs HEAD 2>/dev/null || echo unknown)"
|
||||||
|
|
||||||
epoch="$(date +%s%N)$$"
|
epoch="$(date +%s%N)$$"
|
||||||
docker build --build-arg CHECK_EPOCH="$epoch" .
|
docker build --build-arg CHECK_EPOCH="$epoch" \
|
||||||
|
--build-arg VERSION="$version" \
|
||||||
|
--build-arg COMMIT="$commit" \
|
||||||
|
--build-arg COMMIT_DATE="$commit_date" \
|
||||||
|
.
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
@@ -24,7 +24,24 @@ main() {
|
|||||||
# whether the tree is clean. The Dockerfile now refuses to build
|
# whether the tree is clean. The Dockerfile now refuses to build
|
||||||
# without a non-empty value, so this is required, not optional.
|
# without a non-empty value, so this is required, not optional.
|
||||||
epoch="$(date +%s%N)$$"
|
epoch="$(date +%s%N)$$"
|
||||||
|
|
||||||
|
# Version, commit and build date are computed here on the host,
|
||||||
|
# where .git exists, and passed into the build. The build context
|
||||||
|
# excludes .git (see .dockerignore), so the container cannot derive
|
||||||
|
# them itself -- it used to try and always got "unknown", giving
|
||||||
|
# every image a "commit: unknown" it could not be traced from.
|
||||||
|
# VERSION comes from script/version, the source of truth shared with
|
||||||
|
# the Makefile, so a Docker build reports the same string (tag,
|
||||||
|
# dev-<sha>, or a -dirty variant) that a local build of the same
|
||||||
|
# tree would.
|
||||||
|
version="$("$SCRIPT_DIR/version")"
|
||||||
|
commit="$(git rev-parse HEAD 2>/dev/null || echo unknown)"
|
||||||
|
commit_date="$(git show -s --format=%cs HEAD 2>/dev/null || echo unknown)"
|
||||||
|
|
||||||
docker build --build-arg CHECK_EPOCH="$epoch" \
|
docker build --build-arg CHECK_EPOCH="$epoch" \
|
||||||
|
--build-arg VERSION="$version" \
|
||||||
|
--build-arg COMMIT="$commit" \
|
||||||
|
--build-arg COMMIT_DATE="$commit_date" \
|
||||||
-t "$("$SCRIPT_DIR/projectname")" .
|
-t "$("$SCRIPT_DIR/projectname")" .
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user