Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c22363cc3c |
+19
-3
@@ -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-<sha> / -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
|
||||
|
||||
@@ -25,16 +25,6 @@ release" is exactly the contradiction
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
+11
-1
@@ -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()
|
||||
}
|
||||
|
||||
+90
-39
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+18
-3
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package cli //nolint:testpackage // shares programName and the capture helpers
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// TestEntryReturnsStatusCode pins the contract main() relies on for
|
||||
// issue #75: Entry reports success or failure through its return value
|
||||
// and never calls os.Exit. An os.Exit from inside Entry would skip
|
||||
// main's deferred profile writers and truncate the profile of a failing
|
||||
// command. main turns this code into os.Exit only after those defers
|
||||
// run, so a failing command must come back with a non-zero code rather
|
||||
// than ending the process here.
|
||||
//
|
||||
// Stdout is captured only to keep the banner and command output off the
|
||||
// test log; the assertion is on the returned code.
|
||||
//
|
||||
//nolint:paralleltest // replaces os.Args and rootFlags
|
||||
func TestEntryReturnsStatusCode(t *testing.T) {
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
args []string
|
||||
want int
|
||||
}{
|
||||
{
|
||||
// version is self-contained: it needs no config and no
|
||||
// destination store, so it exercises the success path.
|
||||
name: "successful command returns zero",
|
||||
args: []string{programName, "version"},
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
name: "unknown command returns one",
|
||||
args: []string{programName, "no-such-command"},
|
||||
want: 1,
|
||||
},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
previousArgs := os.Args
|
||||
|
||||
t.Cleanup(func() {
|
||||
os.Args = previousArgs
|
||||
rootFlags = RootFlags{}
|
||||
})
|
||||
|
||||
os.Args = testCase.args
|
||||
|
||||
var code int
|
||||
|
||||
_ = captureProcessStdout(t, func() { code = Entry() })
|
||||
|
||||
assert.Equal(t, testCase.want, code)
|
||||
})
|
||||
}
|
||||
}
|
||||
+6
-37
@@ -1,12 +1,7 @@
|
||||
package cli
|
||||
|
||||
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)
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
+9
-43
@@ -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)
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
+9
-37
@@ -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)
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
+16
-74
@@ -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)
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
+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)
|
||||
}
|
||||
|
||||
// Decrypt blob
|
||||
decryptedReader, err := decryptor.DecryptStream(reader)
|
||||
// Hash the encrypted blob data as it streams through to decryption
|
||||
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 {
|
||||
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()
|
||||
|
||||
// A blob's hash — its remote name — is the double SHA256 of its
|
||||
// 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)
|
||||
chunkCount, err := v.verifyBlobChunks(db, blobInfo.Hash, decompressor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = v.verifyBlobFinalIntegrity(hashedStream, plaintextHasher, blobInfo.Hash)
|
||||
err = v.verifyBlobFinalIntegrity(decompressor, blobHasher, blobInfo.Hash)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -473,13 +470,14 @@ func (v *Vaultik) verifyBlobChunks(
|
||||
}
|
||||
|
||||
// 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(
|
||||
plaintext io.Reader, plaintextHasher hash.Hash, expectedHash string,
|
||||
decompressor io.Reader, blobHasher hash.Hash, expectedHash string,
|
||||
) error {
|
||||
// Verify no remaining data in blob - if the chunk list is accurate,
|
||||
// the blob should be fully consumed.
|
||||
remaining, err := io.Copy(io.Discard, plaintext)
|
||||
remaining, err := io.Copy(io.Discard, decompressor)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
// The blob hash is the double SHA256 of its plaintext content.
|
||||
firstHash := plaintextHasher.Sum(nil)
|
||||
secondHash := sha256.Sum256(firstHash)
|
||||
calculatedBlobHash := hex.EncodeToString(secondHash[:])
|
||||
|
||||
// Verify blob hash matches the encrypted data we downloaded
|
||||
calculatedBlobHash := hex.EncodeToString(blobHasher.Sum(nil))
|
||||
if calculatedBlobHash != expectedHash {
|
||||
return fmt.Errorf("%w: calculated %s, expected %s",
|
||||
errBlobHashMismatch, calculatedBlobHash, expectedHash)
|
||||
|
||||
@@ -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-<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" \
|
||||
--build-arg VERSION="$version" \
|
||||
--build-arg COMMIT="$commit" \
|
||||
--build-arg COMMIT_DATE="$commit_date" \
|
||||
-t "$("$SCRIPT_DIR/projectname")" .
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user