A Kubernetes operator that turns one Mosquitto custom resource into an
Eclipse Mosquitto deployment: a StatefulSet of broker pods, a
ConfigMap carrying the generated mosquitto.conf, a headless Service that gives every pod a
DNS name, and a ClusterIP Service in front of all of them β with optional per-pod
persistence, optional pod anti-affinity and an optional TLS listener. The broker pods are
independent Mosquitto processes behind one Service: there is no bridging between them, no
shared session state, no shared retained messages and no clustering. Raising spec.replicas
buys process redundancy, not a highly available broker. Highly available brokers are the goal
of this project; this version does not deliver them.
flowchart LR
CR["Mosquitto/<name>"] --> OP[mosquitto-operator]
OP --> CM["ConfigMap<br/><name>-config"]
OP --> STS["StatefulSet<br/><name>"]
OP --> HS["Service<br/><name>-headless"]
OP --> CS["Service<br/><name> (ClusterIP)"]
CM -. "mounted read-only" .-> STS
STS --> P0["<name>-0"]
STS --> P1["<name>-1"]
CS --> P0
CS --> P1
HS -. "per-pod DNS" .-> P0
HS -. "per-pod DNS" .-> P1
Every claim in this document was verified by reading this repository. What was not done:
running any of it against a real Kubernetes cluster. This tree is greenfield β two commits on feat/initial-build and
an uncommitted working tree β so the E2E suite in test/e2e/ that exercises the
provisioning path in CI has no observed run to point at. Commands below are transcribed from
the code and from that suite; the ones that need no cluster were executed while writing this
file.
- π¦ One resource, four objects β a
Mosquittoproduces a StatefulSet, a ConfigMap, a headless Service and a ClusterIP Service, all carrying its owner reference. - π§Ύ Generated
mosquitto.confβ logging to stdout, persistence into/mosquitto/data/, one listener, and your own directives fromspec.configappended verbatim. - β»οΈ Config changes reach running pods β the rendered configuration is hashed into a pod-template annotation, so a ConfigMap edit rolls the StatefulSet instead of sitting unread.
- π Optional MQTTS β
spec.tls.secretNamemounts an existingtls.crt/tls.keySecret and moves the listener to 8883. The operator consumes TLS material; it never issues or renews it. - πΎ Optional persistence β
spec.storagerenders adataPVC template; without it the persistence directory is anemptyDirat the same path. - π§ Opt-in anti-affinity β
off(default),soft(scheduler preference) orhard(one broker pod per node, surplus pods stayPending), overkubernetes.io/hostname. - π‘ Never adopts what it does not own β an existing ConfigMap, Service or StatefulSet under a managed name is refused, not overwritten, and reported on the resource.
- π No delete verb β the ClusterRole grants none, so teardown runs entirely through owner references and the garbage collector.
- π Hardened broker pods β non-root uid/gid 1883, read-only root filesystem, all capabilities dropped,
seccompProfile: RuntimeDefault, no ServiceAccount token mounted. - π Status you can read with
kubectlβPHASE,READYandREPLICASprinter columns,observedGeneration, and aReadycondition. - π¦ Two install paths, one authority β Helm chart and
kustomize build config/default;make verify-rbac-paritycompares what they actually render.
Everything below is derived deterministically from the resource. For a Mosquitto named
<name> in namespace <ns>:
| Object | Name | Defined in |
|---|---|---|
| StatefulSet | <name> |
common.StatefulSetName |
| Headless Service | <name>-headless |
common.HeadlessServiceName |
| Client Service (ClusterIP) | <name> |
common.ClientServiceName |
| ConfigMap | <name>-config |
builder.ConfigMapName |
| ConfigMap key | mosquitto.conf |
builder.ConfigKey |
| Broker container | mosquitto |
builder.BrokerContainerName |
| Volumes | config, tls (only with spec.tls), data |
builder.ConfigVolumeName, TLSVolumeName, DataVolumeName |
PVC template (only with spec.storage) |
data |
builder.DataVolumeName |
Kubernetes derives two more names from those, by its own StatefulSet rules rather than by
anything in this repository: the pods are <name>-0 β¦ <name>-(replicas-1), and a PVC template
named data produces data-<name>-<ordinal>.
| What | Address | Port |
|---|---|---|
| Client Service | <name>.<ns>.svc.cluster.local |
1883, or 8883 with spec.tls |
| One specific pod | <name>-<ordinal>.<name>-headless.<ns>.svc.cluster.local |
same |
Both follow from the Service names above and from spec.serviceName: <name>-headless on the
StatefulSet; the cluster domain is whatever the cluster uses, cluster.local by default.
| Thing | Value | Defined in |
|---|---|---|
| Plain MQTT port / port name | 1883 / mqtt |
builder.MQTTPort, MQTTPortName |
| MQTTS port / port name | 8883 / mqtts |
builder.MQTTSPort, MQTTSPortName |
| Config mount | /mosquitto/config (read-only) |
builder.ConfigMountPath |
| TLS mount | /mosquitto/tls (read-only) |
builder.TLSMountPath |
| Data mount / persistence location | /mosquitto/data |
builder.DataMountPath |
| Expected Secret keys | tls.crt, tls.key |
builder.TLSCertKey, TLSKeyKey |
| Broker command | /usr/sbin/mosquitto -c /mosquitto/config/mosquitto.conf |
buildBrokerContainer |
Exactly one container port is declared: mqtt or mqtts, never both. Enabling TLS moves
the generated listener rather than adding one.
| Key | Value | On |
|---|---|---|
app.kubernetes.io/name |
mosquitto |
every created object, and in every selector |
app.kubernetes.io/instance |
<name> |
every created object, and in every selector |
app.kubernetes.io/managed-by |
mosquitto-operator |
every created object, and in every selector |
app.kubernetes.io/component |
broker |
every created object (not in selectors) |
app.kubernetes.io/version |
the image tag, or latest when the image carries none |
every created object (not in selectors) |
mko.gtrfc.com/pod-spec-hash |
8 hex digits over the built pod spec | the pod template |
mko.gtrfc.com/config-hash |
8 hex digits over the generated mosquitto.conf |
the pod template |
The selector deliberately omits component and version
(common.SelectorLabels): a selector carrying the image tag
would stop matching the running pods exactly when the image changes and the Service has to
keep routing.
The two annotations are how a change the StatefulSet controller would otherwise not see
becomes part of the pod template. Mosquitto reads its configuration once at startup and a
ConfigMap update restarts nothing, so mko.gtrfc.com/config-hash is what turns a config edit
into a rollout.
| Object | Helm (helm install mosquitto-operator β¦) |
kustomize (config/default) |
|---|---|---|
| Deployment | mosquitto-operator |
mosquitto-operator-mosquitto-operator |
| ServiceAccount | mosquitto-operator |
mosquitto-operator-mosquitto-operator |
| ClusterRole | mosquitto-operator |
mosquitto-operator-mosquitto-operator-role |
| ClusterRoleBinding | mosquitto-operator |
mosquitto-operator-mosquitto-operator |
| Leader-election Role / RoleBinding | mosquitto-operator-leader-election |
mosquitto-operator-mosquitto-operator-leader-election |
| Metrics Service | mosquitto-operator-metrics |
(none β the kustomize path renders no Service) |
| Namespace | whatever --namespace says |
mosquitto-operator-system (fixed in config/default/kustomization.yaml) |
The kustomize names repeat themselves because config/default sets
namePrefix: mosquitto-operator- over resources already named mosquitto-operator. The
Helm names above are for the release name mosquitto-operator; another release name changes
them through the chart's fullname template. The names differ between the two paths, the
rules do not β that is what make verify-rbac-parity compares.
| Thing | Value |
|---|---|
| API group / version / kind | mko.gtrfc.com / v1 / Mosquitto |
| Resource / short name | mosquittoes / mq |
| CRD | mosquittoes.mko.gtrfc.com |
| ClusterRole name from the markers | mosquitto-operator-role |
| Leader-election Lease | mosquitto-operator.mko.gtrfc.com, in the operator's namespace |
| Operator image | guidedtraffic/mosquitto-operator |
| Default broker image | eclipse-mosquitto:2.1.2-alpine |
| Document | What it covers |
|---|---|
| DEVELOPER.md | Repository layout, per-package responsibilities, the reconcile pipeline, how to add a field, the build/test/lint matrix and the release process |
| SECURITY_ARCHITECTURE.md | Trust boundaries, every RBAC rule and what it permits, where the TLS material lives, what the isolation does not cover, and the hardening checklist |
| docs/adr/ | Architecture Decision Records β what was decided, why, what was rejected and what it costs |
| Full reference (below) | Every spec field, its default and its effect |
| Eclipse Mosquitto documentation | Upstream broker behaviour and every mosquitto.conf option spec.config can carry |
| cert-manager | Optional, and never installed by this project β one of the two ways to fill the Secret spec.tls.secretName names |
Read SECURITY_ARCHITECTURE.md before granting anyone
create mosquittoes: the generated broker accepts anonymous clients, and the operator holds a
cluster-wide grant.
Prerequisites. A Kubernetes cluster, kubectl, and Helm 3. CI provisions Kind nodes at
kindest/node:v1.33.4 (.github/workflows/release.yml) and
the integration tier runs against envtest 1.29.0 (Makefile); no minimum server
version has been established beyond that, and the client libraries are k8s.io/* v0.37.0.
1. Install the operator.
helm install mosquitto-operator deploy/helm/mosquitto-operator \
--namespace mosquitto-operator-system \
--create-namespaceThe chart carries the CRD, the ClusterRole, the leader-election Role and the Deployment, so
one command installs all four. helm lint and helm template on this chart were run while
writing this file; the install itself was not. A chart repository is published to
https://guided-traffic.github.io/mosquitto-operator/ by
.github/workflows/build.yml when a GitHub release is
published β whether one has been is not something this tree can tell you, so the checked-out
chart above is the path documented here.
2. Create a broker.
apiVersion: mko.gtrfc.com/v1
kind: Mosquitto
metadata:
name: broker
spec:
replicas: 1kubectl apply -f broker.yaml3. Verify.
kubectl get mq brokerThe four columns are the CRD's printer columns, in this order (the values below are an example, not a captured run β nothing here has been executed against a cluster):
NAME REPLICAS READY PHASE AGE
broker 1 1 Ready 30s
PHASE=Ready means the StatefulSet reports every requested pod ready β and readiness here is
a TCP connect, not an MQTT session. To prove the broker actually speaks MQTT, publish a
retained message and read it back with the client tools that ship in the same image (this is
what test/e2e/mosquitto_test.go does):
kubectl exec broker-0 -- mosquitto_pub -h 127.0.0.1 -p 1883 -q 1 -r -t demo/probe -m hello
kubectl exec broker-0 -- mosquitto_sub -h 127.0.0.1 -p 1883 -q 1 -t demo/probe -C 1 -W 15
# helloFrom another pod in the cluster, the address is broker.<namespace>.svc.cluster.local:1883.
The operator creates no LoadBalancer, NodePort or Ingress: reaching the broker from outside
the cluster is something you add yourself β and worth reading
Two modes, and what each one protects first, because
the generated broker authenticates nobody.
Upgrade, rollback and uninstall
Upgrade. helm upgrade with the chart is the supported path. The CRD lives in the
chart's templates/, so schema, permissions and image move forward together:
helm upgrade mosquitto-operator deploy/helm/mosquitto-operator \
--namespace mosquitto-operator-systemUpdating the operator image on its own β kubectl set image, or a bumped tag applied against
an older chart β leaves the CRD and the ClusterRole behind and is not a supported upgrade
path.
An operator upgrade does not restart running brokers by itself. The broker pod template
contains no operator image and no sidecar; it changes only when the Mosquitto spec changes
or when the generated configuration does.
Rollback.
helm rollback mosquitto-operator --namespace mosquitto-operator-systemThe CRD is part of the release, so a rollback restores the previous CRD schema with it. Spec fields only the newer schema knows are pruned from existing resources by the API server, so roll back before adopting new fields, or re-apply them after upgrading again.
Uninstall.
kubectl delete mq --all --all-namespaces # do this knowingly, see below
helm uninstall mosquitto-operator --namespace mosquitto-operator-systemThe CRD is a normal chart template with no helm.sh/resource-policy: keep, so
helm uninstall deletes it β and deleting the CRD removes every Mosquitto with it, which
garbage-collects the StatefulSets, Services and ConfigMaps they own.
PersistentVolumeClaims created from spec.storage are not removed: the StatefulSet sets
no PVC retention policy (buildVolumeClaimTemplates), so
the data stays on disk and is reattached when a broker of the same name is created again.
Every field of the API, with defaults marked. Defaults marked # default come from the CRD
schema in config/crd/bases/mko.gtrfc.com_mosquittoes.yaml
unless noted; # example values have no default and are shown at a realistic setting.
apiVersion: mko.gtrfc.com/v1
kind: Mosquitto
metadata:
name: broker
namespace: default
spec:
replicas: 1 # default β minimum 1, maximum 9
image: eclipse-mosquitto:2.1.2-alpine # example β no schema default; this is the value the
# operator substitutes when the field is empty
antiAffinity: "off" # default β one of "off", "soft", "hard"
config: | # example β appended to the generated file verbatim
max_keepalive 120
tls: # example β omitted means a plaintext listener on 1883
secretName: broker-tls # required inside tls, minimum length 1
storage: # example β omitted means an emptyDir for /mosquitto/data
size: 1Gi # required inside storage, minimum length 1
storageClassName: standard # example β omitted or empty uses the cluster default class
resources: # example β omitted means no requests and no limits
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 500m
memory: 256Mi| Field | Type | Default | Effect |
|---|---|---|---|
replicas |
int32 |
1 |
Broker pods in the StatefulSet. Schema-validated to 1β¦9. They are independent processes; see the note under the pitch. |
image |
string |
(empty β eclipse-mosquitto:2.1.2-alpine) |
The broker image. The fallback is builder.DefaultImage, pinned to the 2.x line and tracked by Renovate. |
config |
string |
(empty) | Extra mosquitto.conf content, appended after everything the operator generates. Nothing validates it: a rejected file is a CrashLoopBackOff, not a rejected resource. |
antiAffinity |
string |
"off" |
off renders no affinity block at all; soft renders a preferred term with weight 100; hard renders a required term. Topology key kubernetes.io/hostname, selector limited to this resource's own pods. |
tls |
object |
(unset) | Mounts an existing Secret and moves the listener to MQTTS. See Two modes. |
storage |
object |
(unset) | Renders a data PVC template with access mode ReadWriteOnce. Unset means an emptyDir at the same mount path. |
resources |
corev1.ResourceRequirements |
(unset) | Passed to the broker container unchanged β requests, limits and claims, the standard Kubernetes type. |
spec.antiAffinity: hard guarantees the spread by refusing to place two broker pods of this
resource on one node, so replicas beyond the number of schedulable nodes stay Pending. Any
value outside the enum is treated as off (Mosquitto.AntiAffinityMode),
which is the weakest setting rather than a guess.
A digest-pinned spec.image works, and the version label is abbreviated.
app.kubernetes.io/version is derived from the image reference
(common.ExtractVersionFromImage), and a label value may not contain
a colon or exceed 63 bytes β which sha256:<64 hex> violates on both counts. The function
therefore reduces a digest to its 12-character hex prefix
(repo@sha256:e3b0c44298fc1c14β¦ β e3b0c44298fc), sanitises anything else outside
[A-Za-z0-9._-], truncates to 63 bytes, and falls back to unknown when nothing usable is left.
So the label identifies the image by eye without ever being a value the API server refuses.
This is a fix, not a design: before it, a digest reference produced sha256:<64 hex> and every
object written for that resource was rejected, leaving it in Failed indefinitely.
TestExtractVersionFromImage_AlwaysProducesAValidLabel now asserts the result against
apimachinery's validation.IsValidLabelValue rather than against expected strings, so no future
edit can pin an invalid value as intended behaviour. Verified by running that test; not
verified against a live API server.
| Field | Type | Default | Effect |
|---|---|---|---|
secretName |
string |
(required) | Name of a Secret in the resource's own namespace carrying tls.crt and tls.key. Minimum length 1; an empty value is treated as TLS off (Mosquitto.IsTLSEnabled) so a half-filled spec cannot produce a listener with no certificate. |
The operator neither creates nor renews that Secret. Both ways of filling it are first class and neither needs anything from this project:
kubectl create secret tls broker-tls --cert=tls.crt --key=tls.keyor a cert-manager Certificate on a cluster that already runs cert-manager β the
administrator owns that object; this project has no cert-manager dependency, ships no
Certificate and installs nothing. (make cert-manager-install exists to give the E2E suite
a real issuer to test against; it is a test fixture, not part of any install path.)
Rotation does not reach a running pod. The operator does not watch the Secret. Renewing the certificate changes the Secret, and the kubelet updates the mounted files, but Mosquitto reads them once at startup β so the pods keep serving the old material until they restart:
kubectl rollout restart statefulset/brokerTurning TLS on is a pod-template change, not a recreate: the operator rewrites the
StatefulSet's template and the pods roll. It does not wait for the Secret either β
test/integration/tls_test.go pins that the StatefulSet is
written whether or not the named Secret exists, and that the operator never creates it. A
missing Secret then surfaces as a kubelet-level mount error on the pod rather than as a
reconcile failure.
| Field | Type | Default | Effect |
|---|---|---|---|
size |
string |
(required) | PVC size, e.g. 1Gi. An unparsable quantity fails the reconcile visibly instead of being silently replaced. |
storageClassName |
string |
(unset β cluster default class) | Storage class for the PVC template. |
volumeClaimTemplates are immutable once the StatefulSet exists, and the operator writes them
only on creation. Changing spec.storage afterwards therefore does not converge; the
StatefulSet has to be deleted and recreated by hand.
status:
phase: Ready
readyReplicas: 1
observedGeneration: 1
conditions:
- type: Ready
status: "True"
reason: AllReplicasReady
message: 1/1 broker pods are ready
observedGeneration: 1
lastTransitionTime: "2026-09-01T12:00:00Z" # example| Field | Meaning |
|---|---|
phase |
Coarse rollout state, see below |
readyReplicas |
Mirrors the StatefulSet's ready replica count |
observedGeneration |
The .metadata.generation the operator last acted on |
conditions |
Standard Kubernetes conditions; Ready is present once one pass has completed |
| Phase | Set when | Condition reason |
|---|---|---|
Pending |
The StatefulSet does not exist yet, or none of its pods are ready | StatefulSetNotFound, NoReplicasReady |
Progressing |
Some but not all requested pods are ready | ReplicasNotReady |
Ready |
Every requested pod is ready | AllReplicasReady |
Failed |
The operator could not write one of the objects it manages | ReconcileFailed |
Failed describes the operator, not the brokers: pods that were already running keep running.
The most likely cause is the ownership refusal β an object under a managed name that this
resource does not control is never adopted, and the message names it:
ConfigMap default/broker-config exists and is not owned by this Mosquitto
This is the exact file for a resource with neither spec.tls nor spec.config, rendered from
builder.GenerateMosquittoConf while writing this document:
# Generated by mosquitto-operator. Edits are overwritten on the next reconcile;
# append your own directives through spec.config instead.
# Logging goes to the container log so kubectl logs is the single source.
log_dest stdout
log_type error
log_type warning
log_type notice
log_type information
# Persistence writes into the data mount, which is a PVC when spec.storage
# is set and an emptyDir otherwise.
persistence true
persistence_location /mosquitto/data/
# Plain MQTT listener.
listener 1883
# This broker accepts anonymous clients: the CRD models no authentication,
# and Mosquitto 2.x would otherwise reject every client. Anything that can
# route to the ClusterIP Service can publish and subscribe. To change that,
# configure the password-file or acl-file plugin through spec.config, which
# is appended below this line.
allow_anonymous trueWith spec.tls set, the listener block is replaced by:
# MQTTS listener. The certificate and key come from the secret named in
# spec.tls.secretName; the operator neither creates nor renews them.
listener 8883
certfile /mosquitto/tls/tls.crt
keyfile /mosquitto/tls/tls.keyand spec.config, when non-empty, is appended last under a
# spec.config, appended verbatim. marker.
Without spec.tls |
With spec.tls |
|
|---|---|---|
| Listener | listener 1883, port name mqtt |
listener 8883, port name mqtts |
| On the wire | Plaintext | TLS, using the mounted tls.crt / tls.key |
| Broker identity | Not proven to anyone | Proven to clients that validate the certificate |
| Client identity | Not established | Still not established β the generated file sets no require_certificate |
| Who may publish and subscribe | Anyone who can route to the Service | Anyone who can route to the Service |
| Certificate rotation | n/a | Only on pod restart; the operator does not watch the Secret |
Anonymous access is the default and it is deliberate. Mosquitto 2.x rejects every client
on a listener with no configured authentication, this API models none, so the generated
configuration sets allow_anonymous true β without it the default resource would serve
nobody. It is emitted on both branches: turning on TLS encrypts the connection and changes
nothing about who may connect.
The exposure is bounded by what the operator creates, which is a ClusterIP Service and nothing
else β no NodePort, no LoadBalancer, no Ingress, and no NetworkPolicy either, so any pod in
the cluster that can reach the Service is a client. Until the API models authentication,
spec.config is where it goes. On the pinned Mosquitto 2.1 image that means the
password-file and acl-file plugins: their password_file and acl_file predecessors
are deprecated in 2.1 and removed in 3.0, so configuring the old options writes a migration
for your future self.
spec:
config: |
# spec.config is appended last, so a global option repeated here wins.
allow_anonymous false
# ... plus the plugin configuration and the mounted password file it readsTwo things spec.config can do that are worth knowing before you use it: a listener line
adds a listener the operator neither models nor exposes as a container or Service port, and
nothing validates the content β the broker sees it first at startup, so a mistake is a
CrashLoopBackOff rather than a rejected resource.
Defaults from deploy/helm/mosquitto-operator/values.yaml.
| Value | Default | Effect |
|---|---|---|
replicaCount |
1 |
Operator Deployment replicas. More than one requires leaderElection.enabled: true to stay sane. |
image.repository |
guidedtraffic/mosquitto-operator |
Operator image. |
image.tag |
"" |
Empty uses the chart's appVersion. |
image.pullPolicy |
IfNotPresent |
|
imagePullSecrets |
[] |
|
nameOverride / fullnameOverride |
"" |
Standard chart naming overrides. |
serviceAccount.create |
true |
Create the operator ServiceAccount. |
serviceAccount.annotations |
{} |
|
serviceAccount.name |
"" |
Empty uses the chart fullname (or default when create: false). |
podAnnotations / podLabels |
{} |
Applied to the operator pod. |
resources |
requests 10m / 256Mi, limits 500m / 512Mi |
Operator container resources. Broker resources come from spec.resources instead. |
nodeSelector / tolerations / affinity |
{} / [] / {} |
Operator pod scheduling. Broker scheduling is spec.antiAffinity. |
maxConcurrentReconciles |
4 |
How many Mosquitto resources reconcile at once. Passes for one resource stay serialised at any value. |
leaderElection.enabled |
true |
Passes --leader-elect and renders the namespaced leader-election Role/RoleBinding. With it off, neither is created. |
metrics.enabled |
true |
Renders the metrics Service and passes --metrics-bind-address=:8080; false passes 0, which is what controller-runtime reads as "do not start the metrics server". |
That endpoint serves controller-runtime's own reconcile, work-queue, client and Go runtime
series. This operator registers no metric of its own, and there is no broker metrics
exporter β Mosquitto publishes broker statistics as $SYS/# topics and nothing here
translates them yet; the decision on how that will be done is recorded in
ADR 0002, with none of it implemented.
Security note on the metrics endpoint: it is plain HTTP with no authentication or
authorization filter. Anything that can route to the operator pod reads it whether or not the
Service exists β metrics.enabled: false closes the port, deleting the Service only hides the
DNS name. The chart ships no NetworkPolicy on purpose; restricting ingress is left to the
cluster administrator.
From cmd/main.go. The chart sets the first four from the values above.
| Flag | Default | Effect |
|---|---|---|
--metrics-bind-address |
:8080 |
Metrics endpoint address; 0 disables the server. |
--health-probe-bind-address |
:8081 |
Serves /healthz and /readyz. |
--leader-elect |
false |
Leader election under the Lease mosquitto-operator.mko.gtrfc.com. |
--max-concurrent-reconciles |
4 |
Concurrent reconciles across resources. |
--zap-* |
Development: true |
The standard zap logging flags, e.g. --zap-log-level=debug as used by make run. bindZapFlags in cmd/main.go starts from zap.Options{Development: true}, so the shipped default is development-mode logging (console encoder, DEBUG level, stack traces from WARN) rather than controller-runtime's production default. |
make help # every target with its one-line description
make build # gofmt, go vet, then bin/manager
make test-unit # unit tier (envtest binaries are fetched on demand)
make test-integration # controller tier against envtest 1.29.0
make lint gosec vuln cyclo # golangci-lint, gosec, govulncheck, gocyclo
make generate-all # regenerate CRD + DeepCopy and sync the chart; run after any api/v1 change
make verify-rbac-parity # compare what Helm and kustomize actually grant (needs helm + kustomize)
make e2e-local # Kind cluster, cert-manager, Helm install, full E2E suitemake generate-all must leave git status clean β CI fails the build otherwise, because a
stale checked-in CRD would ship in the chart while the Go types said something else.
DEVELOPER.md has the repository layout, the reconcile pipeline, the extension checklists and the CI/release process.
Apache-2.0 β see LICENSE.