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.
Building with the Operator SDK
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
| Tool | Why |
|---|---|
operator-sdk | Scaffolding, bundle generation, OLM interaction, scorecard |
| Go, matching the SDK release's supported version | The scaffolded go.mod pins a toolchain. Fighting it is not worth the time |
controller-gen | Pulled in by the Makefile. Generates CRDs, RBAC, and deepcopy from markers |
kustomize | Also Makefile-managed. Assembles the deployment manifests |
setup-envtest | Downloads API server and etcd binaries for integration tests |
| A cluster with KubeVirt and CDI | Anything past unit tests. Section 9 explains why this cannot be faked |
2. Scaffolding
The order matters in one place, marked below.
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.
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.
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.
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:
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.
| Tier | Runs where | What it proves |
|---|---|---|
| Unit | Anywhere, no cluster | Template 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, envtest | CI, no kubelet | Reconcile behaviour against a real API server. Phase transitions, finalizer ordering, identity allocation and release, garbage collection of owned objects |
| End to end | A real cluster with KubeVirt and CDI | That a VM object is actually accepted, scheduled, and started, and that clones and stops work |
| Manual | Prepared hardware | That 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 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.
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.
| Step | Deliverable | Why here |
|---|---|---|
| 0 | One macOS VM booting as a hand-written KubeVirt VirtualMachine, with the domain hook injecting the SMC | No operator code at all. This validates the entire substrate decision, and if the hook does not hold, everything after it is wasted |
| 1 | Scaffold, MacOSImage plus its controller. A golden image imports through CDI | Simplest real controller, and every machine depends on it |
| 2 | MacOSMachineClass and its webhook, plus the KubeVirt template renderer with unit tests | Pure functions, no cluster state. Gets the hardest logic right while it is still cheap to change |
| 3 | MacOSMachine using a shared containerDisk bootdisk, so no identity is involved yet | A full working machine while skipping the most complex subsystem. This is the first genuinely useful milestone |
| 4 | The guest agent and real readiness conditions | Turns a VM launcher into something that knows its own state |
| 5 | IdentityPool, MachineIdentity, the builder Job, allocation and release | Now the stateful part, with everything around it already working and observable |
| 6 | MacOSMachinePool and MacOSMachineClaim with warm pools and TTL | The consumer-facing API, designed once real machine behaviour is understood |
| 7 | Bundle, OLM packaging, preflight checks, metrics | Making it installable and operable by someone who is not me |
| 8 | GPU support as an additional class dimension | Last, deliberately. It affects the fewest workloads and costs the most per node |
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.
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.