Projects · Dockerios

The MacRack API

MacRack's CRDs, which are not versions of DockerIOS types. Four groups around a leased chassis: rack, instance, image, and config, with the workload group left empty on purpose. Specs, status, ownership, phases, and the webhook rules that make the illegal states unrepresentable.

Updated Aug 22, 2026 · 5 min read

The MacRack API

These types live in macrack.io. They are not v2 of anything in dockerios.io. If a field looks familiar, that is because the problem is familiar, not because the packages are shared.

1. Four groups now, one later

Preparing diagram
Why the chassis is its own group

DockerIOS's primary object was a virtual machine. MacRack's primary object is a leased Mac mini. That shift is the whole product.

If I had put MacNodeLease in the instance group, every future feature would start from the VM and treat the hardware as a scheduling detail. Starting from the rack group forces every design conversation through "who owns this chassis, and is the floor elapsed".

The workload group is still empty. Same rule as DockerIOS: a GitLab runner holds a lease and creates instances. It never learns what Virtualization.framework is.

2. MacNodeClass and MacNode

Admin-owned hardware catalogue.

Quarantined is the phase for a Mac whose kernel quota is stuck, or whose agent cannot reattach to running VMs. The operator does not schedule onto it. An admin (or a controller) may reboot it. I would rather a node sit empty than hand a tenant a chassis that will refuse the second VM.

3. MacNodeLease, the tenant-facing resource that matters

This is what a customer creates. Everything else is a consequence.

Phases:

Preparing diagram
There is no SharedLease, and there is no spec.tenants

I am not going to list the fields I omitted unless they are the ones people will try to add.

A lease has one namespace. A node has at most one leaseRef. The webhook rejects a second lease targeting an already-bound serial, from any namespace. There is no way to express "Customer A and Customer B, same Mac".

If a future version needs an internal-only mode where a company runs its own CI on its own Macs without commercial tenancy, that is a different install, a different admission profile, and a conversation with counsel. It is not a field on this object.

4. InstanceClass and MacOSInstance

The field that is not on this object

There is no nodeName in spec. Placement is: this instance belongs to this lease, and the lease is already bound to one serial.

That removes the entire class of bugs where an instance lands on a Mac the tenant does not hold. The scheduler is still involved, because the MacOSInstance is backed by a placeholder pod that requests macrack.io/macos-vm: 1 against the leased node, which is how rule 1 stays in the native scheduler rather than in my own placement loop.

I use the scheduler for counting. I use the lease for tenancy. Neither is asked to do the other's job.

Admission rules on MacOSInstance, all webhook, all refuse-don't-fix:

RuleRejects
Lease exists, is Bound or Held, and is in this namespaceCross-namespace lease references, expired leases, Pending leases
Count of non-terminal instances on that lease is already 2A third create
Resource request is exactly one macrack.io/macos-vmAnyone asking for two on a single instance object
Class fits the leased node's classA 64 GiB instance on a 16 GiB mini

Held leases still accept instance creates. If a tenant deleted the lease at +3 hours and then realises they still need a VM at +10 hours, the chassis is still theirs. At +24 hours it is not.

5. MacOSImage

A MacOSImage is one macOS version plus the Xcode that belongs on it, plus the guest agent. That is a catalogue rule, not a suggestion. GitLab Runner and anything tenant-specific is not a field here, because it is not in the file.

Images are pulled onto a mini by the agent when a lease actually needs that OS, not pre-sprayed onto every chassis. replicatedOn is the cache: if the serial is already listed, skip the download. If the next tenant wants a different MacOSImage and the 1 TB is tight, the agent evicts the unused golden, then pulls. Instance disks are not this object and always die with the lease.

The webhook rejects a spec that tries to encode a runner, a second Xcode, or a tenant name into contents. New OS or new Xcode is a new MacOSImage name. Immutable once Ready.

6. Ownership

Preparing diagram

The lease owns the instances, so deleting a lease (once it is allowed to complete) garbage-collects the VMs. The node is cluster-scoped and is not owned by a namespaced lease, so the two-way leaseRef plus a finalizer is the same pattern as DockerIOS identity, for the same Kubernetes scoping reason.

Finalizer order on the lease: stop instances, wait for the agent to report them stopped, wait until earliestReleaseAt, clear the node taint and leaseRef, then remove the finalizer. Skipping the clock is how this operator would fail its own product test.

7. Printer columns

Held in the phase column, with a ReleaseAt in the future, is the support team's entire 24-hour conversation.

8. Workload layer, still a sketch, now on the right product

That resource creates two leases and up to four instances. It picks a golden that is macOS plus Xcode, then installs and registers GitLab Runner inside the clone. It never asks for a special "image with the runner baked in", never sets a node name, never mentions Virtualization.framework, and cannot ask for three VMs on a chassis because instancesPerChassis is webhook-capped at 2.

The decoupling test, restated for two products

A GitLab runner implemented against MacRack must not import DockerIOS types. A GitLab runner implemented against DockerIOS, if anyone ever does that for a lab, must not import MacRack types.

They will look similar. They will both want a machine and a registration token. They are still two resources, in two groups, in two repositories. Similarity is not a reason to start a shared github.com/dockerios/workload module. That module is how the public binary acquires a KubeVirt dependency it must not have.