House conventions port and valid listener names for wildcard certificates #1

Merged
kacerr merged 11 commits from feat/house-conventions into main 2026-10-08 20:48:32 +02:00
9 changed files with 195 additions and 23 deletions
Showing only changes of commit 3766e94b35 - Show all commits
+9 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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 (
+47
View File
@@ -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
}
+74
View File
@@ -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)
}
})
}
}
+17 -7
View File
@@ -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 {