Curtis' Blog

GitOps-Native Talos Clusters on Omni

Curtis Goolsby

If you run self-hosted Omni, you already have a declarative control plane for Talos. But there’s a gap that bites the moment you go all-in on GitOps: there’s no Kubernetes-native way to say “here’s the cluster I want” in Git and have it provisioned through Omni and have its kubeconfig land where Flux can use it. You click through the UI, or you write glue. Neither is reproducible from a repo.

As far as I can find, nobody has published an operator that closes this loop. Omni has its own templates; Flux and Argo can target Omni clusters once they exist. But wrapping Omni provisioning in a custom resource — that didn’t exist. So I wrote omni-gitops-controller.

One CRD, reconciled into Omni

You declare a cluster in Git and Flux applies it to a small management cluster that runs the controller:

apiVersion: omni.gitops.dev/v1alpha1
kind: OmniCluster
metadata:
  name: my-cluster
  namespace: omni-gitops-system
spec:
  kubernetesVersion: "1.31.0"
  talosVersion: "v1.9.0"
  controlPlane:
    replicas: 3
    machineSelector:                      # matched against Omni machine labels
      matchLabels: { omni.sidero.dev/mem: "65536" }   # 64 GiB boxes
  workers:
    - name: general
      replicas: 3
      machineSelector:
        matchLabels: { omni.sidero.dev/mem: "32768" }

From there the controller does the rest:

graph LR
    G[Git Repo] -->|Flux| CR[OmniCluster CR]
    CR --> C[omni-gitops-controller]
    C -->|COSI gRPC| O[Omni: Cluster / MachineSet / ConfigPatch]
    C -->|writes| S[flux-system/<name>-kubeconfig Secret]
    O --> M[Talos machines provision & join]
    S -->|kubeConfig.secretRef| F[Flux targets the child cluster]

It calls Omni’s native COSI gRPC API to create the cluster, allocates machines from Omni’s inventory by label selector, then writes the kubeconfig as a Secret into flux-system. Flux picks it up with a Kustomization and starts deploying workloads. No manual steps, no out-of-band credentials.

The controller keeps no state of its own — Omni stays authoritative, desired state lives in the CR, runtime state in status. Kill the pod and it just resumes.

Machines are Pods

The shape will look familiar: a top-level OmniCluster orchestrator creates, updates, and deletes first-class Machine objects, each reconciled on its own. That’s Deployment → ReplicaSet → Pod, applied to bare metal. Each Machine is named for its Omni UUID and owned by its cluster, so status changes flow back up through a .Owns watch — event-driven, no polling — and machines are garbage-collected on delete.

That split buys the thing I actually cared about: safe rolling reboots. One reboot in flight cluster-wide, per-machine cooldown, quorum-safe control-plane scale-down — and it needs almost no state. The orchestrator stamps a RebootRequestedAt; the machine stamps LastRebootTime after the RPC returns. A reboot is “owed” iff request is newer than last. Two timestamps, one comparison, no flag to reset.

Why not Cluster API?

Because CAPI-with-Talos uses cluster-api-provider-talos, which drives talosctl directly and bypasses Omni — so Omni stops being the source of truth. I wanted the opposite: Omni stays authoritative and this is a thin orchestration layer speaking its protocol. One binary, one CRD, one helm install. If you need portability across clouds, use CAPI. If Omni is your platform, this is a much smaller thing to run.

It’s Apache-2.0, cosign-signed with an SBOM, installable via Helm or Kustomize: cgoolsby/omni-gitops-controller. If you’re running self-hosted Omni with GitOps, I’d like to hear how it fits.