Projects · Dockerios

Building with the Operator SDK

The concrete build: scaffolding a multi-group Operator SDK project, the resulting layout, registering the KubeVirt and CDI schemes, controller anatomy with finalizers and indexes, RBAC markers, admission webhooks, a three-tier test strategy that accounts for macOS being untestable in CI, and packaging as an OLM bundle.

Updated Aug 22, 2026 · 11 min read

Building with the Operator SDK

This scaffolding is the DockerIOS repo

Domain dockerios.io, module github.com/boualleiguie/dockerios-operator. MacRack is scaffolded separately with domain macrack.io. CI on each repo fails if it imports the other. The MacRack commands live in Building and operating MacRack.

Everything up to here was design. This page is the part somebody can execute, so it is deliberately command-heavy and I have tried to include the details that cost a day when they are missing rather than the ones that are obvious from the tutorial.

1. Toolchain

ToolWhy
operator-sdkScaffolding, bundle generation, OLM interaction, scorecard
Go, matching the SDK release's supported versionThe scaffolded go.mod pins a toolchain. Fighting it is not worth the time
controller-genPulled in by the Makefile. Generates CRDs, RBAC, and deepcopy from markers
kustomizeAlso Makefile-managed. Assembles the deployment manifests
setup-envtestDownloads API server and etcd binaries for integration tests
A cluster with KubeVirt and CDIAnything past unit tests. Section 9 explains why this cannot be faked

2. Scaffolding

The order matters in one place, marked below.

The one ordering mistake that costs real time

operator-sdk edit --multigroup=true only changes how future scaffolding is laid out. It does not move APIs or controllers that already exist.

Create one API first and you are hand-migrating api/v1alpha1 into api/machine/v1alpha1, moving controllers, updating the path field for every resource in the PROJECT file, and fixing every import. It is entirely mechanical and entirely avoidable.

Init, then multigroup, then APIs. Two commands in the right order.

Now the resources. Note --namespaced=false on everything cluster-scoped, matching the scoping decisions from the API page.

Then the webhooks. Defaulting matters more than usual here, because a class with a sensible default set is the difference between a usable API and one where every field must be spelled out.

Why MacOSMachineClass gets a resource but no controller

A class is pure configuration. Nothing reconciles it, because nothing happens when one is created: it only has meaning when a machine references it.

So it needs generated types, a CRD, and a validating webhook, and no Reconcile function. Scaffolding a controller for it would produce an empty loop that logs and requeues forever, which is noise in the metrics and confusion for the next reader.

The webhook is doing real work though. It is where I reject the incoherent combinations: dedicated: true with no hugepages, a GPU class without a matching node selector, a system disk on a storage class with no clone support, sockets greater than one.

3. The resulting layout

The four packages under internal/ that the SDK does not scaffold are where I would put the actual thinking. Rendering a VirtualMachine from a class is the most intricate logic in the project and it should be a pure function that a table test can hammer, not something buried inside a reconcile loop.

4. Registering external schemes

The operator reads and writes KubeVirt and CDI objects, so their types have to be in the manager's scheme. This is a small step that produces a confusing runtime error when it is missed.

A practical dependency warning

kubevirt.io/api pulls a substantial transitive dependency graph, and it is opinionated about the Kubernetes library versions it wants. Adding it to a freshly scaffolded project is frequently the first real go.mod fight.

I would pin the KubeVirt and CDI API modules to versions matching the KubeVirt actually deployed, and treat bumping them as a deliberate change with a test run rather than something a dependency bot does on a Tuesday. The operator's compatibility with the cluster is expressed entirely through these two module versions.

5. Controller anatomy

The machine controller is the one worth showing, because its shape follows directly from the phase machine and from the requirement that a restarted operator can recover.

Each ensure function is idempotent and answers one question: does the object exist, does it match what the class says it should be, and is it ready. That structure is what makes the whole thing safe to interrupt.

Wiring it up, with the parts that are easy to leave out:

Why the concurrency setting is not the default

Provisioning a macOS machine involves a build Job, a volume clone, and a VM boot. Those are minutes long, and a single-threaded reconciler would serialise a burst of claims into a queue that drains at one machine at a time.

The reconcile loop itself is fast, since it only observes and requeues, but the requeue traffic is heavy. Five concurrent workers is a starting point I would tune against real queue depth metrics rather than a number I am confident in.

6. RBAC

Generated from markers, which means the deployed role is always consistent with what the code actually does. This is my favourite thing about the Kubebuilder model and worth using carefully rather than pasting a wildcard.

The subresources.kubevirt.io line is easy to miss and produces a puzzling failure: the operator can create and delete VMs but cannot gracefully stop one, so graceful shutdown silently degrades into deletion. That is exactly the APFS-corrupting behaviour the design was meant to eliminate, reintroduced by a missing RBAC rule.

7. Testing, and being honest about its limits

This is where I want to set expectations carefully, because the most important behaviour in the system cannot be tested in CI.

TierRuns whereWhat it proves
UnitAnywhere, no clusterTemplate rendering. Given a class and an identity, is the generated VirtualMachine exactly right? Table-driven, fast, and where most bugs will actually be caught
Integration, envtestCI, no kubeletReconcile behaviour against a real API server. Phase transitions, finalizer ordering, identity allocation and release, garbage collection of owned objects
End to endA real cluster with KubeVirt and CDIThat a VM object is actually accepted, scheduled, and started, and that clones and stops work
ManualPrepared hardwareThat macOS boots, the agent reports, and APFS is clean after a stop. Not automatable

