Compare commits
1
Commits
next
..
1e7b0c3b5a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e7b0c3b5a |
@@ -104,12 +104,7 @@ Version: 2025-06-08
|
|||||||
|
|
||||||
13. Pre-1.0: NEVER write database migrations. There are no live databases
|
13. Pre-1.0: NEVER write database migrations. There are no live databases
|
||||||
anywhere — every user's local index can be rebuilt from a fresh full
|
anywhere — every user's local index can be rebuilt from a fresh full
|
||||||
backup. To change the schema, edit `internal/database/schema/001.sql`
|
backup. When the schema changes, just change `schema.sql` (and any code
|
||||||
(and any code that touches the affected tables) directly; do not add new
|
that touches the affected tables). The local index is disposable until
|
||||||
numbered schema files. Those numbered files and the `schema_migrations`
|
1.0 ships and is tagged.
|
||||||
table they populate only bootstrap a fresh database — they are not an
|
|
||||||
upgrade path. The local index is disposable until 1.0 ships and is
|
|
||||||
tagged; once 1.0 is tagged that clause expires and the question of
|
|
||||||
upgrading existing indexes returns. See [`docs/DATAMODEL.md`](docs/DATAMODEL.md)
|
|
||||||
for the full explanation.
|
|
||||||
|
|
||||||
|
|||||||
+6
-29
@@ -20,10 +20,10 @@
|
|||||||
# 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
|
||||||
|
|
||||||
# Build tooling: make, plus a C toolchain because `go test -race` needs cgo.
|
ARG VERSION=dev
|
||||||
# The sqlite driver is pure Go (modernc.org/sqlite), so no sqlite library or
|
|
||||||
# CLI is required.
|
# Install build dependencies for CGO (mattn/go-sqlite3) and sqlite3 CLI (tests)
|
||||||
RUN apk add --no-cache make build-base
|
RUN apk add --no-cache make build-base sqlite
|
||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
@@ -64,37 +64,14 @@ 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=${COMMIT}' -X 'sneak.berlin/go/vaultik/internal/globals.CommitDate=${COMMIT_DATE}'" -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=$(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
|
||||||
|
|
||||||
# Runtime stage
|
# 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
|
||||||
|
|
||||||
RUN apk add --no-cache ca-certificates
|
RUN apk add --no-cache ca-certificates sqlite
|
||||||
|
|
||||||
# Copy binary from builder
|
# Copy binary from builder
|
||||||
COPY --from=builder /vaultik /usr/local/bin/vaultik
|
COPY --from=builder /vaultik /usr/local/bin/vaultik
|
||||||
|
|||||||
+5
-5
@@ -72,11 +72,11 @@ RUN [ -n "$CHECK_EPOCH" ] || exit 1
|
|||||||
# running, and exits 0 reporting `0 issues.` on a tree the real config
|
# running, and exits 0 reporting `0 issues.` on a tree the real config
|
||||||
# fails. Demonstrated on this repo at this pin, recorded on
|
# fails. Demonstrated on this repo at this pin, recorded on
|
||||||
# https://git.eeqj.de/sneak/vaultik/pulls/114: with a planted
|
# https://git.eeqj.de/sneak/vaultik/pulls/114: with a planted
|
||||||
# over-length line, `script/lint` exits 1 naming the `revive` finding
|
# over-length line, `script/lint` exits 1 naming the `lll` finding with
|
||||||
# with `linters:` and exits 0 with `linterz:`. A set-but-ineffective
|
# `linters:` and exits 0 with `linterz:`. A set-but-ineffective config
|
||||||
# config quietly falling back to defaults is precisely the false-green
|
# quietly falling back to defaults is precisely the false-green class
|
||||||
# class this gate exists to eliminate, so it must not sit in the gate's
|
# this gate exists to eliminate, so it must not sit in the gate's own
|
||||||
# own configuration.
|
# configuration.
|
||||||
#
|
#
|
||||||
# `config verify` catches it, and it does so OFFLINE at this pinned
|
# `config verify` catches it, and it does so OFFLINE at this pinned
|
||||||
# version -- verified, not assumed. Under `docker run --network none`
|
# version -- verified, not assumed. Under `docker run --network none`
|
||||||
|
|||||||
@@ -251,8 +251,7 @@ local index alone, and still exits zero.
|
|||||||
per-snapshot-name (`--keep-latest` keeps the latest of each name, not the
|
per-snapshot-name (`--keep-latest` keeps the latest of each name, not the
|
||||||
latest globally).
|
latest globally).
|
||||||
* `--keep-latest`: Keep only the most recent snapshot of each name
|
* `--keep-latest`: Keep only the most recent snapshot of each name
|
||||||
* `--older-than <duration>`: Remove snapshots older than duration (e.g. `30d`,
|
* `--older-than <duration>`: Remove snapshots older than duration (e.g. `30d`, `6m`, `1y`)
|
||||||
`4w`, `6mo`, `1y`; `m` is minutes, `mo` is months)
|
|
||||||
* `--snapshot <name>`: Restrict to specific snapshot names (repeat for multiple)
|
* `--snapshot <name>`: Restrict to specific snapshot names (repeat for multiple)
|
||||||
* `--force`: Skip confirmation prompt
|
* `--force`: Skip confirmation prompt
|
||||||
|
|
||||||
@@ -457,13 +456,9 @@ Key fields:
|
|||||||
sequentially. Restore speed is bound by single-stream throughput.
|
sequentially. Restore speed is bound by single-stream throughput.
|
||||||
* **Device nodes, named pipes, and sockets are silently skipped.** Only
|
* **Device nodes, named pipes, and sockets are silently skipped.** Only
|
||||||
regular files, directories, and symlinks are backed up.
|
regular files, directories, and symlinks are backed up.
|
||||||
* **No upgrade path between versions.** There is no supported way to carry
|
* **No database migrations.** If the local SQLite schema changes between
|
||||||
an existing local index across a schema change; if the local SQLite
|
versions, delete the local database (`vaultik database delete`) and run
|
||||||
schema changes between versions, delete the local database (`vaultik
|
a full backup. Remote storage is unaffected.
|
||||||
database delete`) and run a full backup. Remote storage is unaffected.
|
|
||||||
(The binary does embed numbered schema files and a `schema_migrations`
|
|
||||||
table to bootstrap a fresh database — see [`docs/DATAMODEL.md`](docs/DATAMODEL.md)
|
|
||||||
— but that is not an upgrade path.)
|
|
||||||
* **Files that change during backup may be inconsistent.** There is no
|
* **Files that change during backup may be inconsistent.** There is no
|
||||||
filesystem snapshot or freeze. If a file is modified between the scan
|
filesystem snapshot or freeze. If a file is modified between the scan
|
||||||
and chunk phases, the backed-up copy may reflect a partial write.
|
and chunk phases, the backed-up copy may reflect a partial write.
|
||||||
@@ -533,12 +528,10 @@ priority.
|
|||||||
another host" workflow works but isn't documented as a
|
another host" workflow works but isn't documented as a
|
||||||
first-class operation in this README. Worth a dedicated section
|
first-class operation in this README. Worth a dedicated section
|
||||||
once it's settled.
|
once it's settled.
|
||||||
* **Cross-version schema upgrades.** There is no upgrade path between
|
* **Schema migrations.** Currently nonexistent — pre-1.0 schema
|
||||||
released versions — pre-1.0 schema changes are handled by `vaultik
|
changes are handled by `vaultik database delete` plus a full
|
||||||
database delete` plus a full re-scan (see
|
re-scan. Post-1.0 we'll need a migration story to keep existing
|
||||||
[`docs/DATAMODEL.md`](docs/DATAMODEL.md)). Post-1.0 we'll need a
|
index databases usable across upgrades.
|
||||||
migration story to keep existing index databases usable across
|
|
||||||
upgrades.
|
|
||||||
* **Storage backend coverage tests.** S3, file://, and rclone://
|
* **Storage backend coverage tests.** S3, file://, and rclone://
|
||||||
all share the Storer interface but the rclone path is the least
|
all share the Storer interface but the rclone path is the least
|
||||||
exercised in CI.
|
exercised in CI.
|
||||||
@@ -620,6 +613,7 @@ regardless of color setting (emoji are not color).
|
|||||||
and the pre-commit hook both run it. A `golangci-lint` installed on
|
and the pre-commit hook both run it. A `golangci-lint` installed on
|
||||||
`PATH` is not a substitute and is never used on a host, whatever its
|
`PATH` is not a substitute and is never used on a host, whatever its
|
||||||
version.
|
version.
|
||||||
|
* `sqlite3` CLI, which the test suite shells out to
|
||||||
* S3-compatible object storage (or local filesystem, or rclone remote)
|
* S3-compatible object storage (or local filesystem, or rclone remote)
|
||||||
|
|
||||||
## development workflow
|
## development workflow
|
||||||
@@ -650,8 +644,8 @@ standard: normalized scripts in `script/` are the entrypoints for the
|
|||||||
development workflow, and the Makefile targets are thin shims that call
|
development workflow, and the Makefile targets are thin shims that call
|
||||||
them. We provide:
|
them. We provide:
|
||||||
|
|
||||||
* `script/bootstrap` — install all development dependencies (go, Go
|
* `script/bootstrap` — install all development dependencies (go, sqlite3,
|
||||||
module download). It deliberately does not install `golangci-lint`;
|
Go module download). It deliberately does not install `golangci-lint`;
|
||||||
see `script/lint` below.
|
see `script/lint` below.
|
||||||
* `script/setup` — make a fresh clone ready for development: runs
|
* `script/setup` — make a fresh clone ready for development: runs
|
||||||
`script/bootstrap`, then `script/install-precommit`
|
`script/bootstrap`, then `script/install-precommit`
|
||||||
|
|||||||
@@ -25,43 +25,6 @@ release" is exactly the contradiction
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
- 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`
|
- 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
|
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
|
base is `next`) and `next` itself get a CI run instead of relying on a
|
||||||
@@ -78,24 +41,6 @@ release" is exactly the contradiction
|
|||||||
keeps that exact compiler from auto-switching. Bumping Go now touches
|
keeps that exact compiler from auto-switching. Bumping Go now touches
|
||||||
`go.mod`, the checksum, and the `Dockerfile` `golang` digest together.
|
`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
|
- 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)).
|
step ([issue #113](https://git.eeqj.de/sneak/vaultik/issues/113)).
|
||||||
New root `Dockerfile.lint`, built by `script/lint`, runs
|
New root `Dockerfile.lint`, built by `script/lint`, runs
|
||||||
@@ -124,11 +69,10 @@ release" is exactly the contradiction
|
|||||||
into each check command, and a fresh `$(date +%s%N)$$` per invocation
|
into each check command, and a fresh `$(date +%s%N)$$` per invocation
|
||||||
computed as a bare assignment. `cmd/vaultik/lintdocker_test.go`
|
computed as a bare assignment. `cmd/vaultik/lintdocker_test.go`
|
||||||
parses both Dockerfiles and both scripts and fails if any part of
|
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
|
that is dropped, because every way of losing it is silent. Its
|
||||||
asserts that no script runs the host linter: `script/lint` is the one
|
host-lint assertion is structural — no script runs `golangci-lint`
|
||||||
lint entry point and runs `golangci-lint` only inside the container,
|
except through `docker` — rather than a search for the one retired
|
||||||
and keeping it that way is a review matter, not something a test
|
variable name, which nothing could ever reintroduce.
|
||||||
proves.
|
|
||||||
|
|
||||||
The product `Dockerfile` lost its lint stage rather than gaining a
|
The product `Dockerfile` lost its lint stage rather than gaining a
|
||||||
second linter pin: `make lint` is now `docker build`, so the stage
|
second linter pin: `make lint` is now `docker build`, so the stage
|
||||||
@@ -595,7 +539,7 @@ release" is exactly the contradiction
|
|||||||
was green was wrong.
|
was green was wrong.
|
||||||
- 2026-08-07: Added the standard `.golangci.yml` and `.editorconfig`
|
- 2026-08-07: Added the standard `.golangci.yml` and `.editorconfig`
|
||||||
(issue #59); lint findings under the new config are tracked in issue
|
(issue #59); lint findings under the new config are tracked in issue
|
||||||
#61.
|
#61. `script/bootstrap` now installs sqlite3 (needed by tests).
|
||||||
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints,
|
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints,
|
||||||
Makefile shims, README Entrypoints section
|
Makefile shims, README Entrypoints section
|
||||||
- 2026-07-02: Consolidated CLI verbs, retired overlapping commands; bound
|
- 2026-07-02: Consolidated CLI verbs, retired overlapping commands; bound
|
||||||
|
|||||||
@@ -1,102 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
+153
-13
@@ -28,11 +28,6 @@ import (
|
|||||||
// -- that a real finding actually fails the build -- is verified by
|
// -- that a real finding actually fails the build -- is verified by
|
||||||
// hand against a deliberately broken tree, recorded on the pull
|
// hand against a deliberately broken tree, recorded on the pull
|
||||||
// request.
|
// 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.
|
// The files under guard, relative to the repository root.
|
||||||
const (
|
const (
|
||||||
@@ -42,8 +37,9 @@ const (
|
|||||||
cibuildScript = "script/cibuild"
|
cibuildScript = "script/cibuild"
|
||||||
)
|
)
|
||||||
|
|
||||||
// linterBinary is the linter's command name, used to locate the
|
// linterBinary is the linter's command name. Every occurrence of it in
|
||||||
// config-verify and lint steps in Dockerfile.lint.
|
// executable shell in this repo must be inside a docker invocation; see
|
||||||
|
// TestNoHostLintPathRemains.
|
||||||
const linterBinary = "golangci-lint"
|
const linterBinary = "golangci-lint"
|
||||||
|
|
||||||
// checkEpochARG is the declaration, with no default value. A default
|
// checkEpochARG is the declaration, with no default value. A default
|
||||||
@@ -223,6 +219,90 @@ func TestCibuildBuildsBothDockerfilesWithFreshEpochs(t *testing.T) {
|
|||||||
"%s must build %s", cibuildScript, lintDockerfile)
|
"%s must build %s", cibuildScript, lintDockerfile)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestNoHostLintPathRemains fails if any escape hatch to a host linter
|
||||||
|
// comes back. The owner's ruling is that every lint run happens inside
|
||||||
|
// a container; a PATH binary that happens to match the pinned version
|
||||||
|
// is a different build reached by a different code path, and admitting
|
||||||
|
// it is what lets a local pass disagree with CI.
|
||||||
|
//
|
||||||
|
// This asserts the PROPERTY -- no script invokes the linter except
|
||||||
|
// through docker -- rather than the absence of any particular variable
|
||||||
|
// name. An earlier version of this test looked only for the literal
|
||||||
|
// VAULTIK_LINT_IN_CONTAINER, the name of the hatch that was removed
|
||||||
|
// alongside it, so nothing could ever trip it again: a hatch under any
|
||||||
|
// other name left it passing. A structural test that passes on a broken
|
||||||
|
// tree is worse than no test, because it is what a later reader trusts
|
||||||
|
// instead of re-deriving the invariant.
|
||||||
|
//
|
||||||
|
// script/lint-fix is not exempted. It is the one script that runs the
|
||||||
|
// linter as a container rather than as a build step, but it still runs
|
||||||
|
// it in one, so the same property holds of it.
|
||||||
|
func TestNoHostLintPathRemains(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := repoRoot(t)
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(filepath.Join(root, "script"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEmpty(t, entries, "no scripts found to scan")
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
name := filepath.Join("script", entry.Name())
|
||||||
|
for _, line := range shellCode(readRepoFile(t, name)) {
|
||||||
|
assertLinterIsContainerised(t, name, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertLinterIsContainerised fails if the line runs the linter without
|
||||||
|
// handing it to docker first. Position matters: docker has to come
|
||||||
|
// before the binary, or the line is running the host linter and merely
|
||||||
|
// mentioning docker afterwards.
|
||||||
|
func assertLinterIsContainerised(t *testing.T, name, line string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
at := strings.Index(line, linterBinary)
|
||||||
|
if at < 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
docker := strings.Index(line, "docker")
|
||||||
|
|
||||||
|
assert.True(t, docker >= 0 && docker < at,
|
||||||
|
"%s runs %s on the host; every lint run happens in a container"+
|
||||||
|
" (line: %s)", name, linterBinary, line)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestShellCodeSeesCodeAndNotProse keeps the scanner above honest. It
|
||||||
|
// has to ignore comments and here-document bodies, because script/lint
|
||||||
|
// and script/bootstrap both NAME golangci-lint in prose -- in comments,
|
||||||
|
// and in the error text they print -- precisely to say that the host
|
||||||
|
// binary is never used. A scanner that went blind, by over-eager
|
||||||
|
// stripping or by failing to join continuation lines, would make
|
||||||
|
// TestNoHostLintPathRemains pass on everything.
|
||||||
|
func TestShellCodeSeesCodeAndNotProse(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
script := strings.Join([]string{
|
||||||
|
"#!/bin/sh",
|
||||||
|
"# a comment naming golangci-lint",
|
||||||
|
"cat >&2 <<EOF",
|
||||||
|
"prose naming golangci-lint, printed not executed",
|
||||||
|
"EOF",
|
||||||
|
"docker run --rm \\",
|
||||||
|
" \"$image\" \\",
|
||||||
|
" golangci-lint run ./...",
|
||||||
|
}, "\n")
|
||||||
|
|
||||||
|
assert.Equal(t,
|
||||||
|
[]string{"cat >&2 <<EOF", `docker run --rm "$image" golangci-lint run ./...`},
|
||||||
|
shellCode(script))
|
||||||
|
}
|
||||||
|
|
||||||
// assertEpochExpandedInto fails unless some instruction runs the named
|
// assertEpochExpandedInto fails unless some instruction runs the named
|
||||||
// command with the epoch expanded into it. Expansion, not mere
|
// command with the epoch expanded into it. Expansion, not mere
|
||||||
// declaration: an ARG that no instruction references is not guaranteed
|
// declaration: an ARG that no instruction references is not guaranteed
|
||||||
@@ -304,14 +384,10 @@ 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. An `ARG NAME=default`
|
// beginning with, want; -1 if there is none.
|
||||||
// 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 ||
|
if instruction == want || strings.HasPrefix(instruction, want+" ") {
|
||||||
strings.HasPrefix(instruction, want+" ") ||
|
|
||||||
strings.HasPrefix(instruction, want+"=") {
|
|
||||||
return i
|
return i
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -331,6 +407,70 @@ func indexContaining(found []string, want string) int {
|
|||||||
return -1
|
return -1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// shellCode returns a POSIX shell script's executable lines: comments
|
||||||
|
// dropped, here-document bodies dropped, and backslash continuations
|
||||||
|
// joined so a multi-line command is a single string. Whitespace is
|
||||||
|
// collapsed, as it is for Dockerfile instructions.
|
||||||
|
//
|
||||||
|
// Both exclusions are load-bearing rather than tidiness. The scripts
|
||||||
|
// name golangci-lint in prose to state that the host binary is never
|
||||||
|
// used, and joining continuations is what lets the one legitimate
|
||||||
|
// container invocation -- script/lint-fix's `docker run`, whose linter
|
||||||
|
// command sits several lines below the word `docker` -- be recognised
|
||||||
|
// as containerised.
|
||||||
|
func shellCode(contents string) []string {
|
||||||
|
var (
|
||||||
|
out []string
|
||||||
|
joined string
|
||||||
|
terminate string
|
||||||
|
)
|
||||||
|
|
||||||
|
for line := range strings.SplitSeq(contents, "\n") {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
|
||||||
|
if terminate != "" {
|
||||||
|
if trimmed == terminate {
|
||||||
|
terminate = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if joined == "" && (trimmed == "" || strings.HasPrefix(trimmed, "#")) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
joined += strings.TrimSuffix(trimmed, `\`) + " "
|
||||||
|
if strings.HasSuffix(trimmed, `\`) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
joined = strings.Join(strings.Fields(joined), " ")
|
||||||
|
terminate = heredocTerminator(joined)
|
||||||
|
|
||||||
|
out = append(out, joined)
|
||||||
|
joined = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// heredocTerminator returns the terminator of the here-document a
|
||||||
|
// command opens, or "" if it opens none. Only the first on a line is
|
||||||
|
// recognised; nothing in script/ opens two.
|
||||||
|
func heredocTerminator(line string) string {
|
||||||
|
_, after, opens := strings.Cut(line, "<<")
|
||||||
|
if !opens {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// `<<-` strips leading tabs from the body; the terminator word is
|
||||||
|
// the same either way, and callers compare against trimmed lines.
|
||||||
|
word, _, _ := strings.Cut(strings.TrimPrefix(after, "-"), " ")
|
||||||
|
|
||||||
|
return strings.Trim(word, `'"`)
|
||||||
|
}
|
||||||
|
|
||||||
// readRepoFile reads a file by its path relative to the repository
|
// readRepoFile reads a file by its path relative to the repository
|
||||||
// root.
|
// root.
|
||||||
func readRepoFile(t *testing.T, name string) string {
|
func readRepoFile(t *testing.T, name string) string {
|
||||||
|
|||||||
+1
-11
@@ -10,16 +10,6 @@ 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
|
||||||
@@ -56,5 +46,5 @@ func run() int {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
return cli.Entry()
|
cli.Entry()
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-25
@@ -5,30 +5,11 @@
|
|||||||
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:**
|
||||||
|
- **No Migration Support (pre-1.0)**: Vaultik does not support database schema
|
||||||
This section is the authoritative explanation of the schema/migration story;
|
migrations. The local index is treated as disposable — if the schema changes,
|
||||||
other documents (the README and `AGENTS.md`) link here.
|
delete the local SQLite database (`vaultik database delete`) and run a full
|
||||||
|
backup. The remote storage is unaffected; the new index will re-deduplicate
|
||||||
- **No upgrade path between versions (pre-1.0)**: Vaultik has no supported way to
|
against existing remote blobs.
|
||||||
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.
|
||||||
@@ -211,7 +192,7 @@ Tracks blob upload metrics.
|
|||||||
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. Export to SQL dump using sqlite3
|
||||||
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`
|
||||||
|
|||||||
+39
-90
@@ -11,7 +11,6 @@ import (
|
|||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -197,90 +196,13 @@ 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, then run op against the Vaultik instance through
|
// resolve the config, start the fx app, run op against the Vaultik
|
||||||
// RunOperation, reporting a failure prefixed with failMsg (suppressed
|
// instance in a goroutine, report a failure prefixed with failMsg
|
||||||
// while suppressErrors is true, e.g. under --json). extraQuiet is OR-ed
|
// (suppressed while suppressErrors is true, e.g. under --json), then
|
||||||
// into LogOptions.Quiet (e.g. --json output modes).
|
// trigger shutdown. The operation is cancelled when the app stops.
|
||||||
|
// 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,
|
||||||
@@ -292,20 +214,47 @@ func runVaultikApp(
|
|||||||
|
|
||||||
rootFlags := GetRootFlags()
|
rootFlags := GetRootFlags()
|
||||||
|
|
||||||
return RunOperation(cmd.Context(), AppOptions{
|
return RunWithApp(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,
|
||||||
},
|
},
|
||||||
}, op, func(err error) {
|
Modules: []fx.Option{},
|
||||||
if suppressErrors {
|
Invokes: []fx.Option{
|
||||||
return
|
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 {
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
if !suppressErrors {
|
||||||
|
log.Error(failMsg, "error", err)
|
||||||
|
ReportErrorf("%s: %v", failMsg, err)
|
||||||
|
}
|
||||||
|
|
||||||
log.Error(failMsg, "error", err)
|
os.Exit(1)
|
||||||
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
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Approximate lengths of the extended calendar units accepted by
|
||||||
|
// parseDuration.
|
||||||
|
const (
|
||||||
|
durationDay = 24 * time.Hour
|
||||||
|
durationWeek = 7 * durationDay
|
||||||
|
durationMonth = 30 * durationDay
|
||||||
|
durationYear = 365 * durationDay
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
errNegativeDuration = errors.New("negative durations are not supported")
|
||||||
|
errInvalidDuration = errors.New("invalid duration format")
|
||||||
|
errUnknownTimeUnit = errors.New("unknown time unit")
|
||||||
|
)
|
||||||
|
|
||||||
|
// parseDuration parses duration strings. Supports standard Go duration format
|
||||||
|
// (e.g., "3h30m", "1h45m30s") as well as extended units:
|
||||||
|
// - d: days (e.g., "30d", "7d")
|
||||||
|
// - w: weeks (e.g., "2w", "4w")
|
||||||
|
// - mo: months (30 days) (e.g., "6mo", "1mo")
|
||||||
|
// - y: years (365 days) (e.g., "1y", "2y")
|
||||||
|
//
|
||||||
|
// Can combine units: "1y6mo", "2w3d", "1d12h30m"
|
||||||
|
func parseDuration(s string) (time.Duration, error) {
|
||||||
|
// First try standard Go duration parsing
|
||||||
|
d, err := time.ParseDuration(s)
|
||||||
|
if err == nil {
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extended duration parsing
|
||||||
|
// Check for negative values
|
||||||
|
if strings.HasPrefix(strings.TrimSpace(s), "-") {
|
||||||
|
return 0, errNegativeDuration
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pattern matches: number + unit, repeated
|
||||||
|
re := regexp.MustCompile(`(\d+(?:\.\d+)?)\s*([a-zA-Z]+)`)
|
||||||
|
matches := re.FindAllStringSubmatch(s, -1)
|
||||||
|
|
||||||
|
if len(matches) == 0 {
|
||||||
|
return 0, fmt.Errorf("%w: %q", errInvalidDuration, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
var total time.Duration
|
||||||
|
|
||||||
|
for _, match := range matches {
|
||||||
|
valueStr := match[1]
|
||||||
|
unit := strings.ToLower(match[2])
|
||||||
|
|
||||||
|
value, err := strconv.ParseFloat(valueStr, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("invalid number %q: %w", valueStr, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
d, err := durationForUnit(value, unit)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
total += d
|
||||||
|
}
|
||||||
|
|
||||||
|
return total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// durationForUnit converts a value with a (case-normalized) unit suffix
|
||||||
|
// into a time.Duration, accepting Go's standard units plus the extended
|
||||||
|
// calendar units.
|
||||||
|
func durationForUnit(value float64, unit string) (time.Duration, error) {
|
||||||
|
switch unit {
|
||||||
|
// Standard time units
|
||||||
|
case "ns", "nanosecond", "nanoseconds":
|
||||||
|
return time.Duration(value), nil
|
||||||
|
case "us", "µs", "microsecond", "microseconds":
|
||||||
|
return time.Duration(value * float64(time.Microsecond)), nil
|
||||||
|
case "ms", "millisecond", "milliseconds":
|
||||||
|
return time.Duration(value * float64(time.Millisecond)), nil
|
||||||
|
case "s", "sec", "second", "seconds":
|
||||||
|
return time.Duration(value * float64(time.Second)), nil
|
||||||
|
case "m", "min", "minute", "minutes":
|
||||||
|
return time.Duration(value * float64(time.Minute)), nil
|
||||||
|
case "h", "hr", "hour", "hours":
|
||||||
|
return time.Duration(value * float64(time.Hour)), nil
|
||||||
|
// Extended units
|
||||||
|
case "d", "day", "days":
|
||||||
|
return time.Duration(value * float64(durationDay)), nil
|
||||||
|
case "w", "week", "weeks":
|
||||||
|
return time.Duration(value * float64(durationWeek)), nil
|
||||||
|
case "mo", "month", "months":
|
||||||
|
// Using 30 days as approximation
|
||||||
|
return time.Duration(value * float64(durationMonth)), nil
|
||||||
|
case "y", "year", "years":
|
||||||
|
// Using 365 days as approximation
|
||||||
|
return time.Duration(value * float64(durationYear)), nil
|
||||||
|
default:
|
||||||
|
// Try parsing as standard Go duration unit
|
||||||
|
testStr := "1" + unit
|
||||||
|
|
||||||
|
_, err := time.ParseDuration(testStr)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("%w: %q", errUnknownTimeUnit, unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// It's a valid Go duration unit, parse the full value
|
||||||
|
fullStr := fmt.Sprintf("%g%s", value, unit)
|
||||||
|
|
||||||
|
d, err := time.ParseDuration(fullStr)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("invalid duration %q: %w", fullStr, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
package cli //nolint:testpackage // needs access to unexported parseDuration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
type parseDurationCase struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expected time.Duration
|
||||||
|
wantErr bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// runParseDurationCases executes a table of parseDuration cases as
|
||||||
|
// parallel subtests.
|
||||||
|
func runParseDurationCases(t *testing.T, tests []parseDurationCase) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got, err := parseDuration(tt.input)
|
||||||
|
|
||||||
|
if tt.wantErr {
|
||||||
|
require.Error(t, err, "expected error for input %q", tt.input)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, err, "unexpected error for input %q", tt.input)
|
||||||
|
assert.Equal(t, tt.expected, got, "duration mismatch for input %q", tt.input)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseDurationStandard(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
runParseDurationCases(t, []parseDurationCase{
|
||||||
|
{
|
||||||
|
name: "standard seconds",
|
||||||
|
input: "30s",
|
||||||
|
expected: 30 * time.Second,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "standard minutes",
|
||||||
|
input: "45m",
|
||||||
|
expected: 45 * time.Minute,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "standard hours",
|
||||||
|
input: "2h",
|
||||||
|
expected: 2 * time.Hour,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "standard combined",
|
||||||
|
input: "3h30m",
|
||||||
|
expected: 3*time.Hour + 30*time.Minute,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "standard complex",
|
||||||
|
input: "1h45m30s",
|
||||||
|
expected: 1*time.Hour + 45*time.Minute + 30*time.Second,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "standard with milliseconds",
|
||||||
|
input: "1s500ms",
|
||||||
|
expected: 1*time.Second + 500*time.Millisecond,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseDurationExtendedUnits(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
runParseDurationCases(t, []parseDurationCase{
|
||||||
|
// Extended units - days
|
||||||
|
{
|
||||||
|
name: "single day",
|
||||||
|
input: "1d",
|
||||||
|
expected: 24 * time.Hour,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple days",
|
||||||
|
input: "7d",
|
||||||
|
expected: 7 * 24 * time.Hour,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "fractional days",
|
||||||
|
input: "1.5d",
|
||||||
|
expected: 36 * time.Hour,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "days spelled out",
|
||||||
|
input: "3days",
|
||||||
|
expected: 3 * 24 * time.Hour,
|
||||||
|
},
|
||||||
|
// Extended units - weeks
|
||||||
|
{
|
||||||
|
name: "single week",
|
||||||
|
input: "1w",
|
||||||
|
expected: 7 * 24 * time.Hour,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple weeks",
|
||||||
|
input: "4w",
|
||||||
|
expected: 4 * 7 * 24 * time.Hour,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "weeks spelled out",
|
||||||
|
input: "2weeks",
|
||||||
|
expected: 2 * 7 * 24 * time.Hour,
|
||||||
|
},
|
||||||
|
// Extended units - months
|
||||||
|
{
|
||||||
|
name: "single month",
|
||||||
|
input: "1mo",
|
||||||
|
expected: 30 * 24 * time.Hour,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple months",
|
||||||
|
input: "6mo",
|
||||||
|
expected: 6 * 30 * 24 * time.Hour,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "months spelled out",
|
||||||
|
input: "3months",
|
||||||
|
expected: 3 * 30 * 24 * time.Hour,
|
||||||
|
},
|
||||||
|
// Extended units - years
|
||||||
|
{
|
||||||
|
name: "single year",
|
||||||
|
input: "1y",
|
||||||
|
expected: 365 * 24 * time.Hour,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple years",
|
||||||
|
input: "2y",
|
||||||
|
expected: 2 * 365 * 24 * time.Hour,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "years spelled out",
|
||||||
|
input: "1year",
|
||||||
|
expected: 365 * 24 * time.Hour,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseDurationCombinedAndErrors(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
runParseDurationCases(t, []parseDurationCase{
|
||||||
|
// Combined extended units
|
||||||
|
{
|
||||||
|
name: "weeks and days",
|
||||||
|
input: "2w3d",
|
||||||
|
expected: 2*7*24*time.Hour + 3*24*time.Hour,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "years and months",
|
||||||
|
input: "1y6mo",
|
||||||
|
expected: 365*24*time.Hour + 6*30*24*time.Hour,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "days and hours",
|
||||||
|
input: "1d12h",
|
||||||
|
expected: 24*time.Hour + 12*time.Hour,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "complex combination",
|
||||||
|
input: "1y2mo3w4d5h6m7s",
|
||||||
|
expected: 365*24*time.Hour + 2*30*24*time.Hour +
|
||||||
|
3*7*24*time.Hour + 4*24*time.Hour +
|
||||||
|
5*time.Hour + 6*time.Minute + 7*time.Second,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with spaces",
|
||||||
|
input: "1d 12h 30m",
|
||||||
|
expected: 24*time.Hour + 12*time.Hour + 30*time.Minute,
|
||||||
|
},
|
||||||
|
// Edge cases
|
||||||
|
{
|
||||||
|
name: "zero duration",
|
||||||
|
input: "0s",
|
||||||
|
expected: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "large duration",
|
||||||
|
input: "10y",
|
||||||
|
expected: 10 * 365 * 24 * time.Hour,
|
||||||
|
},
|
||||||
|
// Error cases
|
||||||
|
{
|
||||||
|
name: "empty string",
|
||||||
|
input: "",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid format",
|
||||||
|
input: "abc",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown unit",
|
||||||
|
input: "5x",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid number",
|
||||||
|
input: "xyzd",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "negative not supported",
|
||||||
|
input: "-5d",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseDurationSpecialCases(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Test that standard Go durations work exactly as expected
|
||||||
|
standardDurations := []string{
|
||||||
|
"300ms",
|
||||||
|
"1.5h",
|
||||||
|
"2h45m",
|
||||||
|
"72h",
|
||||||
|
"1us",
|
||||||
|
"1µs",
|
||||||
|
"1ns",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, d := range standardDurations {
|
||||||
|
expected, err := time.ParseDuration(d)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
got, err := parseDuration(d)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, expected, got, "standard duration %q should parse identically", d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseDurationRealWorldExamples(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Test real-world snapshot purge scenarios
|
||||||
|
tests := []struct {
|
||||||
|
description string
|
||||||
|
input string
|
||||||
|
olderThan time.Duration
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
description: "keep snapshots from last 30 days",
|
||||||
|
input: "30d",
|
||||||
|
olderThan: 30 * 24 * time.Hour,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "keep snapshots from last 6 months",
|
||||||
|
input: "6mo",
|
||||||
|
olderThan: 6 * 30 * 24 * time.Hour,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "keep snapshots from last year",
|
||||||
|
input: "1y",
|
||||||
|
olderThan: 365 * 24 * time.Hour,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "keep snapshots from last week and a half",
|
||||||
|
input: "1w3d",
|
||||||
|
olderThan: 10 * 24 * time.Hour,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "keep snapshots from last 90 days",
|
||||||
|
input: "90d",
|
||||||
|
olderThan: 90 * 24 * time.Hour,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.description, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got, err := parseDuration(tt.input)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tt.olderThan, got)
|
||||||
|
|
||||||
|
// Verify the duration makes sense for snapshot purging
|
||||||
|
assert.Greater(t, got, time.Hour,
|
||||||
|
"snapshot purge duration should be at least an hour")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-18
@@ -1,7 +1,6 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -20,11 +19,7 @@ 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()
|
||||||
@@ -32,19 +27,9 @@ 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
|
ReportErrorf("%s", err.Error())
|
||||||
// its own failure (and suppressed it under --json); errReported
|
os.Exit(1)
|
||||||
// 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, func() { _ = Entry() })
|
stdout := captureProcessStdout(t, 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, func() { _ = Entry() })
|
stdout := captureProcessStdout(t, Entry)
|
||||||
|
|
||||||
requireExactlyOneJSONDocument(t, stdout)
|
requireExactlyOneJSONDocument(t, stdout)
|
||||||
|
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+37
-6
@@ -1,7 +1,12 @@
|
|||||||
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"
|
||||||
)
|
)
|
||||||
@@ -28,18 +33,44 @@ func NewInfoCommand() *cobra.Command {
|
|||||||
// Use the app framework
|
// Use the app framework
|
||||||
rootFlags := GetRootFlags()
|
rootFlags := GetRootFlags()
|
||||||
|
|
||||||
return RunOperation(cmd.Context(), AppOptions{
|
return RunWithApp(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,
|
||||||
},
|
},
|
||||||
}, func(v *vaultik.Vaultik) error {
|
Modules: []fx.Option{},
|
||||||
return v.ShowInfo()
|
Invokes: []fx.Option{
|
||||||
}, func(err error) {
|
fx.Invoke(func(v *vaultik.Vaultik, lc fx.Lifecycle) {
|
||||||
log.Error("Failed to show info", "error", err)
|
lc.Append(fx.Hook{
|
||||||
ReportErrorf("Failed to show info: %v", err)
|
OnStart: func(_ context.Context) error {
|
||||||
|
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
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
},
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
+43
-9
@@ -1,7 +1,12 @@
|
|||||||
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"
|
||||||
)
|
)
|
||||||
@@ -36,22 +41,51 @@ 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 RunOperation(cmd.Context(), AppOptions{
|
return RunWithApp(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,
|
||||||
},
|
},
|
||||||
}, func(v *vaultik.Vaultik) error {
|
Modules: []fx.Option{},
|
||||||
return v.Prune(opts)
|
Invokes: []fx.Option{
|
||||||
}, func(err error) {
|
fx.Invoke(func(v *vaultik.Vaultik, lc fx.Lifecycle) {
|
||||||
if opts.JSON {
|
lc.Append(fx.Hook{
|
||||||
return
|
OnStart: func(_ context.Context) error {
|
||||||
}
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
log.Error("Prune operation failed", "error", err)
|
os.Exit(1)
|
||||||
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
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
},
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-9
@@ -1,9 +1,12 @@
|
|||||||
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"
|
||||||
)
|
)
|
||||||
@@ -80,22 +83,47 @@ func newRemoteInfoCommand() *cobra.Command {
|
|||||||
|
|
||||||
rootFlags := GetRootFlags()
|
rootFlags := GetRootFlags()
|
||||||
|
|
||||||
return RunOperation(cmd.Context(), AppOptions{
|
return RunWithApp(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,
|
||||||
},
|
},
|
||||||
}, func(v *vaultik.Vaultik) error {
|
Modules: []fx.Option{},
|
||||||
return v.RemoteInfo(jsonOutput)
|
Invokes: []fx.Option{
|
||||||
}, func(err error) {
|
fx.Invoke(func(v *vaultik.Vaultik, lc fx.Lifecycle) {
|
||||||
if jsonOutput {
|
lc.Append(fx.Hook{
|
||||||
return
|
OnStart: func(_ context.Context) error {
|
||||||
}
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
log.Error("Failed to get remote info", "error", err)
|
os.Exit(1)
|
||||||
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
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
},
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
+76
-20
@@ -1,10 +1,13 @@
|
|||||||
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"
|
||||||
)
|
)
|
||||||
@@ -83,8 +86,7 @@ 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()
|
||||||
|
|
||||||
// --cron suppression is wired through v.UI by setupGlobals.
|
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,
|
||||||
@@ -92,11 +94,42 @@ 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,
|
||||||
},
|
},
|
||||||
}, func(v *vaultik.Vaultik) error {
|
Modules: []fx.Option{},
|
||||||
return v.CreateSnapshot(opts)
|
Invokes: []fx.Option{
|
||||||
}, func(err error) {
|
fx.Invoke(func(v *vaultik.Vaultik, lc fx.Lifecycle) {
|
||||||
log.Error("Snapshot creation failed", "error", err)
|
lc.Append(fx.Hook{
|
||||||
ReportErrorf("Snapshot creation failed: %v", err)
|
OnStart: func(_ context.Context) error {
|
||||||
|
// 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
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
},
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -108,8 +141,7 @@ specifying a path using --config or by setting VAULTIK_CONFIG to a path.`,
|
|||||||
"orphaned blobs")
|
"orphaned blobs")
|
||||||
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. 4w, 30d, 6mo) instead of only the latest")
|
||||||
"instead of only the latest")
|
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
@@ -172,8 +204,7 @@ restrict the operation to specific snapshot names.`,
|
|||||||
cmd.Flags().BoolVar(&opts.KeepLatest, "keep-latest", false,
|
cmd.Flags().BoolVar(&opts.KeepLatest, "keep-latest", false,
|
||||||
"Keep only the latest snapshot of each name")
|
"Keep only the latest snapshot of each name")
|
||||||
cmd.Flags().StringVar(&opts.OlderThan, "older-than", "",
|
cmd.Flags().StringVar(&opts.OlderThan, "older-than", "",
|
||||||
"Remove snapshots older than duration "+
|
"Remove snapshots older than duration (e.g., 30d, 6m, 1y)")
|
||||||
"(e.g. 30d, 4w, 6mo, 1y; m is minutes, mo is months)")
|
|
||||||
cmd.Flags().BoolVar(&opts.Force, "force", false, "Skip confirmation prompt")
|
cmd.Flags().BoolVar(&opts.Force, "force", false, "Skip confirmation prompt")
|
||||||
cmd.Flags().StringArrayVar(&opts.Names, "snapshot", nil,
|
cmd.Flags().StringArrayVar(&opts.Names, "snapshot", nil,
|
||||||
"Restrict to snapshots with these names (repeat for multiple)")
|
"Restrict to snapshots with these names (repeat for multiple)")
|
||||||
@@ -201,22 +232,47 @@ func newSnapshotVerifyCommand() *cobra.Command {
|
|||||||
|
|
||||||
rootFlags := GetRootFlags()
|
rootFlags := GetRootFlags()
|
||||||
|
|
||||||
return RunOperation(cmd.Context(), AppOptions{
|
return RunWithApp(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,
|
||||||
},
|
},
|
||||||
}, func(v *vaultik.Vaultik) error {
|
Modules: []fx.Option{},
|
||||||
return v.VerifySnapshotWithOptions(snapshotID, opts)
|
Invokes: []fx.Option{
|
||||||
}, func(err error) {
|
fx.Invoke(func(v *vaultik.Vaultik, lc fx.Lifecycle) {
|
||||||
if opts.JSON {
|
lc.Append(fx.Hook{
|
||||||
return
|
OnStart: func(_ context.Context) error {
|
||||||
}
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
log.Error("Verification failed", "error", err)
|
os.Exit(1)
|
||||||
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,8 +1,16 @@
|
|||||||
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"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,6 +25,15 @@ 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{}
|
||||||
@@ -60,8 +77,7 @@ Examples:
|
|||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
// runRestore parses arguments and runs the restore operation through the
|
// runRestore parses arguments and runs the restore operation through the app framework
|
||||||
// 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]
|
||||||
|
|
||||||
@@ -70,30 +86,87 @@ 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 RunOperation(cmd.Context(), AppOptions{
|
return RunWithApp(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,
|
||||||
},
|
},
|
||||||
}, func(v *vaultik.Vaultik) error {
|
Modules: buildRestoreModules(),
|
||||||
return v.Restore(&vaultik.RestoreOptions{
|
Invokes: buildRestoreInvokes(snapshotID, opts),
|
||||||
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
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+5
-13
@@ -219,7 +219,11 @@ func (c *Client) HeadObject(ctx context.Context, key string) (bool, error) {
|
|||||||
Key: aws.String(fullKey),
|
Key: aws.String(fullKey),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if IsNotFound(err) {
|
var (
|
||||||
|
notFound *s3types.NotFound
|
||||||
|
noSuchKey *s3types.NoSuchKey
|
||||||
|
)
|
||||||
|
if errors.As(err, ¬Found) || errors.As(err, &noSuchKey) {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,18 +233,6 @@ func (c *Client) HeadObject(ctx context.Context, key string) (bool, error) {
|
|||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsNotFound reports whether err indicates that an object does not exist.
|
|
||||||
// Head and Get requests surface a missing object as different SDK types,
|
|
||||||
// so both are checked here.
|
|
||||||
func IsNotFound(err error) bool {
|
|
||||||
var (
|
|
||||||
notFound *s3types.NotFound
|
|
||||||
noSuchKey *s3types.NoSuchKey
|
|
||||||
)
|
|
||||||
|
|
||||||
return errors.As(err, ¬Found) || errors.As(err, &noSuchKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ObjectInfo contains information about an S3 object.
|
// ObjectInfo contains information about an S3 object.
|
||||||
// It is used by ListObjectsStream to return object metadata
|
// It is used by ListObjectsStream to return object metadata
|
||||||
// along with any errors encountered during listing.
|
// along with any errors encountered during listing.
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -668,31 +669,14 @@ func (sm *SnapshotManager) collectCleanupStats(
|
|||||||
|
|
||||||
// vacuumDatabase runs VACUUM on the database to remove deleted data and compact
|
// vacuumDatabase runs VACUUM on the database to remove deleted data and compact
|
||||||
// This is critical for security - ensures no stale/deleted data pages are uploaded
|
// This is critical for security - ensures no stale/deleted data pages are uploaded
|
||||||
//
|
|
||||||
// VACUUM runs through the modernc.org/sqlite driver, on a freshly opened
|
|
||||||
// connection with no transaction in flight (VACUUM cannot run inside one).
|
|
||||||
// The database opens in WAL mode, so VACUUM's rewrite lands in the WAL; the
|
|
||||||
// checkpoint on Close flushes it into the main file, which is the file we
|
|
||||||
// then compress and upload.
|
|
||||||
func (sm *SnapshotManager) vacuumDatabase(ctx context.Context, dbPath string) error {
|
func (sm *SnapshotManager) vacuumDatabase(ctx context.Context, dbPath string) error {
|
||||||
log.Debug("Running VACUUM on database", "path", dbPath)
|
log.Debug("Running VACUUM on database", "path", dbPath)
|
||||||
|
//nolint:gosec // G204: fixed argv; dbPath is our own temp file path
|
||||||
|
cmd := exec.CommandContext(ctx, "sqlite3", dbPath, "VACUUM;")
|
||||||
|
|
||||||
db, err := database.New(ctx, dbPath)
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("opening database for VACUUM: %w", err)
|
return fmt.Errorf("running VACUUM: %w (output: %s)", err, string(output))
|
||||||
}
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
cerr := db.Close()
|
|
||||||
if cerr != nil {
|
|
||||||
log.Debug("Failed to close database after VACUUM",
|
|
||||||
"path", dbPath, "error", cerr)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
_, err = db.ExecWithLog(ctx, "VACUUM")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("running VACUUM: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
package snapshot
|
package snapshot
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"io"
|
"io"
|
||||||
@@ -97,97 +96,6 @@ func verifyCleanedDB(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestVacuumDatabaseRemovesDeletedData proves the export path uploads a
|
|
||||||
// compacted database: after rows carrying a recognizable marker are deleted
|
|
||||||
// and vacuumDatabase runs, no page holding that marker survives in the file
|
|
||||||
// on disk (the file compressFile later reads for upload).
|
|
||||||
func TestVacuumDatabaseRemovesDeletedData(t *testing.T) {
|
|
||||||
log.Initialize(log.Config{})
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
fs := afero.NewOsFs()
|
|
||||||
|
|
||||||
tempDir := t.TempDir()
|
|
||||||
dbPath := filepath.Join(tempDir, "snapshot.db")
|
|
||||||
|
|
||||||
db, err := database.New(ctx, dbPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to create database: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// A marker distinctive enough that its presence in the raw file can only
|
|
||||||
// come from the rows inserted below.
|
|
||||||
marker := []byte("VACUUM_PROBE_DEADBEEF_DELETED_ROW")
|
|
||||||
payload := bytes.Repeat(marker, 128) // ~4 KiB per row
|
|
||||||
|
|
||||||
_, err = db.Conn().ExecContext(ctx,
|
|
||||||
"CREATE TABLE vacuum_probe (id INTEGER PRIMARY KEY, payload BLOB)")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to create probe table: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for range 512 {
|
|
||||||
_, err = db.Conn().ExecContext(ctx,
|
|
||||||
"INSERT INTO vacuum_probe (payload) VALUES (?)", payload)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to insert probe row: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = db.Conn().ExecContext(ctx, "DELETE FROM vacuum_probe")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to delete probe rows: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close so the deletes reach the main file, mirroring the state
|
|
||||||
// prepareExportDB hands to vacuumDatabase.
|
|
||||||
err = db.Close()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to close database: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeInfo, err := fs.Stat(dbPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to stat database before vacuum: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeBytes, err := afero.ReadFile(fs, dbPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to read database before vacuum: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !bytes.Contains(beforeBytes, marker) {
|
|
||||||
t.Fatalf("expected deleted-row data to linger before vacuum")
|
|
||||||
}
|
|
||||||
|
|
||||||
sm := &SnapshotManager{fs: fs}
|
|
||||||
|
|
||||||
err = sm.vacuumDatabase(ctx, dbPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("vacuumDatabase failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
afterBytes, err := afero.ReadFile(fs, dbPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to read database after vacuum: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if bytes.Contains(afterBytes, marker) {
|
|
||||||
t.Fatalf("deleted-row data survived vacuum in the uploaded file")
|
|
||||||
}
|
|
||||||
|
|
||||||
afterInfo, err := fs.Stat(dbPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to stat database after vacuum: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if afterInfo.Size() >= beforeInfo.Size() {
|
|
||||||
t.Fatalf("expected vacuum to shrink the file: before=%d after=%d",
|
|
||||||
beforeInfo.Size(), afterInfo.Size())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCleanSnapshotDBEmptySnapshot(t *testing.T) {
|
func TestCleanSnapshotDBEmptySnapshot(t *testing.T) {
|
||||||
// Initialize logger
|
// Initialize logger
|
||||||
log.Initialize(log.Config{})
|
log.Initialize(log.Config{})
|
||||||
|
|||||||
+54
-79
@@ -46,18 +46,31 @@ func (f *FileStorer) SetFilesystem(fs afero.Fs) {
|
|||||||
// storage base path.
|
// storage base path.
|
||||||
const storageDirPerm = 0o755
|
const storageDirPerm = 0o755
|
||||||
|
|
||||||
// tempSuffix marks a partially written object. writeAtomic streams into a
|
|
||||||
// temp file carrying this suffix and only renames it onto the real key once
|
|
||||||
// the whole object is on disk, so an interrupted write can never leave a
|
|
||||||
// truncated object at the key a later run would Stat and trust as a complete
|
|
||||||
// blob. List and ListStream skip these files, so a leftover from an
|
|
||||||
// interrupted write is never listed or trusted as a blob; it is otherwise
|
|
||||||
// harmless and is overwritten when the same key is written again.
|
|
||||||
const tempSuffix = ".partial"
|
|
||||||
|
|
||||||
// Put stores data at the specified key.
|
// Put stores data at the specified key.
|
||||||
func (f *FileStorer) Put(_ context.Context, key string, data io.Reader) error {
|
func (f *FileStorer) Put(_ context.Context, key string, data io.Reader) error {
|
||||||
return f.writeAtomic(key, data, nil)
|
path := f.fullPath(key)
|
||||||
|
|
||||||
|
// Create parent directories
|
||||||
|
dir := filepath.Dir(path)
|
||||||
|
|
||||||
|
err := f.fs.MkdirAll(dir, storageDirPerm)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("creating directories: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := f.fs.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("creating file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = file.Close() }()
|
||||||
|
|
||||||
|
_, err = io.Copy(file, data)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("writing file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// PutWithProgress stores data with progress reporting.
|
// PutWithProgress stores data with progress reporting.
|
||||||
@@ -65,7 +78,35 @@ func (f *FileStorer) PutWithProgress(
|
|||||||
_ context.Context, key string, data io.Reader,
|
_ context.Context, key string, data io.Reader,
|
||||||
_ int64, progress ProgressCallback,
|
_ int64, progress ProgressCallback,
|
||||||
) error {
|
) error {
|
||||||
return f.writeAtomic(key, data, progress)
|
path := f.fullPath(key)
|
||||||
|
|
||||||
|
// Create parent directories
|
||||||
|
dir := filepath.Dir(path)
|
||||||
|
|
||||||
|
err := f.fs.MkdirAll(dir, storageDirPerm)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("creating directories: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := f.fs.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("creating file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = file.Close() }()
|
||||||
|
|
||||||
|
// Wrap with progress tracking
|
||||||
|
pw := &progressWriter{
|
||||||
|
writer: file,
|
||||||
|
callback: progress,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = io.Copy(pw, data)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("writing file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get retrieves data from the specified key.
|
// Get retrieves data from the specified key.
|
||||||
@@ -147,7 +188,7 @@ func (f *FileStorer) List(ctx context.Context, prefix string) ([]string, error)
|
|||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
|
|
||||||
if !info.IsDir() && !strings.HasSuffix(info.Name(), tempSuffix) {
|
if !info.IsDir() {
|
||||||
// Convert back to key (relative path from basePath)
|
// Convert back to key (relative path from basePath)
|
||||||
relPath, err := filepath.Rel(f.basePath, path)
|
relPath, err := filepath.Rel(f.basePath, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -204,7 +245,7 @@ func (f *FileStorer) ListStream(ctx context.Context, prefix string) <-chan Objec
|
|||||||
return nil //nolint:nilerr // continue walking despite errors
|
return nil //nolint:nilerr // continue walking despite errors
|
||||||
}
|
}
|
||||||
|
|
||||||
if !info.IsDir() && !strings.HasSuffix(info.Name(), tempSuffix) {
|
if !info.IsDir() {
|
||||||
relPath, err := filepath.Rel(f.basePath, path)
|
relPath, err := filepath.Rel(f.basePath, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ch <- ObjectInfo{Err: fmt.Errorf("computing relative path: %w", err)}
|
ch <- ObjectInfo{Err: fmt.Errorf("computing relative path: %w", err)}
|
||||||
@@ -234,72 +275,6 @@ func (f *FileStorer) Info() Info {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeAtomic streams data into a temp file in the destination directory,
|
|
||||||
// fsyncs it, and renames it onto the final key. The key therefore appears
|
|
||||||
// only once the whole object has been durably written; a failure part-way
|
|
||||||
// leaves a temp file (removed here on the failing path) rather than a
|
|
||||||
// truncated object at the key.
|
|
||||||
func (f *FileStorer) writeAtomic(
|
|
||||||
key string, data io.Reader, progress ProgressCallback,
|
|
||||||
) error {
|
|
||||||
path := f.fullPath(key)
|
|
||||||
dir := filepath.Dir(path)
|
|
||||||
|
|
||||||
err := f.fs.MkdirAll(dir, storageDirPerm)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("creating directories: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
tmp, err := afero.TempFile(f.fs, dir, filepath.Base(path)+"-*"+tempSuffix)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("creating temp file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
tmpPath := tmp.Name()
|
|
||||||
|
|
||||||
// Remove the temp file unless the rename below claims it. On the success
|
|
||||||
// path renamed is true, so the deferred Close and Remove are harmless
|
|
||||||
// no-ops on a name that no longer exists.
|
|
||||||
renamed := false
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
_ = tmp.Close()
|
|
||||||
|
|
||||||
if !renamed {
|
|
||||||
_ = f.fs.Remove(tmpPath)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
var w io.Writer = tmp
|
|
||||||
if progress != nil {
|
|
||||||
w = &progressWriter{writer: tmp, callback: progress}
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = io.Copy(w, data)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("writing file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = tmp.Sync()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("syncing temp file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = tmp.Close()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("closing temp file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = f.fs.Rename(tmpPath, path)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("renaming temp file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
renamed = true
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// fullPath returns the full filesystem path for a key.
|
// fullPath returns the full filesystem path for a key.
|
||||||
func (f *FileStorer) fullPath(key string) string {
|
func (f *FileStorer) fullPath(key string) string {
|
||||||
return filepath.Join(f.basePath, key)
|
return filepath.Join(f.basePath, key)
|
||||||
|
|||||||
@@ -1,119 +0,0 @@
|
|||||||
package storage_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"sneak.berlin/go/vaultik/internal/storage"
|
|
||||||
)
|
|
||||||
|
|
||||||
// errStreamInterrupted stands in for an upload cut off mid-stream.
|
|
||||||
var errStreamInterrupted = errors.New("connection reset mid-upload")
|
|
||||||
|
|
||||||
// failingReader yields its data once, then fails.
|
|
||||||
type failingReader struct {
|
|
||||||
data []byte
|
|
||||||
done bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *failingReader) Read(p []byte) (int, error) {
|
|
||||||
if r.done {
|
|
||||||
return 0, errStreamInterrupted
|
|
||||||
}
|
|
||||||
|
|
||||||
n := copy(p, r.data)
|
|
||||||
r.done = true
|
|
||||||
|
|
||||||
return n, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestFileStorer_InterruptedWriteLeavesNoTrustedObject checks that a write
|
|
||||||
// cut off mid-stream leaves nothing at the destination key, so a later run
|
|
||||||
// cannot Stat a truncated object and trust it as a complete blob.
|
|
||||||
func TestFileStorer_InterruptedWriteLeavesNoTrustedObject(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
f, err := storage.NewFileStorer(t.TempDir())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewFileStorer: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
key := "blobs/aa/bb/aabbccddeeff"
|
|
||||||
|
|
||||||
err = f.PutWithProgress(ctx, key, &failingReader{data: []byte("partial")}, 4096, nil)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected the interrupted write to fail, got nil")
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = f.Stat(ctx, key)
|
|
||||||
if !errors.Is(err, storage.ErrNotFound) {
|
|
||||||
t.Fatalf("expected key absent after interrupted write, got Stat err %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
keys, err := f.List(ctx, "blobs/")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("List: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(keys) != 0 {
|
|
||||||
t.Fatalf("expected no keys listed after interrupted write, got %v", keys)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestFileStorer_ListSkipsPartialFiles checks that a leftover temp file (the
|
|
||||||
// storage layer names them with a ".partial" suffix) is never surfaced as a
|
|
||||||
// key by List or ListStream.
|
|
||||||
func TestFileStorer_ListSkipsPartialFiles(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
base := t.TempDir()
|
|
||||||
|
|
||||||
f, err := storage.NewFileStorer(base)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewFileStorer: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
realKey := "blobs/aa/bb/aabbccddeeff"
|
|
||||||
|
|
||||||
err = f.Put(ctx, realKey, strings.NewReader("blob-bytes"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Put: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// A stray temp file, as an interrupted write would leave behind.
|
|
||||||
leftover := filepath.Join(base, "blobs/aa/bb/aabbccddeeff-123456.partial")
|
|
||||||
|
|
||||||
err = os.WriteFile(leftover, []byte("half"), 0o600)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("writing leftover temp file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
keys, err := f.List(ctx, "blobs/")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("List: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(keys) != 1 || keys[0] != realKey {
|
|
||||||
t.Fatalf("List should return only the real key, got %v", keys)
|
|
||||||
}
|
|
||||||
|
|
||||||
var streamed []string
|
|
||||||
|
|
||||||
for obj := range f.ListStream(ctx, "blobs/") {
|
|
||||||
if obj.Err != nil {
|
|
||||||
t.Fatalf("ListStream: %v", obj.Err)
|
|
||||||
}
|
|
||||||
|
|
||||||
streamed = append(streamed, obj.Key)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(streamed) != 1 || streamed[0] != realKey {
|
|
||||||
t.Fatalf("ListStream should return only the real key, got %v", streamed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+1
-16
@@ -38,29 +38,14 @@ func (s *S3Storer) PutWithProgress(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get retrieves data from the specified key.
|
// Get retrieves data from the specified key.
|
||||||
// Returns ErrNotFound if the object does not exist.
|
|
||||||
func (s *S3Storer) Get(ctx context.Context, key string) (io.ReadCloser, error) {
|
func (s *S3Storer) Get(ctx context.Context, key string) (io.ReadCloser, error) {
|
||||||
rc, err := s.client.GetObject(ctx, key)
|
return s.client.GetObject(ctx, key)
|
||||||
if err != nil {
|
|
||||||
if s3.IsNotFound(err) {
|
|
||||||
return nil, fmt.Errorf("get %q: %w", key, ErrNotFound)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return rc, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stat returns metadata about an object without retrieving its contents.
|
// Stat returns metadata about an object without retrieving its contents.
|
||||||
// Returns ErrNotFound if the object does not exist.
|
|
||||||
func (s *S3Storer) Stat(ctx context.Context, key string) (*ObjectInfo, error) {
|
func (s *S3Storer) Stat(ctx context.Context, key string) (*ObjectInfo, error) {
|
||||||
info, err := s.client.StatObject(ctx, key)
|
info, err := s.client.StatObject(ctx, key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if s3.IsNotFound(err) {
|
|
||||||
return nil, fmt.Errorf("stat %q: %w", key, ErrNotFound)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
package storage_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/johannesboyne/gofakes3"
|
|
||||||
"github.com/johannesboyne/gofakes3/backend/s3mem"
|
|
||||||
|
|
||||||
"sneak.berlin/go/vaultik/internal/s3"
|
|
||||||
"sneak.berlin/go/vaultik/internal/storage"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestS3StorerMissingKeyMapsToErrNotFound verifies that the s3 backend reports
|
|
||||||
// a missing object as storage.ErrNotFound, matching the file and rclone
|
|
||||||
// backends and the Storer contract. Without the mapping, Get and Stat leak the
|
|
||||||
// raw SDK error and errors.Is(err, storage.ErrNotFound) is false.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // shares an in-process S3 server via t.Cleanup
|
|
||||||
func TestS3StorerMissingKeyMapsToErrNotFound(t *testing.T) {
|
|
||||||
const bucket = "test-bucket"
|
|
||||||
|
|
||||||
backend := s3mem.New()
|
|
||||||
|
|
||||||
err := backend.CreateBucket(bucket)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create bucket: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
srv := httptest.NewServer(gofakes3.New(backend).Server())
|
|
||||||
t.Cleanup(srv.Close)
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
client, err := s3.NewClient(ctx, s3.Config{
|
|
||||||
Endpoint: srv.URL,
|
|
||||||
Bucket: bucket,
|
|
||||||
AccessKeyID: "test",
|
|
||||||
SecretAccessKey: "test",
|
|
||||||
Region: "us-east-1",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("new client: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
storer := storage.NewS3Storer(client)
|
|
||||||
|
|
||||||
_, err = storer.Get(ctx, "does-not-exist")
|
|
||||||
if !errors.Is(err, storage.ErrNotFound) {
|
|
||||||
t.Errorf("Get on missing key: got %v, want ErrNotFound", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = storer.Stat(ctx, "does-not-exist")
|
|
||||||
if !errors.Is(err, storage.ErrNotFound) {
|
|
||||||
t.Errorf("Stat on missing key: got %v, want ErrNotFound", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
package vaultik_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/spf13/afero"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"sneak.berlin/go/vaultik/internal/log"
|
|
||||||
"sneak.berlin/go/vaultik/internal/ui"
|
|
||||||
"sneak.berlin/go/vaultik/internal/vaultik"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestDeepVerifyAcceptsHealthyAndRejectsCorruptBlob backs up a real
|
|
||||||
// snapshot with the on-disk storage backend, runs deep verification on
|
|
||||||
// it, then flips a byte inside one stored blob and runs deep
|
|
||||||
// verification again. A healthy snapshot must pass; a corrupted blob
|
|
||||||
// must fail. The healthy case is the regression guard: deep
|
|
||||||
// verification used to hash the encrypted blob bytes and compare them
|
|
||||||
// to the blob's ID (the double SHA256 of the plaintext), so it reported
|
|
||||||
// every healthy blob as corrupt.
|
|
||||||
func TestDeepVerifyAcceptsHealthyAndRejectsCorruptBlob(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")
|
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
|
||||||
|
|
||||||
chunkSize := int64(64 * 1024)
|
|
||||||
maxBlobSize := int64(512 * 1024)
|
|
||||||
|
|
||||||
// One file large enough to span several chunks within a single blob.
|
|
||||||
require.NoError(t, fs.MkdirAll(dataDir, 0o755))
|
|
||||||
require.NoError(t, afero.WriteFile(fs,
|
|
||||||
filepath.Join(dataDir, "data.bin"),
|
|
||||||
bytesPattern("deep-", int(chunkSize*3)), 0o644))
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// runFileStorageBackup writes a real snapshot to storeDir and closes
|
|
||||||
// the source index, so verification runs from remote bytes only.
|
|
||||||
cfg, storer, snapshotID := runFileStorageBackup(
|
|
||||||
ctx, t, fs, dataDir, storeDir, dbPath, chunkSize, maxBlobSize)
|
|
||||||
|
|
||||||
newVerifier := func() *vaultik.Vaultik {
|
|
||||||
v := &vaultik.Vaultik{
|
|
||||||
Config: cfg,
|
|
||||||
Storage: storer,
|
|
||||||
Fs: fs,
|
|
||||||
Stdout: io.Discard,
|
|
||||||
Stderr: io.Discard,
|
|
||||||
UI: ui.NewWithColor(io.Discard, false),
|
|
||||||
}
|
|
||||||
v.SetContext(ctx)
|
|
||||||
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
|
|
||||||
require.NoError(t,
|
|
||||||
newVerifier().RunDeepVerify(snapshotID, &vaultik.VerifyOptions{Deep: true}),
|
|
||||||
"deep verify should pass on a healthy snapshot")
|
|
||||||
|
|
||||||
// Flip a byte inside one blob without changing its length, so the
|
|
||||||
// blob-existence and size checks still pass and verification reaches
|
|
||||||
// the blob-content stage.
|
|
||||||
corruptOneBlob(t, fs, filepath.Join(storeDir, "blobs"))
|
|
||||||
|
|
||||||
require.Error(t,
|
|
||||||
newVerifier().RunDeepVerify(snapshotID, &vaultik.VerifyOptions{Deep: true}),
|
|
||||||
"deep verify should fail on a corrupted blob")
|
|
||||||
}
|
|
||||||
|
|
||||||
// corruptOneBlob flips a middle byte of the first blob file found under
|
|
||||||
// blobsDir, leaving the file length unchanged.
|
|
||||||
func corruptOneBlob(t *testing.T, fs afero.Fs, blobsDir string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
var blobPath string
|
|
||||||
|
|
||||||
err := afero.Walk(fs, blobsDir,
|
|
||||||
func(path string, info os.FileInfo, err error) error {
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if blobPath == "" && !info.IsDir() {
|
|
||||||
blobPath = path
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotEmpty(t, blobPath, "expected at least one blob on disk")
|
|
||||||
|
|
||||||
data, err := afero.ReadFile(fs, blobPath)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotEmpty(t, data)
|
|
||||||
|
|
||||||
data[len(data)/2] ^= 0xff
|
|
||||||
require.NoError(t, afero.WriteFile(fs, blobPath, data, 0o644))
|
|
||||||
}
|
|
||||||
@@ -33,9 +33,8 @@ func ubytes(n int64) string {
|
|||||||
var (
|
var (
|
||||||
errMalformedSnapshotID = errors.New(
|
errMalformedSnapshotID = errors.New(
|
||||||
"invalid snapshot ID format: expected hostname_snapshotname_timestamp")
|
"invalid snapshot ID format: expected hostname_snapshotname_timestamp")
|
||||||
errInvalidDuration = errors.New("invalid duration")
|
errInvalidDuration = errors.New("invalid duration")
|
||||||
errUnknownTimeUnit = errors.New("unknown time unit")
|
errUnknownTimeUnit = errors.New("unknown time unit")
|
||||||
errNegativeDuration = errors.New("negative durations are not supported")
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Time-unit lengths used by parseDuration.
|
// Time-unit lengths used by parseDuration.
|
||||||
@@ -139,13 +138,8 @@ func parseSnapshotName(snapshotID string) string {
|
|||||||
|
|
||||||
// parseDuration parses a duration string with support for human-friendly units:
|
// parseDuration parses a duration string with support for human-friendly units:
|
||||||
// d/day/days, w/week/weeks, mo/month/months, y/year/years, plus standard Go
|
// d/day/days, w/week/weeks, mo/month/months, y/year/years, plus standard Go
|
||||||
// duration units. Following Go, m is minutes and mo is months. A bare number,
|
// duration units (h, m, s).
|
||||||
// an unknown unit, and a negative value are all rejected.
|
|
||||||
func parseDuration(s string) (time.Duration, error) {
|
func parseDuration(s string) (time.Duration, error) {
|
||||||
if strings.HasPrefix(strings.TrimSpace(s), "-") {
|
|
||||||
return 0, errNegativeDuration
|
|
||||||
}
|
|
||||||
|
|
||||||
d, err := time.ParseDuration(s)
|
d, err := time.ParseDuration(s)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return d, nil
|
return d, nil
|
||||||
|
|||||||
@@ -51,32 +51,13 @@ func TestParseDuration(t *testing.T) {
|
|||||||
want time.Duration
|
want time.Duration
|
||||||
err bool
|
err bool
|
||||||
}{
|
}{
|
||||||
// Go units, including the m-is-minutes / mo-is-months distinction
|
|
||||||
// that this parser exists to keep straight.
|
|
||||||
{"10ns", 10 * time.Nanosecond, false},
|
|
||||||
{"10us", 10 * time.Microsecond, false},
|
|
||||||
{"500ms", 500 * time.Millisecond, false},
|
|
||||||
{"30s", 30 * time.Second, false},
|
|
||||||
{"6m", 6 * time.Minute, false},
|
|
||||||
{"1h", time.Hour, false},
|
|
||||||
// Extended calendar units.
|
|
||||||
{"30d", 30 * 24 * time.Hour, false},
|
{"30d", 30 * 24 * time.Hour, false},
|
||||||
{"3days", 3 * 24 * time.Hour, false},
|
|
||||||
{"4w", 4 * 7 * 24 * time.Hour, false},
|
{"4w", 4 * 7 * 24 * time.Hour, false},
|
||||||
{"2weeks", 2 * 7 * 24 * time.Hour, false},
|
{"6mo", 6 * 30 * 24 * time.Hour, false},
|
||||||
{"6mo", 180 * 24 * time.Hour, false},
|
|
||||||
{"1month", 30 * 24 * time.Hour, false},
|
|
||||||
{"1y", 365 * 24 * time.Hour, false},
|
{"1y", 365 * 24 * time.Hour, false},
|
||||||
{"2years", 2 * 365 * 24 * time.Hour, false},
|
|
||||||
// Combined units.
|
|
||||||
{"2w3d", 2*7*24*time.Hour + 3*24*time.Hour, false},
|
{"2w3d", 2*7*24*time.Hour + 3*24*time.Hour, false},
|
||||||
{"1y6mo", 365*24*time.Hour + 180*24*time.Hour, false},
|
{"1h", time.Hour, false},
|
||||||
// Rejected inputs.
|
{"30s", 30 * time.Second, false},
|
||||||
{"6", 0, true}, // bare number, no unit
|
|
||||||
{"5x", 0, true}, // unknown unit
|
|
||||||
{"-5d", 0, true}, // negative, extended unit
|
|
||||||
{"-5h", 0, true}, // negative, Go unit
|
|
||||||
{"", 0, true}, // empty
|
|
||||||
{"garbage", 0, true},
|
{"garbage", 0, true},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
package vaultik //nolint:testpackage // exercises unexported count helpers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"sneak.berlin/go/vaultik/internal/database"
|
|
||||||
"sneak.berlin/go/vaultik/internal/log"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestTableCountForReportSurfacesReadFailure is the regression guard for
|
|
||||||
// the discarded-error bug: getTableCount for a table its query cannot
|
|
||||||
// resolve must not silently become 0. A count that could not be read is
|
|
||||||
// reported as unknown, which a reader can tell apart from an empty table.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // installs the global logger via log.Initialize
|
|
||||||
func TestTableCountForReportSurfacesReadFailure(t *testing.T) {
|
|
||||||
log.Initialize(log.Config{})
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
db, err := database.New(ctx, ":memory:")
|
|
||||||
require.NoError(t, err)
|
|
||||||
t.Cleanup(func() { _ = db.Close() })
|
|
||||||
|
|
||||||
v := &Vaultik{DB: db}
|
|
||||||
v.SetContext(ctx)
|
|
||||||
|
|
||||||
// A table present in the schema reads as a real count.
|
|
||||||
blobs := v.tableCountForReport("blobs")
|
|
||||||
require.NotNil(t, blobs, "an existing table must read as a real count")
|
|
||||||
assert.Equal(t, int64(0), *blobs)
|
|
||||||
|
|
||||||
// A syntactically valid name the sanitizer accepts but whose table
|
|
||||||
// the query cannot resolve is the exact shape #96 describes: a
|
|
||||||
// would-be loud failure that used to be discarded into a 0.
|
|
||||||
_, err = v.getTableCount("snapshots_missing")
|
|
||||||
require.Error(t, err, "a query against a nonexistent table must fail")
|
|
||||||
|
|
||||||
missing := v.tableCountForReport("snapshots_missing")
|
|
||||||
assert.Nil(t, missing, "a failed read is unknown, not a count")
|
|
||||||
|
|
||||||
// The rendered count for a failed read must say unknown, never 0.
|
|
||||||
assert.Equal(t, countUnknown, countText(missing))
|
|
||||||
assert.NotEqual(t, "0", countText(missing))
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestCountTextDistinguishesEmptyFromUnknown pins the distinction the
|
|
||||||
// output has to preserve: 0 means the table was empty, "unknown" means
|
|
||||||
// the count could not be read.
|
|
||||||
func TestCountTextDistinguishesEmptyFromUnknown(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
zero := int64(0)
|
|
||||||
seven := int64(7)
|
|
||||||
|
|
||||||
assert.Equal(t, "0", countText(&zero))
|
|
||||||
assert.Equal(t, "7", countText(&seven))
|
|
||||||
assert.Equal(t, countUnknown, countText(nil))
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestCountDiffUnknownWhenEitherSideUnknown checks that a delta computed
|
|
||||||
// from an unreadable count is itself unknown rather than a plausible
|
|
||||||
// number.
|
|
||||||
func TestCountDiffUnknownWhenEitherSideUnknown(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
before := int64(10)
|
|
||||||
after := int64(3)
|
|
||||||
|
|
||||||
require.NotNil(t, countDiff(&before, &after))
|
|
||||||
assert.Equal(t, int64(7), *countDiff(&before, &after))
|
|
||||||
|
|
||||||
assert.Nil(t, countDiff(nil, &after), "unknown before yields unknown delta")
|
|
||||||
assert.Nil(t, countDiff(&before, nil), "unknown after yields unknown delta")
|
|
||||||
assert.Nil(t, countDiff(nil, nil))
|
|
||||||
}
|
|
||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -1541,17 +1540,12 @@ func (v *Vaultik) outputRemoveJSON(result *RemoveResult) error {
|
|||||||
return encoder.Encode(result)
|
return encoder.Encode(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PruneResult contains statistics about the prune operation.
|
// PruneResult contains statistics about the prune operation
|
||||||
// SnapshotsDeleted counts snapshots actually deleted. FilesDeleted,
|
|
||||||
// ChunksDeleted, and BlobsDeleted are derived from before/after row
|
|
||||||
// counts of the local index; each is nil when a count could not be read,
|
|
||||||
// so an unreadable count is reported as unknown rather than silently
|
|
||||||
// as 0.
|
|
||||||
type PruneResult struct {
|
type PruneResult struct {
|
||||||
SnapshotsDeleted int64
|
SnapshotsDeleted int64
|
||||||
FilesDeleted *int64
|
FilesDeleted int64
|
||||||
ChunksDeleted *int64
|
ChunksDeleted int64
|
||||||
BlobsDeleted *int64
|
BlobsDeleted int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// PruneDatabase removes incomplete snapshots and orphaned files, chunks,
|
// PruneDatabase removes incomplete snapshots and orphaned files, chunks,
|
||||||
@@ -1566,7 +1560,7 @@ func (v *Vaultik) PruneDatabase() (*PruneResult, error) {
|
|||||||
result := &PruneResult{}
|
result := &PruneResult{}
|
||||||
|
|
||||||
// Snapshot counts before deletion of incompletes.
|
// Snapshot counts before deletion of incompletes.
|
||||||
snapshotCountBefore := v.tableCountForReport("snapshots")
|
snapshotCountBefore, _ := v.getTableCount("snapshots")
|
||||||
|
|
||||||
// First, delete any incomplete snapshots
|
// First, delete any incomplete snapshots
|
||||||
incompleteSnapshots, err := v.Repositories.Snapshots.GetIncompleteSnapshots(v.ctx)
|
incompleteSnapshots, err := v.Repositories.Snapshots.GetIncompleteSnapshots(v.ctx)
|
||||||
@@ -1581,9 +1575,9 @@ func (v *Vaultik) PruneDatabase() (*PruneResult, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get counts before cleanup for reporting
|
// Get counts before cleanup for reporting
|
||||||
fileCountBefore := v.tableCountForReport("files")
|
fileCountBefore, _ := v.getTableCount("files")
|
||||||
chunkCountBefore := v.tableCountForReport("chunks")
|
chunkCountBefore, _ := v.getTableCount("chunks")
|
||||||
blobCountBefore := v.tableCountForReport("blobs")
|
blobCountBefore, _ := v.getTableCount("blobs")
|
||||||
|
|
||||||
// Run the cleanup
|
// Run the cleanup
|
||||||
err = v.SnapshotManager.CleanupOrphanedData(v.ctx)
|
err = v.SnapshotManager.CleanupOrphanedData(v.ctx)
|
||||||
@@ -1592,83 +1586,36 @@ func (v *Vaultik) PruneDatabase() (*PruneResult, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get counts after cleanup
|
// Get counts after cleanup
|
||||||
fileCountAfter := v.tableCountForReport("files")
|
fileCountAfter, _ := v.getTableCount("files")
|
||||||
chunkCountAfter := v.tableCountForReport("chunks")
|
chunkCountAfter, _ := v.getTableCount("chunks")
|
||||||
blobCountAfter := v.tableCountForReport("blobs")
|
blobCountAfter, _ := v.getTableCount("blobs")
|
||||||
|
|
||||||
result.FilesDeleted = countDiff(fileCountBefore, fileCountAfter)
|
result.FilesDeleted = fileCountBefore - fileCountAfter
|
||||||
result.ChunksDeleted = countDiff(chunkCountBefore, chunkCountAfter)
|
result.ChunksDeleted = chunkCountBefore - chunkCountAfter
|
||||||
result.BlobsDeleted = countDiff(blobCountBefore, blobCountAfter)
|
result.BlobsDeleted = blobCountBefore - blobCountAfter
|
||||||
|
|
||||||
log.Info("Local database prune complete",
|
log.Info("Local database prune complete",
|
||||||
"incomplete_snapshots", result.SnapshotsDeleted,
|
"incomplete_snapshots", result.SnapshotsDeleted,
|
||||||
"orphaned_files", countText(result.FilesDeleted),
|
"orphaned_files", result.FilesDeleted,
|
||||||
"orphaned_chunks", countText(result.ChunksDeleted),
|
"orphaned_chunks", result.ChunksDeleted,
|
||||||
"orphaned_blobs", countText(result.BlobsDeleted),
|
"orphaned_blobs", result.BlobsDeleted,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Snapshots remaining after removing the incomplete ones; unknown if
|
snapshotCountAfter := snapshotCountBefore - result.SnapshotsDeleted
|
||||||
// the pre-prune snapshot count could not be read.
|
|
||||||
snapshotsRemain := countDiff(snapshotCountBefore, &result.SnapshotsDeleted)
|
|
||||||
|
|
||||||
v.UI.Completef("Pruned local index database.")
|
v.UI.Completef("Pruned local index database.")
|
||||||
v.UI.Detailf("Incomplete snapshots: %s removed (%s remain).",
|
v.UI.Detailf("Incomplete snapshots: %d removed (%d remain).",
|
||||||
countText(&result.SnapshotsDeleted), countText(snapshotsRemain))
|
result.SnapshotsDeleted, snapshotCountAfter)
|
||||||
v.UI.Detailf("Orphaned files: %s removed (%s remain).",
|
v.UI.Detailf("Orphaned files: %d removed (%d remain).",
|
||||||
countText(result.FilesDeleted), countText(fileCountAfter))
|
result.FilesDeleted, fileCountAfter)
|
||||||
v.UI.Detailf("Orphaned chunks: %s removed (%s remain).",
|
v.UI.Detailf("Orphaned chunks: %d removed (%d remain).",
|
||||||
countText(result.ChunksDeleted), countText(chunkCountAfter))
|
result.ChunksDeleted, chunkCountAfter)
|
||||||
v.UI.Detailf("Orphaned blobs: %s removed (%s remain).",
|
v.UI.Detailf("Orphaned blobs: %d removed (%d remain).",
|
||||||
countText(result.BlobsDeleted), countText(blobCountAfter))
|
result.BlobsDeleted, blobCountAfter)
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// countUnknown is what a count reads as when its query could not be run,
|
|
||||||
// distinct from "0", which means the table really was empty.
|
|
||||||
const countUnknown = "unknown"
|
|
||||||
|
|
||||||
// tableCountForReport returns the row count of a table for the prune
|
|
||||||
// summary, or nil if the count could not be read. A read failure is
|
|
||||||
// logged at warn — visible even under --json, which routes warnings to
|
|
||||||
// stderr — and then rendered as unknown rather than silently becoming 0,
|
|
||||||
// so a broken query is a visible failure instead of a plausible wrong
|
|
||||||
// number.
|
|
||||||
func (v *Vaultik) tableCountForReport(tableName string) *int64 {
|
|
||||||
count, err := v.getTableCount(tableName)
|
|
||||||
if err != nil {
|
|
||||||
log.Warn("could not read table row count for prune summary",
|
|
||||||
"table", tableName, "error", err)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return &count
|
|
||||||
}
|
|
||||||
|
|
||||||
// countDiff returns before-after, or nil if either count is unknown so
|
|
||||||
// that an unreadable count does not collapse into a plausible delta.
|
|
||||||
func countDiff(before, after *int64) *int64 {
|
|
||||||
if before == nil || after == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
diff := *before - *after
|
|
||||||
|
|
||||||
return &diff
|
|
||||||
}
|
|
||||||
|
|
||||||
// countText renders a count that may be unknown: nil (the read failed)
|
|
||||||
// becomes "unknown", never "0", so a reader can tell an empty table from
|
|
||||||
// one that could not be queried.
|
|
||||||
func countText(count *int64) string {
|
|
||||||
if count == nil {
|
|
||||||
return countUnknown
|
|
||||||
}
|
|
||||||
|
|
||||||
return strconv.FormatInt(*count, 10)
|
|
||||||
}
|
|
||||||
|
|
||||||
// validTableNameRe matches table names containing only lowercase
|
// validTableNameRe matches table names containing only lowercase
|
||||||
// alphanumeric characters and underscores.
|
// alphanumeric characters and underscores.
|
||||||
var validTableNameRe = regexp.MustCompile(`^[a-z0-9_]+$`)
|
var validTableNameRe = regexp.MustCompile(`^[a-z0-9_]+$`)
|
||||||
|
|||||||
+14
-19
@@ -344,8 +344,12 @@ func (v *Vaultik) verifyBlob(blobInfo snapshot.BlobInfo, db *sql.DB) error {
|
|||||||
return fmt.Errorf("failed to get decryptor: %w", err)
|
return fmt.Errorf("failed to get decryptor: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decrypt blob
|
// Hash the encrypted blob data as it streams through to decryption
|
||||||
decryptedReader, err := decryptor.DecryptStream(reader)
|
blobHasher := sha256.New()
|
||||||
|
teeReader := io.TeeReader(reader, blobHasher)
|
||||||
|
|
||||||
|
// Decrypt blob (reading through teeReader to hash encrypted data)
|
||||||
|
decryptedReader, err := decryptor.DecryptStream(teeReader)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to decrypt: %w", err)
|
return fmt.Errorf("failed to decrypt: %w", err)
|
||||||
}
|
}
|
||||||
@@ -357,19 +361,12 @@ func (v *Vaultik) verifyBlob(blobInfo snapshot.BlobInfo, db *sql.DB) error {
|
|||||||
}
|
}
|
||||||
defer decompressor.Close()
|
defer decompressor.Close()
|
||||||
|
|
||||||
// A blob's hash — its remote name — is the double SHA256 of its
|
chunkCount, err := v.verifyBlobChunks(db, blobInfo.Hash, decompressor)
|
||||||
// decompressed plaintext (see blobgen.Writer.Sum256), not of the
|
|
||||||
// encrypted bytes. Hash the plaintext as chunk verification streams
|
|
||||||
// it, then compare on completion.
|
|
||||||
plaintextHasher := sha256.New()
|
|
||||||
hashedStream := io.TeeReader(decompressor, plaintextHasher)
|
|
||||||
|
|
||||||
chunkCount, err := v.verifyBlobChunks(db, blobInfo.Hash, hashedStream)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = v.verifyBlobFinalIntegrity(hashedStream, plaintextHasher, blobInfo.Hash)
|
err = v.verifyBlobFinalIntegrity(decompressor, blobHasher, blobInfo.Hash)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -473,13 +470,14 @@ func (v *Vaultik) verifyBlobChunks(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// verifyBlobFinalIntegrity checks that no trailing data exists in the
|
// verifyBlobFinalIntegrity checks that no trailing data exists in the
|
||||||
// decompressed stream and that the blob hash matches the expected value.
|
// decompressed stream and that the encrypted blob hash matches the
|
||||||
|
// expected value.
|
||||||
func (v *Vaultik) verifyBlobFinalIntegrity(
|
func (v *Vaultik) verifyBlobFinalIntegrity(
|
||||||
plaintext io.Reader, plaintextHasher hash.Hash, expectedHash string,
|
decompressor io.Reader, blobHasher hash.Hash, expectedHash string,
|
||||||
) error {
|
) error {
|
||||||
// Verify no remaining data in blob - if the chunk list is accurate,
|
// Verify no remaining data in blob - if the chunk list is accurate,
|
||||||
// the blob should be fully consumed.
|
// the blob should be fully consumed.
|
||||||
remaining, err := io.Copy(io.Discard, plaintext)
|
remaining, err := io.Copy(io.Discard, decompressor)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to check for remaining blob data: %w", err)
|
return fmt.Errorf("failed to check for remaining blob data: %w", err)
|
||||||
}
|
}
|
||||||
@@ -488,11 +486,8 @@ func (v *Vaultik) verifyBlobFinalIntegrity(
|
|||||||
return fmt.Errorf("%w: %d bytes", errTrailingBlobData, remaining)
|
return fmt.Errorf("%w: %d bytes", errTrailingBlobData, remaining)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The blob hash is the double SHA256 of its plaintext content.
|
// Verify blob hash matches the encrypted data we downloaded
|
||||||
firstHash := plaintextHasher.Sum(nil)
|
calculatedBlobHash := hex.EncodeToString(blobHasher.Sum(nil))
|
||||||
secondHash := sha256.Sum256(firstHash)
|
|
||||||
calculatedBlobHash := hex.EncodeToString(secondHash[:])
|
|
||||||
|
|
||||||
if calculatedBlobHash != expectedHash {
|
if calculatedBlobHash != expectedHash {
|
||||||
return fmt.Errorf("%w: calculated %s, expected %s",
|
return fmt.Errorf("%w: calculated %s, expected %s",
|
||||||
errBlobHashMismatch, calculatedBlobHash, expectedHash)
|
errBlobHashMismatch, calculatedBlobHash, expectedHash)
|
||||||
|
|||||||
@@ -114,6 +114,9 @@ main() {
|
|||||||
# from CI. Nothing on the host is ever used as a linter, at any
|
# from CI. Nothing on the host is ever used as a linter, at any
|
||||||
# version, so installing one here would buy nothing.
|
# version, so installing one here would buy nothing.
|
||||||
|
|
||||||
|
# sqlite3 CLI: the test suite shells out to it (VACUUM).
|
||||||
|
if missing sqlite3; then pkg_install sqlite sqlite3 sqlite sqlite; fi
|
||||||
|
|
||||||
# goreleaser, at the version pinned by script/install-goreleaser and
|
# goreleaser, at the version pinned by script/install-goreleaser and
|
||||||
# verified against a hardcoded sha256. Package managers are not used
|
# verified against a hardcoded sha256. Package managers are not used
|
||||||
# for it: they ship whatever version they happen to carry, and the
|
# for it: they ship whatever version they happen to carry, and the
|
||||||
|
|||||||
+1
-16
@@ -56,23 +56,8 @@ 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,24 +24,7 @@ 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