
Kubernetes Operator
- 436 installs
- 23.8k repo stars
- Updated July 17, 2026
- alirezarezvani/claude-skills
kubernetes-operator is a Claude Code skill that scaffolds, extends, and troubleshoots Kubernetes operators with correct CRDs, controllers, reconciliation loops, and deployment manifests for production clusters.
About
kubernetes-operator is a skill from alirezarezvani/claude-skills for building and debugging Kubernetes operators in Go-centric controller ecosystems. The skill covers custom resource definition design, controller scaffolding, reconciliation loop patterns, status subresource updates, RBAC manifests, and production deployment YAML for cluster operators. Developers reach for kubernetes-operator when automating stateful application lifecycle inside Kubernetes, extending the platform API, or fixing drift between desired and observed custom resource state. The skill targets platform and backend engineers who already run clusters and need operator patterns that survive upgrades, leader election, and failure recovery. kubernetes-operator emphasizes correct CRD versioning, watch semantics, and operational manifests rather than generic kubectl usage.
- CRD and controller scaffolding
- Reconciliation loop patterns
- RBAC, webhooks, and admission config
- Helm/Kustomize deployment guidance
- Production troubleshooting for operator failures
Kubernetes Operator by the numbers
- 436 all-time installs (skills.sh)
- Ranked #378 of 1,039 Cloud & Infrastructure skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/alirezarezvani/claude-skills --skill kubernetes-operatorAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 436 |
|---|---|
| repo stars | ★ 23.8k |
| Last updated | July 17, 2026 |
| Repository | alirezarezvani/claude-skills ↗ |
How do you scaffold a Kubernetes operator with CRDs?
Scaffold, extend, and troubleshoot Kubernetes operators with correct CRDs, controllers, reconciliation loops, and deployment manifests for production clusters.
Who is it for?
Platform and backend engineers building or troubleshooting Kubernetes operators with CRDs and reconciliation loops in production clusters.
Skip if: Developers only deploying existing Helm charts without writing controllers should skip kubernetes-operator.
When should I use this skill?
The user asks to create, extend, or debug a Kubernetes operator, CRD, controller, or reconciliation loop.
What you get
CRD definitions, controller reconciliation code, RBAC manifests, and operator deployment YAML for production clusters.
- CRD manifests
- Controller reconciliation code
- Operator deployment YAML
Files
Kubernetes Operator
Build operators that reconcile correctly. Most operator bugs are not Kubernetes bugs — they are reconcile-loop bugs: missing finalizers, blocking calls, no requeue on transient errors, status drift, RBAC over-grants. This skill catches them deterministically before they reach a cluster.
When to use
- Building a new Kubernetes Operator (controller for a CRD)
- Reviewing an existing operator for capability-level gaps
- Auditing a CRD spec for status/conditions/finalizer correctness
- Choosing a framework (controller-runtime / kubebuilder / operator-sdk / metacontroller / KOPF)
- Designing the API surface of a Custom Resource
- Hardening RBAC, leader election, or webhook validation
When NOT to use
- Plain Helm chart packaging → use
helm-chart-builder - Standard kubectl operations / blue-green deploys → use
senior-devops - General k8s security posture → use
cloud-security - "I want to run a workload" — that's a Deployment / Job, not an operator
Core principle: an operator is a reconcile loop, not a script
observe(actual) → desired = read(spec) → diff(actual, desired) → act → update(status)
↓
requeue / doneOperators that fail are the ones that: 1. Treat reconcile as imperative (do this, then this, then this) instead of declarative (make actual=desired, idempotently) 2. Don't requeue transient failures 3. Don't use finalizers, leaving orphan resources 4. Mutate spec instead of status 5. Don't use the status subresource (status updates trigger spec reconciles → loop) 6. Block in reconcile (long HTTP calls, locks) 7. Forget leader election → split-brain on multi-replica deploys
The 3 tools below catch each of these.
Quick start
SKILL=engineering/kubernetes-operator/skills/kubernetes-operator
# Validate a CRD design
python "$SKILL/scripts/crd_validator.py" --crd config/crd/myapp.yaml
# Lint a Go reconcile function
python "$SKILL/scripts/reconcile_lint.py" --controller controllers/myapp_controller.go
# Score against OperatorHub Capability Levels (1-5)
python "$SKILL/scripts/operator_capability_audit.py" --operator-dir .The 3 Python tools
All stdlib-only. Run with --help.
crd_validator.py
Validates a CRD YAML against operator-pattern best practices.
python scripts/crd_validator.py --crd config/crd/myapp.yaml
python scripts/crd_validator.py --crd config/crd/ --format jsonChecks:
spec.versions[*].subresources.statusis set (status subresource)spec.scopeisNamespaced(notCluster) unless explicitly justified- Singular and listKind defined
spec.versions[*].schema.openAPIV3Schemahas type definitions (nox-kubernetes-preserve-unknown-fields: trueat top level)- A version is marked
served: trueANDstorage: true - Conditions array is in the schema (allows
metav1.Conditions) - Printer columns include
AgeandStatus/Phase
reconcile_lint.py
Lints a Go controller reconcile function for anti-patterns.
python scripts/reconcile_lint.py --controller controllers/myapp_controller.goChecks (regex-based heuristics):
- Returns are
(ctrl.Result, error)shape - Errors trigger a non-zero requeue (
return ctrl.Result{Requeue: true}, err) client.Update()on the spec object is flagged (controllers should update only status)time.Sleepinside reconcile is flagged (useRequeueAfter)- HTTP calls without context cancellation are flagged
- Missing
deferafter a finalizer add - No
IsConditionTrue/SetConditioncalls when conditions present in CRD - Reconcile function exceeds 80 lines (extract subroutines)
operator_capability_audit.py
Scores an operator against OperatorHub's 5 Capability Levels.
python scripts/operator_capability_audit.py --operator-dir .Levels:
- L1 — Basic Install: CRD defined, controller deploys it
- L2 — Seamless Upgrades: PDBs, conversion webhooks, version skew strategy
- L3 — Full Lifecycle: backups, restores, failure recovery
- L4 — Deep Insights: metrics endpoint, Prometheus rules, alerts
- L5 — Auto Pilot: auto-scaling, auto-tuning, anomaly detection
Reports current level + concrete next steps to advance one level.
Tooling landscape
Pick a framework based on language and complexity. See references/tooling_landscape.md.
| Framework | Language | Best for | Maintenance |
|---|---|---|---|
| controller-runtime | Go | Production-grade, low-level control | Active (sig-api-machinery) |
| kubebuilder | Go | Standard scaffolding, opinionated | Active (Kubernetes SIGs) |
| operator-sdk | Go / Helm / Ansible | OpenShift / mixed-paradigm teams | Active (Red Hat) |
| metacontroller | Any (webhook-based) | Polyglot teams, avoiding Go | Less active |
| KOPF | Python | Python shops, async-first | Active (community) |
| java-operator-sdk | Java | JVM shops | Active (Red Hat / Java SIG) |
Decision rules:
- New operator + Go shop → kubebuilder
- New operator + Python shop → KOPF
- New operator + can't pick a language → metacontroller
- OpenShift target → operator-sdk
CRD design principles
See references/crd_design.md for full detail. Quick rules:
1. status is the source of truth for the controller's view of the world. Spec is what the user wants; status is what the controller observed. 2. Use the status subresource. Without it, status updates re-trigger reconcile (loop). 3. Use Conditions. Ready, Reconciling, Degraded. Each carries a reason and message. 4. Add finalizers. Without finalizers, deletion races the controller and orphans external resources. 5. Version your CRD from day 1. v1alpha1 → v1beta1 → v1. Plan a conversion webhook. 6. Validate via OpenAPI v3 schema. Don't rely on the controller for validation that should fail at admission. 7. Use `additionalPrinterColumns` for `kubectl get`. Show Age, Phase, Ready at minimum. 8. Namespace your CRDs unless they manage cluster-scoped resources.
Reconcile loop principles
See references/reconcile_loop.md for full detail. Quick rules:
1. Idempotent. Reconciling the same state twice → same result, zero side effects. 2. Read once, decide, act. Don't observe the world repeatedly during reconcile. 3. Update status, not spec. Spec belongs to the user. 4. Return errors that requeue. Use ctrl.Result{RequeueAfter: ...} for known transient cases. 5. Never block. No time.Sleep. No long HTTP calls without context. 6. Use the cache. Read via the controller's cached client; only escape the cache for a specific reason. 7. Leader-elect when running >1 replica. Otherwise enable single-replica mode. 8. Set OwnerReferences. Cascading deletion is the operator pattern's free gift.
Workflows
Workflow 1: Bootstrap a new operator (Go + kubebuilder)
1. Pick a Group/Version/Kind: e.g., apps.example.com/v1alpha1, kind=MyApp
2. kubebuilder init --domain example.com --repo github.com/org/myapp-operator
3. kubebuilder create api --group apps --version v1alpha1 --kind MyApp
4. Run crd_validator.py on config/crd/bases/apps.example.com_myapps.yaml
→ Fix every WARN before writing controller code
5. Implement the reconcile function (Karpathy principle 2: simplest correct version first)
6. Run reconcile_lint.py on controllers/myapp_controller.go
7. Run operator_capability_audit.py --operator-dir . — confirm L1
8. Test in a kind cluster: kubectl apply -f config/samples/
9. Add status conditions; aim for L2 in the same PRWorkflow 2: Audit an existing operator
1. Run operator_capability_audit.py --operator-dir <path>
2. Run crd_validator.py --crd config/crd/
3. Run reconcile_lint.py --controller controllers/
4. Triage findings:
- FAIL → block release; fix before next deploy
- WARN → file an issue; fix in next 30 days
5. Document current capability level in README; commit
6. Plan one capability level advancement per quarterWorkflow 3: Choose a framework
1. Identify primary language constraint (team skill)
2. Identify deployment target (vanilla k8s vs OpenShift)
3. Identify operator complexity (single CRD vs multi-CRD vs cluster-wide)
4. Cross-reference with references/tooling_landscape.md
5. Build a 1-week proof-of-concept before committingReferences
references/operator_pattern.md— what an operator IS, when to use vs alternativesreferences/crd_design.md— CRD design principles, versioning, conversion webhooksreferences/reconcile_loop.md— reconcile patterns, error handling, idempotencyreferences/tooling_landscape.md— framework comparison + decision tree
Slash command
/operator-audit — Run all 3 tools on an operator repo and produce a markdown report.
Asset templates
assets/crd_template.yaml— CRD with status subresource, conditions, finalizer hint, printer columnsassets/reconcile_skeleton.go— Go controller reconcile function with idempotency, conditions, finalizers, requeue patterns
Anti-patterns
- *`time.Sleep(30 time.Second)
inside reconcile** — block other reconciles. UseRequeueAfter`. - `r.Client.Update(ctx, obj)` to set status — use
r.Status().Update(ctx, obj)instead. - No leader election + 2+ replicas — split-brain.
- No finalizer — external resources orphan on deletion.
- CRD without status subresource — status updates trigger spec reconciles (infinite loop).
- Reconcile function > 200 lines — extract reconcileXxx subroutines per condition.
- `x-kubernetes-preserve-unknown-fields: true` on spec root — defeats validation.
- Imperative reconcile — "if creating, do A; if updating, do B; if deleting, do C". Wrong shape. Reconcile = make actual=desired, regardless of how we got here.
Verifiable success
A team using this skill should achieve:
- 100% of new CRDs pass
crd_validator.pybefore merge - All reconcile functions pass
reconcile_lint.pystrict mode - Operators reach OperatorHub Capability Level 3 (Full Lifecycle) before public release
- Mean time to fix a reconcile bug: <1 day (no infinite loops in production)
# Production CRD template — passes crd_validator.py
# Fill in <PLACEHOLDERS>; remove these comments before applying.
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: <plural>.<group> # e.g., myapps.apps.example.com
spec:
group: <group> # e.g., apps.example.com
names:
kind: <Kind> # e.g., MyApp
plural: <plural> # e.g., myapps
singular: <singular> # e.g., myapp
listKind: <Kind>List # e.g., MyAppList
shortNames: [<short>] # optional, 2-3 letters
scope: Namespaced # default; Cluster requires justification
versions:
- name: v1alpha1
served: true
storage: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
required: [version]
properties:
version:
type: string
pattern: '^[0-9]+\.[0-9]+\.[0-9]+$'
description: Semver version of the application
replicas:
type: integer
minimum: 1
maximum: 100
default: 3
description: Number of replicas to run
status:
type: object
properties:
phase:
type: string
enum: [Pending, Running, Failed]
observedGeneration:
type: integer
description: Spec generation last reconciled
conditions:
type: array
items:
type: object
required: [type, status, lastTransitionTime]
properties:
type: { type: string }
status: { type: string, enum: ["True", "False", "Unknown"] }
reason: { type: string }
message: { type: string }
lastTransitionTime: { type: string, format: date-time }
observedGeneration: { type: integer }
subresources:
status: {} # CRITICAL — enables /status subresource
additionalPrinterColumns:
- name: Phase
type: string
jsonPath: .status.phase
- name: Ready
type: string
jsonPath: .status.conditions[?(@.type=="Ready")].status
- name: Age
type: date
jsonPath: .metadata.creationTimestamp
// Reconcile skeleton — passes reconcile_lint.py.
// Replace <PLACEHOLDER> markers; rename receiver + types to match your CR.
package controllers
import (
"context"
"errors"
"time"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
"sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/predicate"
appsv1alpha1 "<MODULE>/api/v1alpha1"
)
const finalizerName = "<group>/finalizer"
type MyAppReconciler struct {
client.Client
Scheme *runtime.Scheme
}
func (r *MyAppReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
logger := log.FromContext(ctx).WithValues("myapp", req.NamespacedName)
var cr appsv1alpha1.MyApp
if err := r.Get(ctx, req.NamespacedName, &cr); err != nil {
if apierrors.IsNotFound(err) {
return ctrl.Result{}, nil
}
return ctrl.Result{}, err
}
if !cr.DeletionTimestamp.IsZero() {
return r.reconcileDelete(ctx, &cr)
}
if !controllerutil.ContainsFinalizer(&cr, finalizerName) {
controllerutil.AddFinalizer(&cr, finalizerName)
return ctrl.Result{}, r.Update(ctx, &cr)
}
meta.SetStatusCondition(&cr.Status.Conditions, metav1.Condition{
Type: "Reconciling",
Status: metav1.ConditionTrue,
Reason: "InProgress",
Message: "Converging to desired state",
ObservedGeneration: cr.Generation,
})
res, recErr := r.reconcileNormal(ctx, &cr)
if recErr == nil {
meta.SetStatusCondition(&cr.Status.Conditions, metav1.Condition{
Type: "Ready", Status: metav1.ConditionTrue,
Reason: "AllReady", Message: "all components healthy",
ObservedGeneration: cr.Generation,
})
} else {
meta.SetStatusCondition(&cr.Status.Conditions, metav1.Condition{
Type: "Ready", Status: metav1.ConditionFalse,
Reason: "ReconcileError", Message: recErr.Error(),
ObservedGeneration: cr.Generation,
})
}
cr.Status.ObservedGeneration = cr.Generation
if statusErr := r.Status().Update(ctx, &cr); statusErr != nil {
logger.Error(statusErr, "failed to update status")
return res, errors.Join(recErr, statusErr)
}
return res, recErr
}
func (r *MyAppReconciler) reconcileNormal(ctx context.Context, cr *appsv1alpha1.MyApp) (ctrl.Result, error) {
// Idempotent: read desired, build child, CreateOrUpdate.
deployment := &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Name: cr.Name, Namespace: cr.Namespace}}
op, err := controllerutil.CreateOrUpdate(ctx, r.Client, deployment, func() error {
deployment.Spec.Replicas = &cr.Spec.Replicas
// Build container spec from cr.Spec — extracted helper for clarity
// deployment.Spec.Template.Spec.Containers = buildContainers(&cr.Spec)
return controllerutil.SetControllerReference(cr, deployment, r.Scheme)
})
if err != nil {
return ctrl.Result{}, err
}
log.FromContext(ctx).Info("deployment", "operation", op)
// Periodic resync — keeps status fresh even when nothing changes.
return ctrl.Result{RequeueAfter: 5 * time.Minute}, nil
}
func (r *MyAppReconciler) reconcileDelete(ctx context.Context, cr *appsv1alpha1.MyApp) (ctrl.Result, error) {
if !controllerutil.ContainsFinalizer(cr, finalizerName) {
return ctrl.Result{}, nil
}
if err := r.deleteExternalResources(ctx, cr); err != nil {
return ctrl.Result{RequeueAfter: 30 * time.Second}, err
}
controllerutil.RemoveFinalizer(cr, finalizerName)
return ctrl.Result{}, r.Update(ctx, cr)
}
func (r *MyAppReconciler) deleteExternalResources(ctx context.Context, cr *appsv1alpha1.MyApp) error {
// Implement teardown of external state (cloud DB, S3 bucket, DNS record, ...)
return nil
}
func (r *MyAppReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&appsv1alpha1.MyApp{}).
Owns(&appsv1.Deployment{}).
WithEventFilter(predicate.GenerationChangedPredicate{}).
Complete(r)
}
CRD design
Custom Resource Definitions (CRDs) define the API surface of your operator. A bad CRD design locks you into hard-to-evolve schemas, forces wrapper APIs, and creates user-facing UX problems via kubectl.
Anatomy of a production CRD
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: myapps.apps.example.com # plural.group
spec:
group: apps.example.com
names:
kind: MyApp # PascalCase
plural: myapps # lowercase
singular: myapp # lowercase
listKind: MyAppList # KindList
shortNames: [ma] # optional
scope: Namespaced # or Cluster (justify)
versions:
- name: v1alpha1
served: true
storage: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
required: [version]
properties:
version:
type: string
pattern: '^[0-9]+\.[0-9]+\.[0-9]+$'
replicas:
type: integer
minimum: 1
maximum: 100
default: 3
status:
type: object
properties:
phase:
type: string
enum: [Pending, Running, Failed]
conditions:
type: array
items:
type: object
required: [type, status, lastTransitionTime]
properties:
type: { type: string }
status: { type: string, enum: ["True", "False", "Unknown"] }
reason: { type: string }
message: { type: string }
lastTransitionTime: { type: string, format: date-time }
observedGeneration: { type: integer }
subresources:
status: {} # CRITICAL — see below
scale: # if scaling is meaningful
specReplicasPath: .spec.replicas
statusReplicasPath: .status.readyReplicas
additionalPrinterColumns:
- name: Phase
type: string
jsonPath: .status.phase
- name: Ready
type: string
jsonPath: .status.conditions[?(@.type=="Ready")].status
- name: Age
type: date
jsonPath: .metadata.creationTimestampRequired structural elements
1. Status subresource — subresources.status: {}
Without it:
r.Status().Update(ctx, obj)doesn't work — falls back tor.Update- Status updates re-trigger spec reconcile → loop
- RBAC can't be split between spec writers and status writers
Always declare it.
2. Conditions array
Use the standard metav1.Condition shape. Required fields: type, status, lastTransitionTime. Recommended: reason, message, observedGeneration.
Conventional condition types:
Ready— overall readinessReconciling— controller is actively workingDegraded— operating but with reduced capabilityProgressing— change in progress (mostly for Deployments-style flows)
Use meta.SetStatusCondition() from k8s.io/apimachinery/pkg/api/meta — don't write to the slice directly.
3. observedGeneration
Track which spec generation the controller has acted on:
status.ObservedGeneration = obj.GenerationLets users tell whether status reflects the latest spec or a previous one.
4. Printer columns
kubectl get myapp UX is determined by additionalPrinterColumns. Always include:
PhaseorReady(status)Age(so users know when it was created)
Optionally: replicas, version, key spec field.
5. Validation in the schema, not the controller
Express constraints declaratively:
| Constraint | OpenAPI |
|---|---|
| Range | minimum/maximum |
| String pattern | pattern: '^...$' |
| Enum | enum: [Pending, Running] |
| Required field | required: [...] |
| Default value | default: 3 |
| Min/max length | minLength/maxLength |
Reserve controller validation for cross-field rules and external dependencies (e.g., "this name is taken in our DB").
6. Avoid x-kubernetes-preserve-unknown-fields: true
It disables structural validation. Sometimes needed (e.g., raw kubectl apply patches), but never at the spec root. Use it sparingly on a single sub-tree.
Versioning strategy
CRDs evolve. Plan from day 1:
| Stage | Version | Stability | Allowed changes |
|---|---|---|---|
| Internal preview | v1alpha1 | None | Anything; document breaking changes |
| Beta | v1beta1 | Some | Additive only; deprecate fields |
| GA | v1 | Strong | Additive only; never remove fields |
Conversion webhook required when:
- Multiple versions are served simultaneously
- A field's shape changed between versions
For simple field renames, x-kubernetes-conversion-strategy: None works.
Scope: Namespaced vs Cluster
Default to Namespaced. Cluster-scoped CRDs:
- Can't be RBAC-restricted by namespace
- Can't have
OwnerReferencesfrom namespaced parents - Are appropriate only for cluster-wide resources (
StorageClass-like things)
If your operator manages namespace-bound things (apps, databases, queues), use Namespaced.
Naming
- Group:
<domain>.<reverse-domain>— e.g.,apps.example.com. Don't use generic groups (com,io). - Kind: PascalCase, singular, descriptive —
MyApp,Database,Cache. AvoidMyAppResource(theResourcesuffix is implicit). - Plural: lowercase, plural —
myapps,databases,caches. - Short name: 2-3 letters; check for conflicts with built-in resources.
Validation tooling
kubectl apply --dry-run=server— validates against your CRDkubectl explain <kind>.<field>— shows what your schema documentscrd_validator.py— this skill's tool, structural rules
Documentation in the schema
Use the description field on every property. kubectl explain reads it:
properties:
replicas:
type: integer
minimum: 1
description: |
Number of replicas to run. Production deployments should use ≥3.
Increases above 100 require quota approval.Anti-patterns
- Top-level `x-kubernetes-preserve-unknown-fields: true` — defeats validation
- No `scope:` declared — defaults to namespaced but make intent explicit
- No printer columns —
kubectl getshows onlyNAME AGE - Conditions written by hand (not via
SetStatusCondition) — easy to loselastTransitionTime - Status fields that duplicate spec — keep them separate
- Using `metadata.annotations` to encode operator state — use status fields
- Single huge CRD with 50+ fields — split into multiple CRDs (e.g., MyApp + MyAppBackup + MyAppRestore)
The operator pattern
An operator is a controller that reconciles a Custom Resource (CR) toward its declared spec. It encodes operational knowledge — installation, upgrades, backups, failover — that would otherwise live in tribal knowledge or runbooks.
When you need an operator
Build an operator when:
- The application has nontrivial lifecycle operations (backup, restore, version upgrade, failover) that go beyond a simple Deployment
- The application has statefulness or topology that Helm/Deployment can't express (leader election, peer discovery, rolling state migration)
- Multiple teams need to provision instances of the application via a Kubernetes API, not a custom UI
- The application's operational discipline is documented in runbooks but unevenly applied
Don't build an operator when:
- A Helm chart is enough (most stateless apps fit here)
- A CronJob can run the operational task on a schedule
- The custom logic is a one-time migration (use a Job)
- Three engineers can manage it via Deployment + ConfigMap
Operator pattern shape
┌────────────────────────────────────────────────────────┐
│ apiVersion: apps.example.com/v1alpha1 │
│ kind: MyApp ← Custom Resource │
│ spec: │
│ replicas: 3 ← user's intent │
│ version: 1.4.2 │
│ status: │
│ conditions: ← controller's view │
│ - type: Ready │
│ status: "True" │
│ phase: Running │
└────────────────────────────────────────────────────────┘
↑
│ owns
│
┌────────────────────────────────────────────────────────┐
│ controller.Reconcile(ctx, req) ⟶ ctrl.Result, error │
│ 1. read CR (the spec) from the cache │
│ 2. read actual state (Pods, Services, ConfigMaps) │
│ 3. diff actual against desired │
│ 4. act idempotently to converge │
│ 5. update status with observed state │
│ 6. return RequeueAfter or done │
└────────────────────────────────────────────────────────┘Reconcile runs whenever:
- The CR changes
- A child resource changes
- A periodic resync fires (default 10h, configurable)
- An explicit requeue from a previous run
Spec vs status — the cardinal split
| spec | status |
|---|---|
| Authored by the user | Authored by the controller |
Mutable through kubectl edit | Mutable only via the status subresource |
| Captures intent | Captures observed reality |
| Triggers reconcile | Does NOT trigger reconcile (when subresource is enabled) |
Violating the split is the #1 cause of operator bugs:
- Mutating spec from the controller → user changes get overwritten
- Updating status without the subresource → status update triggers spec reconcile → loop
Reconcile must be idempotent
Reconcile is called repeatedly for the same state. The function must:
- Produce the same outcome regardless of call count
- Use
Create-or-Updatepatterns (controllerutil.CreateOrUpdate) - Compare current state to desired before writing
- Never assume "this is the first time we've seen this resource"
Idempotence test: if reconcile is called 100 times in a row with the same spec and no external change, the system must converge after the first call and do nothing on the next 99.
OwnerReferences and cascading deletion
Every child resource the operator creates must have its OwnerReferences set to the parent CR. Then:
- Deleting the CR deletes children automatically
- The garbage collector handles orphan cleanup
- The operator doesn't need explicit teardown logic for owned resources
External resources (cloud DBs, S3 buckets, DNS records) don't have OwnerReferences. Use finalizers to clean them up.
Finalizers
A finalizer blocks deletion until the controller has cleaned up external state.
1. User: kubectl delete myapp foo
2. API server: sets metadata.deletionTimestamp; does NOT delete
3. Controller: sees deletionTimestamp; does cleanup; removes finalizer
4. API server: deletion now proceedsWithout a finalizer, external resources orphan. With one, the controller has a guaranteed hook to run cleanup before the CR disappears.
Conditions
The standard pattern for status reporting:
status:
conditions:
- type: Ready # type values are operator-defined
status: "True" # True | False | Unknown
reason: "AllReady" # PascalCase, programmatic
message: "All replicas ready" # human-readable
lastTransitionTime: "2026-05-08T12:00:00Z"
- type: Reconciling
status: "False"
reason: "Idle"
lastTransitionTime: "2026-05-08T12:00:00Z"Use meta/v1.Conditions and meta/v1.SetStatusCondition from kubebuilder/controller-runtime — don't roll your own.
Webhooks
Two types:
- ValidatingWebhook — reject invalid CRs at admission (better than failing in reconcile)
- MutatingWebhook — fill in defaults / inject sidecars (use sparingly; surprising side effects)
Run webhooks in the same controller binary or a sidecar; cert-manager rotates the certs.
Anti-patterns
- Imperative reconcile: "if event = create, do X; if event = update, do Y". Wrong shape. Reconcile = make actual=desired regardless of how we got here.
- No status subresource: status updates re-trigger reconcile.
- Status mutation in many places: centralize in a
setStatushelper. - Reconcile depending on event order: events can be missed; reconcile must converge from any starting state.
- Long reconcile (>2 min): blocks the work queue; split work via RequeueAfter.
Decision flow: when an operator is the right answer
Need: I want to manage <X> in Kubernetes.
Is <X> a stateless web app? → Deployment + Service. Done.
Is <X> a stateless web app with config? → Deployment + ConfigMap.
Need version upgrade automation? → Helm. Done.
Need stateful behaviour (leader, peers)? → StatefulSet.
Need application-aware operations
(backup, version migration, repair)? → Operator.
Need to expose <X> as a k8s resource
to other teams? → Operator.When in doubt: start with Helm. Move to an operator only when Helm can't express the operational logic.
The reconcile loop
Reconcile is the heart of an operator. Most operator bugs are reconcile-loop bugs. The patterns below are deterministic — copy them.
Skeleton — Reconcile(ctx, req)
func (r *MyAppReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
log := log.FromContext(ctx)
// 1. Fetch the CR
var cr appsv1alpha1.MyApp
if err := r.Get(ctx, req.NamespacedName, &cr); err != nil {
if apierrors.IsNotFound(err) {
return ctrl.Result{}, nil // CR is gone; nothing to do
}
return ctrl.Result{}, err // transient error → requeue
}
// 2. Handle deletion via finalizer
if !cr.DeletionTimestamp.IsZero() {
return r.reconcileDelete(ctx, &cr)
}
if !controllerutil.ContainsFinalizer(&cr, finalizerName) {
controllerutil.AddFinalizer(&cr, finalizerName)
return ctrl.Result{}, r.Update(ctx, &cr)
}
// 3. Mark Reconciling
meta.SetStatusCondition(&cr.Status.Conditions, metav1.Condition{
Type: "Reconciling", Status: metav1.ConditionTrue,
Reason: "InProgress", Message: "Converging to desired state",
ObservedGeneration: cr.Generation,
})
// 4. Do the work, idempotently
res, err := r.reconcileNormal(ctx, &cr)
// 5. Update status (always — even on error)
if statusErr := r.Status().Update(ctx, &cr); statusErr != nil {
log.Error(statusErr, "failed to update status")
return res, errors.Join(err, statusErr)
}
return res, err
}The 5-step shape
1. Fetch the CR. Handle NotFound cleanly — the CR may have been deleted between event and reconcile. 2. Handle deletion. If DeletionTimestamp is set, run cleanup, remove finalizer, return. 3. Set Reconciling condition. Mark that the controller is working. 4. Do work idempotently. Use CreateOrUpdate, compare desired-vs-actual, only act on differences. 5. Update status. Even on error — partial progress is signal.
Idempotence patterns
Pattern: CreateOrUpdate
deployment := &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Name: cr.Name, Namespace: cr.Namespace}}
op, err := controllerutil.CreateOrUpdate(ctx, r.Client, deployment, func() error {
deployment.Spec.Replicas = &cr.Spec.Replicas
deployment.Spec.Template.Spec.Containers = buildContainers(&cr.Spec)
return controllerutil.SetControllerReference(&cr, deployment, r.Scheme)
})
if err != nil { return ctrl.Result{}, err }
log.Info("deployment", "operation", op) // "created", "updated", or "unchanged"This pattern is idempotent by construction.
Pattern: SetControllerReference
Always set the OwnerReference so cascading deletion works:
controllerutil.SetControllerReference(&cr, child, r.Scheme)Pattern: Finalizer for external resources
const finalizerName = "myapp.apps.example.com/finalizer"
func (r *MyAppReconciler) reconcileDelete(ctx context.Context, cr *appsv1alpha1.MyApp) (ctrl.Result, error) {
if !controllerutil.ContainsFinalizer(cr, finalizerName) {
return ctrl.Result{}, nil
}
if err := r.deleteExternalResources(ctx, cr); err != nil {
return ctrl.Result{RequeueAfter: 30 * time.Second}, err
}
controllerutil.RemoveFinalizer(cr, finalizerName)
return ctrl.Result{}, r.Update(ctx, cr)
}Error handling and requeue
| Situation | Return |
|---|---|
| Permanent error (bad spec) | ctrl.Result{}, nil + condition with reason |
| Transient error (API timeout, throttling) | ctrl.Result{}, err (auto-requeue with backoff) |
| Need a retry in N seconds | ctrl.Result{RequeueAfter: 30*time.Second}, nil |
| Done; no follow-up | ctrl.Result{}, nil |
Don't use `time.Sleep` inside reconcile. It blocks the work queue, starving other reconciles. Use RequeueAfter.
Status update patterns
// Set a condition
meta.SetStatusCondition(&cr.Status.Conditions, metav1.Condition{
Type: "Ready", Status: metav1.ConditionTrue,
Reason: "AllReady", Message: "all components healthy",
ObservedGeneration: cr.Generation,
})
// Track observed generation
cr.Status.ObservedGeneration = cr.Generation
// Update status — uses /status subresource
if err := r.Status().Update(ctx, &cr); err != nil { ... }Never call r.Update(ctx, &cr) to update status. It uses the spec subresource, which the user owns.
Read once, decide, act
Don't observe the world repeatedly during reconcile. The cache is read-only and consistent within a single reconcile pass:
// Good: read once, decide, act
var pods corev1.PodList
r.List(ctx, &pods, client.InNamespace(cr.Namespace), client.MatchingLabels{"app": cr.Name})
desired := computeDesired(&cr, &pods)
applyDesired(ctx, r.Client, desired)
// Bad: observe-act-observe-act
for _, container := range cr.Spec.Containers {
pod := r.Get(...) // re-reading the cache
if needsRestart(pod) {
r.Delete(...)
pod = r.Get(...) // again
...
}
}Predicates — filter events you don't care about
func (r *MyAppReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&appsv1alpha1.MyApp{}).
Owns(&appsv1.Deployment{}).
WithEventFilter(predicate.GenerationChangedPredicate{}). // ignore status-only updates
Complete(r)
}GenerationChangedPredicate skips reconciles when only status changed — important to avoid loops.
Leader election
Always enable leader election when running >1 controller replica:
mgr, _ := manager.New(cfg, manager.Options{
LeaderElection: true,
LeaderElectionID: "myapp-operator-leader",
})Without it: split-brain. Two controllers both think they own the resource and fight.
Performance — bounded reconcile time
A reconcile pass should complete in <30s for typical work, <2min for heavy work. Longer = the work queue starves other reconciles.
If work takes longer:
- Break into phases; emit
RequeueAfterbetween them - Move long-running work to a separate process (Job)
- Cache expensive computations on
cr.Status
Logging conventions
log := log.FromContext(ctx).WithValues("phase", "create-deployment")
log.Info("creating deployment", "name", cr.Name)
log.Error(err, "failed to create deployment")- Use
log.FromContext(ctx)— picks up controller-runtime's contextual logger - Use
Infofor normal flow,Errorfor retryable failures - Add structured fields, not formatted strings
Anti-patterns checklist
time.Sleepinside reconcile → starves queue; useRequeueAfteros.Exit/log.Fatal→ kills the controller; return an errorpanic→ same; return an errorr.Updateto set status → user.Status().Updater.Updateof the CR while the user could be editing it → user.Status().Updateor use Patch- Reading the same resource multiple times in one reconcile → read once
- Reconcile body > 80 lines → extract
reconcileXxxsubroutines per phase - HTTP calls without
ctx→ can't cancel during shutdown - No requeue path for transient errors → silent failures
- Missing
OwnerReferenceson children → cascading deletion broken
Tooling landscape
Five mainstream operator frameworks. Pick by language, complexity, and target environment.
At-a-glance
| Framework | Language | Scaffolding | Webhook support | Best for | Project status |
|---|---|---|---|---|---|
| controller-runtime | Go | None (library) | Yes | Production-grade, low-level | Active (sig-api-machinery) |
| kubebuilder | Go | Yes (CLI) | Yes | Standard Go operator path | Active (Kubernetes SIGs) |
| operator-sdk | Go / Helm / Ansible | Yes (CLI) | Yes | OpenShift, mixed paradigms | Active (Red Hat) |
| metacontroller | Any (webhook) | None | N/A (uses webhooks) | Polyglot, avoid Go | Less active |
| KOPF | Python | None (library) | Yes | Python shops, async-first | Active (community) |
| java-operator-sdk | Java | Yes | Yes | JVM shops | Active (Red Hat / Java SIG) |
Decision tree
Primary language?
├── Go ──┬── Need scaffolding + opinionated path → kubebuilder
│ ├── Targeting OpenShift / OLM → operator-sdk (Go)
│ └── Library-only, full control → controller-runtime
├── Python ─────────────────────────────────────────→ KOPF
├── Java ─────────────────────────────────────────→ java-operator-sdk
└── Other (Node, Ruby, Rust)
└── webhook-based, polyglot → metacontrollercontroller-runtime (Go library)
What it is: The Go library that everyone else builds on. Provides Manager, Reconciler, cache, client, predicates, leader election.
Use when:
- You need fine-grained control over the manager and event sources
- You're building reusable operator components
- Your team has Go experience and prefers libraries to scaffolders
Skip when:
- You want bootstrap-by-CLI (use kubebuilder)
- You don't speak Go
Example:
mgr, _ := ctrl.NewManager(cfg, ctrl.Options{Scheme: scheme})
ctrl.NewControllerManagedBy(mgr).
For(&apps.MyApp{}).
Complete(&MyAppReconciler{Client: mgr.GetClient()})
mgr.Start(ctx)kubebuilder (Go scaffolder)
What it is: The standard scaffolding tool. Wraps controller-runtime with project layout, code generation, and the kubebuilder CLI.
Use when:
- New Go operator
- You want predictable project structure
- You'll publish the operator publicly
Workflow:
kubebuilder init --domain example.com --repo github.com/org/myapp-operator
kubebuilder create api --group apps --version v1alpha1 --kind MyApp
make manifests
make generate
make runStrengths: Excellent docs, mature, used by everyone from cert-manager to Crossplane.
Weaknesses: Some teams find the layout opinionated; sometimes hard to escape from.
operator-sdk (Red Hat / OpenShift)
What it is: Wraps kubebuilder for Go and adds Helm-based and Ansible-based operators (no Go required).
Use when:
- Targeting OpenShift / OLM (Operator Lifecycle Manager)
- Building a Helm-based operator from an existing chart
- Building an Ansible-based operator from existing playbooks
Helm-based operator:
operator-sdk init --plugins=helm --domain example.com --group apps --version v1 --kind MyApp
operator-sdk create api --group apps --version v1 --kind MyApp --helm-chart=./mychartThe operator's reconcile becomes helm upgrade --install. Fast on-ramp; less power.
Ansible-based operator: Similar, but reconcile invokes a playbook. Useful for ops teams already deep in Ansible.
Skip when:
- Vanilla k8s target (kubebuilder is more direct)
- You want a Go operator without OpenShift coupling
metacontroller (webhook-based, language-agnostic)
What it is: Runs in-cluster, watches your CRDs, and POSTs webhook calls to your endpoints with desired-state computations. You implement the logic in any language behind an HTTP endpoint.
Use when:
- Polyglot team (Python, Node, Ruby, etc.)
- Want to avoid Go and Java
- Operator logic is genuinely simple (compute children from parent)
Example sync hook:
# Python webhook returns desired children given parent + observed
def sync(request):
parent = request['parent']
return {
'status': {'phase': 'Ready'},
'children': [{'apiVersion': 'apps/v1', 'kind': 'Deployment', ...}],
}Strengths: No Go required; fast iteration in any language.
Weaknesses: Lower ecosystem activity; not great for complex multi-CRD operators; webhook-based latency.
KOPF (Python)
What it is: A Python framework for building operators. Async-first, decorator-based, no scaffolding step.
Use when:
- Python shop
- Operator logic is moderate complexity
- Want fast iteration without recompilation
Example:
import kopf
@kopf.on.create('apps.example.com', 'v1alpha1', 'myapps')
async def create_fn(spec, name, namespace, logger, **_):
logger.info(f"creating MyApp {name}")
# ... create children
return {'phase': 'Ready'}
@kopf.on.delete('apps.example.com', 'v1alpha1', 'myapps')
async def delete_fn(spec, name, namespace, **_):
# cleanup external resources
passStrengths:
- Async/await native (good for many concurrent reconciles)
- No code generation
- Good for ML/data teams already in Python
Weaknesses:
- Smaller ecosystem than Go
- Some features lag controller-runtime (e.g., complex caching)
- Python startup cost in the controller pod
java-operator-sdk
What it is: Java framework, Quarkus integration, modeled after controller-runtime.
Use when: JVM shop with strong Spring/Quarkus skills.
Skip when: You don't already have a JVM ops setup.
Comparison: complexity vs control
control ↑
│ controller-runtime (full control, library)
│ │
│ kubebuilder (scaffolded controller-runtime)
│ │
│ operator-sdk Go (kubebuilder + OLM)
│ │
│ KOPF (Python decorators)
│ │
│ java-operator-sdk (JVM)
│ │
│ operator-sdk Ansible (playbooks)
│ │
│ operator-sdk Helm (chart-based)
│ │
│ metacontroller (webhook hooks)
↓
complexity ↓Higher control = more code, more flexibility. Lower complexity = faster start, less power.
Cross-cutting concerns
Regardless of framework:
- Webhooks for validation — reject bad CRs at admission
- cert-manager — rotate webhook certs automatically
- Prometheus —
/metricsendpoint via controller-runtime's built-in metrics - OLM (Operator Lifecycle Manager) — for OperatorHub publishing
- OperatorHub Capability Levels — see
operator_capability_audit.py
Migration paths
| From | To | Effort |
|---|---|---|
| controller-runtime | kubebuilder | Low (kubebuilder uses controller-runtime) |
| Helm chart | Helm-based operator-sdk | Low |
| Helm chart | Go operator (kubebuilder) | High (rewrite logic in Go) |
| KOPF | Go operator | High (language change) |
| Any | metacontroller | Medium (move logic behind HTTP) |
Selection checklist
Before committing:
- [ ] Identify primary language constraint
- [ ] Target environment (vanilla k8s vs OpenShift/OLM)
- [ ] Operator complexity: 1 CRD vs many
- [ ] Need webhooks?
- [ ] Need OLM publishing?
- [ ] Build a 1-week proof-of-concept; verify reconcile latency, status update flow, and dev-loop ergonomics
#!/usr/bin/env python3
"""Validate a Kubernetes CRD YAML against operator-pattern best practices.
Checks for status subresource, structural schema, conditions support, printer
columns, version policy, and other operator-grade design rules. Stdlib-only —
parses YAML via a minimal embedded reader (no PyYAML dependency).
"""
import argparse
import json
import os
import re
import sys
CHECKS = [
("status_subresource", "Each version must declare subresources.status (otherwise status updates loop spec reconciles)"),
("storage_version", "Exactly one version must be storage:true"),
("served_version", "At least one version must be served:true"),
("schema_present", "Each version must declare schema.openAPIV3Schema"),
("schema_typed", "Schema must declare 'type: object' at root (no x-kubernetes-preserve-unknown-fields at root)"),
("conditions_array", "Schema should declare a conditions array under status (for metav1.Conditions)"),
("printer_columns", "additionalPrinterColumns should include Age and a status indicator"),
("scope", "scope should be Namespaced unless cluster-scoped is justified"),
("singular_listkind", "names.singular and names.listKind must be declared"),
]
def _load_yaml_minimal(path):
"""Yield top-level YAML documents from a multi-doc file as text blocks.
Stdlib-only — splits on '---' separators. We grep relevant fields with
regex rather than fully parse. Crude but enough for the structural
checks below; a full YAML parser would be the upgrade path."""
with open(path, "r", encoding="utf-8", errors="replace") as f:
text = f.read()
docs = re.split(r"^---\s*$", text, flags=re.MULTILINE)
return [d for d in docs if d.strip()]
def _is_crd_doc(doc):
return bool(re.search(r"^kind:\s*CustomResourceDefinition\s*$", doc, re.MULTILINE))
def _check_one(doc, path):
findings = []
has_status_sub = bool(re.search(r"subresources:\s*\n\s*status:\s*\{?\s*\}?", doc))
if not has_status_sub:
findings.append(("FAIL", "status_subresource", "no subresources.status block found"))
storage_count = len(re.findall(r"storage:\s*true\b", doc))
if storage_count != 1:
findings.append(("FAIL", "storage_version", f"expected exactly 1 storage:true, found {storage_count}"))
served_count = len(re.findall(r"served:\s*true\b", doc))
if served_count < 1:
findings.append(("FAIL", "served_version", "no served:true version"))
if "openAPIV3Schema" not in doc:
findings.append(("FAIL", "schema_present", "no openAPIV3Schema declared"))
if re.search(r"x-kubernetes-preserve-unknown-fields:\s*true", doc):
findings.append(("WARN", "schema_typed", "x-kubernetes-preserve-unknown-fields: true present (defeats validation)"))
if "conditions" not in doc.lower():
findings.append(("WARN", "conditions_array", "no conditions array referenced (Karpathy: declare an explicit shape)"))
if "additionalPrinterColumns" not in doc:
findings.append(("WARN", "printer_columns", "no additionalPrinterColumns (kubectl get UX is poor)"))
elif not re.search(r"name:\s*Age\b", doc):
findings.append(("WARN", "printer_columns", "additionalPrinterColumns missing Age column"))
if not re.search(r"^\s*scope:\s*\w+", doc, re.MULTILINE):
findings.append(("WARN", "scope", "scope not explicitly set"))
if not re.search(r"^\s*singular:\s*[\w<]", doc, re.MULTILINE):
findings.append(("WARN", "singular_listkind", "names.singular not declared"))
if not re.search(r"^\s*listKind:\s*[\w<]", doc, re.MULTILINE):
findings.append(("WARN", "singular_listkind", "names.listKind not declared"))
return findings
def _walk_yaml_files(root):
if os.path.isfile(root):
yield root
return
for r, _, files in os.walk(root):
for f in files:
if f.endswith((".yaml", ".yml")):
yield os.path.join(r, f)
def audit(target):
results = []
for path in _walk_yaml_files(target):
for doc in _load_yaml_minimal(path):
if not _is_crd_doc(doc):
continue
kind_match = re.search(r"kind:\s*(\w+)\s*$", doc, re.MULTILINE)
crd_kind = kind_match.group(1) if kind_match else "?"
name_match = re.search(r"^\s+name:\s*([\w.\-]+)\s*$", doc, re.MULTILINE)
crd_name = name_match.group(1) if name_match else os.path.basename(path)
findings = _check_one(doc, path)
results.append({"path": path, "name": crd_name, "kind": crd_kind, "findings": findings})
return results
def render_text(results):
if not results:
print("No CRD documents found.")
return 0
fails = sum(1 for r in results for f in r["findings"] if f[0] == "FAIL")
warns = sum(1 for r in results for f in r["findings"] if f[0] == "WARN")
print(f"CRD Validator — {len(results)} CRD(s) inspected, {fails} FAIL, {warns} WARN")
print("")
for r in results:
print(f"== {r['name']} ({r['path']})")
if not r["findings"]:
print(" PASS: all checks green")
continue
for level, key, msg in r["findings"]:
print(f" [{level}] {key}: {msg}")
print("")
return 1 if fails else 0
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--crd", required=True, help="Path to a CRD YAML file or a directory of YAMLs")
ap.add_argument("--format", choices=["text", "json"], default="text")
args = ap.parse_args()
if not os.path.exists(args.crd):
print(f"ERROR: not found: {args.crd}", file=sys.stderr)
return 2
results = audit(args.crd)
if args.format == "json":
print(json.dumps(results, indent=2))
return 0
return render_text(results)
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env python3
"""Score an operator against OperatorHub Capability Levels (1-5).
Walks an operator repo and detects evidence for each level. Level achieved =
highest level for which all required signals are present. Reports next-level
gaps as concrete advancement steps.
Levels:
L1 Basic Install — CRD + controller + Deployment manifest
L2 Seamless Upgrades — version conversion + PDB + leader election
L3 Full Lifecycle — backup/restore + finalizers + status conditions
L4 Deep Insights — /metrics endpoint + Prometheus rules
L5 Auto Pilot — HPA / VPA / autotuning logic referenced
"""
import argparse
import json
import os
import re
import sys
SIGNALS = {
"L1": [
("crd_present", lambda files, contents: any("CustomResourceDefinition" in c for c in contents.values())),
("deployment_present", lambda files, contents: any(re.search(r"^kind:\s*Deployment", c, re.MULTILINE) for c in contents.values())),
("controller_code", lambda files, contents: any(p.endswith(".go") and "Reconcile" in c for p, c in contents.items())),
],
"L2": [
("conversion_webhook", lambda files, contents: any("conversion" in c.lower() and "webhook" in c.lower() for c in contents.values())),
("leader_election", lambda files, contents: any("LeaderElection" in c or "leader-elect" in c for c in contents.values())),
("pdb_present", lambda files, contents: any(re.search(r"kind:\s*PodDisruptionBudget", c) for c in contents.values())),
],
"L3": [
("finalizers", lambda files, contents: any("Finalizer" in c or "finalizers" in c for c in contents.values())),
("status_conditions", lambda files, contents: any("metav1.Condition" in c or "SetStatusCondition" in c for c in contents.values())),
("backup_restore_hint", lambda files, contents: any(re.search(r"\b(backup|restore|snapshot)\b", c, re.IGNORECASE) for c in contents.values())),
],
"L4": [
("metrics_endpoint", lambda files, contents: any(re.search(r"/metrics|prometheus", c) for c in contents.values())),
("prometheus_rules", lambda files, contents: any(re.search(r"PrometheusRule|alert:", c) for c in contents.values())),
],
"L5": [
("autoscaling_referenced", lambda files, contents: any(re.search(r"\bHorizontalPodAutoscaler|VerticalPodAutoscaler|autoscal", c) for c in contents.values())),
("autotune_logic", lambda files, contents: any(re.search(r"autotune|self-heal|anomaly", c, re.IGNORECASE) for c in contents.values())),
],
}
LEVEL_NAMES = {
"L1": "Basic Install",
"L2": "Seamless Upgrades",
"L3": "Full Lifecycle",
"L4": "Deep Insights",
"L5": "Auto Pilot",
}
SCAN_EXTS = {".go", ".yaml", ".yml", ".md"}
SKIP_DIRS = {".git", "node_modules", "vendor", "bin", "dist", "__pycache__"}
def _walk(root):
files = {}
for r, dirs, fnames in os.walk(root):
dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
for f in fnames:
if os.path.splitext(f)[1] in SCAN_EXTS:
p = os.path.join(r, f)
try:
with open(p, "r", encoding="utf-8", errors="replace") as fh:
files[p] = fh.read()
except OSError:
continue
return files
def evaluate(operator_dir):
contents = _walk(operator_dir)
file_paths = list(contents.keys())
results = {}
achieved_max = None
for level in ["L1", "L2", "L3", "L4", "L5"]:
signals = SIGNALS[level]
passing = []
failing = []
for key, check in signals:
ok = check(file_paths, contents)
(passing if ok else failing).append(key)
all_pass = len(failing) == 0
results[level] = {
"name": LEVEL_NAMES[level],
"passing": passing,
"missing": failing,
"achieved": all_pass,
}
if all_pass:
achieved_max = level
else:
break
return {"current_level": achieved_max, "details": results}
def render_text(report, operator_dir):
print(f"Operator Capability Audit — {operator_dir}")
current = report["current_level"]
if current is None:
print("Current level: BELOW_L1 (no operator structure detected)")
else:
print(f"Current level: {current} — {LEVEL_NAMES[current]}")
print("")
for level in ["L1", "L2", "L3", "L4", "L5"]:
d = report["details"].get(level)
if d is None:
continue
marker = "✓" if d["achieved"] else "✗"
print(f" {marker} {level} {d['name']}: pass={len(d['passing'])} miss={len(d['missing'])}")
for k in d["missing"]:
print(f" - missing: {k}")
print("")
next_level = None
for lv in ["L1", "L2", "L3", "L4", "L5"]:
if lv == current:
continue
if not report["details"].get(lv, {}).get("achieved"):
next_level = lv
break
if next_level:
misses = report["details"][next_level]["missing"]
print(f"Next: advance to {next_level} ({LEVEL_NAMES[next_level]}) by addressing:")
for k in misses:
print(f" - {k}")
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--operator-dir", required=True, help="Path to operator repo root")
ap.add_argument("--format", choices=["text", "json"], default="text")
args = ap.parse_args()
if not os.path.isdir(args.operator_dir):
print(f"ERROR: not a directory: {args.operator_dir}", file=sys.stderr)
return 2
report = evaluate(args.operator_dir)
if args.format == "json":
print(json.dumps(report, indent=2))
else:
render_text(report, args.operator_dir)
return 0
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env python3
"""Lint a Go controller reconcile function for operator anti-patterns.
Detects common operator bugs from static patterns in Go source: blocking calls
inside reconcile, spec mutation (instead of status), missing requeue on error,
oversized reconcile functions, and missing finalizer/condition handling. Pure
regex heuristics; not a Go AST parser, but catches the recurring mistakes.
"""
import argparse
import json
import os
import re
import sys
CODE_EXTS = {".go"}
CHECKS = [
("time_sleep", r"\btime\.Sleep\s*\(", "FAIL", "time.Sleep inside reconcile blocks the work queue. Use ctrl.Result{RequeueAfter: ...}."),
("update_spec", r"r\.(?:Client\.)?Update\(\s*ctx\s*,\s*\w+\)", "WARN", "r.Client.Update on the reconciled object likely mutates spec. Use r.Status().Update for status."),
("missing_context_in_http", r"http\.(?:Get|Post|Do)\s*\(", "WARN", "HTTP calls without ctx-aware client; cannot cancel during shutdown."),
("os_exit", r"\bos\.Exit\s*\(", "FAIL", "os.Exit inside reconcile kills the controller; return an error instead."),
("panic_call", r"\bpanic\s*\(", "WARN", "panic inside reconcile crashes the controller; return an error so it requeues."),
("log_fatal", r"\blog\.Fatal", "FAIL", "log.Fatal exits the process; return an error instead."),
]
def _read(path):
try:
with open(path, "r", encoding="utf-8", errors="replace") as f:
return f.read()
except OSError:
return ""
def _find_reconcile_blocks(src):
"""Return list of (start_line, end_line, body) for each Reconcile func."""
blocks = []
sig = re.compile(r"func\s+\([^)]*\)\s+Reconcile\s*\(", re.MULTILINE)
for m in sig.finditer(src):
start = m.start()
i = src.find("{", m.end())
if i < 0:
continue
depth = 1
j = i + 1
while j < len(src) and depth > 0:
c = src[j]
if c == "{":
depth += 1
elif c == "}":
depth -= 1
j += 1
if depth == 0:
body = src[i:j]
start_line = src[:start].count("\n") + 1
end_line = src[:j].count("\n") + 1
blocks.append((start_line, end_line, body))
return blocks
def _check_block(body, start_line):
findings = []
for key, pattern, level, msg in CHECKS:
for m in re.finditer(pattern, body):
line_offset = body[: m.start()].count("\n")
findings.append({
"level": level,
"key": key,
"line": start_line + line_offset,
"msg": msg,
})
body_lines = body.count("\n")
if body_lines > 80:
findings.append({
"level": "WARN",
"key": "reconcile_length",
"line": start_line,
"msg": f"Reconcile body is {body_lines} lines (>80). Extract reconcileXxx subroutines.",
})
has_finalizer_add = re.search(r"controllerutil\.AddFinalizer\b|finalizers\s*=", body)
has_finalizer_remove = re.search(r"controllerutil\.RemoveFinalizer\b", body)
if has_finalizer_add and not has_finalizer_remove:
findings.append({
"level": "WARN",
"key": "finalizer_unbalanced",
"line": start_line,
"msg": "AddFinalizer found but no RemoveFinalizer call — orphaned external resources on delete.",
})
if not re.search(r"ctrl\.Result\{", body):
findings.append({
"level": "WARN",
"key": "missing_requeue",
"line": start_line,
"msg": "Reconcile body does not return ctrl.Result{...}. Confirm error returns trigger requeue.",
})
return findings
def audit_file(path):
src = _read(path)
if not src or "Reconcile" not in src:
return []
blocks = _find_reconcile_blocks(src)
out = []
for start_line, _, body in blocks:
out.extend(_check_block(body, start_line))
# Cross-function check: AddFinalizer present in file → RemoveFinalizer must be too.
has_add = "controllerutil.AddFinalizer" in src or re.search(r"finalizers\s*=", src)
has_remove = "controllerutil.RemoveFinalizer" in src
if has_add and not has_remove:
out = [f for f in out if f["key"] != "finalizer_unbalanced"]
out.append({
"level": "WARN",
"key": "finalizer_unbalanced",
"line": 0,
"msg": "AddFinalizer is called somewhere in this file but RemoveFinalizer is not — orphaned external resources on delete.",
})
elif has_remove:
# Suppress per-block warnings if file-level pairing is balanced.
out = [f for f in out if f["key"] != "finalizer_unbalanced"]
return out
def _walk(target):
if os.path.isfile(target):
yield target
return
for r, _, files in os.walk(target):
for f in files:
if os.path.splitext(f)[1] in CODE_EXTS:
yield os.path.join(r, f)
def audit(target):
results = []
for path in _walk(target):
findings = audit_file(path)
if findings:
results.append({"path": path, "findings": findings})
return results
def render_text(results):
fails = sum(1 for r in results for f in r["findings"] if f["level"] == "FAIL")
warns = sum(1 for r in results for f in r["findings"] if f["level"] == "WARN")
print(f"Reconcile Lint — {len(results)} controller file(s), {fails} FAIL, {warns} WARN")
print("")
if not results:
print("PASS: no anti-patterns detected.")
return 0
for r in results:
print(f"== {r['path']}")
for f in r["findings"]:
print(f" [{f['level']}] line {f['line']} {f['key']}: {f['msg']}")
print("")
return 1 if fails else 0
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--controller", required=True, help="Path to a Go controller file or directory")
ap.add_argument("--format", choices=["text", "json"], default="text")
args = ap.parse_args()
if not os.path.exists(args.controller):
print(f"ERROR: not found: {args.controller}", file=sys.stderr)
return 2
results = audit(args.controller)
if args.format == "json":
print(json.dumps(results, indent=2))
return 0
return render_text(results)
if __name__ == "__main__":
sys.exit(main())
Related skills
FAQ
What does kubernetes-operator help developers build?
kubernetes-operator guides creation of Kubernetes operators with custom resource definitions, controller reconciliation loops, RBAC rules, and deployment manifests. Platform engineers use it when automating application lifecycle logic inside production clusters.
Does kubernetes-operator cover operator troubleshooting?
kubernetes-operator includes troubleshooting guidance for reconciliation failures, CRD schema issues, and desired-versus-observed state drift. Developers apply it when extending existing operators or fixing controller behavior in running clusters.