Files
gatewayapi-certificates-ope…/CLAUDE.md
T
kacerrandClaude Opus 5.5 60360ca5b9 fix: address review findings on the conventions branch
- ci: reject a manual dispatch with tag "latest"; concurrency group per tag
- Dockerfile: declare GIT_COMMIT after the module download layer
- Makefile: docker-build targets linux/amd64 by default
- patch.go: no shared package-level pointers; main.go: no init()
- tests: assert that merge replaces managed listener content
- docs: flows.md startup section matches main.go; review saved to docs/reviews

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 20:43:02 +02:00

269 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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. Manual edits to `auto-*` listeners are reverted only in the fields
`ListenersEqual` compares, and only while at least one Certificate targets the Gateway.
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<Function>_<scenario>` (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-<slug>.md` instead of the default `~/.claude/plans/`
location. Get the timestamp with `date "+%Y-%m-%d-%H%M"`. The `<slug>` is a short
kebab-case summary of the plan's topic.
Include the same timestamp in the plan's header, e.g.:
# Plan: <title>
**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 with a `tag` input. A `check` job (`go vet`, `go build`,
`go test -short`) gates the build; it does not run lint or `-race`, so run
`make check` before tagging. Every build pushes `:<tag>` and an immutable
`:sha-<12-char-commit>`; `:latest` moves only on real tag pushes, and a dispatch
with `tag: latest` is rejected. 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.
- **`make docker-build` builds `linux/amd64`** regardless of the host, because that
is what the cluster runs. Override with `PLATFORM=`.
- **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.