Compare commits
17
Commits
0.01
...
5c4a148b36
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c4a148b36 | ||
|
|
42b53b7538 | ||
|
|
6698eab1d8 | ||
|
|
60360ca5b9 | ||
|
|
8c074288c6 | ||
|
|
ab408403bc | ||
|
|
3766e94b35 | ||
|
|
0eb588e8bd | ||
|
|
2be69b9aac | ||
|
|
7a559bacd0 | ||
|
|
36030f4011 | ||
|
|
21468b9f78 | ||
|
|
e61e397229 | ||
|
|
12969b137e | ||
|
|
2c4126eac8 | ||
|
|
f7353d48f4 | ||
|
|
4d800ffbfb |
@@ -0,0 +1,115 @@
|
|||||||
|
---
|
||||||
|
name: go-operator-reviewer
|
||||||
|
description: Critically reviews Go controller-runtime code for bugs, correctness, and over-abstraction. Invoke after a milestone or before merging a branch.
|
||||||
|
tools: Read, Grep, Glob, Bash
|
||||||
|
model: opus
|
||||||
|
---
|
||||||
|
|
||||||
|
You are a senior Go reviewer auditing a Kubernetes operator built on controller-runtime.
|
||||||
|
Your job: find real problems and call out over-engineering. You are NOT here to
|
||||||
|
nitpick anything `gofmt`, `goimports`, or `golangci-lint` already enforce — assume
|
||||||
|
those run in CI. Be terse. Every flagged item costs the reader attention; spend it
|
||||||
|
only on things that change a decision.
|
||||||
|
|
||||||
|
Adopt a skeptical, "delete code" mindset. Default assumption: an abstraction is
|
||||||
|
guilty until it proves it earns its keep.
|
||||||
|
|
||||||
|
## Method (do this in order)
|
||||||
|
|
||||||
|
1. Run the toolchain first and read its output — do not redo by hand what tools
|
||||||
|
already report:
|
||||||
|
- `golangci-lint run ./... 2>&1 | head -100` (if no config, run with
|
||||||
|
`--enable=staticcheck,gocritic,revive,unparam,unconvert,ineffassign,prealloc,gocyclo,gocognit`)
|
||||||
|
- `go vet ./...`
|
||||||
|
- `deadcode ./...` (golang.org/x/tools/cmd/deadcode) — unreachable funcs
|
||||||
|
- `gocyclo -over 15 .` and `gocognit -over 20 .` — complexity hotspots
|
||||||
|
Summarize only what's actionable; don't paste raw tool dumps.
|
||||||
|
|
||||||
|
2. Build an interface inventory before reading logic:
|
||||||
|
`grep -rn 'type .* interface' --include='*.go'`, then for each interface count
|
||||||
|
its implementations. This is your single highest-signal artifact for an operator
|
||||||
|
that talks to the API server through client-go/controller-runtime interfaces.
|
||||||
|
|
||||||
|
3. Map controller wiring: where do `Reconcile(ctx, req)` methods and
|
||||||
|
`SetupWithManager` live, and does business logic leak into `Reconcile` itself
|
||||||
|
instead of a testable package?
|
||||||
|
|
||||||
|
4. Only then read the logic-heavy packages in full.
|
||||||
|
|
||||||
|
## What to flag — over-abstraction (testable rules)
|
||||||
|
|
||||||
|
- **Single-implementation interface.** Any interface with exactly 1 impl → name the
|
||||||
|
interface, name the impl, recommend collapsing to the concrete type. Exception:
|
||||||
|
a genuine test seam that's actually mocked (e.g. a narrowed `client.Client` seam),
|
||||||
|
or a documented second impl in flight. "Might need it later" is not an exception.
|
||||||
|
- **Constructor returns an interface.** `New*()` returning an interface instead of
|
||||||
|
`*Concrete` → flag. Rule: accept interfaces, return structs.
|
||||||
|
- **Interface defined next to its producer**, not at the consumer. Recommend moving
|
||||||
|
it to where it's consumed (or deleting per the rule above).
|
||||||
|
- **Microservice-template layout on an operator** (domain/usecase/repository/handler
|
||||||
|
ceremony, deep `internal/` nesting that wraps one type each, when a flat
|
||||||
|
`controllers/` + `api/` + `internal/` layout would do) → recommend flattening.
|
||||||
|
- **Wrapper types whose methods only forward** (Manager/Service/Provider that adds
|
||||||
|
no behavior over `client.Client`) → recommend inlining.
|
||||||
|
- **Premature generics** (one concrete instantiation) and **DI frameworks**
|
||||||
|
(wire/fx) where hand-wiring in `main` is clearer.
|
||||||
|
- **Logic inside `Reconcile`** → should fetch the object, delegate to a testable
|
||||||
|
package for resolve/compute-desired-state/diff, and only handle status/requeue
|
||||||
|
wiring itself; business logic belongs outside the handler.
|
||||||
|
|
||||||
|
## What to flag — correctness & bugs
|
||||||
|
|
||||||
|
- Error handling model inconsistency: mixed sentinel / typed / `fmt.Errorf` string
|
||||||
|
errors; missing `%w` wrapping where callers need `errors.Is/As`.
|
||||||
|
- `context.Context` not propagated to I/O (API server calls), or `context.TODO()`
|
||||||
|
left in real reconcile paths.
|
||||||
|
- **Reconcile-loop specifics:**
|
||||||
|
- Missing `client.IgnoreNotFound` on the initial `Get` (spurious errors/requeues
|
||||||
|
on delete).
|
||||||
|
- No finalizer handling where the controller owns external (non-cluster)
|
||||||
|
resources that need cleanup on delete.
|
||||||
|
- Missing or wrong owner references, causing orphaned child objects or GC loops.
|
||||||
|
- Status updates that don't use `Status().Update()`/`Status().Patch()` (silently
|
||||||
|
clobbering spec, or clobbering status subresource semantics).
|
||||||
|
- Reconcile not idempotent: side effects that aren't safe to repeat, or that
|
||||||
|
aren't guarded against being re-run on every requeue.
|
||||||
|
- Uncontrolled requeue: `RequeueAfter` values that busy-loop, or errors returned
|
||||||
|
as `(ctrl.Result{}, err)` when a controlled backoff would be correct instead.
|
||||||
|
- Full-object `Update()` where a `Patch` would avoid clobbering concurrent
|
||||||
|
writers, or vice versa — check which is actually intended.
|
||||||
|
- List calls without label selectors/field indexers where the object count could
|
||||||
|
grow unbounded (scales badly, watches too much).
|
||||||
|
- Goroutine leaks, unbuffered-channel deadlock risk, `WaitGroup`/`errgroup` misuse,
|
||||||
|
data races on package-level vars (esp. shared informer caches).
|
||||||
|
- Unchecked errors on `Close()`/`Flush()` where the result matters.
|
||||||
|
- Resource leaks (files, HTTP bodies, contexts not cancelled).
|
||||||
|
- Global mutable state that makes the tool untestable or unsafe under `-race`.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
New and modified code must comply. Don't rewrite untouched existing code unless it
|
||||||
|
directly blocks a fix. If broad cleanup is warranted, say so as a separate timeboxed
|
||||||
|
task, don't fold it into this review.
|
||||||
|
|
||||||
|
## Output format
|
||||||
|
|
||||||
|
**Verdict:** one line — Approve / Approve with comments / Request changes.
|
||||||
|
|
||||||
|
**Critical (bugs/correctness):** numbered, `file:line`, the problem in one sentence,
|
||||||
|
then the concrete fix as a minimal Go snippet. Skip if none.
|
||||||
|
|
||||||
|
**Simplification:** the over-abstraction findings, ranked by impact × (inverse) effort
|
||||||
|
so the cheap high-value deletes come first. `file:line`, what to collapse, what it
|
||||||
|
becomes.
|
||||||
|
|
||||||
|
**Nice-to-have:** anything minor worth noting, max 3 items. Optional.
|
||||||
|
|
||||||
|
Cap the whole thing at the top 5–7 issues across all sections. Rank ruthlessly.
|
||||||
|
|
||||||
|
## Hard rules
|
||||||
|
|
||||||
|
- Never edit or write files. If you find a fix, describe it; do not implement it.
|
||||||
|
- Cite `file:line` for every flagged issue. No line ref = don't flag it.
|
||||||
|
- Never speculate about code you didn't read. If you skipped files, say which.
|
||||||
|
- Don't flag anything gofmt/golangci-lint already catches.
|
||||||
|
- Total output under 1200 words. Concision is part of the value.
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
---
|
||||||
|
name: operator-reviewer
|
||||||
|
description: Reviews Kubernetes operator PRs for controller-runtime correctness, reconcile semantics, and API design
|
||||||
|
tools: Read, Grep, Glob, Bash
|
||||||
|
---
|
||||||
|
You are a senior reviewer specializing in Kubernetes operators.
|
||||||
|
Review with focus on:
|
||||||
|
- Reconcile idempotency and requeue behavior; no state assumptions between reconciles
|
||||||
|
- Informer cache reads vs direct API reads; stale-cache races
|
||||||
|
- Finalizer handling, deletion flow, orphaned resources
|
||||||
|
- CRD schema evolution, conversion webhooks, status subresource / conditions conventions
|
||||||
|
- RBAC minimality vs what the controller actually touches
|
||||||
|
- Leader election, watch predicates, event filtering for churn reduction
|
||||||
|
- Go: context propagation, error wrapping, client.Object handling
|
||||||
|
Output: findings ranked by severity, with file:line refs. No praise padding.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
description: Tag a release and push it after confirmation
|
||||||
|
---
|
||||||
|
|
||||||
|
Commit all staged and unstaged changes into git, assign the next release tag, then
|
||||||
|
ask for confirmation before pushing.
|
||||||
|
|
||||||
|
Before committing, check the current branch (`git branch --show-current`). Releases
|
||||||
|
are cut from `main`; on any other branch, stop and ask.
|
||||||
|
|
||||||
|
Tags are semver `vMAJOR.MINOR.PATCH` (see CLAUDE.md, "Git Commits, Pushes and Tags").
|
||||||
|
Find the latest with `git tag --list 'v*' --sort=-v:refname | head -1`; if there is
|
||||||
|
none, start at `v0.1.0`. The old `0.01`–`0.07` tags are not part of the sequence.
|
||||||
|
Default to a patch bump for fixes and a minor bump for features; state the tag you
|
||||||
|
picked and why when asking for confirmation.
|
||||||
|
|
||||||
|
Pushing the tag triggers `.gitea/workflows/build.yaml`, which builds and pushes the
|
||||||
|
image and moves `:latest`.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
---
|
||||||
|
description: Commit current changes and push to the remote
|
||||||
|
---
|
||||||
|
|
||||||
|
Commit the current working-tree changes and push them.
|
||||||
|
|
||||||
|
1. Run `git status` and `git diff` to see what will be committed.
|
||||||
|
2. Check the current branch (`git branch --show-current`). If it's `main` and the
|
||||||
|
change looks feature-sized (not one of CLAUDE.md's "commit straight to main"
|
||||||
|
exceptions: small fixes, typos/formatting, or the user explicitly said `main`),
|
||||||
|
stop and tell the user to branch first — don't push a feature to `main`.
|
||||||
|
3. Stage the changed files (`git add -A` is fine here).
|
||||||
|
4. Create a commit with a short message describing the changes (infer it from the
|
||||||
|
diff), including a `Co-Authored-By:` trailer for Claude.
|
||||||
|
Pass the message via a HEREDOC.
|
||||||
|
5. Push with `git push`. If the current branch has no upstream, use `git push -u origin HEAD`.
|
||||||
|
6. If the branch isn't `main`, remind the user to open an MR with `tea pr create`
|
||||||
|
(see CLAUDE.md's Branching & Merge Requests section) rather than opening one
|
||||||
|
automatically.
|
||||||
|
7. Report the commit hash and push result.
|
||||||
|
|
||||||
|
If there are no changes, stop and say so — do not create an empty commit.
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(git status)",
|
||||||
|
"Bash(git status:*)",
|
||||||
|
"Bash(git diff:*)",
|
||||||
|
"Bash(git log:*)",
|
||||||
|
"Bash(git branch:*)",
|
||||||
|
"Bash(git add:*)",
|
||||||
|
"Bash(git checkout:*)",
|
||||||
|
"Bash(git pull:*)",
|
||||||
|
"Bash(git fetch:*)",
|
||||||
|
"Bash(date:*)",
|
||||||
|
"Read(//tmp/**)",
|
||||||
|
"Read(//private/tmp/**)",
|
||||||
|
"Bash(go build:*)",
|
||||||
|
"Bash(go test:*)",
|
||||||
|
"Bash(go vet:*)",
|
||||||
|
"Bash(go mod:*)",
|
||||||
|
"Bash(go run:*)",
|
||||||
|
"Bash(go version:*)",
|
||||||
|
"Bash(go get:*)",
|
||||||
|
"Bash(golangci-lint run:*)",
|
||||||
|
"Bash(golangci-lint --version)",
|
||||||
|
"Bash(gofumpt:*)",
|
||||||
|
"Bash(tea pr create:*)",
|
||||||
|
"Bash(make help *)",
|
||||||
|
"Bash(make build *)",
|
||||||
|
"Bash(make test *)",
|
||||||
|
"Bash(make test-short *)",
|
||||||
|
"Bash(make vet *)",
|
||||||
|
"Bash(make fmt *)",
|
||||||
|
"Bash(make lint *)",
|
||||||
|
"Bash(make tidy *)",
|
||||||
|
"Bash(make check *)",
|
||||||
|
"Bash(make docker-build *)",
|
||||||
|
"Bash(docker build:*)",
|
||||||
|
"Bash(kubectl kustomize:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# More info: https://docs.docker.com/engine/reference/builder/#dockerignore-file
|
||||||
|
# Ignore everything by default and re-include only needed files
|
||||||
|
**
|
||||||
|
|
||||||
|
# Re-include Go source files (but not *_test.go)
|
||||||
|
!**/*.go
|
||||||
|
**/*_test.go
|
||||||
|
|
||||||
|
# Re-include Go module files
|
||||||
|
!go.mod
|
||||||
|
!go.sum
|
||||||
+55
-14
@@ -4,36 +4,77 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
tag:
|
tag:
|
||||||
description: 'Image tag'
|
description: 'Image tag (not "latest")'
|
||||||
required: true
|
required: true
|
||||||
default: 'latest'
|
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- '*'
|
- '*'
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: build-${{ github.ref }}-${{ inputs.tag }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
check:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
cache: true
|
||||||
|
- name: Vet
|
||||||
|
run: go vet ./...
|
||||||
|
- name: Build
|
||||||
|
run: go build ./...
|
||||||
|
- name: Test (short)
|
||||||
|
run: go test -short ./...
|
||||||
|
|
||||||
build:
|
build:
|
||||||
|
needs: check
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
packages: write
|
packages: write
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Compute image tags
|
||||||
|
id: meta
|
||||||
|
run: |
|
||||||
|
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||||
|
TAG="${{ inputs.tag }}"
|
||||||
|
# The Deployment runs :latest; only real tag pushes may move it.
|
||||||
|
if [ "$TAG" = "latest" ]; then
|
||||||
|
echo "refusing to dispatch with tag 'latest'" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
TAG="${{ github.ref_name }}"
|
||||||
|
fi
|
||||||
|
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "sha=sha-$(echo '${{ github.sha }}' | cut -c1-12)" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Login to Gitea registry
|
- name: Login to Gitea registry
|
||||||
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login -u ${{ github.actor }} --password-stdin gitea.home.hrajfrisbee.cz
|
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login -u ${{ github.actor }} --password-stdin gitea.home.hrajfrisbee.cz
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
run: |
|
run: |
|
||||||
TAG=${{ github.ref_name }}
|
IMAGE=gitea.home.hrajfrisbee.cz/${{ github.repository }}
|
||||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
docker build \
|
||||||
TAG=${{ inputs.tag }}
|
--build-arg GIT_COMMIT=$(echo '${{ github.sha }}' | cut -c1-12) \
|
||||||
fi
|
--label org.opencontainers.image.source=https://gitea.home.hrajfrisbee.cz/${{ github.repository }} \
|
||||||
IMAGE=gitea.home.hrajfrisbee.cz/${{ github.repository }}:$TAG
|
--label org.opencontainers.image.created=$(date -u +%Y-%m-%dT%H:%M:%SZ) \
|
||||||
docker build -f Dockerfile \
|
-t "$IMAGE:${{ steps.meta.outputs.tag }}" \
|
||||||
--build-arg GIT_TAG=$TAG \
|
-t "$IMAGE:${{ steps.meta.outputs.sha }}" \
|
||||||
--build-arg GIT_COMMIT=${{ github.sha }} \
|
.
|
||||||
--build-arg BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) \
|
docker push "$IMAGE:${{ steps.meta.outputs.tag }}"
|
||||||
-t $IMAGE .
|
docker push "$IMAGE:${{ steps.meta.outputs.sha }}"
|
||||||
docker push $IMAGE
|
|
||||||
|
# Only real tag pushes move :latest — an ad-hoc dispatch of an old ref must not clobber it.
|
||||||
|
- name: Push latest (tag builds only)
|
||||||
|
if: github.event_name == 'push'
|
||||||
|
run: |
|
||||||
|
IMAGE=gitea.home.hrajfrisbee.cz/${{ github.repository }}
|
||||||
|
docker tag "$IMAGE:${{ steps.meta.outputs.tag }}" "$IMAGE:latest"
|
||||||
|
docker push "$IMAGE:latest"
|
||||||
|
|||||||
@@ -106,4 +106,4 @@ jobs:
|
|||||||
kubectl auth whoami
|
kubectl auth whoami
|
||||||
kubectl get ns
|
kubectl get ns
|
||||||
|
|
||||||
kubectl apply -f deploy/
|
kubectl apply -k deploy/
|
||||||
|
|||||||
@@ -11,3 +11,6 @@
|
|||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
# Claude Code
|
||||||
|
.claude/settings.local.json
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
version: "2"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Changelog
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
# 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}-{sanitized dnsName}` belong to the operator. The DNS
|
||||||
|
name is lowercased, `*` becomes `wildcard`, anything else outside `[a-z0-9]` becomes
|
||||||
|
`-`; names over 253 chars are cut and get a hash suffix. Changing this scheme renames
|
||||||
|
existing listeners on live Gateways. 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 listener-name collision (same cert name in two namespaces,
|
||||||
|
or two DNS names that sanitize alike, such as `a.b.com` and `a-b.com`) 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.
|
||||||
+14
-3
@@ -1,15 +1,26 @@
|
|||||||
FROM golang:1.25 AS builder
|
FROM golang:1.25 AS builder
|
||||||
|
ARG TARGETOS
|
||||||
|
ARG TARGETARCH
|
||||||
|
|
||||||
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. Declared this late so a new
|
||||||
|
# commit does not invalidate the module-download layer.
|
||||||
|
ARG GIT_COMMIT=unknown
|
||||||
|
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
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
BINARY := manager
|
||||||
|
BIN_DIR := bin
|
||||||
|
IMG ?= gateway-cert-operator:dev
|
||||||
|
# The cluster is amd64; without this an Apple Silicon host builds arm64.
|
||||||
|
PLATFORM ?= linux/amd64
|
||||||
|
|
||||||
|
# GIT_COMMIT is baked into the image (-ldflags in the Dockerfile); -dirty
|
||||||
|
# covers staged and untracked changes too, which `git diff --quiet` misses.
|
||||||
|
GIT_COMMIT ?= $(shell git rev-parse --short=12 HEAD 2>/dev/null || echo unknown)$(shell test -z "$$(git status --porcelain 2>/dev/null)" || echo -dirty)
|
||||||
|
|
||||||
|
# golangci-lint type-checks the standard library of whatever toolchain is active,
|
||||||
|
# and fails on one newer than the Go it was built with. Linting with the go.mod
|
||||||
|
# version keeps it working when the host Go is ahead.
|
||||||
|
GO_MOD_VERSION := $(shell awk '$$1 == "go" {print $$2}' go.mod)
|
||||||
|
|
||||||
|
.PHONY: all build run test test-short lint vet fmt tidy check docker-build clean help
|
||||||
|
|
||||||
|
all: check build
|
||||||
|
|
||||||
|
build: ## Build the manager binary to bin/manager
|
||||||
|
mkdir -p $(BIN_DIR)
|
||||||
|
go build -o $(BIN_DIR)/$(BINARY) .
|
||||||
|
|
||||||
|
run: ## Run the manager against the current kubeconfig context
|
||||||
|
go run .
|
||||||
|
|
||||||
|
test: ## Run the full test suite with the race detector
|
||||||
|
go test -race -cover ./...
|
||||||
|
|
||||||
|
test-short: ## Run unit tests only — skips tests guarded by testing.Short()
|
||||||
|
go test -short -cover ./...
|
||||||
|
|
||||||
|
lint: ## Run golangci-lint
|
||||||
|
GOTOOLCHAIN=go$(GO_MOD_VERSION) golangci-lint run ./...
|
||||||
|
|
||||||
|
vet: ## Run go vet
|
||||||
|
go vet ./...
|
||||||
|
|
||||||
|
fmt: ## Format all Go files with gofumpt
|
||||||
|
gofumpt -w .
|
||||||
|
|
||||||
|
tidy: ## Tidy go.mod and go.sum
|
||||||
|
go mod tidy
|
||||||
|
|
||||||
|
check: lint vet test-short ## Run all quality checks (lint + vet + test-short)
|
||||||
|
|
||||||
|
docker-build: ## Build the container image (IMG=name:tag)
|
||||||
|
docker build --platform $(PLATFORM) --build-arg GIT_COMMIT=$(GIT_COMMIT) -t $(IMG) .
|
||||||
|
|
||||||
|
clean: ## Remove build artifacts
|
||||||
|
rm -rf $(BIN_DIR)
|
||||||
|
|
||||||
|
help: ## Show available targets
|
||||||
|
@echo 'Usage: make <target>'
|
||||||
|
@echo ''
|
||||||
|
@echo 'Targets:'
|
||||||
|
@grep -E '^[a-zA-Z_-]+:.*##' $(MAKEFILE_LIST) | \
|
||||||
|
awk 'BEGIN {FS = ":.*## "}; {printf " \033[36m%-14s\033[0m %s\n", $$1, $$2}'
|
||||||
@@ -53,12 +53,14 @@ spec:
|
|||||||
3. **Patching**: For each DNS name defined in `spec.dnsNames`, it mathematically appends a new HTTPS `Listener` on port `443` terminating TLS using the secret created by `cert-manager` (`my-app-tls`).
|
3. **Patching**: For each DNS name defined in `spec.dnsNames`, it mathematically appends a new HTTPS `Listener` on port `443` terminating TLS using the secret created by `cert-manager` (`my-app-tls`).
|
||||||
4. **Safe Coexistence**: To safely coexist with manually defined listeners, the operator uniquely prefixes its configured listeners with `auto-` (e.g., `auto-my-app-cert-app-example-com`). It solely manages listeners with this prefix.
|
4. **Safe Coexistence**: To safely coexist with manually defined listeners, the operator uniquely prefixes its configured listeners with `auto-` (e.g., `auto-my-app-cert-app-example-com`). It solely manages listeners with this prefix.
|
||||||
|
|
||||||
When a certificate is deleted or updated (for instance if a DNS name is changed), the operator will instantly reconcile the modified states by cleanly removing older `auto-*` listeners and inserting the new ones while preserving the manually defined resources.
|
When a certificate is updated (for instance if a DNS name is changed), the operator removes the older `auto-*` listeners and inserts the new ones while preserving the manually defined resources.
|
||||||
|
|
||||||
|
Known limitation: when a certificate is deleted, its listeners are removed only on the next reconcile of another certificate targeting the same Gateway.
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
To deploy the operator, applying the provided manifest sets up the necessary RBAC permissions and the controller manager:
|
To deploy the operator, applying the provided manifest sets up the necessary RBAC permissions and the controller manager:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
kubectl apply -f deploy/manifests.yaml
|
kubectl apply -k deploy/
|
||||||
```
|
```
|
||||||
@@ -17,6 +17,8 @@ spec:
|
|||||||
app.kubernetes.io/name: gateway-cert-operator
|
app.kubernetes.io/name: gateway-cert-operator
|
||||||
app.kubernetes.io/component: operator
|
app.kubernetes.io/component: operator
|
||||||
spec:
|
spec:
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: gitea-registry
|
||||||
serviceAccountName: gateway-cert-operator
|
serviceAccountName: gateway-cert-operator
|
||||||
terminationGracePeriodSeconds: 10
|
terminationGracePeriodSeconds: 10
|
||||||
securityContext:
|
securityContext:
|
||||||
@@ -25,7 +27,7 @@ spec:
|
|||||||
type: RuntimeDefault
|
type: RuntimeDefault
|
||||||
containers:
|
containers:
|
||||||
- name: manager
|
- name: manager
|
||||||
image: gateway-cert-operator:latest
|
image: gitea.home.hrajfrisbee.cz/kacerr/gatewayapi-certificates-operator:latest
|
||||||
args:
|
args:
|
||||||
- --metrics-bind-address=:8080
|
- --metrics-bind-address=:8080
|
||||||
- --health-probe-bind-address=:8081
|
- --health-probe-bind-address=:8081
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
apiVersion: external-secrets.io/v1
|
||||||
|
kind: ExternalSecret
|
||||||
|
metadata:
|
||||||
|
name: gitea-registry
|
||||||
|
namespace: gateway-cert-operator-system
|
||||||
|
spec:
|
||||||
|
refreshInterval: 1h
|
||||||
|
secretStoreRef:
|
||||||
|
name: vault-backend
|
||||||
|
kind: ClusterSecretStore
|
||||||
|
target:
|
||||||
|
name: gitea-registry
|
||||||
|
creationPolicy: Owner
|
||||||
|
template:
|
||||||
|
type: kubernetes.io/dockerconfigjson
|
||||||
|
data:
|
||||||
|
.dockerconfigjson: "{{ .token }}"
|
||||||
|
data:
|
||||||
|
- secretKey: token
|
||||||
|
remoteRef:
|
||||||
|
key: k8s_home/gitea/container-registry
|
||||||
|
property: token
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||||
|
kind: Kustomization
|
||||||
|
|
||||||
|
resources:
|
||||||
|
- namespace_gateway-cert-operator-system.yaml
|
||||||
|
- externalsecret_gitea-registry.yaml
|
||||||
|
- serviceaccount_gateway-cert-operator.yaml
|
||||||
|
- clusterrole_gateway-cert-operator.yaml
|
||||||
|
- clusterrolebinding_gateway-cert-operator.yaml
|
||||||
|
- deployment_gateway-cert-operator.yaml
|
||||||
|
- service_gateway-cert-operator-metrics.yaml
|
||||||
+501
@@ -0,0 +1,501 @@
|
|||||||
|
# Runtime Flows
|
||||||
|
|
||||||
|
Deep-dive reference for every control path in the gateway-cert-operator. Complements the user-facing overview in `docs/README.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
|
||||||
|
The operator sits between two Kubernetes APIs and keeps them in sync:
|
||||||
|
|
||||||
|
```
|
||||||
|
+--------------------+ +------------------------+
|
||||||
|
| cert-manager | | gateway-cert-operator |
|
||||||
|
| Certificate |----(watches)-------->| (this binary) |
|
||||||
|
| (source of | | |
|
||||||
|
| truth for TLS) | +----------+-------------+
|
||||||
|
+--------------------+ |
|
||||||
|
| patches Spec.Listeners
|
||||||
|
v
|
||||||
|
+--------------------+ +------------------------+
|
||||||
|
| Gateway API | | Gateway |
|
||||||
|
| controller |<----(watches)--------| (target object) |
|
||||||
|
| (routes traffic) | +------------------------+
|
||||||
|
+--------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
The operator makes no changes to cert-manager objects. Its only write action is a `PATCH` on `Gateway.Spec.Listeners`. All other interactions are reads.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Startup Flow
|
||||||
|
|
||||||
|
Executed once at process start (`main.go`):
|
||||||
|
|
||||||
|
```
|
||||||
|
process start
|
||||||
|
│
|
||||||
|
└─ main() [main.go:24]
|
||||||
|
│
|
||||||
|
├─ flag.StringVar --metrics-bind-address default ":8080" [main.go:29]
|
||||||
|
├─ flag.StringVar --health-probe-bind-address default ":8081" [main.go:30]
|
||||||
|
├─ flag.BoolVar --version [main.go:31]
|
||||||
|
├─ zap.Options{Development: true, AddCaller}.BindFlags [main.go:32-33]
|
||||||
|
│ adds --zap-devel, --zap-encoder, --zap-log-level, …
|
||||||
|
├─ flag.Parse() [main.go:34]
|
||||||
|
│
|
||||||
|
├─ --version ──► print version.Resolve(), return [main.go:36-39]
|
||||||
|
│ before any logger or cluster access
|
||||||
|
│
|
||||||
|
├─ scheme := runtime.NewScheme() [main.go:41-44]
|
||||||
|
│ ├─ clientgoscheme.AddToScheme(scheme) # core/v1: Secrets, Events, …
|
||||||
|
│ ├─ cmv1.AddToScheme(scheme) # cert-manager.io/v1 Certificate
|
||||||
|
│ └─ gwapiv1.Install(scheme) # gateway.networking.k8s.io/v1 Gateway
|
||||||
|
│
|
||||||
|
├─ ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) [main.go:46]
|
||||||
|
│ dev-mode console logger with file:line call site unless
|
||||||
|
│ --zap-devel=false is passed
|
||||||
|
├─ log "starting gateway-cert-operator" commit=… goVersion=… [main.go:48]
|
||||||
|
│
|
||||||
|
├─ ctrl.GetConfigOrDie() # KUBECONFIG or in-cluster config
|
||||||
|
│ on err ──► os.Exit(1)
|
||||||
|
│
|
||||||
|
├─ ctrl.NewManager(cfg, Options{Scheme, Metrics, HealthProbeBindAddress})
|
||||||
|
│ on err ──► log + os.Exit(1) [main.go:50-54]
|
||||||
|
│
|
||||||
|
├─ controller.SetupCertificateReconciler(mgr) [main.go:60]
|
||||||
|
│ registers field index + watches (see §3)
|
||||||
|
│ on err ──► log + os.Exit(1)
|
||||||
|
│
|
||||||
|
├─ mgr.AddHealthzCheck("healthz", healthz.Ping) → GET /healthz:8081
|
||||||
|
├─ mgr.AddReadyzCheck("readyz", healthz.Ping) → GET /readyz:8081
|
||||||
|
│ on err ──► log + os.Exit(1)
|
||||||
|
│
|
||||||
|
└─ mgr.Start(ctrl.SetupSignalHandler()) [main.go:75]
|
||||||
|
SetupSignalHandler installs SIGINT / SIGTERM cancel
|
||||||
|
mgr.Start blocks; on shutdown → graceful stop
|
||||||
|
on err ──► log + os.Exit(1)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Watch Wiring and Event Sources
|
||||||
|
|
||||||
|
`SetupCertificateReconciler` registers two watches before starting the controller
|
||||||
|
(`certificate_controller.go:59-63`):
|
||||||
|
|
||||||
|
```
|
||||||
|
controller-runtime manager
|
||||||
|
│
|
||||||
|
├─ For(&cmv1.Certificate{}) primary watch
|
||||||
|
│ every Certificate create/update/delete
|
||||||
|
│ ──► enqueue Reconcile(cert.Namespace/cert.Name)
|
||||||
|
│
|
||||||
|
└─ Watches(&gwapiv1.Gateway{},
|
||||||
|
EnqueueRequestsFromMapFunc(r.gatewayToCertificates))
|
||||||
|
secondary watch
|
||||||
|
every Gateway create/update/delete
|
||||||
|
──► gatewayToCertificates maps Gateway → []Certificates
|
||||||
|
──► enqueue Reconcile for each matching Certificate
|
||||||
|
```
|
||||||
|
|
||||||
|
Full trigger table:
|
||||||
|
|
||||||
|
| ID | Event | Enqueued object | Code path |
|
||||||
|
|----|-------|-----------------|-----------|
|
||||||
|
| E1 | Certificate **create** | that Certificate | primary watch |
|
||||||
|
| E2 | Certificate **update** (any field — no predicate filter) | that Certificate | primary watch |
|
||||||
|
| E3 | Certificate **delete** | that Certificate | primary watch |
|
||||||
|
| E4 | Gateway **create / update / delete** | every Certificate targeting it (via field index) | secondary watch + `gatewayToCertificates` |
|
||||||
|
| E5 | Reconcile returns non-nil error | same Certificate | controller-runtime rate-limited backoff re-queue |
|
||||||
|
| E6 | Controller-runtime cache resync (~10 h default) | every known Certificate | implicit periodic replay |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Field Index
|
||||||
|
|
||||||
|
The field index is the only mechanism that lets the operator find all Certificates pointing at a given Gateway without scanning the entire namespace. It is registered during setup and queried in two places.
|
||||||
|
|
||||||
|
```
|
||||||
|
IndexFieldGatewayTarget = ".metadata.annotations.gatewayTarget"
|
||||||
|
(identifier used by controller-runtime; no annotation by this name exists)
|
||||||
|
|
||||||
|
Indexer function (certificate_controller.go:44-55):
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ for each Certificate obj: │
|
||||||
|
│ gwName = obj.Annotations["…/gateway-name"] │
|
||||||
|
│ if gwName == "" → return nil (not indexed) │
|
||||||
|
│ gwNs = obj.Annotations["…/gateway-namespace"] │
|
||||||
|
│ if gwNs == "" → gwNs = obj.Namespace (fallback) │
|
||||||
|
│ return [ "<gwNs>/<gwName>" ] │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
Key shape: "<targetGatewayNamespace>/<targetGatewayName>"
|
||||||
|
|
||||||
|
Two query sites:
|
||||||
|
|
||||||
|
┌──────────────────────────────────────────────────────┐
|
||||||
|
│ Reconcile [certificate_controller.go:95-99] │
|
||||||
|
│ List(Certs, MatchingFields{index: "<gwNs>/<gwName>"})│
|
||||||
|
│ returns all sibling Certs targeting the same GW │
|
||||||
|
└──────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌──────────────────────────────────────────────────────┐
|
||||||
|
│ gatewayToCertificates [certificate_controller.go:138]│
|
||||||
|
│ List(Certs, MatchingFields{index: "<gw.Ns>/<gw.Name>"})│
|
||||||
|
│ used by the secondary watch to fan-out GW events │
|
||||||
|
│ to the relevant Certs │
|
||||||
|
└──────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
The namespace-fallback rule (`gwNs = obj.Namespace` when the annotation is absent) is duplicated in two places: the indexer (`:51-53`) and `Reconcile` (`:89-91`). Both must apply the same rule or the index key used during write will differ from the key used during lookup.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Reconcile Decision Tree
|
||||||
|
|
||||||
|
Every enqueued item arrives here. Inputs: one `Reconcile.Request` containing the namespace/name of a Certificate. Side effects: 0 or 1 `PATCH` on a Gateway.
|
||||||
|
|
||||||
|
```
|
||||||
|
Reconcile(ctx, req)
|
||||||
|
│
|
||||||
|
├─(A) r.Get(ctx, req.NamespacedName, &cert) [controller:71]
|
||||||
|
│ │
|
||||||
|
│ ├─ NotFound ──► client.IgnoreNotFound → nil err
|
||||||
|
│ │ return Result{}, nil ►► STOP — NO ACTION
|
||||||
|
│ │
|
||||||
|
│ │ ┌─ IMPORTANT: the deleted cert's listener is NOT removed here.
|
||||||
|
│ │ │ The controller has no record of which Gateway was targeted.
|
||||||
|
│ │ │ Cleanup happens on the next E2/E4/E6 event for any sibling
|
||||||
|
│ │ │ cert that still references the same Gateway — at that point
|
||||||
|
│ │ │ BuildListenersFromCertificates omits the deleted cert and
|
||||||
|
│ │ │ MergeListeners replaces all auto-* listeners. Until that
|
||||||
|
│ │ │ event fires, the stale listener remains in the Gateway.
|
||||||
|
│ │ │ (See §10, item 3 for details.)
|
||||||
|
│ │ └───────────────────────────────────────────────────────────
|
||||||
|
│ │
|
||||||
|
│ └─ other error ──► return Result{}, err ►► REQUEUE (E5)
|
||||||
|
│
|
||||||
|
├─(B) gwName, ok := cert.Annotations["…/gateway-name"] [controller:82]
|
||||||
|
│ │
|
||||||
|
│ └─ !ok ──► log V(1) "missing gateway annotation"
|
||||||
|
│ return Result{}, nil ►► STOP — NO ACTION
|
||||||
|
│
|
||||||
|
├─(C) gwNamespace := cert.Annotations["…/gateway-namespace"] [controller:88]
|
||||||
|
│ └─ if "" ──► gwNamespace = cert.Namespace (DEFAULT FALLBACK)
|
||||||
|
│
|
||||||
|
├─(D) r.List(ctx, &certList, [controller:95]
|
||||||
|
│ MatchingFields{IndexFieldGatewayTarget: "<gwNs>/<gwName>"})
|
||||||
|
│ │
|
||||||
|
│ └─ err ──► return Result{}, err ►► REQUEUE
|
||||||
|
│
|
||||||
|
├─(E) desiredListeners :=
|
||||||
|
│ gw.BuildListenersFromCertificates(certList.Items, "auto-")
|
||||||
|
│ (see §6a) [controller:102]
|
||||||
|
│
|
||||||
|
├─(F) r.Get(ctx, {gwNs, gwName}, &gateway) [controller:106]
|
||||||
|
│ │
|
||||||
|
│ └─ err (including NotFound) ──► return Result{}, err ►► REQUEUE
|
||||||
|
│
|
||||||
|
│ NOTE: Gateway-not-found is NOT specially handled — the
|
||||||
|
│ reconciler will retry with backoff indefinitely. A typo'd
|
||||||
|
│ gateway-name annotation produces a perpetual retry loop.
|
||||||
|
│ (See §10, item 5.)
|
||||||
|
│
|
||||||
|
├─(G) updated := gw.MergeListeners( [controller:111]
|
||||||
|
│ gateway.Spec.Listeners,
|
||||||
|
│ desiredListeners, "auto-")
|
||||||
|
│ (see §6b)
|
||||||
|
│
|
||||||
|
├─(H) gw.ListenersEqual(gateway.Spec.Listeners, updated) [controller:113]
|
||||||
|
│ │
|
||||||
|
│ └─ true ──► log V(1) "already up to date"
|
||||||
|
│ return Result{}, nil ►► SHORT-CIRCUIT — NO PATCH
|
||||||
|
│
|
||||||
|
├─(I) patch := client.MergeFrom(gateway.DeepCopy()) [controller:118]
|
||||||
|
│ gateway.Spec.Listeners = updated
|
||||||
|
│ r.Patch(ctx, &gateway, patch) [controller:120]
|
||||||
|
│ │
|
||||||
|
│ └─ err ──► return Result{}, err ►► REQUEUE
|
||||||
|
│
|
||||||
|
└─(J) log Info "patched gateway listeners" [controller:124]
|
||||||
|
return Result{}, nil ►► DONE
|
||||||
|
|
||||||
|
Cluster I/O per reconcile:
|
||||||
|
Reads: 1 Certificate Get (step A)
|
||||||
|
1 Certificate List via index (step D)
|
||||||
|
1 Gateway Get (step F)
|
||||||
|
Writes: 0 or 1 Gateway Patch (step I, only when listeners changed)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Build / Merge / Equal Pipeline
|
||||||
|
|
||||||
|
The three pure functions in `internal/gateway/patch.go` do all computation. `Reconcile` only calls them; it holds no business logic of its own.
|
||||||
|
|
||||||
|
```
|
||||||
|
certList.Items
|
||||||
|
│
|
||||||
|
v
|
||||||
|
BuildListenersFromCertificates(certs, "auto-") [patch.go:22]
|
||||||
|
│
|
||||||
|
└──► desired []gwapiv1.Listener
|
||||||
|
│
|
||||||
|
│ gateway.Spec.Listeners (current state from API server)
|
||||||
|
│ │
|
||||||
|
v v
|
||||||
|
MergeListeners(existing, desired, "auto-") [patch.go:76]
|
||||||
|
│
|
||||||
|
└──► updated []gwapiv1.Listener
|
||||||
|
│
|
||||||
|
v
|
||||||
|
ListenersEqual(gateway.Spec.Listeners, updated) [patch.go:93]
|
||||||
|
│
|
||||||
|
├─ true ──► short-circuit, no patch
|
||||||
|
└─ false ──► Patch(gateway)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6a. BuildListenersFromCertificates
|
||||||
|
|
||||||
|
```
|
||||||
|
for cert in certs:
|
||||||
|
│
|
||||||
|
├─ if cert.DeletionTimestamp != nil ──► skip [patch.go:26]
|
||||||
|
│ (cert is being deleted; omitting it removes its listeners
|
||||||
|
│ from the desired set and MergeListeners will drop them)
|
||||||
|
│
|
||||||
|
├─ if cert.Spec.SecretName == "" ──► skip [patch.go:30]
|
||||||
|
│
|
||||||
|
└─ for dns in cert.Spec.DNSNames: [patch.go:34]
|
||||||
|
│
|
||||||
|
└─ emit Listener{
|
||||||
|
Name: "auto-<certName>-<sanitized-dns>"
|
||||||
|
Hostname: dns
|
||||||
|
Port: 443 (hardcoded, see §8)
|
||||||
|
Protocol: HTTPS
|
||||||
|
AllowedRoutes: { From: All }
|
||||||
|
TLS: {
|
||||||
|
Mode: Terminate
|
||||||
|
CertificateRefs: [{
|
||||||
|
Group: "" (core)
|
||||||
|
Kind: "Secret"
|
||||||
|
Name: cert.Spec.SecretName
|
||||||
|
Namespace: cert.Namespace (same as cert)
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sort by Name ascending [patch.go:67]
|
||||||
|
(deterministic order prevents spurious diffs on every reconcile)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6b. MergeListeners
|
||||||
|
|
||||||
|
```
|
||||||
|
existing listeners
|
||||||
|
│
|
||||||
|
├─ Name starts with "auto-" ──► DROP (managed, will be replaced)
|
||||||
|
└─ Name does NOT start with "auto-" ──► KEEP (user-managed)
|
||||||
|
|
||||||
|
merged = [kept user listeners] + [all desired auto-* listeners]
|
||||||
|
```
|
||||||
|
|
||||||
|
The result: user-created listeners appear first in their original order; operator-managed listeners follow, sorted by name.
|
||||||
|
|
||||||
|
**Edge case:** a user-created listener whose name happens to start with `auto-` will be treated as managed and will be deleted on the next reconcile.
|
||||||
|
|
||||||
|
### 6c. ListenersEqual
|
||||||
|
|
||||||
|
`ListenersEqual` is a **partial comparison** — only the fields the operator actively manages are checked:
|
||||||
|
|
||||||
|
| Compared | Ignored |
|
||||||
|
|----------|---------|
|
||||||
|
| listener `Name` | `Protocol` |
|
||||||
|
| `Hostname` | `AllowedRoutes` |
|
||||||
|
| `Port` | `TLS.Options` |
|
||||||
|
| first `CertificateRef` (`<ns>/<name>`) | cert refs beyond index 0 |
|
||||||
|
| | `Group`, `Kind` of ref |
|
||||||
|
|
||||||
|
Drift in an ignored field will not trigger a corrective patch. For example: if someone manually flips the Protocol of an auto-managed listener from HTTPS to HTTP, the operator will consider it "equal" and leave it alone.
|
||||||
|
|
||||||
|
The comparison is done via a name-keyed map (`listenerMap`, `patch.go:118`), so listener order does not affect the result.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Listener Naming and Sanitization
|
||||||
|
|
||||||
|
`listenerNameFor` and `sanitizeDNSName` (`patch.go`):
|
||||||
|
|
||||||
|
```
|
||||||
|
input: prefix="auto-" certName="my-app-cert" dnsName="app.example.com"
|
||||||
|
|
||||||
|
step 1: sanitize the DNS name
|
||||||
|
lowercase; "*" ──► "wildcard"; every other character outside
|
||||||
|
[a-z0-9] ──► "-"; trim leading and trailing "-"
|
||||||
|
"app.example.com" ──► "app-example-com"
|
||||||
|
|
||||||
|
step 2: concatenate
|
||||||
|
"auto-" + "my-app-cert" + "-" + "app-example-com"
|
||||||
|
──► "auto-my-app-cert-app-example-com"
|
||||||
|
|
||||||
|
step 3: if len > 253: cut to 244, trim trailing "-" and ".", append
|
||||||
|
"-" + first 8 hex chars of sha256(full name)
|
||||||
|
```
|
||||||
|
|
||||||
|
Example outputs for a cert with two DNS names:
|
||||||
|
|
||||||
|
| `dnsName` | listener `Name` |
|
||||||
|
|-----------|-----------------|
|
||||||
|
| `app.example.com` | `auto-my-app-cert-app-example-com` |
|
||||||
|
| `*.example.com` | `auto-my-app-cert-wildcard-example-com` |
|
||||||
|
|
||||||
|
The listener `Hostname` keeps the original DNS name, wildcard included.
|
||||||
|
|
||||||
|
The sanitization is also lossy: `a.b.com` and `a-b-com` produce the same result. If a certificate lists both, two listeners with the same name are appended; the API server rejects the patch (listener names must be unique), so no listener on that Gateway is updated.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Defaults Baked In
|
||||||
|
|
||||||
|
All of these are hardcoded in `internal/gateway/patch.go`. None are configurable via annotations (the `listener-port` annotation exists as a constant but is not read — see §10, item 1).
|
||||||
|
|
||||||
|
| Property | Value | Source |
|
||||||
|
|----------|-------|--------|
|
||||||
|
| Listener port | `443` | `defaultPort` `patch.go:13` |
|
||||||
|
| TLS mode | `TLSModeTerminate` | `tlsModeTerminate` `patch.go:14` |
|
||||||
|
| Allowed routes | `NamespacesFromAll` | `namespacesFromAll` `patch.go:15` |
|
||||||
|
| Protocol | `HTTPSProtocolType` | inline `patch.go:45` |
|
||||||
|
| CertificateRef Group | `""` (core API group) | `patch.go:55` |
|
||||||
|
| CertificateRef Kind | `"Secret"` | `patch.go:56` |
|
||||||
|
| Secret namespace | cert's own namespace | `patch.go:39,58` |
|
||||||
|
| Listener name prefix | `"auto-"` | `ListenerPrefix` `controller:28` |
|
||||||
|
| SectionName max length | 253 bytes | `patch.go:147` |
|
||||||
|
| Gateway namespace fallback | cert's own namespace | `controller:51-53` and `controller:89-91` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. End-to-End Scenarios
|
||||||
|
|
||||||
|
### Scenario 1 — First Certificate created, Gateway has no auto-listeners
|
||||||
|
|
||||||
|
```
|
||||||
|
1. User creates Certificate "my-cert" (namespace: default)
|
||||||
|
Annotations: gateway-name=my-gateway
|
||||||
|
Spec: dnsNames=[app.example.com], secretName=my-cert-tls
|
||||||
|
|
||||||
|
2. (E1) Cert create event fires → Reconcile(default/my-cert)
|
||||||
|
|
||||||
|
3. (A) Get cert → found
|
||||||
|
(B) gateway-name="my-gateway" → ok
|
||||||
|
(C) gateway-namespace missing → fallback to "default"
|
||||||
|
(D) List certs by index "default/my-gateway" → [my-cert]
|
||||||
|
(E) BuildListeners → [Listener{Name:"auto-my-cert-app-example-com", Port:443, …}]
|
||||||
|
(F) Get gateway "default/my-gateway" → found, Listeners=[]
|
||||||
|
(G) MergeListeners([], desired) → [auto-my-cert-app-example-com]
|
||||||
|
(H) ListenersEqual([], updated) → false (len differs)
|
||||||
|
(I) Patch gateway → Spec.Listeners=[auto-my-cert-app-example-com]
|
||||||
|
(J) Done. Log: "patched gateway listeners"
|
||||||
|
|
||||||
|
Cluster writes: 1 Gateway PATCH
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scenario 2 — Second Certificate added for the same Gateway
|
||||||
|
|
||||||
|
```
|
||||||
|
1. User creates Certificate "other-cert" targeting the same Gateway
|
||||||
|
dnsNames=[api.example.com], secretName=other-cert-tls
|
||||||
|
|
||||||
|
2. (E1) Reconcile(default/other-cert)
|
||||||
|
|
||||||
|
3. (D) List certs by index "default/my-gateway" → [my-cert, other-cert]
|
||||||
|
(E) BuildListeners → [
|
||||||
|
auto-my-cert-app-example-com,
|
||||||
|
auto-other-cert-api-example-com
|
||||||
|
] (sorted alphabetically by name)
|
||||||
|
(F) Gateway still has [auto-my-cert-app-example-com]
|
||||||
|
(G) MergeListeners → [auto-my-cert-app-example-com,
|
||||||
|
auto-other-cert-api-example-com]
|
||||||
|
(H) ListenersEqual(old 1-item, new 2-item) → false
|
||||||
|
(I) Patch → Gateway now has both listeners
|
||||||
|
|
||||||
|
Cluster writes: 1 Gateway PATCH
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scenario 3 — Certificate dnsNames updated
|
||||||
|
|
||||||
|
```
|
||||||
|
1. User edits "my-cert": dnsNames=[app.example.com] → [app.example.com, www.example.com]
|
||||||
|
|
||||||
|
2. (E2) Cert update event → Reconcile(default/my-cert)
|
||||||
|
|
||||||
|
3. (E) BuildListeners produces TWO listeners for my-cert now:
|
||||||
|
auto-my-cert-app-example-com
|
||||||
|
auto-my-cert-www-example-com
|
||||||
|
(plus any from other-cert if still present)
|
||||||
|
(H) ListenersEqual: len changed → false
|
||||||
|
(I) Patch gateway with new full set
|
||||||
|
|
||||||
|
Cluster writes: 1 Gateway PATCH
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scenario 4 — Certificate deleted
|
||||||
|
|
||||||
|
```
|
||||||
|
1. User deletes "my-cert"
|
||||||
|
|
||||||
|
2. (E3) Cert delete event → Reconcile(default/my-cert)
|
||||||
|
|
||||||
|
3. (A) Get cert → NotFound
|
||||||
|
return Result{}, nil ──► STOP, NO ACTION
|
||||||
|
|
||||||
|
4. Gateway still contains "auto-my-cert-app-example-com" — stale.
|
||||||
|
|
||||||
|
5. Cleanup happens only when the next relevant event fires:
|
||||||
|
- If "other-cert" is updated or created → (E2) Reconcile(other-cert)
|
||||||
|
(D) List by index returns [other-cert] only (my-cert is gone)
|
||||||
|
(E) BuildListeners → only other-cert's listeners
|
||||||
|
(G) MergeListeners drops the stale auto-my-cert-* listener
|
||||||
|
(I) Patch removes it
|
||||||
|
- OR if the Gateway is manually edited → (E4) all targeting Certs
|
||||||
|
re-enqueued, same convergence path
|
||||||
|
- OR on the next cache resync (E6, ~10 h)
|
||||||
|
|
||||||
|
Every path above needs at least one surviving Certificate that targets
|
||||||
|
the Gateway. If my-cert was the last one, nothing is ever enqueued for
|
||||||
|
it and the stale listener stays until someone removes it by hand.
|
||||||
|
|
||||||
|
Cluster writes: 0 at deletion time; 1 Gateway PATCH at next sibling event
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Known Limitations
|
||||||
|
|
||||||
|
Recorded as-found. None are fixed by this document.
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
**2. `--metrics-bind-address` flag was not wired** (fixed)
|
||||||
|
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**
|
||||||
|
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.
|
||||||
|
|
||||||
|
**4. `ListenersEqual` is a partial comparison**
|
||||||
|
Described in §6c. Drift in `Protocol`, `AllowedRoutes`, `TLS.Options`, or any `CertificateRef` beyond the first will not be detected or corrected.
|
||||||
|
|
||||||
|
**5. Gateway-not-found retries forever**
|
||||||
|
Branch (F) of Reconcile treats all Gateway `Get` errors the same (including `NotFound`) and returns them as re-queue-able errors. A Certificate with a typo'd `gateway-name` annotation will retry with rate-limited backoff indefinitely, generating log noise and API load. There is no terminal failure path.
|
||||||
|
|
||||||
|
**6. No leader election**
|
||||||
|
`ctrl.NewManager` at `main.go:50` does not set `LeaderElection: true`. The `deploy/deployment_gateway-cert-operator.yaml` runs `replicas: 1` which makes this safe today. Scaling to multiple replicas without enabling leader election would result in concurrent Gateway patches.
|
||||||
|
|
||||||
|
**7. `listenerNameFor` edge cases**
|
||||||
|
- **Wildcard hostnames** (fixed): `*` used to end up in the listener name and the API server rejected the patch. It is now spelled out as `wildcard`.
|
||||||
|
- **Collisions after sanitization**: `a.b.com` and `a-b-com` produce identical sanitized names, as do `*.x.com` and `wildcard.x.com`. The same happens across certificates (cert `a` with `b-c.com` and cert `a-b` with `c.com`) and across namespaces, since the name omits the cert namespace. Both listeners are appended and the API server rejects the patch for the whole Gateway.
|
||||||
|
- **Truncation at 253 bytes** (fixed): long names are cut and get a hash of the full name appended, so they stay valid and distinct.
|
||||||
|
|
||||||
|
**Dead symbols in `patch.go`**
|
||||||
|
`gatewayGroup` and `gatewayKind` are declared at `patch.go:16-17` but never referenced. They can be removed without effect.
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
# Execution: Bring gateway-helper-operator up to house conventions
|
||||||
|
|
||||||
|
Plan: `docs/plans/2026-10-08-2028-house-conventions.md`
|
||||||
|
|
||||||
|
- [x] Step 0 — Save and commit the plan
|
||||||
|
- [x] Step 1 — CLAUDE.md
|
||||||
|
- [x] Step 2 — `.claude/` and docs scaffolding
|
||||||
|
- [x] Step 3 — Makefile, lint, formatting
|
||||||
|
- [x] Step 4 — Module path and version identity
|
||||||
|
- [x] Step 5 — Baseline tests
|
||||||
|
- [x] Step 6 — CI build workflow
|
||||||
|
- [x] Step 7 — Close out
|
||||||
|
|
||||||
|
## Step 1 — CLAUDE.md
|
||||||
|
|
||||||
|
Rewrote `CLAUDE.md` in the five-section shape. The Go, Testing, Changelog and
|
||||||
|
Branching text comes from the `go-project-bootstrap` skill references; the plan
|
||||||
|
ordering and execution-summary rules come from `egress-proxies-operator/CLAUDE.md`,
|
||||||
|
condensed from prose into bullets.
|
||||||
|
|
||||||
|
Deviations from the plan:
|
||||||
|
|
||||||
|
- The house container rules about Ubuntu dev images and separate dev/prod
|
||||||
|
Dockerfiles were left out: this repo has one production Dockerfile and no dev
|
||||||
|
image, and listing rules nobody follows only adds noise.
|
||||||
|
- The `listener-port` row was removed from the annotation table and moved to
|
||||||
|
Gotchas, so the table lists only what works.
|
||||||
|
- Added a short Reviews subsection so the reviewer agents and `docs/reviews/` have a
|
||||||
|
documented trigger.
|
||||||
|
- `README.md` also claimed that deleting a certificate removes its listeners
|
||||||
|
"instantly". Reworded and added the known limitation.
|
||||||
|
|
||||||
|
Worth noting: Commands and CI/CD describe the repo as it is at this commit; Steps 3,
|
||||||
|
4 and 6 update them. `docs/flows.md` cites `main.go` line numbers that Step 4 shifts.
|
||||||
|
|
||||||
|
## Step 2 — `.claude/` and docs scaffolding
|
||||||
|
|
||||||
|
Copied the two reviewer agents and `/ship` from `egress-proxies-operator/.claude/`.
|
||||||
|
`/release` was rewritten for the semver rule instead of copied, because the egress
|
||||||
|
text says "no tagging scheme is documented" and this repo now has one.
|
||||||
|
|
||||||
|
Deviations from the plan:
|
||||||
|
|
||||||
|
- `/ship` names "a `Co-Authored-By:` trailer for Claude" instead of the fixed
|
||||||
|
`Claude <noreply@anthropic.com>` string, matching CLAUDE.md.
|
||||||
|
- `.gitignore` already covered `/bin/` and `*.out`, so only
|
||||||
|
`.claude/settings.local.json` was added.
|
||||||
|
- `settings.json` also allows `kubectl kustomize`, which the Commands section uses.
|
||||||
|
|
||||||
|
Worth noting: `.claude/settings.json` leaves out `git commit`, `git push` and
|
||||||
|
`git tag` on purpose, but `~/.claude/settings.json` allows `git commit` and
|
||||||
|
`git push` globally, so on this machine the push confirmation rests on CLAUDE.md,
|
||||||
|
not on the permission system.
|
||||||
|
|
||||||
|
## Step 3 — Makefile, lint, formatting
|
||||||
|
|
||||||
|
Added `Makefile` (uh-cli's shape plus `run`, `docker-build` and `-race` on `test`),
|
||||||
|
`.golangci.yml` (v2 defaults) and `.dockerignore` (egress's allowlist).
|
||||||
|
|
||||||
|
`make lint` failed on first run, before reaching this repo's code:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ golangci-lint run ./...
|
||||||
|
.../go/1.27.1/libexec/src/crypto/internal/randutil/randutil.go:11:2: could not import math/rand/v2
|
||||||
|
(.../math/rand/v2/rand.go:213:17: method must have no type parameters) (typecheck)
|
||||||
|
```
|
||||||
|
|
||||||
|
The installed golangci-lint 2.12.2 was built with go1.26.4 and cannot type-check the
|
||||||
|
Go 1.27 standard library. The `lint` target now runs under the go.mod toolchain,
|
||||||
|
which is also what CI uses:
|
||||||
|
|
||||||
|
```make
|
||||||
|
GO_MOD_VERSION := $(shell awk '$$1 == "go" {print $$2}' go.mod)
|
||||||
|
lint:
|
||||||
|
GOTOOLCHAIN=go$(GO_MOD_VERSION) golangci-lint run ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
With that, lint reported the two expected dead variables (`gatewayGroup`,
|
||||||
|
`gatewayKind` in `patch.go`), which were removed. `make fmt` reformatted `main.go`
|
||||||
|
(import order) and the `patch.go` var block.
|
||||||
|
|
||||||
|
Worth noting: this is likely the same failure the egress changelog records as
|
||||||
|
"`make lint` is blocked by a Go 1.27 toolchain/staticcheck panic"; the same
|
||||||
|
`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`
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Step 5 — Baseline tests
|
||||||
|
|
||||||
|
`internal/gateway/patch_test.go`: five table-driven tests over the pure functions.
|
||||||
|
|
||||||
|
```
|
||||||
|
$ go test -race -cover ./...
|
||||||
|
ok .../internal/gateway 1.870s coverage: 96.9% of statements
|
||||||
|
ok .../internal/version 2.196s coverage: 94.7% of statements
|
||||||
|
```
|
||||||
|
|
||||||
|
Worth noting: `internal/controller` and `main` are at 0%; reconciler tests with the
|
||||||
|
fake client come with the first reconcile fix. Wildcard names, name collisions and
|
||||||
|
truncation are deliberately not asserted, so the suite does not pin behaviour that
|
||||||
|
the bug-fix phase will change. `TestListenersEqual_managedFields` only asserts the
|
||||||
|
four fields the comparison covers today; it says nothing about protocol, allowed
|
||||||
|
routes or TLS mode.
|
||||||
|
|
||||||
|
## Step 6 — CI build workflow
|
||||||
|
|
||||||
|
`.gitea/workflows/build.yaml` is now a verbatim copy of the egress workflow; it has
|
||||||
|
no project-specific strings (the image name comes from `github.repository`).
|
||||||
|
|
||||||
|
Worth noting: not run yet. The first tag push or manual dispatch is the test. Two
|
||||||
|
things could differ from egress on this repo's runner: `actions/setup-go@v5` (uh-cli
|
||||||
|
had to install Go with curl instead), and lint is not part of the `check` job, same
|
||||||
|
as egress. The unused `GIT_TAG` and `BUILD_DATE` build args are gone;
|
||||||
|
`kubernetes-deploy.yaml` is untouched.
|
||||||
|
|
||||||
|
## Step 7 — Close out (review)
|
||||||
|
|
||||||
|
Ran both reviewer agents on `main...HEAD`; findings and their status are in
|
||||||
|
`docs/reviews/2026-10-08-2040-house-conventions.md`. Ten items fixed on the branch, four new
|
||||||
|
bugs deferred to the bug-fix phase, one test simplification declined.
|
||||||
|
|
||||||
|
Worth noting: the reviewer agents were not registered as agent types when the review
|
||||||
|
started (they were added in this session), so they were run as general-purpose
|
||||||
|
agents told to follow the agent files. The `:latest` dispatch hole came in with the
|
||||||
|
workflow copied from egress and is still open there. Pushed together with the listener-name fix
|
||||||
|
(`docs/plans/2026-10-08-2046-listener-name-sanitize.md`) as one MR. The CHANGELOG
|
||||||
|
entry is still owed, after the owner confirms it works.
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Execution: Sanitize listener names derived from certificate DNS names
|
||||||
|
|
||||||
|
Plan: `docs/plans/2026-10-08-2046-listener-name-sanitize.md`
|
||||||
|
|
||||||
|
- [x] Step 0 — Save and commit the plan
|
||||||
|
- [x] Step 1 — Regression tests first
|
||||||
|
- [x] Step 2 — Fix
|
||||||
|
- [x] Step 3 — Docs
|
||||||
|
- [x] Step 4 — Ship
|
||||||
|
|
||||||
|
## Steps 1–3 — Tests, fix, docs
|
||||||
|
|
||||||
|
Tests were written first and failed on the old code (wildcard, uppercase, `_`,
|
||||||
|
trailing dot, long names), then passed after the change to `listenerNameFor`.
|
||||||
|
|
||||||
|
```
|
||||||
|
$ go test -race -run 'TestListenerNameFor|TestBuildListeners' -v ./internal/gateway/
|
||||||
|
--- PASS: TestListenerNameFor_validSectionName/wildcard_label_is_spelled_out
|
||||||
|
--- PASS: TestListenerNameFor_longNames
|
||||||
|
--- PASS: TestBuildListenersFromCertificates_wildcard
|
||||||
|
$ make check
|
||||||
|
ok .../internal/gateway coverage: 98.6% of statements
|
||||||
|
```
|
||||||
|
|
||||||
|
Deviations from the plan:
|
||||||
|
|
||||||
|
- A trailing `-` or `.` is trimmed from the whole name, not only from the DNS part,
|
||||||
|
so a DNS name that sanitizes to nothing cannot leave the name ending in `-`.
|
||||||
|
- One extra case: a trailing dot (`app.example.com.`) is dropped.
|
||||||
|
- Steps 1–3 are one commit, since the tests do not pass without the fix.
|
||||||
|
|
||||||
|
Worth noting: names that were valid before are unchanged, so existing listeners are
|
||||||
|
not renamed. Not verified against a cluster; the production case is covered by the
|
||||||
|
test using the exact cert and DNS name from the error log.
|
||||||
|
|
||||||
|
## Step 4 — Ship
|
||||||
|
|
||||||
|
Pushed `feat/house-conventions` and opened one MR covering the conventions port and
|
||||||
|
this fix. Still owed after merge: tag `v0.1.0` with `/release`, restart the
|
||||||
|
deployment (it pins `:latest`), confirm the reconcile error is gone, then the
|
||||||
|
CHANGELOG entries.
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
# Plan: Bring gateway-helper-operator up to house conventions
|
||||||
|
|
||||||
|
**Created:** 2026-10-08 20:28 CEST
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
This repo dates from March 2026: 381 lines of Go, no tests, no Makefile, no lint
|
||||||
|
config, a `/init`-generated CLAUDE.md (uncommitted, with factual errors) and a
|
||||||
|
one-line `/ship`. The newer repos share a consistent set of practices, distilled in
|
||||||
|
the `go-project-bootstrap` skill (which names this repo as its operator example) and
|
||||||
|
extended in `egress-proxies-operator`, the closest analog. This plan ports those
|
||||||
|
practices here so the following bug-fix and feature work runs on the same rails:
|
||||||
|
tests, a quality gate, plans with execution logs, a changelog, and branch + MR flow.
|
||||||
|
|
||||||
|
No reconcile behaviour changes in this plan. Bugs found during the survey are listed
|
||||||
|
at the end as the backlog for the next phase.
|
||||||
|
|
||||||
|
### What the newer projects have in common
|
||||||
|
|
||||||
|
| Practice | Source | Here today |
|
||||||
|
|---|---|---|
|
||||||
|
| CLAUDE.md in five sections (What this is, Commands, Architecture, Conventions, Gotchas), under ~150 lines | skill | `/init` output, three factual errors |
|
||||||
|
| House Go rules (ctx first, `%w` / `errors.Join`, no `init()`, stdlib first, fail fast) | skill, uh-cli, egress | absent |
|
||||||
|
| Testing rules: stdlib `testing`, table-driven, `Test<Func>_<scenario>`, `t.Parallel()`, `-short` guard, `-race` | skill, uh-cli, egress | no tests at all |
|
||||||
|
| Plans in `docs/plans/YYYY-MM-DD-HHMM-<slug>.md`, committed alone before work | uh-cli, egress, capi-lab | absent |
|
||||||
|
| Paired `docs/plans-executions/` log with a resumable step checklist | egress, capi-lab | absent |
|
||||||
|
| Timestamped `CHANGELOG.md`, newest first, written after you confirm it works | all three | absent |
|
||||||
|
| `feat/<slug>` branch, `tea pr create`, never merge or delete from the CLI | all three | everything on `main` |
|
||||||
|
| `/ship` = commit + push with branch guard; `/release` = tag + confirm + push | egress, capi-lab | `/ship` tags every commit `0.NN` |
|
||||||
|
| Reviewer agents, findings saved to `docs/reviews/` | egress | absent |
|
||||||
|
| Committed `.claude/settings.json` allowlist, `settings.local.json` ignored | uh-cli, egress, capi-lab | only an untracked, un-ignored local file |
|
||||||
|
| Makefile with `help` and a one-shot `check` gate | uh-cli | absent |
|
||||||
|
| `.golangci.yml` v2 | uh-cli, egress | absent |
|
||||||
|
| Commit baked into the binary (`internal/version`, `--version`, startup log, OCI label, `sha-<12>` image tag) | egress | CI passes build args the Dockerfile discards |
|
||||||
|
| CI `check` job gating the image build; `:latest` moves only on tag pushes | egress | build only, `:latest` on every run |
|
||||||
|
|
||||||
|
### Decisions
|
||||||
|
|
||||||
|
- Docs workflow: egress-style (plans, execution logs, changelog, reviews). No
|
||||||
|
decisions/sessions/future-tasks files.
|
||||||
|
- `/ship` and `/release` split; new tags are semver `vMAJOR.MINOR.PATCH`, starting
|
||||||
|
at `v0.1.0`. Old `0.NN` tags stay as they are.
|
||||||
|
- `kubernetes-deploy.yaml` is not touched here (deferred to the bug-fix phase).
|
||||||
|
- Deviations from the house text, written into CLAUDE.md as such:
|
||||||
|
- Logging stays on logr/zap through controller-runtime, not `slog` (same as egress
|
||||||
|
does in practice).
|
||||||
|
- Integration tests mean envtest behind `testing.Short()`, not testcontainers.
|
||||||
|
- Commit subjects keep this repo's existing prefixes (`feat:`, `fix:`, `deploy:`,
|
||||||
|
`ci:`, `docs:`).
|
||||||
|
- `.agents/`, `.codex/`, `AGENTS.md` are not ported: in egress they are untracked
|
||||||
|
auto-migrations or kubebuilder scaffold, not conventions.
|
||||||
|
|
||||||
|
## Steps (one commit each, execution-log entry per step)
|
||||||
|
|
||||||
|
Branch: `feat/house-conventions` off `main`.
|
||||||
|
|
||||||
|
### Step 0 — Save and commit the plan
|
||||||
|
|
||||||
|
Copy this file to `docs/plans/2026-10-08-2028-house-conventions.md`, create
|
||||||
|
`docs/plans-executions/2026-10-08-2028-house-conventions.md` with the step
|
||||||
|
checklist, commit alone.
|
||||||
|
|
||||||
|
### Step 1 — CLAUDE.md
|
||||||
|
|
||||||
|
Rewrite `CLAUDE.md` in the skill's five-section shape
|
||||||
|
(`~/.claude/skills/go-project-bootstrap/references/claude-md-structure.md`).
|
||||||
|
|
||||||
|
- **What this is / Commands / Architecture**: keep the annotation table and
|
||||||
|
reconcile-loop write-up; point to `docs/flows.md` for detail. Fix the errors:
|
||||||
|
leader election is not enabled; cert deletion does not clean up; `listener-port`
|
||||||
|
must not be listed as supported.
|
||||||
|
- **Conventions**: paste verbatim from the skill references
|
||||||
|
(`go-conventions.md`, `docs-conventions.md`, `git-workflow.md` Gitea variant,
|
||||||
|
`containers-k8s.md`) plus egress's "Plan execution summaries" and plan "Ordering
|
||||||
|
matters" paragraphs from
|
||||||
|
`/Users/jan.novak/srv/go/egress-proxies-operator/CLAUDE.md`. Remote:
|
||||||
|
`gitea.home.hrajfrisbee.cz/kacerr/gatewayapi-certificates-operator`. Add the
|
||||||
|
deviations above, the semver tag rule, and the push rule (confirm before
|
||||||
|
`git push`, except inside `/ship`).
|
||||||
|
- **Gotchas**: three project names (directory, remote/image, module/manifests);
|
||||||
|
port annotation declared but unused; stale listeners on cert delete, annotation
|
||||||
|
removal and retarget; `--metrics-bind-address` parsed but ignored (metrics still
|
||||||
|
serve on `:8080`); `ListenersEqual` is partial; `auto-` prefix is the only
|
||||||
|
ownership marker; deployment uses mutable `:latest`; Dockerfile base images are
|
||||||
|
not digest-pinned; `kubernetes-deploy.yaml` runs on every branch push.
|
||||||
|
|
||||||
|
Also in this step: commit `docs/flows.md` after correcting its three wrong claims
|
||||||
|
(metrics "never bound"; cleanup via resync; name collisions being "last write
|
||||||
|
wins" — the API server rejects the patch), and move `docs/README.md` to the repo
|
||||||
|
root with `kubectl apply -f deploy/manifests.yaml` corrected to
|
||||||
|
`kubectl apply -k deploy/`.
|
||||||
|
|
||||||
|
### Step 2 — `.claude/` and docs scaffolding
|
||||||
|
|
||||||
|
- `.claude/settings.json`: the skill's `assets/settings.json` starter allowlist
|
||||||
|
plus `Bash(tea pr create:*)`, `Bash(make <target> *)` for the Step 3 targets and
|
||||||
|
`Bash(docker build:*)`. No `git commit`, `git push` or `git tag`.
|
||||||
|
- `.claude/commands/ship.md` and `release.md`: copy from
|
||||||
|
`egress-proxies-operator/.claude/commands/`; in `release.md` replace the "no
|
||||||
|
tagging scheme" paragraph with the semver rule.
|
||||||
|
- `.claude/agents/operator-reviewer.md`, `go-operator-reviewer.md`: copy from
|
||||||
|
`egress-proxies-operator/.claude/agents/` unchanged.
|
||||||
|
- `CHANGELOG.md` (`# Changelog` header only), `docs/reviews/.gitkeep`.
|
||||||
|
- `.gitignore`: add `.claude/settings.local.json`, `bin/`, `cover.out`; fix the
|
||||||
|
missing trailing newline.
|
||||||
|
|
||||||
|
### Step 3 — Makefile, lint, formatting
|
||||||
|
|
||||||
|
- `Makefile` modelled on `/Users/jan.novak/srv/go/uh-cli/Makefile`: `help`
|
||||||
|
(self-documenting), `build` (to `bin/manager`), `run`, `test` (`-race -cover`),
|
||||||
|
`test-short`, `vet`, `fmt`, `lint`, `tidy`, `check` (lint + vet + test-short),
|
||||||
|
`docker-build` (passes `GIT_COMMIT`, using egress's dirty-aware definition),
|
||||||
|
`clean`.
|
||||||
|
- `.golangci.yml`: v2, default linters, as in uh-cli. Egress's config is unmodified
|
||||||
|
kubebuilder scaffold and currently does not pass, so it is not the model.
|
||||||
|
- `.dockerignore`: egress's allowlist form.
|
||||||
|
- `gofmt` `main.go` and `internal/gateway/patch.go`; fix whatever `make lint`
|
||||||
|
reports (the dead `gatewayGroup` / `gatewayKind` vars are expected).
|
||||||
|
- Add the quality-gate sentence for `make check` to CLAUDE.md.
|
||||||
|
|
||||||
|
### Step 4 — Module path and version identity
|
||||||
|
|
||||||
|
- Rename the module from `github.com/example/gateway-cert-operator` to
|
||||||
|
`gitea.home.hrajfrisbee.cz/kacerr/gatewayapi-certificates-operator` (`go.mod`,
|
||||||
|
the import in `main.go`).
|
||||||
|
- `internal/version/version.go` + test, ported from
|
||||||
|
`egress-proxies-operator/internal/version/` (`Commit` via ldflags, fallback to
|
||||||
|
`debug.ReadBuildInfo`).
|
||||||
|
- `main.go`: `--version` flag; first log line carries commit and Go version; zap
|
||||||
|
options bound to flags (`opts.BindFlags`, `Development: true` default, so output
|
||||||
|
is unchanged unless flags are passed); pass `metricsAddr` to
|
||||||
|
`metricsserver.Options` so the existing flag takes effect (default stays `:8080`).
|
||||||
|
- `Dockerfile`: `ARG GIT_COMMIT=unknown`, `-ldflags -X …/internal/version.Commit`,
|
||||||
|
`GOARCH=${TARGETARCH:-amd64}`, `org.opencontainers.image.revision` label, drop the
|
||||||
|
unused `GIT_TAG` / `BUILD_DATE` expectations.
|
||||||
|
|
||||||
|
### Step 5 — Baseline tests
|
||||||
|
|
||||||
|
`internal/gateway/patch_test.go`: table-driven tests for
|
||||||
|
`BuildListenersFromCertificates`, `MergeListeners`, `ListenersEqual` and
|
||||||
|
`listenerNameFor`, covering current correct behaviour only (single and multiple
|
||||||
|
DNS names, sort order, manual listeners preserved, stale `auto-` listeners
|
||||||
|
dropped, equality ignoring order). Known-buggy inputs (wildcards, collisions,
|
||||||
|
truncation) are left for the bug-fix phase so the tests do not pin wrong
|
||||||
|
behaviour. Reconciler tests with the fake client come with the first reconcile fix.
|
||||||
|
|
||||||
|
### Step 6 — CI build workflow
|
||||||
|
|
||||||
|
`.gitea/workflows/build.yaml`, following
|
||||||
|
`egress-proxies-operator/.gitea/workflows/build.yaml`: `check` job (`go vet`,
|
||||||
|
`go build`, `go test -short`) gating `build`; `GIT_COMMIT` build arg and OCI
|
||||||
|
labels; push `:<tag>` and `:sha-<12>`; `:latest` only on real tag pushes;
|
||||||
|
concurrency group. Update the CI/CD paragraph in CLAUDE.md to match.
|
||||||
|
|
||||||
|
Not verifiable locally. If `actions/setup-go@v5` fails on the runner (uh-cli hit
|
||||||
|
that), fall back to uh-cli's curl install from the `go.mod` version.
|
||||||
|
|
||||||
|
### Step 7 — Close out
|
||||||
|
|
||||||
|
Run both reviewer agents on the branch, save findings to
|
||||||
|
`docs/reviews/<timestamp>-house-conventions.md`, fix what is in scope. Push with
|
||||||
|
`/ship`, open the MR with `tea pr create`. The CHANGELOG entry is added after you
|
||||||
|
confirm it works.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make check # lint + vet + test-short, all green
|
||||||
|
go test -race -cover ./... # new tests pass under -race
|
||||||
|
go build ./... && go vet ./...
|
||||||
|
make build && ./bin/manager --version # prints the 12-char commit
|
||||||
|
make docker-build IMG=gateway-cert-operator:dev
|
||||||
|
docker run --rm gateway-cert-operator:dev --version
|
||||||
|
kubectl kustomize deploy/ >/dev/null # manifests still render
|
||||||
|
```
|
||||||
|
|
||||||
|
Live check after merge: push tag `v0.1.0`, confirm the `check` job runs before
|
||||||
|
`build`, and that the registry has `:v0.1.0`, `:sha-<12>` and `:latest`.
|
||||||
|
|
||||||
|
## Out of scope — backlog for the bug-fix phase
|
||||||
|
|
||||||
|
Found during the survey, with file references; each is high confidence unless noted.
|
||||||
|
|
||||||
|
1. Stale listeners after cert deletion, annotation removal or retarget
|
||||||
|
(`certificate_controller.go:71-91`). Root cause: the reconcile key is the
|
||||||
|
Certificate but the unit of desired state is the Gateway.
|
||||||
|
2. Wildcard DNS names produce an invalid listener name, and the rejected patch
|
||||||
|
blocks every cert on that Gateway (`patch.go:145`).
|
||||||
|
3. Listener name collisions: across namespaces, across cert/DNS splits, and after
|
||||||
|
truncation at 253 chars (`patch.go:143-151`).
|
||||||
|
4. `listener-port` annotation never read (`certificate_controller.go:25`,
|
||||||
|
`patch.go:44`).
|
||||||
|
5. Whole-array merge patch from a cached read, no optimistic lock
|
||||||
|
(`certificate_controller.go:118`).
|
||||||
|
6. `ListenersEqual` ignores protocol, allowed routes, TLS mode and extra refs
|
||||||
|
(`patch.go:93-139`).
|
||||||
|
7. Missing Gateway retried forever as an error (`certificate_controller.go:105-108`).
|
||||||
|
8. Cross-namespace secret refs without a ReferenceGrant; any Certificate author
|
||||||
|
can add `AllowedRoutes: All` listeners to any Gateway.
|
||||||
|
9. No events, status or metrics; no watch predicates; no leader election.
|
||||||
|
10. `kubernetes-deploy.yaml`: Vault, Kanidm and OIDC tokens echoed to the log,
|
||||||
|
TLS verification off, deploys on every branch push.
|
||||||
|
11. Deployment pinned to `:latest`, so new builds do not roll out.
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
# Plan: Sanitize listener names derived from certificate DNS names
|
||||||
|
|
||||||
|
**Created:** 2026-10-08 20:46 CEST
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The operator in the home-lab cluster fails every reconcile of
|
||||||
|
`kube-system/wildcard-home-hrajfrisbee`:
|
||||||
|
|
||||||
|
```
|
||||||
|
spec.listeners[4].name: Invalid value: "auto-wildcard-home-hrajfrisbee-*-home-hrajfrisbee-cz":
|
||||||
|
should match '^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$'
|
||||||
|
```
|
||||||
|
|
||||||
|
`listenerNameFor` (`internal/gateway/patch.go:137`) only replaces dots in the DNS
|
||||||
|
name, so `*.home.hrajfrisbee.cz` puts a `*` into the listener name. Gateway API
|
||||||
|
listener names are `SectionName`: lowercase alphanumerics, `-` and `.`, each
|
||||||
|
dot-separated label starting and ending alphanumeric, max 253 chars. Because the
|
||||||
|
patch replaces the whole listeners array, this one name blocks every other
|
||||||
|
certificate on `cilium-gateway` too.
|
||||||
|
|
||||||
|
Outcome: any DNS name cert-manager accepts yields a valid listener name, and names
|
||||||
|
that are valid today do not change (no listener churn on existing Gateways).
|
||||||
|
|
||||||
|
Lands on `feat/house-conventions` (decided), in the same MR as the conventions port.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
Only the DNS-name part is rewritten; the listener `Hostname` keeps the original
|
||||||
|
value (`*.home.hrajfrisbee.cz` is a valid Gateway hostname).
|
||||||
|
|
||||||
|
`listenerNameFor(prefix, certName, dnsName)` in `internal/gateway/patch.go`:
|
||||||
|
|
||||||
|
1. Sanitize the DNS name with a new unexported `sanitizeDNSName`:
|
||||||
|
- lowercase;
|
||||||
|
- a leading `*` label becomes `wildcard` (`*.example.com` → `wildcard-example-com`);
|
||||||
|
- `.` becomes `-` (as today);
|
||||||
|
- any other character outside `[a-z0-9-]` becomes `-`;
|
||||||
|
- trim leading and trailing `-`.
|
||||||
|
2. Name is `prefix + certName + "-" + sanitized`, as today.
|
||||||
|
3. If longer than 253: cut to 244, trim trailing `-` and `.`, append `-` plus the
|
||||||
|
first 8 hex chars of the SHA-256 of the untruncated name. This replaces the
|
||||||
|
current blind `name[:253]`, which can end in `-` (invalid) and makes long names
|
||||||
|
collide.
|
||||||
|
|
||||||
|
Result for the failing cert:
|
||||||
|
`auto-wildcard-home-hrajfrisbee-wildcard-home-hrajfrisbee-cz`.
|
||||||
|
|
||||||
|
Not changed here (stay on the backlog): collisions between distinct inputs that
|
||||||
|
sanitize to the same name (e.g. `*.x.com` and `wildcard.x.com` in one cert, or the
|
||||||
|
same cert name in two namespaces), uppercase in the listener `Hostname`, and the
|
||||||
|
other items in `docs/reviews/`.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
### Step 0 — Save and commit the plan
|
||||||
|
|
||||||
|
Copy to `docs/plans/2026-10-08-2046-listener-name-sanitize.md`, create the paired
|
||||||
|
execution log, commit alone.
|
||||||
|
|
||||||
|
### Step 1 — Regression tests first
|
||||||
|
|
||||||
|
In `internal/gateway/patch_test.go`, extend `TestListenerNameFor_sanitizesDots`
|
||||||
|
(rename to `TestListenerNameFor_validSectionName`) with cases, each asserting the
|
||||||
|
exact name and that it matches the `SectionName` pattern and length:
|
||||||
|
|
||||||
|
- `*.home.hrajfrisbee.cz` with cert `wildcard-home-hrajfrisbee` (the production case);
|
||||||
|
- uppercase DNS name;
|
||||||
|
- a character outside the allowed set;
|
||||||
|
- a name over 253 chars: result is ≤ 253, valid, ends with the hash suffix, and two
|
||||||
|
long inputs sharing a 253-char prefix produce different names;
|
||||||
|
- existing plain names unchanged (already in the table).
|
||||||
|
|
||||||
|
Add a wildcard case to `TestBuildListenersFromCertificates_names` and assert in
|
||||||
|
`_listenerFields` style that the hostname stays `*.example.com`. Run and confirm the
|
||||||
|
new cases fail before the fix.
|
||||||
|
|
||||||
|
### Step 2 — Fix
|
||||||
|
|
||||||
|
Implement the design in `internal/gateway/patch.go` (stdlib only: `strings`,
|
||||||
|
`crypto/sha256`, `encoding/hex`). Update the function comment.
|
||||||
|
|
||||||
|
### Step 3 — Docs
|
||||||
|
|
||||||
|
- `CLAUDE.md`: Gotchas — drop the wildcard half of "a single bad listener blocks the
|
||||||
|
whole Gateway" (collisions remain); Architecture — describe the name as sanitized.
|
||||||
|
- `docs/flows.md` §7 and §10 item 7: wildcard and truncation rows updated.
|
||||||
|
- `docs/reviews/…-house-conventions.md` is not touched (it predates this).
|
||||||
|
|
||||||
|
### Step 4 — Ship
|
||||||
|
|
||||||
|
`make check` and `go test -race ./...`, then `/ship` (pushes
|
||||||
|
`feat/house-conventions`) and open the MR with `tea pr create`. Tick Step 7 of the
|
||||||
|
conventions execution log in the same commit.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test -race -run 'TestListenerNameFor|TestBuildListeners' -v ./internal/gateway/
|
||||||
|
make check
|
||||||
|
```
|
||||||
|
|
||||||
|
Live, after you merge the MR:
|
||||||
|
|
||||||
|
1. `/release` → tag `v0.1.0`; CI builds and moves `:latest`.
|
||||||
|
2. `kubectl -n gateway-cert-operator-system rollout restart deploy/gateway-cert-operator`
|
||||||
|
(the Deployment pins `:latest`, so it does not roll on its own).
|
||||||
|
3. The "Reconciler error" for `wildcard-home-hrajfrisbee` stops, and
|
||||||
|
`kubectl -n kube-system get gateway cilium-gateway -o jsonpath='{.spec.listeners[*].name}'`
|
||||||
|
lists `auto-wildcard-home-hrajfrisbee-wildcard-home-hrajfrisbee-cz`.
|
||||||
|
4. Then the CHANGELOG entries for both the conventions port and this fix.
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# Review: house conventions port
|
||||||
|
|
||||||
|
**Created:** 2026-10-08 20:40 CEST
|
||||||
|
**Scope:** `main...feat/house-conventions` at `8c07428` (7 commits)
|
||||||
|
**Reviewers:** `operator-reviewer`, `go-operator-reviewer`
|
||||||
|
|
||||||
|
Both reviewers ran `make check` and `go test -race ./...` (green) and confirmed the
|
||||||
|
branch changes no reconcile behaviour. No merge-blockers.
|
||||||
|
|
||||||
|
## Worth fixing
|
||||||
|
|
||||||
|
- [x] `build.yaml`: a manual dispatch with the default `tag: latest` overwrote
|
||||||
|
`:latest`, the tag the Deployment runs. Default removed; `latest` is rejected.
|
||||||
|
(Inherited from the egress workflow, which still has it.)
|
||||||
|
- [x] `Dockerfile`: `ARG GIT_COMMIT` sat above `go mod download`, so every commit
|
||||||
|
re-downloaded all modules. Moved to just above the build.
|
||||||
|
- [x] `patch_test.go`: `TestMergeListeners_ownership` compared names only; a
|
||||||
|
mutation that kept stale listener content passed. Added
|
||||||
|
`TestMergeListeners_replacesManagedContent`.
|
||||||
|
- [x] `make docker-build` followed the host architecture (arm64 on this Mac, where
|
||||||
|
the Dockerfile used to hard-code amd64). Now builds `linux/amd64` by default.
|
||||||
|
- [x] `docs/flows.md` §2 described the old `main.go` (line refs, hardcoded dev
|
||||||
|
logger, no `--version`). Rewritten against the current file.
|
||||||
|
- [x] `CLAUDE.md` overstated the reverse watch. Reworded.
|
||||||
|
- [x] `build.yaml`: two dispatches from the same ref shared a concurrency group and
|
||||||
|
cancelled each other. Group now includes the tag input.
|
||||||
|
|
||||||
|
## Simplifications
|
||||||
|
|
||||||
|
- [x] `patch.go`: package-level `tlsModeTerminate` / `namespacesFromAll` were shared
|
||||||
|
by address across every listener. Replaced with `ptrTo(...)`; `defaultPort` is a
|
||||||
|
const.
|
||||||
|
- [x] `main.go`: `init()` removed, scheme built in `main()` (CLAUDE.md says no
|
||||||
|
`init()`).
|
||||||
|
- [x] `Makefile` comment claimed the lint toolchain pin "matches CI"; CI does not
|
||||||
|
lint. Claim dropped.
|
||||||
|
- [ ] `TestListenerNameFor_sanitizesDots` tests an unexported helper and overlaps the
|
||||||
|
names table. Kept as the home for truncation and collision cases in the bug-fix
|
||||||
|
phase.
|
||||||
|
|
||||||
|
## Deferred to the bug-fix phase (new, not in the plan's backlog)
|
||||||
|
|
||||||
|
- [ ] Empty desired set: when a Gateway has only `auto-*` listeners and no cert
|
||||||
|
yields any, `MergeListeners` returns nil and the patch sends `listeners: null`,
|
||||||
|
which the API server rejects (`MinItems=1`). Permanent error requeue. Any fix for
|
||||||
|
"last cert deleted" runs straight into this. `patch.go` `MergeListeners`,
|
||||||
|
`certificate_controller.go:119-120`.
|
||||||
|
- [ ] Listeners are added before the Certificate is Ready, so they reference a
|
||||||
|
Secret that may not exist yet.
|
||||||
|
- [ ] ClusterRole grants `events` create/patch; nothing records events.
|
||||||
|
- [ ] CI `check` job has no golangci-lint and no `-race`. Needs a linter install
|
||||||
|
step on the runner (uh-cli does it from a pinned tarball).
|
||||||
|
|
||||||
|
## Nice-to-have
|
||||||
|
|
||||||
|
- [x] Assert the secret ref `Group` in `TestBuildListenersFromCertificates_listenerFields`.
|
||||||
@@ -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 (
|
||||||
|
|||||||
+34
-16
@@ -1,6 +1,8 @@
|
|||||||
package gateway
|
package gateway
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -9,13 +11,7 @@ import (
|
|||||||
gwapiv1 "sigs.k8s.io/gateway-api/apis/v1"
|
gwapiv1 "sigs.k8s.io/gateway-api/apis/v1"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
const defaultPort gwapiv1.PortNumber = 443
|
||||||
defaultPort gwapiv1.PortNumber = 443
|
|
||||||
tlsModeTerminate = gwapiv1.TLSModeTerminate
|
|
||||||
namespacesFromAll gwapiv1.FromNamespaces = gwapiv1.NamespacesFromAll
|
|
||||||
gatewayGroup gwapiv1.Group = gwapiv1.GroupName
|
|
||||||
gatewayKind gwapiv1.Kind = "Gateway"
|
|
||||||
)
|
|
||||||
|
|
||||||
// BuildListenersFromCertificates creates the desired set of managed listeners
|
// BuildListenersFromCertificates creates the desired set of managed listeners
|
||||||
// from all annotated Certificate resources targeting a single Gateway.
|
// from all annotated Certificate resources targeting a single Gateway.
|
||||||
@@ -45,11 +41,11 @@ func BuildListenersFromCertificates(certs []cmv1.Certificate, prefix string) []g
|
|||||||
Protocol: gwapiv1.HTTPSProtocolType,
|
Protocol: gwapiv1.HTTPSProtocolType,
|
||||||
AllowedRoutes: &gwapiv1.AllowedRoutes{
|
AllowedRoutes: &gwapiv1.AllowedRoutes{
|
||||||
Namespaces: &gwapiv1.RouteNamespaces{
|
Namespaces: &gwapiv1.RouteNamespaces{
|
||||||
From: &namespacesFromAll,
|
From: ptrTo(gwapiv1.NamespacesFromAll),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
TLS: &gwapiv1.ListenerTLSConfig{
|
TLS: &gwapiv1.ListenerTLSConfig{
|
||||||
Mode: &tlsModeTerminate,
|
Mode: ptrTo(gwapiv1.TLSModeTerminate),
|
||||||
CertificateRefs: []gwapiv1.SecretObjectReference{
|
CertificateRefs: []gwapiv1.SecretObjectReference{
|
||||||
{
|
{
|
||||||
Group: ptrTo(gwapiv1.Group("")),
|
Group: ptrTo(gwapiv1.Group("")),
|
||||||
@@ -138,17 +134,39 @@ func listenerMap(listeners []gwapiv1.Listener) map[string]listenerKey {
|
|||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// maxSectionNameLen is the Gateway API limit for listener names.
|
||||||
|
const maxSectionNameLen = 253
|
||||||
|
|
||||||
// listenerNameFor generates a deterministic listener name from cert name and DNS name.
|
// listenerNameFor generates a deterministic listener name from cert name and DNS name.
|
||||||
// Truncates to stay within Gateway API's 253-char limit for section names.
|
// The result is a valid Gateway API SectionName: names that would exceed the limit
|
||||||
|
// are cut and get a hash of the full name appended, so distinct long names stay distinct.
|
||||||
func listenerNameFor(prefix, certName, dnsName string) string {
|
func listenerNameFor(prefix, certName, dnsName string) string {
|
||||||
// Replace dots with dashes for DNS names, combine with cert name.
|
name := strings.TrimRight(fmt.Sprintf("%s%s-%s", prefix, certName, sanitizeDNSName(dnsName)), "-.")
|
||||||
sanitized := strings.ReplaceAll(dnsName, ".", "-")
|
if len(name) <= maxSectionNameLen {
|
||||||
name := fmt.Sprintf("%s%s-%s", prefix, certName, sanitized)
|
|
||||||
if len(name) > 253 {
|
|
||||||
name = name[:253]
|
|
||||||
}
|
|
||||||
return name
|
return name
|
||||||
}
|
}
|
||||||
|
sum := sha256.Sum256([]byte(name))
|
||||||
|
suffix := "-" + hex.EncodeToString(sum[:4])
|
||||||
|
return strings.TrimRight(name[:maxSectionNameLen-len(suffix)], "-.") + suffix
|
||||||
|
}
|
||||||
|
|
||||||
|
// sanitizeDNSName maps a certificate DNS name onto the characters allowed in a
|
||||||
|
// listener name. A wildcard label is spelled out so "*.example.com" and
|
||||||
|
// "example.com" stay distinct.
|
||||||
|
func sanitizeDNSName(dnsName string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, r := range strings.ToLower(dnsName) {
|
||||||
|
switch {
|
||||||
|
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
|
||||||
|
b.WriteRune(r)
|
||||||
|
case r == '*':
|
||||||
|
b.WriteString("wildcard")
|
||||||
|
default:
|
||||||
|
b.WriteByte('-')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Trim(b.String(), "-")
|
||||||
|
}
|
||||||
|
|
||||||
func ptrTo[T any](v T) *T {
|
func ptrTo[T any](v T) *T {
|
||||||
return &v
|
return &v
|
||||||
|
|||||||
@@ -0,0 +1,355 @@
|
|||||||
|
package gateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
cmv1 "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1"
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
gwapiv1 "sigs.k8s.io/gateway-api/apis/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
const testPrefix = "auto-"
|
||||||
|
|
||||||
|
func cert(namespace, name, secret string, dnsNames ...string) cmv1.Certificate {
|
||||||
|
return cmv1.Certificate{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: name},
|
||||||
|
Spec: cmv1.CertificateSpec{SecretName: secret, DNSNames: dnsNames},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func listener(name, hostname, secretNamespace, secret string, port gwapiv1.PortNumber) gwapiv1.Listener {
|
||||||
|
h := gwapiv1.Hostname(hostname)
|
||||||
|
ns := gwapiv1.Namespace(secretNamespace)
|
||||||
|
return gwapiv1.Listener{
|
||||||
|
Name: gwapiv1.SectionName(name),
|
||||||
|
Hostname: &h,
|
||||||
|
Port: port,
|
||||||
|
Protocol: gwapiv1.HTTPSProtocolType,
|
||||||
|
TLS: &gwapiv1.ListenerTLSConfig{
|
||||||
|
CertificateRefs: []gwapiv1.SecretObjectReference{
|
||||||
|
{Name: gwapiv1.ObjectName(secret), Namespace: &ns},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func names(listeners []gwapiv1.Listener) []string {
|
||||||
|
out := make([]string, 0, len(listeners))
|
||||||
|
for _, l := range listeners {
|
||||||
|
out = append(out, string(l.Name))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func equalStrings(a, b []string) bool {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := range a {
|
||||||
|
if a[i] != b[i] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildListenersFromCertificates_names(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
deleted := cert("default", "gone", "gone-tls", "gone.example.com")
|
||||||
|
now := metav1.NewTime(time.Unix(0, 0))
|
||||||
|
deleted.DeletionTimestamp = &now
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
certs []cmv1.Certificate
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no certificates",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "one listener per DNS name",
|
||||||
|
certs: []cmv1.Certificate{cert("default", "web", "web-tls", "a.example.com", "b.example.com")},
|
||||||
|
want: []string{"auto-web-a-example-com", "auto-web-b-example-com"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "sorted by listener name across certificates",
|
||||||
|
certs: []cmv1.Certificate{
|
||||||
|
cert("default", "zeta", "zeta-tls", "z.example.com"),
|
||||||
|
cert("default", "alpha", "alpha-tls", "a.example.com"),
|
||||||
|
},
|
||||||
|
want: []string{"auto-alpha-a-example-com", "auto-zeta-z-example-com"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "certificate being deleted is skipped",
|
||||||
|
certs: []cmv1.Certificate{deleted, cert("default", "web", "web-tls", "a.example.com")},
|
||||||
|
want: []string{"auto-web-a-example-com"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "certificate without secret name is skipped",
|
||||||
|
certs: []cmv1.Certificate{cert("default", "web", "", "a.example.com")},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "certificate without DNS names yields nothing",
|
||||||
|
certs: []cmv1.Certificate{cert("default", "web", "web-tls")},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
got := names(BuildListenersFromCertificates(tc.certs, testPrefix))
|
||||||
|
if !equalStrings(got, tc.want) {
|
||||||
|
t.Errorf("listener names = %v, want %v", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildListenersFromCertificates_listenerFields(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got := BuildListenersFromCertificates(
|
||||||
|
[]cmv1.Certificate{cert("apps", "web", "web-tls", "app.example.com")}, testPrefix)
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("got %d listeners, want 1", len(got))
|
||||||
|
}
|
||||||
|
l := got[0]
|
||||||
|
|
||||||
|
if l.Hostname == nil || *l.Hostname != "app.example.com" {
|
||||||
|
t.Errorf("hostname = %v, want app.example.com", l.Hostname)
|
||||||
|
}
|
||||||
|
if l.Port != 443 {
|
||||||
|
t.Errorf("port = %d, want 443", l.Port)
|
||||||
|
}
|
||||||
|
if l.Protocol != gwapiv1.HTTPSProtocolType {
|
||||||
|
t.Errorf("protocol = %q, want HTTPS", l.Protocol)
|
||||||
|
}
|
||||||
|
if l.TLS == nil || l.TLS.Mode == nil || *l.TLS.Mode != gwapiv1.TLSModeTerminate {
|
||||||
|
t.Fatalf("TLS mode is not Terminate: %+v", l.TLS)
|
||||||
|
}
|
||||||
|
if len(l.TLS.CertificateRefs) != 1 {
|
||||||
|
t.Fatalf("got %d certificate refs, want 1", len(l.TLS.CertificateRefs))
|
||||||
|
}
|
||||||
|
ref := l.TLS.CertificateRefs[0]
|
||||||
|
if ref.Name != "web-tls" {
|
||||||
|
t.Errorf("secret name = %q, want web-tls", ref.Name)
|
||||||
|
}
|
||||||
|
if ref.Namespace == nil || *ref.Namespace != "apps" {
|
||||||
|
t.Errorf("secret namespace = %v, want the certificate's namespace apps", ref.Namespace)
|
||||||
|
}
|
||||||
|
if ref.Kind == nil || *ref.Kind != "Secret" {
|
||||||
|
t.Errorf("ref kind = %v, want Secret", ref.Kind)
|
||||||
|
}
|
||||||
|
if ref.Group == nil || *ref.Group != "" {
|
||||||
|
t.Errorf("ref group = %v, want the core group", ref.Group)
|
||||||
|
}
|
||||||
|
if l.AllowedRoutes == nil || l.AllowedRoutes.Namespaces == nil ||
|
||||||
|
l.AllowedRoutes.Namespaces.From == nil ||
|
||||||
|
*l.AllowedRoutes.Namespaces.From != gwapiv1.NamespacesFromAll {
|
||||||
|
t.Errorf("allowed routes = %+v, want namespaces from All", l.AllowedRoutes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeListeners_ownership(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
manual := listener("https", "manual.example.com", "default", "manual-tls", 443)
|
||||||
|
stale := listener("auto-old-old-example-com", "old.example.com", "default", "old-tls", 443)
|
||||||
|
desired := listener("auto-web-a-example-com", "a.example.com", "default", "web-tls", 443)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
existing []gwapiv1.Listener
|
||||||
|
desired []gwapiv1.Listener
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "both empty",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "manual listeners are kept",
|
||||||
|
existing: []gwapiv1.Listener{manual},
|
||||||
|
desired: []gwapiv1.Listener{desired},
|
||||||
|
want: []string{"https", "auto-web-a-example-com"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "stale managed listeners are dropped",
|
||||||
|
existing: []gwapiv1.Listener{manual, stale},
|
||||||
|
desired: []gwapiv1.Listener{desired},
|
||||||
|
want: []string{"https", "auto-web-a-example-com"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no desired listeners removes every managed one",
|
||||||
|
existing: []gwapiv1.Listener{stale, manual},
|
||||||
|
want: []string{"https"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "existing managed listener is not duplicated",
|
||||||
|
existing: []gwapiv1.Listener{desired},
|
||||||
|
desired: []gwapiv1.Listener{desired},
|
||||||
|
want: []string{"auto-web-a-example-com"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
got := names(MergeListeners(tc.existing, tc.desired, testPrefix))
|
||||||
|
if !equalStrings(got, tc.want) {
|
||||||
|
t.Errorf("merged names = %v, want %v", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeListeners_replacesManagedContent(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
old := listener("auto-web-a-example-com", "a.example.com", "default", "old-tls", 443)
|
||||||
|
desired := listener("auto-web-a-example-com", "a.example.com", "default", "web-tls", 443)
|
||||||
|
|
||||||
|
got := MergeListeners([]gwapiv1.Listener{old}, []gwapiv1.Listener{desired}, testPrefix)
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("got %d listeners, want 1", len(got))
|
||||||
|
}
|
||||||
|
if name := got[0].TLS.CertificateRefs[0].Name; name != "web-tls" {
|
||||||
|
t.Errorf("secret name = %q, want the desired web-tls", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListenersEqual_managedFields(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
a := listener("auto-web-a-example-com", "a.example.com", "default", "web-tls", 443)
|
||||||
|
b := listener("auto-web-b-example-com", "b.example.com", "default", "web-tls", 443)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
x, y []gwapiv1.Listener
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{name: "both empty", want: true},
|
||||||
|
{name: "identical", x: []gwapiv1.Listener{a, b}, y: []gwapiv1.Listener{a, b}, want: true},
|
||||||
|
{name: "order is ignored", x: []gwapiv1.Listener{a, b}, y: []gwapiv1.Listener{b, a}, want: true},
|
||||||
|
{name: "different length", x: []gwapiv1.Listener{a, b}, y: []gwapiv1.Listener{a}, want: false},
|
||||||
|
{name: "different name", x: []gwapiv1.Listener{a}, y: []gwapiv1.Listener{b}, want: false},
|
||||||
|
{
|
||||||
|
name: "different hostname",
|
||||||
|
x: []gwapiv1.Listener{a},
|
||||||
|
y: []gwapiv1.Listener{listener("auto-web-a-example-com", "other.example.com", "default", "web-tls", 443)},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "different port",
|
||||||
|
x: []gwapiv1.Listener{a},
|
||||||
|
y: []gwapiv1.Listener{listener("auto-web-a-example-com", "a.example.com", "default", "web-tls", 8443)},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "different secret name",
|
||||||
|
x: []gwapiv1.Listener{a},
|
||||||
|
y: []gwapiv1.Listener{listener("auto-web-a-example-com", "a.example.com", "default", "other-tls", 443)},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "different secret namespace",
|
||||||
|
x: []gwapiv1.Listener{a},
|
||||||
|
y: []gwapiv1.Listener{listener("auto-web-a-example-com", "a.example.com", "other", "web-tls", 443)},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
if got := ListenersEqual(tc.x, tc.y); got != tc.want {
|
||||||
|
t.Errorf("ListenersEqual() = %v, want %v", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sectionNamePattern is the Gateway API SectionName validation rule.
|
||||||
|
var sectionNamePattern = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$`)
|
||||||
|
|
||||||
|
func assertValidSectionName(t *testing.T, name string) {
|
||||||
|
t.Helper()
|
||||||
|
if len(name) > 253 {
|
||||||
|
t.Errorf("name is %d chars, limit is 253", len(name))
|
||||||
|
}
|
||||||
|
if !sectionNamePattern.MatchString(name) {
|
||||||
|
t.Errorf("name %q is not a valid SectionName", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListenerNameFor_validSectionName(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
certName string
|
||||||
|
dnsName string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "dots become dashes", certName: "web", dnsName: "app.example.com", want: "auto-web-app-example-com"},
|
||||||
|
{name: "single label", certName: "web", dnsName: "localhost", want: "auto-web-localhost"},
|
||||||
|
{name: "existing dashes are kept", certName: "my-cert", dnsName: "my-app.example.com", want: "auto-my-cert-my-app-example-com"},
|
||||||
|
{
|
||||||
|
name: "wildcard label is spelled out",
|
||||||
|
certName: "wildcard-home-hrajfrisbee",
|
||||||
|
dnsName: "*.home.hrajfrisbee.cz",
|
||||||
|
want: "auto-wildcard-home-hrajfrisbee-wildcard-home-hrajfrisbee-cz",
|
||||||
|
},
|
||||||
|
{name: "uppercase is lowered", certName: "web", dnsName: "App.Example.COM", want: "auto-web-app-example-com"},
|
||||||
|
{name: "other characters become dashes", certName: "web", dnsName: "my_app.example.com", want: "auto-web-my-app-example-com"},
|
||||||
|
{name: "trailing dot is dropped", certName: "web", dnsName: "app.example.com.", want: "auto-web-app-example-com"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
got := listenerNameFor(testPrefix, tc.certName, tc.dnsName)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("listenerNameFor() = %q, want %q", got, tc.want)
|
||||||
|
}
|
||||||
|
assertValidSectionName(t, got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListenerNameFor_longNames(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// 62 dashes right where the name is cut, so a blind cut would end in "-".
|
||||||
|
shared := strings.Repeat("a", 200) + strings.Repeat("-", 62)
|
||||||
|
first := listenerNameFor(testPrefix, "web", shared+"x.example.com")
|
||||||
|
second := listenerNameFor(testPrefix, "web", shared+"y.example.com")
|
||||||
|
|
||||||
|
assertValidSectionName(t, first)
|
||||||
|
assertValidSectionName(t, second)
|
||||||
|
if first == second {
|
||||||
|
t.Errorf("two long DNS names sharing a prefix got the same listener name %q", first)
|
||||||
|
}
|
||||||
|
if again := listenerNameFor(testPrefix, "web", shared+"x.example.com"); again != first {
|
||||||
|
t.Errorf("name is not deterministic: %q then %q", first, again)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildListenersFromCertificates_wildcard(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got := BuildListenersFromCertificates(
|
||||||
|
[]cmv1.Certificate{cert("default", "wild", "wild-tls", "*.example.com")}, testPrefix)
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("got %d listeners, want 1", len(got))
|
||||||
|
}
|
||||||
|
assertValidSectionName(t, string(got[0].Name))
|
||||||
|
if got[0].Hostname == nil || *got[0].Hostname != "*.example.com" {
|
||||||
|
t.Errorf("hostname = %v, want the wildcard kept as *.example.com", got[0].Hostname)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,47 +2,54 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"flag"
|
"flag"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
goruntime "runtime"
|
||||||
|
|
||||||
gozap "go.uber.org/zap"
|
|
||||||
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"
|
||||||
"k8s.io/apimachinery/pkg/runtime"
|
"k8s.io/apimachinery/pkg/runtime"
|
||||||
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
|
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
|
||||||
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
|
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
|
||||||
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()
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
utilruntime.Must(clientgoscheme.AddToScheme(scheme))
|
|
||||||
utilruntime.Must(cmv1.AddToScheme(scheme))
|
|
||||||
utilruntime.Must(gwapiv1.Install(scheme))
|
|
||||||
}
|
|
||||||
|
|
||||||
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())
|
}
|
||||||
},
|
|
||||||
))
|
scheme := runtime.NewScheme()
|
||||||
|
utilruntime.Must(clientgoscheme.AddToScheme(scheme))
|
||||||
|
utilruntime.Must(cmv1.AddToScheme(scheme))
|
||||||
|
utilruntime.Must(gwapiv1.Install(scheme))
|
||||||
|
|
||||||
|
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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user