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. |
|
| `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/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` |
|
| `internal/gateway/patch.go` | Pure functions: `BuildListenersFromCertificates`, `MergeListeners`, `ListenersEqual` |
|
||||||
| `deploy/` | Kustomize manifests; namespace `gateway-cert-operator-system`, single replica |
|
| `deploy/` | Kustomize manifests; namespace `gateway-cert-operator-system`, single replica |
|
||||||
|
|
||||||
@@ -226,8 +227,8 @@ Gitea Actions, workflows in `.gitea/workflows/`:
|
|||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
- **Three names for one project.** Directory `gateway-helper-operator`; git remote and
|
- **Three names for one project.** Directory `gateway-helper-operator`; git remote,
|
||||||
image `gatewayapi-certificates-operator`; binary, manifests, namespace and annotation
|
Go module and image `gatewayapi-certificates-operator`; binary, manifests, namespace and annotation
|
||||||
prefix `gateway-cert-operator`.
|
prefix `gateway-cert-operator`.
|
||||||
- **`listener-port` annotation is not implemented.** `AnnotationListenerPort` is
|
- **`listener-port` annotation is not implemented.** `AnnotationListenerPort` is
|
||||||
declared, never read; every listener is on port 443.
|
declared, never read; every listener is on port 443.
|
||||||
@@ -242,8 +243,12 @@ Gitea Actions, workflows in `.gitea/workflows/`:
|
|||||||
roll out to existing listeners.
|
roll out to existing listeners.
|
||||||
- **`auto-` prefix is the only ownership marker.** A user listener named `auto-…` gets
|
- **`auto-` prefix is the only ownership marker.** A user listener named `auto-…` gets
|
||||||
deleted.
|
deleted.
|
||||||
- **`--metrics-bind-address` is parsed but ignored**; metrics are still served on
|
- **Logging defaults to zap dev mode** (console encoder, debug level). The deployment
|
||||||
controller-runtime's default `:8080`. Logging is hardcoded to zap dev mode.
|
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
|
- **`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
|
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.
|
`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
|
FROM golang:1.25 AS builder
|
||||||
|
ARG TARGETOS
|
||||||
|
ARG TARGETARCH
|
||||||
|
ARG GIT_COMMIT=unknown
|
||||||
|
|
||||||
WORKDIR /workspace
|
WORKDIR /workspace
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
COPY main.go main.go
|
# .dockerignore filters the context down to Go sources and module files.
|
||||||
COPY internal/ internal/
|
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
|
FROM gcr.io/distroless/static:nonroot
|
||||||
|
ARG GIT_COMMIT=unknown
|
||||||
|
LABEL org.opencontainers.image.revision="${GIT_COMMIT}"
|
||||||
WORKDIR /
|
WORKDIR /
|
||||||
COPY --from=builder /workspace/manager .
|
COPY --from=builder /workspace/manager .
|
||||||
USER 65532:65532
|
USER 65532:65532
|
||||||
|
|||||||
+3
-6
@@ -52,11 +52,8 @@ process start
|
|||||||
├─ ctrl.GetConfigOrDie() # KUBECONFIG or in-cluster config
|
├─ ctrl.GetConfigOrDie() # KUBECONFIG or in-cluster config
|
||||||
│ on err ──► os.Exit(1)
|
│ 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]
|
│ 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]
|
├─ controller.SetupCertificateReconciler(mgr) [main.go:53]
|
||||||
│ registers field index + watches (see §3)
|
│ registers field index + watches (see §3)
|
||||||
@@ -469,8 +466,8 @@ Recorded as-found. None are fixed by this document.
|
|||||||
**1. `AnnotationListenerPort` is dead code**
|
**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.
|
`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**
|
**2. `--metrics-bind-address` flag was not wired** (fixed)
|
||||||
`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.
|
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**
|
**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.
|
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 1 — CLAUDE.md
|
||||||
- [x] Step 2 — `.claude/` and docs scaffolding
|
- [x] Step 2 — `.claude/` and docs scaffolding
|
||||||
- [x] Step 3 — Makefile, lint, formatting
|
- [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 5 — Baseline tests
|
||||||
- [ ] Step 6 — CI build workflow
|
- [ ] Step 6 — CI build workflow
|
||||||
- [ ] Step 7 — Close out
|
- [ ] 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
|
`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`
|
the other fix. `.golangci.yml` carries no settings yet: uh-cli's `errcheck`
|
||||||
exclusions are CLI-specific and nothing here needed one.
|
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
|
go 1.25.0
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
||||||
gwapiv1 "sigs.k8s.io/gateway-api/apis/v1"
|
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 (
|
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 (
|
import (
|
||||||
"flag"
|
"flag"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
goruntime "runtime"
|
||||||
|
|
||||||
cmv1 "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1"
|
cmv1 "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1"
|
||||||
gozap "go.uber.org/zap"
|
gozap "go.uber.org/zap"
|
||||||
@@ -12,9 +14,11 @@ import (
|
|||||||
ctrl "sigs.k8s.io/controller-runtime"
|
ctrl "sigs.k8s.io/controller-runtime"
|
||||||
"sigs.k8s.io/controller-runtime/pkg/healthz"
|
"sigs.k8s.io/controller-runtime/pkg/healthz"
|
||||||
"sigs.k8s.io/controller-runtime/pkg/log/zap"
|
"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"
|
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()
|
var scheme = runtime.NewScheme()
|
||||||
@@ -28,21 +32,27 @@ func init() {
|
|||||||
func main() {
|
func main() {
|
||||||
var metricsAddr string
|
var metricsAddr string
|
||||||
var probeAddr string
|
var probeAddr string
|
||||||
|
var showVersion bool
|
||||||
|
|
||||||
flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "The address the metrics endpoint binds to.")
|
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.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()
|
flag.Parse()
|
||||||
|
|
||||||
ctrl.SetLogger(zap.New(
|
if showVersion {
|
||||||
zap.UseDevMode(true),
|
fmt.Println(version.Resolve())
|
||||||
func(o *zap.Options) {
|
return
|
||||||
o.ZapOpts = append(o.ZapOpts, gozap.AddCaller())
|
}
|
||||||
},
|
|
||||||
))
|
ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts)))
|
||||||
log := ctrl.Log.WithName("setup")
|
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{
|
mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
|
||||||
Scheme: scheme,
|
Scheme: scheme,
|
||||||
|
Metrics: metricsserver.Options{BindAddress: metricsAddr},
|
||||||
HealthProbeBindAddress: probeAddr,
|
HealthProbeBindAddress: probeAddr,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user