kube-bmc: Modeling Server Hardware as Kubernetes CRDs in Go
Power-cycling a physical server from Kubernetes sounds simple until you ask what happens when the pod running the power cycle gets evicted mid-operation. Or what stops someone from patching action: IdentifyOn into action: ForceOff while a controller is halfway through reconciling it. kube-bmc handles one problem in its CRD schema and the other with a small status-and-controller protocol. That split is what makes the codebase worth reading.
Start with the health-ranking helper:
// Rank orders health values so the worst one can be picked.
func (h Health) Rank() int {
switch h {
case HealthOK:
return 1
case HealthWarning:
return 2
case HealthCritical:
return 3
}
return 0
}
Health is a string type in api/v1alpha1/bmc_types.go. On the wire it’s "OK" or "Critical"; the CRD schema makes the API server validate that set, while the Go type carries the ordering logic as a method. A plain string in JSON, a domain type in Go: that shape repeats across the project.
kube-bmc exposes each server’s baseboard management controller (BMC) as a Kubernetes object. A DaemonSet agent reads the local BMC in-band and publishes a BMC resource; a server component executes power actions out-of-band over IPMI or Redfish. Below is how the Go is organised and which parts transfer to your own operator.
String enums with kubebuilder validation markers
Every constrained field in bmc_types.go is a named string type with an enum marker above it:
// Protocol is the out-of-band management protocol used to reach a BMC over the network.
// +kubebuilder:validation:Enum=Redfish;IPMI
type Protocol string
const (
ProtocolRedfish Protocol = "Redfish"
ProtocolIPMI Protocol = "IPMI"
)
The marker isn’t a Go construct. controller-gen reads the comment and writes an enum constraint into the generated CRD schema, so the API server rejects protocol: Foo before your controller ever wakes up. The named Go type also stops accidental mixing with other named string types. It does not reject an untyped string constant such as "redfish"; the CRD validation remains the hard boundary for API input.
The same file puts +kubebuilder:default=Redfish on BMCSpec.Protocol. For newly created objects, API-server defaulting fills an omitted value before storage, so kubectl get bmc -o yaml and other clients see the selected protocol. A controller-only fallback could still behave correctly, but the stored object would not explain that choice.
The optional-field typing is deliberate too. CredentialsRef *SecretReference is a pointer; Address string is not. In this conventional generated-API shape, the pointer distinguishes an omitted reference from a present zero-valued struct. For the string, kube-bmc treats "" as the sentinel: an empty address falls back to in-band discovery, while an empty credentials reference falls back to the server’s default Secret.
The slices.Clone detail in the action types
api/v1alpha1/bmcaction_types.go defines the supported operations, split into power actions and everything else:
// PowerActions lists the actions that change the power state.
var PowerActions = []ActionType{
ActionOn, ActionGracefulShutdown, ActionGracefulRestart, ActionForceRestart, ActionPowerCycle, ActionForceOff,
}
// Actions lists all supported actions.
var Actions = append(slices.Clone(PowerActions), ActionIdentifyOn, ActionIdentifyOff, ActionClearSEL)
// Valid reports whether a is a supported action.
func (a ActionType) Valid() bool { return slices.Contains(Actions, a) }
// IsPower reports whether a changes the power state.
func (a ActionType) IsPower() bool { return slices.Contains(PowerActions, a) }
That slices.Clone makes the backing arrays independent. Without it, an append that reused spare capacity would leave Actions and PowerActions aliased; a later element mutation through either slice could then change the other unexpectedly.
package main
import "fmt"
func main() {
base := make([]string, 3, 8) // len 3, cap 8 — spare capacity
copy(base, []string{"a", "b", "c"})
all := append(base, "d") // writes into base's backing array
all[0] = "MUTATED"
fmt.Println(base) // [MUTATED b c]
fmt.Println(all) // [MUTATED b c d]
}
Package-level slices make that alias easy to keep around for the lifetime of the process. One small allocation at init prevents cross-slice mutations here, although both exported variables remain mutable in their own right.
Making a request immutable with CEL
BMCActionSpec carries a single CEL rule:
// +kubebuilder:validation:XValidation:rule="self == oldSelf",message="spec is immutable"
type BMCActionSpec struct {
BMCName string `json:"bmcName"`
Action ActionType `json:"action"`
RequestedBy string `json:"requestedBy"`
Reason string `json:"reason,omitempty"`
}
A BMCAction is a one-shot command: power cycle this machine, turn on the identify light, clear the System Event Log. Swapping the action out mid-reconcile is a good way to hard-power-off a server that someone only wanted to blink. CEL validation rules evaluate self against oldSelf in the API server, so this rule preserves the original spec after creation.
That makes RequestedBy stable, but it does not authenticate the field by itself. The kube-bmc server and kubectl-bmc populate it from an authenticated identity; Kubernetes RBAC still has to control clients that create BMCAction objects directly.
The Go side gets simpler for free. The reconciler in internal/controller/action.go never has to diff a spec against what it already started executing.
Deadline in status, not a timer in memory
The status type has a field I’d copy wholesale:
// Deadline is set by the executor when it starts the action: the time by which it
// records the outcome. An action still Running after its deadline was interrupted,
// for example because the executing process stopped, and is marked Failed.
Deadline *metav1.Time `json:"deadline,omitempty"`
A context.WithTimeout dies with the process that created it. Persisting the deadline gives the server’s ActionReconciler enough information to recover: when it sees a Running action beyond that deadline plus a one-minute grace period, it marks the action Failed. The API server stores the evidence; the controller performs the recovery, so another server replica can resume the check after a crash. The in-memory context still bounds the original call.
Phases get a helper rather than scattered comparisons:
// Done reports whether the phase is terminal.
func (p ActionPhase) Done() bool {
return p == PhaseSucceeded || p == PhaseFailed || p == PhaseRejected
}
Three lines, but it keeps the state machine definition in one place instead of spread across every caller that needs to ask “are we finished”.
One binary, several roles
cmd/kube-bmc/main.go dispatches on os.Args[1] by hand instead of reaching for a CLI framework:
switch os.Args[1] {
case "agent":
return runAgent(ctx, os.Args[2:])
case "server":
return runServer(ctx, os.Args[2:])
case "hash-password":
return hashPassword(os.Args[2:])
...
}
The agent, server, and hash-password paths each build a flag.NewFlagSet; inventory and version stay as small switch cases. For this five-command internal binary that’s less machinery than Cobra, and it ships one image that the DaemonSet and the Deployment both run with different arguments. The user-facing CLI is a separate binary, cmd/kubectl-bmc/main.go, and that one does use a Cobra command tree built in internal/kubectl/kubectl.go, wired to genericiooptions.IOStreams so tests can capture output. Different audiences, different tooling budgets.
Shutdown is handled once, at the top:
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
The long-running subcommands receive that context, so a SIGTERM from the kubelet stops the agent or controller manager. In server mode the same cancellation also reaches the top-level select, which starts a five-second http.Server.Shutdown. For the draining side of that story, see graceful shutdown for Go HTTP servers.
Small thing in hashPassword worth noticing: the password comes from io.LimitReader(os.Stdin, 1024) instead of an argument. That keeps it out of shell history and /proc/*/cmdline, and bounds the memory used by a misdirected cat bigfile |. It does not reject oversized input, though: bytes after the first 1,024 are silently ignored.
Scheme registration and generated deepcopy
api/v1alpha1/groupversion_info.go is short and does what every operator’s does:
var (
GroupVersion = schema.GroupVersion{Group: "bmc.kube-bmc.io", Version: "v1alpha1"}
SchemeBuilder = runtime.NewSchemeBuilder(addKnownTypes)
AddToScheme = SchemeBuilder.AddToScheme
)
func addKnownTypes(s *runtime.Scheme) error {
s.AddKnownTypes(GroupVersion, &BMC{}, &BMCList{}, &BMCAction{}, &BMCActionList{})
metav1.AddToGroupVersion(s, GroupVersion)
return nil
}
main.go adds it to the client-go scheme so one scheme-aware controller-runtime client can read Nodes, Secrets, Leases and BMCs. The runtime.Object interface those resource types satisfy requires DeepCopyObject, which is why zz_generated.deepcopy.go exists. Generated code is easy to skim past, but it shows which pointers and slices need independent copies. Those methods are part of how the controller-runtime cache can return objects without callers sharing the cache’s mutable backing data. For another take on the same file layout, how Wellcake structures its Kubernetes operator API in Go.
Where the hardware work actually happens
The API types are the contract. The protocol work lives elsewhere: ipmi/client.go and bmc/bmc.go handle BMC access, while hardware/hardware.go reads local inventory in-band. The latter is exposed directly through the kube-bmc inventory subcommand, which JSON-encodes to stdout. internal/agent/lease.go has a narrower job than leader election: each node agent renews a namespaced coordination Lease as its heartbeat.
On the observability side, internal/collector/metrics.go and internal/collector/health.go turn sensor readings into Prometheus metrics plus the aggregated Health value that Rank() compares. The dashboard is Vue 3 and Naive UI, served from the web package that main.go imports, so it rides along inside the same binary instead of needing its own deployment. There’s an MCP endpoint too, in internal/mcpserver/mcpserver.go, built on the official Go SDK and covering the same ground as building MCP servers in Go.
The typed string enum is the pattern to steal: Protocol, Health, PowerState, ActionType and ActionPhase are all cut from the same cloth, with API validation in a marker and behaviour in methods where it helps. What I’d think harder about before copying is the retry model around the immutable spec. A failed action cannot be retried in place; a caller creates a new object. Finished actions are not forgotten entirely: the server deletes them after --action-ttl, seven days by default, while 0 keeps them forever. The remaining API-design question is who creates retries and what retention policy an operator needs before this contract becomes stable.