envtest gives a real API server without a kubelet, which is perfect here: I want to assert that the operator produced the right VirtualMachine, not that QEMU ran. The trick is that envtest needs to know about KubeVirt's types, which means vendoring their CRDs:

The test I care most about, and the one I cannot write

The behaviour with the highest consequence is: after a graceful stop, is the APFS volume clean? That is the correctness bug from section 01 that this whole design exists to fix.

It cannot be tested in CI. It needs a real macOS guest on real prepared hardware, and the assertion involves booting again and checking the filesystem.

So it becomes a documented manual gate before any release that touches the lifecycle path. I would rather have one honest manual test in the release checklist than a mock that asserts my own code called a function I wrote. Mocking KubeVirt here would test the mock.

Practical envtest notes: reconcile results are eventually consistent, so assertions want Eventually with real timeouts rather than sleeps. And every test needs its own namespace, because a leaked finalizer in one test will hang teardown for all of them in a way that is genuinely hard to diagnose.

8. Local development

Running out of cluster with webhooks disabled is the fast loop. Webhooks need a serving certificate and a reachable service, which is awkward from a laptop, so defaulting and validation get exercised in envtest and on a real cluster instead.

The cost of that shortcut is real and worth naming: since defaulting is skipped locally, an object created during local development has no defaults applied, and behaviour can differ from the cluster. When something works locally and not deployed, missing defaults is the first thing I would check.

9. Packaging

This is what the Operator SDK adds over plain Kubebuilder, and the reason I chose it.

The generated ClusterServiceVersion is worth editing by hand rather than shipping as scaffolded. Three things in it matter:

alm-examples becomes the example CR set users see first, so it should contain a realistic class, a pool, and a claim rather than empty stubs. The required API list should declare KubeVirt and CDI, which makes OLM refuse to install onto a cluster that cannot support the operator, turning my longest prerequisite list into an admission check. And the install mode should be AllNamespaces, since cluster-scoped resources are half the API.

Why the upgrade story is the reason for all this ceremony

Bundles and OLM are more machinery than a Helm chart, and for a stateless controller I would not bother.

Here, an upgrade happens while machines exist. They hold identities, cloned volumes, exclusive GPUs, and possibly a running build. An upgrade that mishandles a CRD schema change or drops a finalizer does not fail cleanly; it strands expensive stateful resources.

OLM gives me versioned upgrade paths, dependency declarations, and a rollback story, and those are worth real ceremony when the blast radius is a fleet of Macs mid-build.

There is also operator-sdk scorecard ./bundle, which checks basic hygiene like whether CRDs have descriptions and status subresources. It catches nothing profound, it costs nothing in CI, and it does catch the small omissions that make an API unpleasant.

10. CI

The generate-check job is the one I would add first. Generated RBAC drifting from the markers in the code produces permission failures at runtime that look nothing like their cause.

11. The order I would build it

Vertical slices, each one demonstrable, because a horizontal approach means nothing works until everything does.

StepDeliverableWhy here
0One macOS VM booting as a hand-written KubeVirt VirtualMachine, with the domain hook injecting the SMCNo operator code at all. This validates the entire substrate decision, and if the hook does not hold, everything after it is wasted
1Scaffold, MacOSImage plus its controller. A golden image imports through CDISimplest real controller, and every machine depends on it
2MacOSMachineClass and its webhook, plus the KubeVirt template renderer with unit testsPure functions, no cluster state. Gets the hardest logic right while it is still cheap to change
3MacOSMachine using a shared containerDisk bootdisk, so no identity is involved yetA full working machine while skipping the most complex subsystem. This is the first genuinely useful milestone
4The guest agent and real readiness conditionsTurns a VM launcher into something that knows its own state
5IdentityPool, MachineIdentity, the builder Job, allocation and releaseNow the stateful part, with everything around it already working and observable
6MacOSMachinePool and MacOSMachineClaim with warm pools and TTLThe consumer-facing API, designed once real machine behaviour is understood
7Bundle, OLM packaging, preflight checks, metricsMaking it installable and operable by someone who is not me
8GPU support as an additional class dimensionLast, deliberately. It affects the fewest workloads and costs the most per node
Why step 3 skips identity, which is the whole point of the ordering

Identity allocation is the most interesting subsystem, so it is the one I most want to build first, and that would be a mistake.

By using a shared containerDisk bootdisk at step 3, I get a complete working machine, end to end, without touching identity at all. That means when I do build the identity controller at step 5, I am debugging one new thing against a stack that already provably works.

Building the hardest part first means every bug is ambiguous. It also means step 3 is genuinely shippable for CI runners, which never needed per-machine identity in the first place, so the most complex subsystem turns out not to block the primary use case.

12. What is still open

Honestly, so the next person is not surprised.

The domain hook needs to be written and proven against a real macOS boot before anything else has value, and step 0 exists for exactly that reason. The guest agent's protocol is unspecified beyond the status shape on the API page. Multi-tenancy is unaddressed: quota per namespace, whether classes should be restrictable to particular tenants, and how identities are accounted for. And the workload layer is a sketch, which is intentional, since it should be designed against a machine layer that exists rather than one I imagine.

Where this section ends

Section 01 explained Docker-OSX. Section 02 established which constraints have supported answers and which eight do not. This section specified the cluster, the API, and the build.

That is enough to start writing code, which was the whole point. What it is not is proof that the design is right: step 0 is where that gets tested, and I would expect the API to change once real machines have run on it.