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 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
+13
-3
@@ -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
|
||||
|
||||
+3
-6
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
module github.com/example/gateway-cert-operator
|
||||
module gitea.home.hrajfrisbee.cz/kacerr/gatewayapi-certificates-operator
|
||||
|
||||
go 1.25.0
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 <module>/internal/version.Commit=<hash>".
|
||||
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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user