Files
gatewayapi-certificates-ope…/CLAUDE.md
T
kacerrandClaude Opus 5.5 8c074288c6 ci: gate the image build on vet/build/test, push sha tags, protect latest
- check job runs go vet, go build and go test -short before the build
- images are pushed as :<tag> and :sha-<12>; :latest moves only on tag pushes
- GIT_COMMIT build arg and OCI source/created labels

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

13 KiB
Raw Blame History

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

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<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:

    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. Every build pushes :<tag> and an immutable :sha-<12-char-commit>; :latest moves only on real tag pushes. 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.