Go-based Kubernetes operator for managing OpenClaw instances, built with controller-runtime (kubebuilder). CRD API group is openclaw.rocks, version v1alpha1.
- Module:
github.com/paperclipinc/openclaw-operator - Go version: 1.25
- GitHub:
paperclipinc/openclaw-operator(GHCR org:paperclipinc)
make test # Unit + integration tests (requires envtest binaries)
make lint # golangci-lint
make build # Build manager binary
make manifests # Regenerate CRD YAML + RBAC after API type changes
make generate # Regenerate deepcopy methods after API type changes
make install # Install CRDs into current cluster
make run # Run operator locally against current cluster
go test ./internal/resources/ -v # Fast unit tests (no envtest needed)
go vet ./... # Go vet checkapi/v1alpha1/ → CRD types (OpenClawInstance)
internal/controller/ → Reconciliation logic (single controller)
internal/resources/ → Pure resource builder functions (Deployment, Service, etc.)
config/crd/bases/ → Generated CRD YAML (committed to git)
charts/ → Helm chart
bundle/ → OLM bundle for OperatorHub submissions
test/e2e/ → E2E tests (run against kind cluster)
Separation of concerns: Controller logic (internal/controller/) only orchestrates reconciliation. All resource construction happens in pure functions in internal/resources/. This makes builders easy to unit test without envtest.
These rules are enforced by CI (Reconcile Guard check) and must be followed:
Never call r.Update() or r.Create() directly on managed resources (Deployments, Services, ConfigMaps, etc.). Always use:
obj := &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Name: resources.DeploymentName(instance),
Namespace: instance.Namespace,
},
}
_, err := controllerutil.CreateOrUpdate(ctx, r.Client, obj, func() error {
desired := resources.BuildDeployment(instance)
obj.Labels = desired.Labels
obj.Spec = desired.Spec
return controllerutil.SetControllerReference(instance, obj, r.Scheme)
})Why: Direct r.Update() calls are unconditional — they update even when nothing changed, incrementing the resource generation, triggering a watch event, and causing an infinite reconciliation loop. controllerutil.CreateOrUpdate compares before/after and skips no-op updates.
Exception: r.Update(ctx, instance) on the CR itself is allowed for finalizer management. Add // reconcile-guard:allow for any other legitimate exceptions.
When building resources, always set fields that Kubernetes would default on the server side. If omitted, the desired spec differs from the stored spec on every reconcile.
Deployment defaults to always set:
RevisionHistoryLimit,ProgressDeadlineSecondsRestartPolicy,DNSPolicy,SchedulerName,TerminationGracePeriodSecondsTerminationMessagePath,TerminationMessagePolicy,ImagePullPolicyon every containerSuccessThresholdon every probeDefaultModeon ConfigMap volume sources
Service defaults: SessionAffinity: None
When updating resources, preserve fields assigned by the API server:
- Service:
ClusterIP,ClusterIPs - PVC: immutable after creation — only create, never update
return ctrl.Result{}, nil // Success, no requeue
return ctrl.Result{}, err // Requeue with exponential backoff
return ctrl.Result{Requeue: true}, nil // Immediate requeue
return ctrl.Result{RequeueAfter: 5*time.Minute}, nil // Requeue after delayWhen returning err != nil, the RequeueAfter field is ignored (exponential backoff takes over).
- Use
meta.SetStatusConditionfor conditions (follows k8s API conventions) - Track
ObservedGenerationso consumers know the controller processed the latest spec - Status subresource updates (
r.Status().Update) must be separate from spec/metadata updates
Set controllerutil.SetControllerReference on all managed resources. This enables:
- Automatic garbage collection when the parent CR is deleted
- Automatic watch triggers when owned resources change
- Use
0o644(not0644) for octal literals - gocritic lint enforces this - Wrap errors:
fmt.Errorf("context: %w", err) - Use the generic
Ptr[T]helper frominternal/resources/common.gofor pointer values - Never use em dashes or en dashes in code, comments, or strings - use regular hyphens/dashes (
-or--) instead - Run
make fmtandmake lintbefore committing
Use conventional commits: feat:, fix:, docs:, ci:, chore:, refactor:, test:
The goreleaser changelog includes feat: and fix: only. Others are filtered out.
Use prefixes: feat/, fix/, chore/, docs/, ci/, refactor/
Merged branches are auto-deleted. Always delete stale remote branches.
Always use git worktree when working on a separate branch to avoid switching branches and disrupting local state. Never use git checkout or git switch to change branches in the main working directory:
git worktree add ../openclaw-operator-<suffix> -b <branch> main
# work in the worktree directory, then clean up:
git worktree remove ../openclaw-operator-<suffix>After modifying types in api/v1alpha1/openclawinstance_types.go:
- Run
make generate(regenerateszz_generated.deepcopy.go) - Run
make manifests(regenerates CRD YAML inconfig/crd/bases/) - Commit the generated files
- Resource builders: unit tests in
internal/resources/resources_test.go(fast, no deps) - Controller integration: envtest suite in
internal/controller/(needs kubebuilder binaries) - E2E:
test/e2e/(needs kind cluster, runs in CI on PRs and main) - Always add e2e tests when feasible -- any new feature or bug fix that changes the behavior of managed Kubernetes resources should include an e2e test verifying the resources are created correctly on a real cluster
- The
RawConfigtype embedsruntime.RawExtension-- in tests use:instance.Spec.Config.Raw = &openclawv1alpha1.RawConfig{ RawExtension: runtime.RawExtension{Raw: []byte(`{}`)}, }
When adding or changing CRD fields, features, or behavior:
README.md-- update the user-facing overview, examples, and the feature table.docs/api-reference.mdis auto-generated from CRD types viamake api-docs. Do NOT hand-edit it. After modifying types inapi/v1alpha1/, runmake manifests api-docsand commit the regenerated reference together with the type change. CI (API Docs Syncjob) blocks any PR where runningmake api-docswould produce a diff.
The docs site (mkdocs-material) lives in docs-site/. See docs-site/README.md for local preview and contributor flow.
All checks run on every push to main and every PR:
| Job | What it does |
|---|---|
| Lint | golangci-lint v1.64.5 |
| Test | make test (unit + envtest integration) |
| Security Scan | gosec + Trivy (CRITICAL/HIGH) |
| Reconcile Guard | Grep check preventing bare r.Update() on managed resources |
| Chart Image Repository | Verifies the chart image.repository default points at the canonical ghcr.io/paperclipinc namespace (guards against the #536 stale-namespace regression) |
| Helm RBAC Sync | Verifies Helm chart ClusterRole contains all kubebuilder RBAC permissions |
| Docs Build | mkdocs build --strict against docs-site/; uploads preview artifact on PRs |
| API Docs Sync | Verifies docs/api-reference.md matches output of make api-docs |
| Build | Multi-arch Docker image (amd64 + arm64), pushes on main only |
| E2E | Kind cluster tests (PRs and main) |
- RBAC: Use
+kubebuilder:rbacmarkers with minimum required verbs. No wildcards. - Pod security: Default to Restricted PSS —
runAsNonRoot, dropALLcapabilities, seccomp RuntimeDefault - Secrets: Operator has
get;list;watch;create;update;patchon secrets — needed for auto-generating gateway token Secrets (owned by the CR, garbage-collected on deletion) - Images: Signed with Cosign (keyless OIDC), SBOM attested
- NetworkPolicy: Enabled by default with deny-all baseline
- CRDs are Helm templates in
charts/openclaw-operator/templates/crds/-- updated on everyhelm upgrade - Run
make sync-chart-crdsaftermake manifeststo sync CRDs into the Helm chart (CI enforces this) appVersioninChart.yamluses plain semver (novprefix); the deployment template prependsv- Chart version and appVersion are managed by release-please via
extra-filesinrelease-please-config.json
Automated via release-please + GoReleaser:
- Conventional commits (
feat:,fix:) on main trigger release-please to create/update a release PR - Merging the release PR bumps versions in
CHANGELOG.md,.release-please-manifest.json, andChart.yaml - A post-action step creates the
vX.Y.Ztag (using PAT to trigger downstream workflows) - The tag triggers
release.yaml: GoReleaser builds binaries + multi-arch Docker images (draft release) - Cosign signs images, SBOM is generated and attested
- SBOM uploaded to draft release, then release is published (using PAT)
- Published release triggers
operatorhub.yaml: auto-submits bundle PR tok8s-operatorhub/community-operators - Helm chart is packaged and pushed to
oci://ghcr.io/paperclipinc/charts
Key config files:
release-please-config.json—skip-github-release: true(GoReleaser manages the release lifecycle).release-please-manifest.json— tracks current version.goreleaser.yaml—release.draft: true(immutable releases require draft→publish flow)
Secrets required:
RELEASE_PLEASE_TOKEN— classic PAT withreposcope; used for tag creation, release publishing, and OperatorHub cross-repo PRs
Manual trigger: gh workflow run "OperatorHub Submission" -f tag=vX.Y.Z
Image: ghcr.io/paperclipinc/openclaw-operator