Hermes Wiki
Developer/Languages/Go/CLIAndInfraTooling/Fundamentals/cli-and-kubernetes-controller-patterns-in-go

CLI and Kubernetes Controller Patterns in Go

Concept

Go is the default implementation language for platform-engineering tooling — kubectl, docker, terraform, and helm are all written in Go — for reasons that compound: [[../DeploymentAndServing/Fundamentals/static-binaries-minimal-images-and-graceful-shutdown\|static, cross-compilable binaries]] mean a single build produces a distributable tool with no runtime to install, and the same language covers both the CLI surface and (for Kubernetes tooling specifically) the controller/operator surface underneath it.

Cobra (github.com/spf13/cobra) is the de facto standard library for building Go CLIs with the now-familiar APPNAME COMMAND ARG --FLAG shape — it powers Kubernetes, Hugo, and the GitHub CLI, among many others. A Cobra application is a tree of Command values, each with its own Run function, flags, and child subcommands; the library handles POSIX-compliant flag parsing, automatic --help generation, and shell completion generation (bash/zsh/fish/PowerShell) so a tool author writes the command logic, not the argument-parsing plumbing. viper is commonly paired with Cobra to layer config file, environment variable, and flag precedence into one resolved config value.

The Kubernetes Operator pattern is the infra-tooling shape one level deeper than a CLI: instead of a human running commands, a long-running controller watches the difference between an object's desired state (a Kubernetes resource, often a Custom Resource Definition describing something domain-specific, like "a Postgres cluster") and its actual state, and drives the actual state toward the desired one — the same reconciliation idea a thermostat implements, applied to infrastructure objects. controller-runtime (sigs.k8s.io/controller-runtime) is the standard Go library for this: a Manager runs one or more Controllers and provides shared caches/clients, each Controller watches for change events on its target resource type, and a Reconciler is the function an operator author actually writes — it takes a Request naming one object, does whatever work brings that object's actual state in line with its desired state, and returns (possibly asking to be re-invoked after a delay, or on error).

Tradeoffs

Building block Benefit Cost
Cobra + viper (CLI) Ecosystem standard — contributors familiar with kubectl/docker-style CLIs already know the shape; shell completion and help generation for free Adds a dependency and some ceremony for a genuinely trivial single-command tool with no subcommands or config layering
Standard library flag package (CLI) Zero dependencies, in the standard library No subcommand tree, no POSIX-style --long-flag/-s short-flag combos, no shell completion — fine for a small internal script, cumbersome past a handful of flags or any nested-subcommand tool
controller-runtime (operator) Handles the hard, easy-to-get-wrong parts (watch/cache management, work-queue rate limiting, leader election for HA) so the Reconciler function is the only genuinely custom code Steep learning curve relative to a one-shot CLI tool — the reconcile loop's "expect to be re-invoked, make every step idempotent" model is a different mental model than linear CLI logic
A cron job or one-shot script instead of an operator Much simpler mental model, no long-running process to operate No continuous drift correction — if the actual state changes outside the script's run (someone edits the resource by hand, a dependent object is deleted), nothing notices until the next scheduled run, if ever

The CLI-vs-operator choice itself is really a tradeoff between "human-triggered, one-shot" and "continuously reconciling, level-triggered": a CLI is the right shape when a human (or a CI pipeline) decides when an action happens; an operator is the right shape when the platform itself needs to keep converging toward a desired state indefinitely, including recovering from external drift nobody explicitly triggered.

When to use / when not to

  • Reach for Cobra once a tool needs more than one or two flags, or grows subcommands (tool create, tool delete, tool status) — the standard library's flag package is fine for a genuinely single-purpose script but doesn't scale past that without reimplementing what Cobra already provides.
  • Build a Kubernetes operator only when there's a genuine, ongoing desired-vs-actual gap to close continuously — provisioning a resource once and never touching it again is a job for a one-shot script or a Job, not a controller that would otherwise sit idle watching for changes that never come.
  • A Reconciler function must be safe to call repeatedly with no side effects beyond what's needed to converge state — controller-runtime re-invokes reconcile on any relevant watch event, on error with backoff, and periodically as a safety net, so non-idempotent reconcile logic (e.g., "always append a new record") corrupts state on every extra invocation.
  • Don't reach for the full operator pattern (CRD + controller + reconcile loop) for something a ConfigMap and an init container, or a scheduled CronJob, would solve more simply — the operator pattern's operational overhead (running and upgrading the controller itself, RBAC for its service account) only pays for itself when the reconciliation logic is genuinely non-trivial.

Common pitfall

Writing a Reconciler that mutates external state as a side effect of being called, rather than as a side effect of detecting a real gap — for example, always issuing a write to a downstream API on every reconcile invocation instead of first checking whether the downstream state already matches what's desired. Because controller-runtime re-invokes reconcile far more often than a naive mental model expects (any watched-resource change, periodic resync, and retries after error all trigger a fresh call), a reconciler without an idempotency check turns an occasional real change into a continuous stream of redundant writes — at best wasted API calls and downstream rate-limit pressure, at worst duplicate side effects if the downstream operation isn't itself idempotent (see [[../../../../Idempotency/Idempotency/Fundamentals/idempotency-keys\|idempotency keys]] for the downstream-side fix).

Engineering Lens

The Cobra CLI pattern and the controller-runtime operator pattern sit at opposite ends of the same infra-tooling spectrum — one is triggered once by a human and finishes, the other runs forever and re-converges — but both exist because Go's static-binary, single-toolchain story makes it cheap to build and distribute both shapes with the same language and largely the same team's skills. The choice between them in a design review isn't about implementation difficulty so much as about who or what decides when the tool acts: a CLI puts that decision in a human's or a pipeline's hands every time, while an operator puts it under the platform's continuous control — the right answer depends entirely on whether "nobody remembered to re-run the tool" is an acceptable failure mode for the thing it manages.

Sources

Hermes Wiki