From 3766e94b354c908c3ca6b7af89ba14d2f38458c4 Mon Sep 17 00:00:00 2001 From: Jan Novak Date: Thu, 8 Oct 2026 20:34:49 +0200 Subject: [PATCH] feat: stamp the build commit, add --version and zap flags, rename module - module path is now gitea.home.hrajfrisbee.cz/kacerr/gatewayapi-certificates-operator - internal/version resolves the commit from ldflags or Go build info; --version prints it and the first log line carries it - --metrics-bind-address is passed to the manager instead of being dropped - Dockerfile takes GIT_COMMIT, honours TARGETARCH, labels the image revision Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 13 +++- Dockerfile | 16 +++- docs/flows.md | 9 +-- .../2026-10-08-2028-house-conventions.md | 31 +++++++- go.mod | 2 +- internal/controller/certificate_controller.go | 2 +- internal/version/version.go | 47 ++++++++++++ internal/version/version_test.go | 74 +++++++++++++++++++ main.go | 24 ++++-- 9 files changed, 195 insertions(+), 23 deletions(-) create mode 100644 internal/version/version.go create mode 100644 internal/version/version_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 27dfc0c..1aebf9d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,6 +30,7 @@ kubectl kustomize deploy/ # render the manifests |---|---| | `main.go` | Composition root: scheme, flags, manager, health probes. No leader election. | | `internal/controller/certificate_controller.go` | `CertificateReconciler`: watches `Certificate` and `Gateway`, runs the reconcile loop | +| `internal/version/` | Build commit: ldflags stamp, falling back to Go's VCS build info | | `internal/gateway/patch.go` | Pure functions: `BuildListenersFromCertificates`, `MergeListeners`, `ListenersEqual` | | `deploy/` | Kustomize manifests; namespace `gateway-cert-operator-system`, single replica | @@ -226,8 +227,8 @@ Gitea Actions, workflows in `.gitea/workflows/`: ## Gotchas -- **Three names for one project.** Directory `gateway-helper-operator`; git remote and - image `gatewayapi-certificates-operator`; binary, manifests, namespace and annotation +- **Three names for one project.** Directory `gateway-helper-operator`; git remote, + Go module and image `gatewayapi-certificates-operator`; binary, manifests, namespace and annotation prefix `gateway-cert-operator`. - **`listener-port` annotation is not implemented.** `AnnotationListenerPort` is declared, never read; every listener is on port 443. @@ -242,8 +243,12 @@ Gitea Actions, workflows in `.gitea/workflows/`: roll out to existing listeners. - **`auto-` prefix is the only ownership marker.** A user listener named `auto-…` gets deleted. -- **`--metrics-bind-address` is parsed but ignored**; metrics are still served on - controller-runtime's default `:8080`. Logging is hardcoded to zap dev mode. +- **Logging defaults to zap dev mode** (console encoder, debug level). The deployment + does not pass `--zap-devel=false`, so production logs are not JSON. +- **Version identity is the commit, not a tag.** `--version` and the first log line + print the 12-char commit from `internal/version`. Image builds have no `.git` in + the context (`.dockerignore`), so the commit arrives through the `GIT_COMMIT` + build arg; a plain `docker build` without it reports `unknown`. - **`make lint` pins `GOTOOLCHAIN` to the go.mod version.** golangci-lint cannot type-check a standard library newer than the Go it was built with, so running `golangci-lint run` directly fails when the host Go is ahead of it. diff --git a/Dockerfile b/Dockerfile index 638894c..b17fd67 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,15 +1,25 @@ FROM golang:1.25 AS builder +ARG TARGETOS +ARG TARGETARCH +ARG GIT_COMMIT=unknown WORKDIR /workspace COPY go.mod go.sum ./ RUN go mod download -COPY main.go main.go -COPY internal/ internal/ +# .dockerignore filters the context down to Go sources and module files. +COPY . . -RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o manager . +# The build context has no .git, so Go's automatic VCS stamp is absent here; +# the commit comes in through GIT_COMMIT instead. +RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH:-amd64} go build \ + -trimpath \ + -ldflags "-s -w -X gitea.home.hrajfrisbee.cz/kacerr/gatewayapi-certificates-operator/internal/version.Commit=${GIT_COMMIT}" \ + -o manager . 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/docs/flows.md b/docs/flows.md index 1bbda45..08d5030 100644 --- a/docs/flows.md +++ b/docs/flows.md @@ -52,11 +52,8 @@ process start ├─ ctrl.GetConfigOrDie() # KUBECONFIG or in-cluster config │ on err ──► os.Exit(1) │ - ├─ ctrl.NewManager(cfg, Options{Scheme, HealthProbeBindAddress: ":8081"}) + ├─ ctrl.NewManager(cfg, Options{Scheme, Metrics, HealthProbeBindAddress}) │ on err ──► log + os.Exit(1) [main.go:44-47] - │ NOTE: metricsAddr is parsed above but NOT passed here; - │ the manager falls back to its own default, which - │ is also ":8080" (see §10, item 2) │ ├─ controller.SetupCertificateReconciler(mgr) [main.go:53] │ registers field index + watches (see §3) @@ -469,8 +466,8 @@ Recorded as-found. None are fixed by this document. **1. `AnnotationListenerPort` is dead code** `certificate_controller.go:25` declares the constant; `BuildListenersFromCertificates` (`patch.go:44`) always uses `defaultPort = 443`. Setting `gateway-cert-operator.io/listener-port` on a Certificate has no effect. -**2. `--metrics-bind-address` flag is parsed but not wired** -`main.go:32` parses the flag; `main.go:44-47` never passes `MetricsBindAddress` to `ctrl.Options`. controller-runtime defaults an empty bind address to `:8080`, so metrics are served and the Service in `deploy/service_gateway-cert-operator-metrics.yaml` works; only changing the flag has no effect. +**2. `--metrics-bind-address` flag was not wired** (fixed) +The flag used to be parsed and dropped; metrics were served anyway because controller-runtime defaults an empty bind address to `:8080`. `main.go` now passes it through `metricsserver.Options`. **3. No finalizer → cert deletion leaves stale listeners** When a Certificate is deleted, Reconcile returns early at branch (A) with no cluster write. The stale `auto-*` listener persists in the Gateway until a sibling event triggers re-convergence (see Scenario 4 above), or permanently if no other Certificate targets that Gateway. Adding a finalizer to the Certificate would close this gap by allowing cleanup before the object is removed from the API server. diff --git a/docs/plans-executions/2026-10-08-2028-house-conventions.md b/docs/plans-executions/2026-10-08-2028-house-conventions.md index 189bf39..ac9d386 100644 --- a/docs/plans-executions/2026-10-08-2028-house-conventions.md +++ b/docs/plans-executions/2026-10-08-2028-house-conventions.md @@ -6,7 +6,7 @@ Plan: `docs/plans/2026-10-08-2028-house-conventions.md` - [x] Step 1 — CLAUDE.md - [x] Step 2 — `.claude/` and docs scaffolding - [x] Step 3 — Makefile, lint, formatting -- [ ] Step 4 — Module path and version identity +- [x] Step 4 — Module path and version identity - [ ] Step 5 — Baseline tests - [ ] Step 6 — CI build workflow - [ ] Step 7 — Close out @@ -84,3 +84,32 @@ Worth noting: this is likely the same failure the egress changelog records as `GOTOOLCHAIN` pin should unblock it there. Rebuilding golangci-lint with Go 1.27 is the other fix. `.golangci.yml` carries no settings yet: uh-cli's `errcheck` exclusions are CLI-specific and nothing here needed one. + +## Step 4 — Module path and version identity + +Module renamed to `gitea.home.hrajfrisbee.cz/kacerr/gatewayapi-certificates-operator`. +`internal/version` copied from egress unchanged (code and test). `main.go` gained +`--version`, a first log line with commit and Go version, the standard `--zap-*` +flags, and passes `--metrics-bind-address` to the manager. + +``` +$ make build && ./bin/manager --version +0eb588e8bd3a-dirty +$ make docker-build && docker run --rm gateway-cert-operator:dev --version +0eb588e8bd3a-dirty +$ docker inspect gateway-cert-operator:dev --format '{{index .Config.Labels "org.opencontainers.image.revision"}} {{.Config.User}}' +0eb588e8bd3a-dirty 65532:65532 +``` + +Deviations from the plan: + +- The Dockerfile build also got `-trimpath`, and keeps `-s -w` (egress has neither). +- The Dockerfile now does `COPY . .` and relies on the new `.dockerignore` + allowlist, instead of copying `main.go` and `internal/` by name. +- The `gozap.AddCaller()` option was kept so log lines look the same as before. + +Worth noting: the manager was not started against a cluster in this step; the +flag wiring is verified by `--help` output and the build only. `GOARCH` defaults to +`amd64` when `TARGETARCH` is empty (legacy builder, as on the CI runner) and follows +the host under BuildKit, so a local build on an Apple Silicon machine is arm64. +`docs/flows.md` still cites pre-change `main.go` line numbers in §2. diff --git a/go.mod b/go.mod index e1aad4d..efe940a 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/example/gateway-cert-operator +module gitea.home.hrajfrisbee.cz/kacerr/gatewayapi-certificates-operator go 1.25.0 diff --git a/internal/controller/certificate_controller.go b/internal/controller/certificate_controller.go index 8e6084c..8d76c1e 100644 --- a/internal/controller/certificate_controller.go +++ b/internal/controller/certificate_controller.go @@ -13,7 +13,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/reconcile" gwapiv1 "sigs.k8s.io/gateway-api/apis/v1" - gw "github.com/example/gateway-cert-operator/internal/gateway" + gw "gitea.home.hrajfrisbee.cz/kacerr/gatewayapi-certificates-operator/internal/gateway" ) const ( 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) + } + }) + } +} diff --git a/main.go b/main.go index b5892f8..ff65c84 100644 --- a/main.go +++ b/main.go @@ -2,7 +2,9 @@ package main import ( "flag" + "fmt" "os" + goruntime "runtime" cmv1 "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1" gozap "go.uber.org/zap" @@ -12,9 +14,11 @@ import ( ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/healthz" "sigs.k8s.io/controller-runtime/pkg/log/zap" + metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" gwapiv1 "sigs.k8s.io/gateway-api/apis/v1" - "github.com/example/gateway-cert-operator/internal/controller" + "gitea.home.hrajfrisbee.cz/kacerr/gatewayapi-certificates-operator/internal/controller" + "gitea.home.hrajfrisbee.cz/kacerr/gatewayapi-certificates-operator/internal/version" ) var scheme = runtime.NewScheme() @@ -28,21 +32,27 @@ func init() { func main() { var metricsAddr string var probeAddr string + var showVersion bool flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "The address the metrics endpoint binds to.") flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") + flag.BoolVar(&showVersion, "version", false, "Print the commit this binary was built from and exit.") + opts := zap.Options{Development: true, ZapOpts: []gozap.Option{gozap.AddCaller()}} + opts.BindFlags(flag.CommandLine) flag.Parse() - ctrl.SetLogger(zap.New( - zap.UseDevMode(true), - func(o *zap.Options) { - o.ZapOpts = append(o.ZapOpts, gozap.AddCaller()) - }, - )) + if showVersion { + fmt.Println(version.Resolve()) + return + } + + ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) log := ctrl.Log.WithName("setup") + log.Info("starting gateway-cert-operator", "commit", version.Resolve(), "goVersion", goruntime.Version()) mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ Scheme: scheme, + Metrics: metricsserver.Options{BindAddress: metricsAddr}, HealthProbeBindAddress: probeAddr, }) if err != nil {