- CLAUDE.md in five sections with Go, testing, plan, changelog, branching and tagging rules; known sharp edges moved to Gotchas - docs/flows.md: metrics are served on :8080, name collisions are rejected by the API server, last-cert deletion never cleans up - README moved to the repo root, deploy command corrected Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
11 KiB
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
go build ./... # build
go vet ./... # vet
go test ./... # all tests
go test -race -v -run TestName ./path/to/package # single test
golangci-lint run # lint
go run . # run against the current kubeconfig context
docker build -t gateway-cert-operator .
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/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):
- Fetch the
Certificate; on not-found return without doing anything. - Resolve the target Gateway from the annotations; no annotation, no action.
- List all Certificates targeting that Gateway through the
IndexFieldGatewayTargetfield index (key"namespace/name"). BuildListenersFromCertificatescomputes the desiredauto-*listeners.MergeListenerskeeps every non-auto-listener and replaces the rest.- Patch the Gateway only when
ListenersEqualreports 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:
logrthrough controller-runtime (log.FromContext(ctx)), notslog— a deliberate deviation from the house rule, because the manager, client and controller all log through logr. Keys are lowerCamel. context.Contextis 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.Joinfor 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 (
WithIndexfor the field index). Reach for envtest only for what the fake cannot do (API-server validation of listeners), and guard it withtesting.Short(). This replaces the housetestcontainers-gorule, 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.
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:
-
Create a branch off
mainbefore starting work:feat/<slug>for features,fix/<slug>for bug-fix branches the user explicitly asks for;<slug>is short kebab-case. -
Commit on the branch following the commit conventions below.
-
Push the branch to
originwith-uso it tracks. -
Open the MR with
tearather than printing a compare URL:tea pr create --title "<short title>" --description "<body>" --base main --head <branch>Print the resulting PR URL. If
teais unavailable, fall back to printing the compare URL (gitea.home.hrajfrisbee.cz/kacerr/gatewayapi-certificates-operator/compare/main...<branch>). -
Do not merge or delete the branch from the CLI — neither via
tea,gh, norgit 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.PATCHand mark releases, not commits. They are created only through/release, which confirms before pushing. The old0.01–0.07tags predate this rule and stay as they are.
CI/CD
Gitea Actions, workflows in .gitea/workflows/:
build.yamlbuilds the image and pushes it togitea.home.hrajfrisbee.cz/kacerr/gatewayapi-certificates-operatoron any tag push or manual dispatch. Needs theREGISTRY_TOKENrepo secret.kubernetes-deploy.yamlappliesdeploy/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 and imagegatewayapi-certificates-operator; binary, manifests, namespace and annotation prefixgateway-cert-operator. listener-portannotation is not implemented.AnnotationListenerPortis 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. ListenersEqualis partial: name, hostname, port and the first secret ref only. Changing whatBuildListenersFromCertificatesgenerates in other fields will not roll out to existing listeners.auto-prefix is the only ownership marker. A user listener namedauto-…gets deleted.--metrics-bind-addressis parsed but ignored; metrics are still served on controller-runtime's default:8080. Logging is hardcoded to zap dev mode.- No leader election; safe only at
replicas: 1. - Deployment uses the mutable
:latesttag, 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.yamlruns 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.