From d52b4f1240000c3a515a3ace863918c57f49b23d Mon Sep 17 00:00:00 2001 From: clawbot Date: Fri, 2 Oct 2026 14:16:02 +0200 Subject: [PATCH] Let a plain docker build pass and stamp the git version (closes #57) The size tests skip a case whose secret needs more locked memory than the process can lock, found by locking a buffer of that size: memguard panics otherwise, and a plain `docker build .` runs under an 8 MiB RLIMIT_MEMLOCK. script/cibuild, or any process allowed to lock past the limit, runs every case. The build stage stamps the VERSION build argument, else `git describe --tags --always`, and fails when .git is present but yields no version. `make build` stamps `git describe` too instead of the fixed 0.1.0. .dockerignore keeps .git/config out; script/docker is now the canonical copy. Model: opus-5-5 Co-authored-by: clawbot --- .dockerignore | 6 +++++ Dockerfile | 15 ++++++++++- Makefile | 2 +- TODO.md | 8 ++++++ go.mod | 2 +- internal/cli/secrets_size_test.go | 44 +++++++++++++++++++++++++++++++ script/cibuild | 5 ++-- script/docker | 13 +++++++-- 8 files changed, 88 insertions(+), 7 deletions(-) diff --git a/.dockerignore b/.dockerignore index 187a2d9..e532cdd 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,3 +1,9 @@ +# .git is sent without its config. Without a VERSION build argument the +# stage that compiles runs `git describe --tags --always` on .git, which +# does not need .git/config; that file can hold a credential, such as a +# password in a remote URL or the token the CI checkout step stores there. +.git/config + # Build artifacts secret coverage.out diff --git a/Dockerfile b/Dockerfile index bfe4d5f..4ecea59 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,7 +27,20 @@ RUN go mod download COPY . . RUN make test -RUN make build + +# The version stamped into the binary: the VERSION build argument when one +# is given, otherwise `git describe --tags --always` of the .git the build +# context carries: the tag on a tagged commit, tag-N-gHASH on a commit after +# one, the short commit when no tag is reachable. A context that carries .git +# and still yields no version fails the build. +ARG VERSION +RUN version="${VERSION:-$(git describe --tags --always)}"; \ + if [ -e .git ] && { [ -z "$version" ] || [ "$version" = dev ] || \ + [ "$version" = unknown ]; }; then \ + echo "no version could be derived although the build context carries .git" >&2; \ + exit 1; \ + fi; \ + make build VERSION="${version:-dev}" # Runtime stage # alpine 3.23 (2026-03-10) diff --git a/Makefile b/Makefile index 79558aa..dd771bd 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ export CGO_ENABLED=1 export DOCKER_HOST := ssh://root@ber1app1.local # Version information -VERSION := 0.1.0 +VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") GIT_COMMIT := $(shell git rev-parse HEAD 2>/dev/null || echo "unknown") LDFLAGS := -X 'git.eeqj.de/sneak/secret/internal/cli.Version=$(VERSION)' \ -X 'git.eeqj.de/sneak/secret/internal/cli.GitCommit=$(GIT_COMMIT)' diff --git a/TODO.md b/TODO.md index a0eae99..281cbac 100644 --- a/TODO.md +++ b/TODO.md @@ -25,6 +25,14 @@ Bring the repo into policy compliance in one commit: # Completed Steps +- 2026-10-02: A plain `docker build .` builds again: the size tests + skip a case that needs more locked memory than the process can + lock, and run every case under `script/cibuild`. The image stamps the + `VERSION` build argument, else `git describe --tags --always`, into + `Version`, and fails if `.git` is present but yields no version; + `make build` stamps `git describe` too, not a fixed `0.1.0`. + `.dockerignore` keeps `.git/config` out; `script/docker` is the + canonical copy. - 2026-08-07: Updated golangci-lint to v2.12.2 with the canonical `.golangci.yml` (all linters enabled minus the standard disable list, `lll` 88, tests linted); bumped the `Dockerfile` lint-stage diff --git a/go.mod b/go.mod index 841c3ee..594d650 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( github.com/stretchr/testify v1.8.4 github.com/tyler-smith/go-bip39 v1.1.0 golang.org/x/crypto v0.38.0 + golang.org/x/sys v0.33.0 golang.org/x/term v0.32.0 ) @@ -31,7 +32,6 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/spf13/pflag v1.0.6 // indirect - golang.org/x/sys v0.33.0 // indirect golang.org/x/text v0.25.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/internal/cli/secrets_size_test.go b/internal/cli/secrets_size_test.go index 670589d..72682d5 100644 --- a/internal/cli/secrets_size_test.go +++ b/internal/cli/secrets_size_test.go @@ -17,11 +17,51 @@ import ( "github.com/spf13/cobra" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "golang.org/x/sys/unix" ) // testVaultName is the vault name used by the size tests. const testVaultName = "test-vault" +// lockedBytesPerSecretByte bounds the locked memory that storing a secret +// holds at once: the buffers it is read into reach up to 1.5 times its +// size, and they are then copied into one more buffer of its size. +const lockedBytesPerSecretByte = 3 + +// skipIfLockedMemoryTooLow skips the test when this process cannot lock +// the memory a secret of size bytes needs, found by locking a buffer of +// that size and releasing it. memguard panics, ending the whole test run, +// when it cannot lock a buffer, and a plain `docker build .` runs the +// tests under an 8 MiB locked-memory limit (RLIMIT_MEMLOCK). A process +// allowed to lock past that limit runs every case. +func skipIfLockedMemoryTooLow(t *testing.T, size int) { + t.Helper() + + need := lockedBytesPerSecretByte * size + + buf, err := unix.Mmap(-1, 0, need, + unix.PROT_READ|unix.PROT_WRITE, unix.MAP_PRIVATE|unix.MAP_ANON) + require.NoError(t, err) + + lockErr := unix.Mlock(buf) + + // Unmapping the buffer also unlocks it. + err = unix.Munmap(buf) + require.NoError(t, err) + + if lockErr != nil { + var limit unix.Rlimit + + err = unix.Getrlimit(unix.RLIMIT_MEMLOCK, &limit) + require.NoError(t, err) + + t.Skipf("a %d-byte secret needs up to %d bytes of locked memory, "+ + "which could not be locked under the locked-memory limit "+ + "(RLIMIT_MEMLOCK) of %d bytes: %v", + size, need, limit.Cur, lockErr) + } +} + // newSizeTestVault creates an in-memory vault unlocked with the test // mnemonic and returns the filesystem and vault. // @@ -59,6 +99,7 @@ func newSizeTestVault(t *testing.T) (afero.Fs, *vault.Vault) { // verifies the outcome. func runAddSecretSizeCase(t *testing.T, size int, wantErr bool, errMsg string) { t.Helper() + skipIfLockedMemoryTooLow(t, size) fs, vlt := newSizeTestVault(t) @@ -110,6 +151,7 @@ func runAddSecretSizeCase(t *testing.T, size int, wantErr bool, errMsg string) { // verifies the outcome. func runImportSecretSizeCase(t *testing.T, size int, wantErr bool, errMsg string) { t.Helper() + skipIfLockedMemoryTooLow(t, size) fs, vlt := newSizeTestVault(t) @@ -300,6 +342,8 @@ func TestAddSecretBufferGrowth(t *testing.T) { for _, size := range sizes { t.Run(fmt.Sprintf("size_%d", size), func(t *testing.T) { + skipIfLockedMemoryTooLow(t, size) + fs, vlt := newSizeTestVault(t) // Create test data of exactly the specified size diff --git a/script/cibuild b/script/cibuild index ea520d9..3316194 100755 --- a/script/cibuild +++ b/script/cibuild @@ -1,8 +1,9 @@ #!/bin/sh # script/cibuild: run the CI build. The Dockerfile runs script/check # (via make check), so a successful build implies all checks pass. -# The Gitea workflow runs this on push. The memlock ulimit is required -# because the test suite uses memguard, which mlocks memory. +# The Gitea workflow runs this on push. The memlock ulimit lets the tests +# that lock large secrets in memory (memguard mlocks them) run; under the +# lower limit of a plain `docker build .` they are skipped. set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" diff --git a/script/docker b/script/docker index 2884e41..07b626c 100755 --- a/script/docker +++ b/script/docker @@ -1,7 +1,8 @@ #!/bin/sh # script/docker: build the Docker image tagged with the project name. # Identical in all repos; the tag comes from script/projectname. -# Generic: needs no adaptation. +# --no-cache because the gate phases the final stage depends on are RUN +# steps, and a cached one is a check that did not run. set -eu SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" @@ -9,7 +10,15 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" main() { cd "$ROOT" - docker build -t "$("$SCRIPT_DIR/projectname")" . + # Own line: a failing command substitution inside an argument does + # not trip `set -e`, so the inline form degrades silently to an + # empty constant. The VERSION build argument takes precedence over + # the version a build stage derives from the .git in the context. + version="$(git describe --tags --always --dirty 2>/dev/null || true)" + [ -n "$version" ] || version="unknown" + docker build --no-cache \ + --build-arg VERSION="$version" \ + -t "$("$SCRIPT_DIR/projectname")" . } main "$@"