# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## What this is A Kubernetes operator (plain controller-runtime, no CRDs) that bridges cert-manager `Certificate` resources and Gateway API `Gateway` resources. When a `Certificate` carries the right annotations, the operator manages HTTPS listeners on the target Gateway: one listener per entry in `spec.dnsNames`, terminating TLS with the Certificate's secret. ## Commands ```bash make help # list targets make check # lint + vet + test-short (the pre-commit gate) make build # bin/manager make test # full suite, -race -cover make fmt # gofumpt -w . make run # run against the current kubeconfig context make docker-build IMG=gateway-cert-operator:dev go test -race -v -run TestName ./path/to/package # single test kubectl kustomize deploy/ # render the manifests ``` ## Architecture | Path | Role | |---|---| | `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 | Annotations on `cert-manager.io/v1` `Certificate`: | Annotation | Required | Default | |---|---|---| | `gateway-cert-operator.io/gateway-name` | yes | — | | `gateway-cert-operator.io/gateway-namespace` | no | Certificate's namespace | Reconcile loop (keyed by Certificate): 1. Fetch the `Certificate`; on not-found return without doing anything. 2. Resolve the target Gateway from the annotations; no annotation, no action. 3. List all Certificates targeting that Gateway through the `IndexFieldGatewayTarget` field index (key `"namespace/name"`). 4. `BuildListenersFromCertificates` computes the desired `auto-*` listeners. 5. `MergeListeners` keeps every non-`auto-` listener and replaces the rest. 6. Patch the Gateway only when `ListenersEqual` reports a difference. A reverse watch maps Gateway events back to the targeting Certificates through the same index, so manual Gateway edits are reverted for `auto-*` listeners. Listeners named `auto-{certName}-{dnsName with dots as dashes}` belong to the operator. Any other listener is never modified or removed. `docs/flows.md` has the full control-path reference (startup, watch wiring, decision tree, scenarios). ## Conventions ### Go Conventions - Use the latest stable Go version (check https://go.dev/dl/ before starting a new feature). - Logging: `logr` through controller-runtime (`log.FromContext(ctx)`), not `slog` — a deliberate deviation from the house rule, because the manager, client and controller all log through logr. Keys are lowerCamel. - `context.Context` is the first parameter of every function that does I/O or can be cancelled. - No `init()` unless there is no other option. - Error wrapping: `fmt.Errorf("...: %w", err)` for single errors; `errors.Join` for multiple. - Prefer stdlib over third-party dependencies where reasonable. - No vendoring unless explicitly requested. - Fail fast, return early. - No obvious comments. ### Testing **All new Go code must have corresponding tests.** - Use stdlib `testing` — no testify unless already present in the module. - Table-driven tests are the default pattern. - Test files live next to source: `foo.go` → `foo_test.go`. - Name tests `Test_` (e.g. `TestMergeListeners_keepsManual`). - Use `t.Parallel()` where safe. - Use subtests for table entries: `t.Run(tc.name, func(t *testing.T) { ... })`. - Test behaviour, not implementation. - Guard slow tests with `testing.Short()`: `if testing.Short() { t.Skip() }`. - Reconciler tests use controller-runtime's fake client (`WithIndex` for the field index). Reach for envtest only for what the fake cannot do (API-server validation of listeners), and guard it with `testing.Short()`. This replaces the house `testcontainers-go` rule, which does not fit an operator. - A bug fix lands with a regression test. ### Plans When Claude Code's plan mode is used, save the plan file inside the repo at `docs/plans/YYYY-MM-DD-HHMM-.md` instead of the default `~/.claude/plans/` location. Get the timestamp with `date "+%Y-%m-%d-%H%M"`. The `` is a short kebab-case summary of the plan's topic. Include the same timestamp in the plan's header, e.g.: # Plan: **Created:** 2026-08-07 15:30 Plan files are committed to the repo so other contributors can review historical decisions. **Ordering matters:** plan mode itself restricts writes to the default `~/.claude/plans/` location — that's a hard tool restriction during plan mode, not a choice. So the copy into `docs/plans/` cannot happen until *after* `ExitPlanMode`. When it happens, it must be the very first action taken post-exit, committed on its own, *before* any implementation work starts. Do not fold the plan-file commit into the first implementation commit. ### Plan execution summaries After finishing each step of an approved plan, append a short, human-readable summary to `docs/plans-executions/YYYY-MM-DD-HHMM-<slug>.md` — same timestamp and slug as the originating plan, so the two files pair up 1:1. Sections go in chronological order (bottom of the file), not newest-first like `CHANGELOG.md`. - **Status checklist.** The top of the file is a markdown checklist with one line per plan step (`- [ ] Step 0 — <title>`). Check a box in the same commit that adds that step's entry. This checklist is the authoritative status record: a new session should be able to read this file alone and know where to pick up. - **Keep the prose light** — what was done, plus anything non-obvious (a judgment call, a spec gap, something that did not work as expected). Skip steps that went exactly as planned. - **Include exact commands** for anything that is not a plain file edit, as fenced blocks, each with one line on why it was run that way. Call out deviations from the plan explicitly; close with a short "worth noting" paragraph. - Commit the summary update together with that step's implementation commit. `docs/plans-executions/2026-10-08-2028-house-conventions.md` is the reference example. ### Changelog Maintain a running changelog in `CHANGELOG.md` at the repo root. After every significant change, fix, or update — once the user confirms it works — append a new entry **at the top** of the file in this format: ## YYYY-MM-DD HH:MM TZ — short title - One-line summary of what changed and why. - Key files touched (optional, only if useful for traceability). Get the timestamp with `date "+%Y-%m-%d %H:%M %Z"`. Never write a literal placeholder like `HH:MM` — always run `date` first to get the real current time. Skip trivial edits (typos, formatting, comment tweaks); only log changes a future reader would care about. ### Quality gate `make check` is the one-shot pre-commit gate — it runs `golangci-lint`, `go vet` and `go test -short`, in that order. Do not declare a task complete if `make check` reports any failures. ### Reviews Before opening an MR for a feature, run the `operator-reviewer` and `go-operator-reviewer` agents on the branch and save the findings to `docs/reviews/YYYY-MM-DD-HHMM-<slug>.md`, most severe first, as a `- [ ]` list that is ticked as items are processed. ### Branching & Merge Requests The remote is Gitea (`gitea.home.hrajfrisbee.cz/kacerr/gatewayapi-certificates-operator`). For **features**, do not commit to `main` directly. Use a branch + merge request flow: 1. **Create a branch off `main`** before starting work: `feat/<slug>` for features, `fix/<slug>` for bug-fix branches the user explicitly asks for; `<slug>` is short kebab-case. 2. **Commit on the branch** following the commit conventions below. 3. **Push the branch** to `origin` with `-u` so it tracks. 4. **Open the MR with `tea`** rather than printing a compare URL: ```bash tea pr create --title "<short title>" --description "<body>" --base main --head <branch> ``` Print the resulting PR URL. If `tea` is unavailable, fall back to printing the compare URL (`gitea.home.hrajfrisbee.cz/kacerr/gatewayapi-certificates-operator/compare/main...<branch>`). 5. **Do not merge or delete the branch** from the CLI — neither via `tea`, `gh`, nor `git push --delete`. The user does that in Gitea. **Exceptions — committing straight to `main` is fine for:** small bug fixes or hotfixes the user describes as such; typo, comment and formatting tweaks; edits the user explicitly says to push to `main`. When uncertain whether something is a feature or a small fix, ask before branching. ### Git Commits, Pushes and Tags - Subjects use a lowercase type prefix, as the existing history does: `feat:`, `fix:`, `deploy:`, `ci:`, `docs:`, `test:`, `chore:`. - Always append a `Co-Authored-By:` trailer for Claude. - Ask for confirmation before running `git push`. The exception is `/ship`, which commits and pushes without asking. - Tags are semver `vMAJOR.MINOR.PATCH` and mark releases, not commits. They are created only through `/release`, which confirms before pushing. The old `0.01`–`0.07` tags predate this rule and stay as they are. ### CI/CD Gitea Actions, workflows in `.gitea/workflows/`: - `build.yaml` builds the image and pushes it to `gitea.home.hrajfrisbee.cz/kacerr/gatewayapi-certificates-operator` on any tag push or manual dispatch. Needs the `REGISTRY_TOKEN` repo secret. - `kubernetes-deploy.yaml` applies `deploy/` to the home-lab cluster through a Vault → Kanidm → OIDC auth chain. ### Containers - Multi-stage builds; distroless/scratch final images. - Non-root user, read-only rootfs for production builds. - Pin base image digests in production Dockerfiles. ### K8s Manifests - Always include resource requests/limits. - Liveness/readiness probes with sane defaults. - PodDisruptionBudgets for replicated workloads. ## Gotchas - **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. - **Stale listeners.** Deleting a Certificate, removing its annotation or retargeting it does not clean up the old Gateway. A sibling Certificate's next reconcile fixes it; with no sibling the listener stays forever. - **A single bad listener blocks the whole Gateway.** The patch replaces the full listeners array, so a wildcard DNS name (`*` ends up in the listener name) or a name collision makes the API server reject every update for that Gateway. - **`ListenersEqual` is partial**: name, hostname, port and the first secret ref only. Changing what `BuildListenersFromCertificates` generates in other fields will not roll out to existing listeners. - **`auto-` prefix is the only ownership marker.** A user listener named `auto-…` gets deleted. - **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. - **No leader election**; safe only at `replicas: 1`. - **Deployment uses the mutable `:latest` tag**, so a new image does not roll out on its own. - **Dockerfile base images are tags, not digests** (contradicts the Containers rule; not yet fixed). - **`kubernetes-deploy.yaml` runs on every push to any branch** and prints auth tokens to the job log. Do not copy its patterns; fixing it is on the backlog.