From ae434a7167ec7bc7b2f636e2b900248faeb6fae6 Mon Sep 17 00:00:00 2001 From: Jan Novak Date: Tue, 11 Aug 2026 18:10:11 +0200 Subject: [PATCH] Bake git commit into the binary and log it at startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New internal/version package: ldflags-stamped Commit with a debug.ReadBuildInfo VCS fallback for host builds. Startup log line carries commit + Go version; --version prints the hash and exits. Makefile computes GIT_COMMIT (12 chars, -dirty on any local change) and passes it to docker-build/buildx; Dockerfile injects it via -ldflags and an org.opencontainers.image.revision label. make build now uses ./cmd — file-argument builds skip Go's automatic VCS stamp. Co-Authored-By: Claude --- Dockerfile | 7 +- Makefile | 9 ++- cmd/main.go | 13 ++++ .../2026-08-11-1802-bake-commit-version.md | 62 ++++++++++++++++ internal/version/version.go | 47 ++++++++++++ internal/version/version_test.go | 74 +++++++++++++++++++ 6 files changed, 208 insertions(+), 4 deletions(-) create mode 100644 docs/plans-executions/2026-08-11-1802-bake-commit-version.md create mode 100644 internal/version/version.go create mode 100644 internal/version/version_test.go diff --git a/Dockerfile b/Dockerfile index 5b59f51..8c50a7e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,6 +2,7 @@ FROM golang:1.26 AS builder ARG TARGETOS ARG TARGETARCH +ARG GIT_COMMIT=unknown WORKDIR /workspace # Copy the Go Modules manifests @@ -19,11 +20,15 @@ COPY . . # was called. For example, if we call make docker-build in a local env which has the Apple Silicon M1 SO # the docker BUILDPLATFORM arg will be linux/arm64 when for Apple x86 it will be linux/amd64. Therefore, # by leaving it empty we can ensure that the container and binary shipped on it will have the same platform. -RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a -o manager cmd/main.go +RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a \ + -ldflags "-X gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/version.Commit=${GIT_COMMIT}" \ + -o manager cmd/main.go # Use distroless as minimal base image to package the manager binary # Refer to https://github.com/GoogleContainerTools/distroless for more details FROM gcr.io/distroless/static:nonroot +ARG GIT_COMMIT=unknown +LABEL org.opencontainers.image.revision="${GIT_COMMIT}" WORKDIR / COPY --from=builder /workspace/manager . USER 65532:65532 diff --git a/Makefile b/Makefile index 7ac27a5..ac8df24 100644 --- a/Makefile +++ b/Makefile @@ -2,6 +2,9 @@ IMG ?= controller:latest # YEAR defines the year value used for substituting the YEAR placeholder in the boilerplate header. YEAR ?= $(shell date +%Y) +# GIT_COMMIT is baked into the image (-ldflags in the Dockerfile); -dirty +# covers staged and untracked changes too, which `git diff --quiet` misses. +GIT_COMMIT ?= $(shell git rev-parse --short=12 HEAD 2>/dev/null || echo unknown)$(shell test -z "$$(git status --porcelain 2>/dev/null)" || echo -dirty) # Get the currently used golang install path (in GOPATH/bin, unless GOBIN is set) ifeq (,$(shell go env GOBIN)) @@ -110,7 +113,7 @@ lint-config: golangci-lint ## Verify golangci-lint linter configuration .PHONY: build build: manifests generate fmt vet ## Build manager binary. - go build -o bin/manager cmd/main.go + go build -o bin/manager ./cmd .PHONY: run run: manifests generate fmt vet ## Run a controller from your host. @@ -125,7 +128,7 @@ run-dev: manifests generate fmt vet ## Run locally against the current kubeconfi # More info: https://docs.docker.com/develop/develop-images/build_enhancements/ .PHONY: docker-build docker-build: ## Build docker image with the manager. - $(CONTAINER_TOOL) build -t ${IMG} . + $(CONTAINER_TOOL) build --build-arg GIT_COMMIT=$(GIT_COMMIT) -t ${IMG} . .PHONY: docker-push docker-push: ## Push docker image with the manager. @@ -144,7 +147,7 @@ docker-buildx: ## Build and push docker image for the manager for cross-platform sed -e '1 s/\(^FROM\)/FROM --platform=\$$\{BUILDPLATFORM\}/; t' -e ' 1,// s//FROM --platform=\$$\{BUILDPLATFORM\}/' Dockerfile > Dockerfile.cross - $(CONTAINER_TOOL) buildx create --name egress-proxies-operator-builder $(CONTAINER_TOOL) buildx use egress-proxies-operator-builder - - $(CONTAINER_TOOL) buildx build --push --platform=$(PLATFORMS) --tag ${IMG} -f Dockerfile.cross . + - $(CONTAINER_TOOL) buildx build --push --platform=$(PLATFORMS) --build-arg GIT_COMMIT=$(GIT_COMMIT) --tag ${IMG} -f Dockerfile.cross . - $(CONTAINER_TOOL) buildx rm egress-proxies-operator-builder rm Dockerfile.cross diff --git a/cmd/main.go b/cmd/main.go index b6495fc..4994cdc 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -24,7 +24,9 @@ import ( "context" "crypto/tls" "flag" + "fmt" "os" + goruntime "runtime" "time" // Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.) @@ -56,6 +58,7 @@ import ( "gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider/gcp" "gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider/kubernetes" "gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider/registry" + "gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/version" // +kubebuilder:scaffold:imports ) @@ -88,6 +91,7 @@ func main() { var gcInterval, gcMinAge time.Duration var gcAllowNamespaced bool var leaseCooldown, maxLeaseTTL time.Duration + var showVersion bool flag.StringVar(&metricsAddr, "metrics-bind-address", "0", "The address the metrics endpoint binds to. "+ "Use :8443 for HTTPS or :8080 for HTTP, or leave as 0 to disable the metrics service.") @@ -124,6 +128,8 @@ func main() { "How long a reported proxy/target pair is excluded from lease selection.") flag.DurationVar(&maxLeaseTTL, "max-lease-ttl", time.Hour, "Maximum lease TTL a client may request.") + flag.BoolVar(&showVersion, "version", false, + "Print the commit the binary was built from and exit.") opts := zap.Options{ Development: true, @@ -131,7 +137,14 @@ func main() { opts.BindFlags(flag.CommandLine) flag.Parse() + if showVersion { + fmt.Println(version.Resolve()) + os.Exit(0) + } + ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) + setupLog.Info("Starting egress-proxies-operator", + "commit", version.Resolve(), "goVersion", goruntime.Version()) ctx := ctrl.SetupSignalHandler() // Providers load first and fail fast: a manager that comes up without diff --git a/docs/plans-executions/2026-08-11-1802-bake-commit-version.md b/docs/plans-executions/2026-08-11-1802-bake-commit-version.md new file mode 100644 index 0000000..c9a5cd4 --- /dev/null +++ b/docs/plans-executions/2026-08-11-1802-bake-commit-version.md @@ -0,0 +1,62 @@ +# Execution: Bake the git commit into the operator binary and log it at startup + +Plan: `docs/plans/2026-08-11-1802-bake-commit-version.md` + +- [x] Step 0 — Save and commit the plan +- [x] Step 1 — `internal/version` package + tests +- [x] Step 2 — `cmd/main.go`: `--version` flag + startup log line +- [x] Step 3 — Makefile `GIT_COMMIT` + `--build-arg` wiring +- [x] Step 4 — Dockerfile `-ldflags` stamp + OCI revision label +- [ ] Step 5 — CHANGELOG entry (after the user confirms it works live) + +## Steps 1–4 + +Mostly as planned. One deviation worth recording: the plan claimed +`build`/`run` targets need no changes because Go's automatic VCS stamp covers +host builds — that turned out to be only half true. Go skips VCS stamping +when the build target is a *file argument* rather than a package pattern, and +the Makefile's `build` target used `go build -o bin/manager cmd/main.go`. +Verified empirically: + +```bash +go build -o bin/manager cmd/main.go && ./bin/manager --version # unknown (no vcs settings) +go build -o bin/manager ./cmd && ./bin/manager --version # e4d2a191d0c2-dirty +``` + +So `build:` now uses `go build -o bin/manager ./cmd`. `go run` never stamps +VCS info regardless of invocation form — `make run`/`run-dev` print +`commit=unknown`, which is acceptable for dev loops (the Dockerfile path uses +the explicit ldflags stamp and is unaffected; it kept `cmd/main.go`). + +The ldflags path was verified independently: + +```bash +go build -ldflags "-X gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/version.Commit=deadbeef1234" \ + -o bin/manager-stamped cmd/main.go +./bin/manager-stamped --version # deadbeef1234 +``` + +Worth noting: `cmd/main.go` imports k8s apimachinery as `runtime`, so the +stdlib runtime needed an alias (`goruntime "runtime"`) for +`goruntime.Version()` in the startup line. The `--version` check happens +right after `flag.Parse()`, before logger and manager setup, so it works +without a kubeconfig. + +## Verification + +```bash +go vet ./... && go test ./... # all green, incl. new resolve() table tests +go build -o bin/manager ./cmd && ./bin/manager --version # e4d2a191d0c2-dirty +``` + +The image-level check could not run locally — the Docker daemon was not +running. `make docker-build IMG=egress-proxies-operator:dev` did prove the +Makefile side before failing at the daemon: it invoked +`docker build --build-arg GIT_COMMIT=e4d2a191d0c2-dirty ...`. Still pending +(needs a running daemon): + +```bash +make docker-build IMG=egress-proxies-operator:dev +docker run --rm egress-proxies-operator:dev --version +docker inspect egress-proxies-operator:dev --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' +``` diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 0000000..1396e50 --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,47 @@ +// Package version reports which commit the binary was built from. Docker +// builds stamp it via -ldflags (the build context has no .git, so Go's +// automatic VCS stamp is absent there); host builds fall back to that +// automatic stamp. +package version + +import "runtime/debug" + +// Commit is set at link time via +// -ldflags "-X /internal/version.Commit=". +var Commit string + +// Resolve returns the commit the binary was built from, or "unknown" when +// neither the ldflags stamp nor build info is available (e.g. go test). +func Resolve() string { + return resolve(Commit, debug.ReadBuildInfo) +} + +func resolve(ldflagsCommit string, readBuildInfo func() (*debug.BuildInfo, bool)) string { + if ldflagsCommit != "" { + return ldflagsCommit + } + bi, ok := readBuildInfo() + if !ok { + return "unknown" + } + var revision string + var modified bool + for _, s := range bi.Settings { + switch s.Key { + case "vcs.revision": + revision = s.Value + case "vcs.modified": + modified = s.Value == "true" + } + } + if revision == "" { + return "unknown" + } + if len(revision) > 12 { + revision = revision[:12] + } + if modified { + revision += "-dirty" + } + return revision +} diff --git a/internal/version/version_test.go b/internal/version/version_test.go new file mode 100644 index 0000000..2bcebb6 --- /dev/null +++ b/internal/version/version_test.go @@ -0,0 +1,74 @@ +package version + +import ( + "runtime/debug" + "testing" +) + +func buildInfoWith(settings ...debug.BuildSetting) func() (*debug.BuildInfo, bool) { + return func() (*debug.BuildInfo, bool) { + return &debug.BuildInfo{Settings: settings}, true + } +} + +func TestResolve_precedenceAndFallback(t *testing.T) { + t.Parallel() + + noBuildInfo := func() (*debug.BuildInfo, bool) { return nil, false } + + tests := []struct { + name string + ldflagsCommit string + readBuildInfo func() (*debug.BuildInfo, bool) + want string + }{ + { + name: "ldflags stamp wins over build info", + ldflagsCommit: "abc123def456-dirty", + readBuildInfo: buildInfoWith(debug.BuildSetting{Key: "vcs.revision", Value: "ffffffffffffffffffffffffffffffffffffffff"}), + want: "abc123def456-dirty", + }, + { + name: "no stamp, no build info", + readBuildInfo: noBuildInfo, + want: "unknown", + }, + { + name: "build info without vcs settings", + readBuildInfo: buildInfoWith(), + want: "unknown", + }, + { + name: "full revision truncated to 12 chars", + readBuildInfo: buildInfoWith( + debug.BuildSetting{Key: "vcs.revision", Value: "0123456789abcdef0123456789abcdef01234567"}, + debug.BuildSetting{Key: "vcs.modified", Value: "false"}, + ), + want: "0123456789ab", + }, + { + name: "modified tree gets dirty suffix", + readBuildInfo: buildInfoWith( + debug.BuildSetting{Key: "vcs.revision", Value: "0123456789abcdef0123456789abcdef01234567"}, + debug.BuildSetting{Key: "vcs.modified", Value: "true"}, + ), + want: "0123456789ab-dirty", + }, + { + name: "short revision kept as-is", + readBuildInfo: buildInfoWith( + debug.BuildSetting{Key: "vcs.revision", Value: "abc123"}, + ), + want: "abc123", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := resolve(tc.ldflagsCommit, tc.readBuildInfo); got != tc.want { + t.Errorf("resolve() = %q, want %q", got, tc.want) + } + }) + } +}