From c22363cc3c0de3a344fed57f5f1c417d04615636 Mon Sep 17 00:00:00 2001 From: clawbot Date: Mon, 21 Sep 2026 07:43:52 +0000 Subject: [PATCH] Stamp Docker image version from the host; flush profiles on error exit (closes #75) script/docker now computes the version (via script/version), commit and build date on the host and passes them as build args; the Dockerfile no longer runs git, which always returned "unknown" because the build context excludes .git. A dirty tree is reflected through script/version's -dirty suffix. main now exits via os.Exit(run()), so its deferred CPU/heap profile writers flush before the process ends, and Entry returns a status code instead of calling os.Exit. Each command ran its operation in an fx goroutine that called os.Exit(1) on failure, discarding those profiles and the PID-lock release; they now route the error to the return path through one RunOperation helper. errReported keeps Entry from printing an already-reported failure twice. model: claude-opus-4-8 --- Dockerfile | 22 ++++- cmd/vaultik/dockerversion_test.go | 102 ++++++++++++++++++++ cmd/vaultik/main.go | 12 ++- internal/cli/app.go | 129 ++++++++++++++++++-------- internal/cli/entry.go | 21 ++++- internal/cli/entry_banner_test.go | 2 +- internal/cli/entry_prune_json_test.go | 2 +- internal/cli/entry_status_test.go | 58 ++++++++++++ internal/cli/info.go | 43 ++------- internal/cli/prune.go | 52 ++--------- internal/cli/remote.go | 46 ++------- internal/cli/snapshot.go | 90 ++++-------------- internal/cli/snapshot_restore.go | 101 +++----------------- script/docker | 17 ++++ 14 files changed, 371 insertions(+), 326 deletions(-) create mode 100644 cmd/vaultik/dockerversion_test.go create mode 100644 internal/cli/entry_status_test.go diff --git a/Dockerfile b/Dockerfile index 51707b4..07a0c97 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,8 +20,6 @@ # golang:1.26.1-alpine, 2026-03-17 FROM golang:1.26.1-alpine@sha256:2389ebfa5b7f43eeafbd6be0c3700cc46690ef842ad962f6c5bd6be49ed82039 AS builder -ARG VERSION=dev - # Build tooling: make, plus a C toolchain because `go test -race` needs cgo. # The sqlite driver is pure Go (modernc.org/sqlite), so no sqlite library or # CLI is required. @@ -66,8 +64,26 @@ RUN [ -n "$CHECK_EPOCH" ] || exit 1 RUN echo "check epoch: ${CHECK_EPOCH}" && make fmt-check RUN echo "check epoch: ${CHECK_EPOCH}" && make test +# Version, commit and build date are computed on the host by +# script/docker (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- / -dirty rules and a Docker image reports the same +# string a local build of the same tree would. +# +# 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 +ARG COMMIT +ARG COMMIT_DATE + # Build (pure Go, no CGO required since we use modernc.org/sqlite) -RUN CGO_ENABLED=0 go build -ldflags "-X 'sneak.berlin/go/vaultik/internal/globals.Version=${VERSION}' -X 'sneak.berlin/go/vaultik/internal/globals.Commit=$(git rev-parse HEAD 2>/dev/null || echo unknown)' -X 'sneak.berlin/go/vaultik/internal/globals.CommitDate=$(git show -s --format=%cs HEAD 2>/dev/null || echo unknown)'" -o /vaultik ./cmd/vaultik +RUN CGO_ENABLED=0 go build -ldflags "-X 'sneak.berlin/go/vaultik/internal/globals.Version=${VERSION}' -X 'sneak.berlin/go/vaultik/internal/globals.Commit=${COMMIT}' -X 'sneak.berlin/go/vaultik/internal/globals.CommitDate=${COMMIT_DATE}'" -o /vaultik ./cmd/vaultik # Runtime stage # alpine:3.21, 2026-02-25 diff --git a/cmd/vaultik/dockerversion_test.go b/cmd/vaultik/dockerversion_test.go new file mode 100644 index 0000000..f77d274 --- /dev/null +++ b/cmd/vaultik/dockerversion_test.go @@ -0,0 +1,102 @@ +package main_test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// This file guards the version stamping of the product image (issue +// #75). The failure it protects against is silent: the image still +// builds and runs, but `vaultik version` inside it reports "commit: +// unknown", so an operator cannot tell which source produced a given +// backup. .dockerignore excludes .git, so the build cannot derive the +// commit itself; the values must be computed on the host and passed in. +// +// These are parses of the committed files, for the same reason the lint +// guards next door are: shelling out to docker would nest a build +// inside `make test`. That `vaultik version` in the built image really +// prints the host's version is verified by hand and recorded on the +// pull request. + +// dockerScript is script/docker, relative to the repository root. +const dockerScript = "script/docker" + +// versionArgs are the ldflag targets the build stamps and, matching +// them, the build args the host must supply. The names line up so the +// same list checks both files. +func versionArgs() []string { + return []string{"VERSION", "COMMIT", "COMMIT_DATE"} +} + +// TestProductDockerfileTakesVersionAsBuildArgs fails unless the build +// declares each version arg and stamps it into the binary by ldflag +// reference, rather than computing it in the container. +func TestProductDockerfileTakesVersionAsBuildArgs(t *testing.T) { + t.Parallel() + + found := instructions(t, productDockerfile) + + for _, arg := range versionArgs() { + require.GreaterOrEqual(t, indexOf(found, "ARG "+arg), 0, + "%s must declare `ARG %s` so the host can pass it in", + productDockerfile, arg) + + assertLdflagReferences(t, found, arg) + } +} + +// TestProductDockerfileDoesNotDeriveVersionItself is the anti-regression +// for the original defect: the container ran `git rev-parse`, but .git +// is not in the build context, so it always resolved to "unknown". No +// git command may reach into a build that cannot see the history. +func TestProductDockerfileDoesNotDeriveVersionItself(t *testing.T) { + t.Parallel() + + text := instructionText(readRepoFile(t, productDockerfile)) + + assert.NotContains(t, text, "git ", + "%s must not run git: .git is excluded from the build context, so"+ + " any value it derives is wrong. Pass version, commit and date"+ + " in as build args instead.", productDockerfile) +} + +// TestDockerScriptComputesVersionOnTheHost fails unless script/docker +// derives each value where .git exists and passes it as a build arg, +// with VERSION coming from script/version so a Docker build reports the +// same string a local build of the same tree would. +func TestDockerScriptComputesVersionOnTheHost(t *testing.T) { + t.Parallel() + + script := readRepoFile(t, dockerScript) + + for _, arg := range versionArgs() { + assert.Contains(t, script, "--build-arg "+arg+"=", + "%s must pass --build-arg %s to the build", dockerScript, arg) + } + + assert.Contains(t, script, "/version", + "%s must take VERSION from script/version, the source of truth"+ + " shared with the Makefile", dockerScript) +} + +// assertLdflagReferences fails unless some build instruction stamps the +// named variable from the ARG (a ${arg} reference), not from a value +// computed inside the container. +func assertLdflagReferences(t *testing.T, found []string, arg string) { + t.Helper() + + for _, instruction := range found { + if strings.HasPrefix(instruction, "RUN ") && + strings.Contains(instruction, "go build") && + strings.Contains(instruction, "${"+arg+"}") { + return + } + } + + assert.Fail(t, "version arg is declared but never stamped", + "the go build in %s must reference ${%s} in its ldflags, or the"+ + " arg is passed and discarded", productDockerfile, arg) +} diff --git a/cmd/vaultik/main.go b/cmd/vaultik/main.go index 4cda5a9..7340502 100644 --- a/cmd/vaultik/main.go +++ b/cmd/vaultik/main.go @@ -10,6 +10,16 @@ import ( ) 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 if cpuProfile := os.Getenv("VAULTIK_CPUPROFILE"); cpuProfile != "" { f, err := os.Create(cpuProfile) //nolint:gosec // G304: operator-set path @@ -46,5 +56,5 @@ func main() { }() } - cli.Entry() + return cli.Entry() } diff --git a/internal/cli/app.go b/internal/cli/app.go index 492f7e4..e2d8331 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -11,6 +11,7 @@ import ( "os/signal" "path/filepath" "strings" + "sync" "syscall" "time" @@ -196,13 +197,90 @@ func RunApp(ctx context.Context, app *fx.App) error { } } +// errReported marks a failure the operation has already shown the user +// (and deliberately withheld under --json). Entry turns it into a +// non-zero exit status without printing anything further, so the error +// line is not doubled. It flows up from RunOperation through cobra to +// Entry. +var errReported = errors.New("operation failed") + +// RunOperation runs op against the Vaultik instance inside the fx app +// and turns a failure into a returned error rather than an os.Exit from +// within the goroutine. An os.Exit there skipped main's deferred +// profile writers -- so profiling a failing command yielded a truncated +// profile (issue #75) -- and RunWithApp's PID-lock release, and denied +// the app any graceful shutdown; returning the error to the top runs +// all three. +// +// op runs in a goroutine so OnStart returns promptly and an interrupt +// can still cancel through OnStop; when it finishes, success or failure, +// it triggers shutdown, which is what lets RunWithApp return. report is +// called with a non-canceled failure so the caller can log it (and +// suppress it under --json) before it becomes errReported. A context +// cancellation is the interrupt path, not a failure: it is neither +// reported nor counted as one. +func RunOperation( + ctx context.Context, opts AppOptions, + op func(v *vaultik.Vaultik) error, report func(err error), +) error { + var ( + mu sync.Mutex + failed bool + ) + + opts.Invokes = append(opts.Invokes, + fx.Invoke(func(v *vaultik.Vaultik, lc fx.Lifecycle) { + lc.Append(fx.Hook{ + OnStart: func(_ context.Context) error { + go func() { + err := op(v) + if err != nil && !errors.Is(err, context.Canceled) { + report(err) + + mu.Lock() + failed = true + mu.Unlock() + } + + stopErr := v.Shutdowner.Shutdown() + if stopErr != nil { + log.Error("Failed to shutdown", "error", stopErr) + } + }() + + return nil + }, + OnStop: func(_ context.Context) error { + v.Cancel() + + return nil + }, + }) + })) + + err := RunWithApp(ctx, opts) + if err != nil { + return err + } + + // The goroutine sets failed before triggering the shutdown that lets + // RunWithApp return, so the write is in place by the time we read it. + mu.Lock() + defer mu.Unlock() + + if failed { + return errReported + } + + return nil +} + // runVaultikApp runs the standard single-operation command lifecycle // shared by the list/purge/verify/remove/remote-info subcommands: -// resolve the config, start the fx app, run op against the Vaultik -// instance in a goroutine, report a failure prefixed with failMsg -// (suppressed while suppressErrors is true, e.g. under --json), then -// trigger shutdown. The operation is cancelled when the app stops. -// extraQuiet is OR-ed into LogOptions.Quiet (e.g. --json output modes). +// resolve the config, then run op against the Vaultik instance through +// RunOperation, reporting a failure prefixed with failMsg (suppressed +// while suppressErrors is true, e.g. under --json). extraQuiet is OR-ed +// into LogOptions.Quiet (e.g. --json output modes). func runVaultikApp( cmd *cobra.Command, extraQuiet, suppressErrors bool, failMsg string, op func(v *vaultik.Vaultik) error, @@ -214,47 +292,20 @@ func runVaultikApp( rootFlags := GetRootFlags() - return RunWithApp(cmd.Context(), AppOptions{ + return RunOperation(cmd.Context(), AppOptions{ ConfigPath: configPath, LogOptions: log.Options{ Verbose: rootFlags.Verbose, Debug: rootFlags.Debug, Quiet: rootFlags.Quiet || extraQuiet, }, - Modules: []fx.Option{}, - Invokes: []fx.Option{ - 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) - } + }, op, func(err error) { + if suppressErrors { + return + } - 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 - }, - }) - }), - }, + log.Error(failMsg, "error", err) + ReportErrorf("%s: %v", failMsg, err) }) } diff --git a/internal/cli/entry.go b/internal/cli/entry.go index 5f405cc..917e6e7 100644 --- a/internal/cli/entry.go +++ b/internal/cli/entry.go @@ -1,6 +1,7 @@ package cli import ( + "errors" "io" "os" "strings" @@ -19,7 +20,11 @@ const shortCommitLen = 12 // flag is present in os.Args — see bannerSuppressedInArgs), executes the // root cobra command, and routes any returned error through the // 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) rootCmd := NewRootCommand() @@ -27,9 +32,19 @@ func Entry() { err := rootCmd.Execute() if err != nil { - ReportErrorf("%s", err.Error()) - os.Exit(1) + // An operation that ran inside the fx app has already reported + // its own failure (and suppressed it under --json); errReported + // says so. Printing it again here would double the error line. + // Every other error — bad arguments, a config that would not + // load — reaches Entry unreported, so it is shown here. + if !errors.Is(err, errReported) { + ReportErrorf("%s", err.Error()) + } + + return 1 } + + return 0 } // emitStartupBanner writes the startup banner to w unless args (the diff --git a/internal/cli/entry_banner_test.go b/internal/cli/entry_banner_test.go index 1ffad8a..cbb51e3 100644 --- a/internal/cli/entry_banner_test.go +++ b/internal/cli/entry_banner_test.go @@ -230,7 +230,7 @@ func TestEntryJSONStdoutIsExactlyOneDocument(t *testing.T) { programName, flagConfig, configPath, cmdSnapshot, cmdList, flagJSON, } - stdout := captureProcessStdout(t, Entry) + stdout := captureProcessStdout(t, func() { _ = Entry() }) requireExactlyOneJSONDocument(t, stdout) diff --git a/internal/cli/entry_prune_json_test.go b/internal/cli/entry_prune_json_test.go index 15b9cf8..7f4d6e9 100644 --- a/internal/cli/entry_prune_json_test.go +++ b/internal/cli/entry_prune_json_test.go @@ -81,7 +81,7 @@ func TestEntryPruneJSONStdoutIsExactlyOneDocument(t *testing.T) { programName, flagConfig, configPath, cmdPrune, flagJSON, } - stdout := captureProcessStdout(t, Entry) + stdout := captureProcessStdout(t, func() { _ = Entry() }) requireExactlyOneJSONDocument(t, stdout) diff --git a/internal/cli/entry_status_test.go b/internal/cli/entry_status_test.go new file mode 100644 index 0000000..9042bce --- /dev/null +++ b/internal/cli/entry_status_test.go @@ -0,0 +1,58 @@ +package cli //nolint:testpackage // shares programName and the capture helpers + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestEntryReturnsStatusCode pins the contract main() relies on for +// issue #75: Entry reports success or failure through its return value +// and never calls os.Exit. An os.Exit from inside Entry would skip +// main's deferred profile writers and truncate the profile of a failing +// command. main turns this code into os.Exit only after those defers +// run, so a failing command must come back with a non-zero code rather +// than ending the process here. +// +// Stdout is captured only to keep the banner and command output off the +// test log; the assertion is on the returned code. +// +//nolint:paralleltest // replaces os.Args and rootFlags +func TestEntryReturnsStatusCode(t *testing.T) { + for _, testCase := range []struct { + name string + args []string + want int + }{ + { + // version is self-contained: it needs no config and no + // destination store, so it exercises the success path. + name: "successful command returns zero", + args: []string{programName, "version"}, + want: 0, + }, + { + name: "unknown command returns one", + args: []string{programName, "no-such-command"}, + want: 1, + }, + } { + t.Run(testCase.name, func(t *testing.T) { + previousArgs := os.Args + + t.Cleanup(func() { + os.Args = previousArgs + rootFlags = RootFlags{} + }) + + os.Args = testCase.args + + var code int + + _ = captureProcessStdout(t, func() { code = Entry() }) + + assert.Equal(t, testCase.want, code) + }) + } +} diff --git a/internal/cli/info.go b/internal/cli/info.go index 2e5cc9c..fe5a652 100644 --- a/internal/cli/info.go +++ b/internal/cli/info.go @@ -1,12 +1,7 @@ package cli import ( - "context" - "errors" - "os" - "github.com/spf13/cobra" - "go.uber.org/fx" "sneak.berlin/go/vaultik/internal/log" "sneak.berlin/go/vaultik/internal/vaultik" ) @@ -33,44 +28,18 @@ func NewInfoCommand() *cobra.Command { // Use the app framework rootFlags := GetRootFlags() - return RunWithApp(cmd.Context(), AppOptions{ + return RunOperation(cmd.Context(), AppOptions{ ConfigPath: configPath, LogOptions: log.Options{ Verbose: rootFlags.Verbose, Debug: rootFlags.Debug, Quiet: rootFlags.Quiet, }, - Modules: []fx.Option{}, - Invokes: []fx.Option{ - fx.Invoke(func(v *vaultik.Vaultik, lc fx.Lifecycle) { - lc.Append(fx.Hook{ - 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 - }, - }) - }), - }, + }, func(v *vaultik.Vaultik) error { + return v.ShowInfo() + }, func(err error) { + log.Error("Failed to show info", "error", err) + ReportErrorf("Failed to show info: %v", err) }) }, } diff --git a/internal/cli/prune.go b/internal/cli/prune.go index 950e7fd..f37c94c 100644 --- a/internal/cli/prune.go +++ b/internal/cli/prune.go @@ -1,12 +1,7 @@ package cli import ( - "context" - "errors" - "os" - "github.com/spf13/cobra" - "go.uber.org/fx" "sneak.berlin/go/vaultik/internal/log" "sneak.berlin/go/vaultik/internal/vaultik" ) @@ -41,51 +36,22 @@ work (e.g. after a crashed backup or to reclaim storage).`, // Use the app framework like other commands rootFlags := GetRootFlags() - return RunWithApp(cmd.Context(), AppOptions{ + return RunOperation(cmd.Context(), AppOptions{ ConfigPath: configPath, LogOptions: log.Options{ Verbose: rootFlags.Verbose, Debug: rootFlags.Debug, Quiet: rootFlags.Quiet || opts.JSON, }, - Modules: []fx.Option{}, - Invokes: []fx.Option{ - fx.Invoke(func(v *vaultik.Vaultik, lc fx.Lifecycle) { - lc.Append(fx.Hook{ - 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) - } + }, func(v *vaultik.Vaultik) error { + return v.Prune(opts) + }, func(err error) { + if opts.JSON { + return + } - os.Exit(1) - } - } - - // 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 - }, - }) - }), - }, + log.Error("Prune operation failed", "error", err) + ReportErrorf("Prune failed: %v", err) }) }, } diff --git a/internal/cli/remote.go b/internal/cli/remote.go index 61987a3..1c8d315 100644 --- a/internal/cli/remote.go +++ b/internal/cli/remote.go @@ -1,12 +1,9 @@ package cli import ( - "context" "errors" - "os" "github.com/spf13/cobra" - "go.uber.org/fx" "sneak.berlin/go/vaultik/internal/log" "sneak.berlin/go/vaultik/internal/vaultik" ) @@ -83,47 +80,22 @@ func newRemoteInfoCommand() *cobra.Command { rootFlags := GetRootFlags() - return RunWithApp(cmd.Context(), AppOptions{ + return RunOperation(cmd.Context(), AppOptions{ ConfigPath: configPath, LogOptions: log.Options{ Verbose: rootFlags.Verbose, Debug: rootFlags.Debug, Quiet: rootFlags.Quiet || jsonOutput, }, - Modules: []fx.Option{}, - Invokes: []fx.Option{ - fx.Invoke(func(v *vaultik.Vaultik, lc fx.Lifecycle) { - lc.Append(fx.Hook{ - 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) - } + }, func(v *vaultik.Vaultik) error { + return v.RemoteInfo(jsonOutput) + }, func(err error) { + if jsonOutput { + return + } - 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 - }, - }) - }), - }, + log.Error("Failed to get remote info", "error", err) + ReportErrorf("Failed to get remote info: %v", err) }) }, } diff --git a/internal/cli/snapshot.go b/internal/cli/snapshot.go index 7460169..4422320 100644 --- a/internal/cli/snapshot.go +++ b/internal/cli/snapshot.go @@ -1,13 +1,10 @@ package cli import ( - "context" "errors" "fmt" - "os" "github.com/spf13/cobra" - "go.uber.org/fx" "sneak.berlin/go/vaultik/internal/log" "sneak.berlin/go/vaultik/internal/vaultik" ) @@ -86,7 +83,8 @@ specifying a path using --config or by setting VAULTIK_CONFIG to a path.`, // Use the backup functionality from cli package rootFlags := GetRootFlags() - return RunWithApp(cmd.Context(), AppOptions{ + // --cron suppression is wired through v.UI by setupGlobals. + return RunOperation(cmd.Context(), AppOptions{ ConfigPath: configPath, LogOptions: log.Options{ Verbose: rootFlags.Verbose, @@ -94,42 +92,11 @@ specifying a path using --config or by setting VAULTIK_CONFIG to a path.`, Cron: opts.Cron, Quiet: rootFlags.Quiet, }, - Modules: []fx.Option{}, - Invokes: []fx.Option{ - fx.Invoke(func(v *vaultik.Vaultik, lc fx.Lifecycle) { - lc.Append(fx.Hook{ - 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 - }, - }) - }), - }, + }, func(v *vaultik.Vaultik) error { + return v.CreateSnapshot(opts) + }, func(err error) { + log.Error("Snapshot creation failed", "error", err) + ReportErrorf("Snapshot creation failed: %v", err) }) }, } @@ -232,47 +199,22 @@ func newSnapshotVerifyCommand() *cobra.Command { rootFlags := GetRootFlags() - return RunWithApp(cmd.Context(), AppOptions{ + return RunOperation(cmd.Context(), AppOptions{ ConfigPath: configPath, LogOptions: log.Options{ Verbose: rootFlags.Verbose, Debug: rootFlags.Debug, Quiet: rootFlags.Quiet || opts.JSON, }, - Modules: []fx.Option{}, - Invokes: []fx.Option{ - fx.Invoke(func(v *vaultik.Vaultik, lc fx.Lifecycle) { - lc.Append(fx.Hook{ - 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) - } + }, func(v *vaultik.Vaultik) error { + return v.VerifySnapshotWithOptions(snapshotID, opts) + }, func(err error) { + if opts.JSON { + return + } - 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 - }, - }) - }), - }, + log.Error("Verification failed", "error", err) + ReportErrorf("Verification failed: %v", err) }) }, } diff --git a/internal/cli/snapshot_restore.go b/internal/cli/snapshot_restore.go index cf28e8e..6716876 100644 --- a/internal/cli/snapshot_restore.go +++ b/internal/cli/snapshot_restore.go @@ -1,16 +1,8 @@ package cli import ( - "context" - "errors" - "os" - "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/storage" "sneak.berlin/go/vaultik/internal/vaultik" ) @@ -25,15 +17,6 @@ type RestoreOptions struct { 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 func newSnapshotRestoreCommand() *cobra.Command { opts := &RestoreOptions{} @@ -77,7 +60,8 @@ Examples: return cmd } -// runRestore parses arguments and runs the restore operation through the app framework +// runRestore parses arguments and runs the restore operation through the +// app framework. func runRestore(cmd *cobra.Command, args []string, opts *RestoreOptions) error { snapshotID := args[0] @@ -86,87 +70,30 @@ func runRestore(cmd *cobra.Command, args []string, opts *RestoreOptions) error { opts.Paths = args[restoreMinArgs:] } - // Use unified config resolution configPath, err := ResolveConfigPath() if err != nil { return err } - // Use the app framework like other commands rootFlags := GetRootFlags() - return RunWithApp(cmd.Context(), AppOptions{ + return RunOperation(cmd.Context(), AppOptions{ ConfigPath: configPath, LogOptions: log.Options{ Verbose: rootFlags.Verbose, Debug: rootFlags.Debug, Quiet: rootFlags.Quiet, }, - Modules: buildRestoreModules(), - Invokes: buildRestoreInvokes(snapshotID, opts), + }, func(v *vaultik.Vaultik) error { + return v.Restore(&vaultik.RestoreOptions{ + SnapshotID: snapshotID, + TargetDir: opts.TargetDir, + Paths: opts.Paths, + Verify: opts.Verify, + SkipErrors: rootFlags.SkipErrors, + }) + }, func(err error) { + log.Error("Restore operation failed", "error", err) + ReportErrorf("Restore failed: %v", err) }) } - -// buildRestoreModules returns the fx.Options for dependency injection in restore -func buildRestoreModules() []fx.Option { - return []fx.Option{ - fx.Provide(fx.Annotate( - func(g *globals.Globals, cfg *config.Config, - storer storage.Storer, v *vaultik.Vaultik, shutdowner fx.Shutdowner) *RestoreApp { - return &RestoreApp{ - Globals: g, - Config: cfg, - Storage: storer, - Vaultik: v, - Shutdowner: shutdowner, - } - }, - )), - } -} - -// buildRestoreInvokes returns the fx.Options that wire up the restore lifecycle -func buildRestoreInvokes(snapshotID string, opts *RestoreOptions) []fx.Option { - return []fx.Option{ - fx.Invoke(func(app *RestoreApp, lc fx.Lifecycle) { - lc.Append(fx.Hook{ - OnStart: func(_ context.Context) error { - // Start the restore operation in a goroutine - go func() { - // Run the restore operation - restoreOpts := &vaultik.RestoreOptions{ - SnapshotID: snapshotID, - TargetDir: opts.TargetDir, - Paths: opts.Paths, - Verify: opts.Verify, - SkipErrors: GetRootFlags().SkipErrors, - } - - err := app.Vaultik.Restore(restoreOpts) - if err != nil { - if !errors.Is(err, context.Canceled) { - log.Error("Restore operation failed", "error", err) - ReportErrorf("Restore failed: %v", err) - os.Exit(1) - } - } - - // Shutdown the app when restore completes - err = app.Shutdowner.Shutdown() - if err != nil { - log.Error("Failed to shutdown", "error", err) - } - }() - - return nil - }, - OnStop: func(_ context.Context) error { - log.Debug("Stopping restore operation") - app.Vaultik.Cancel() - - return nil - }, - }) - }), - } -} diff --git a/script/docker b/script/docker index 3fb027b..b7f29bd 100755 --- a/script/docker +++ b/script/docker @@ -24,7 +24,24 @@ main() { # whether the tree is clean. The Dockerfile now refuses to build # without a non-empty value, so this is required, not optional. 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-, 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" \ + --build-arg VERSION="$version" \ + --build-arg COMMIT="$commit" \ + --build-arg COMMIT_DATE="$commit_date" \ -t "$("$SCRIPT_DIR/projectname")" . }