Migrating from controller-runtime

3 min read

You have a working Kubernetes operator. The question is not whether controller-runtime works — it does. The question is what it costs: informers, workqueues, worker pools, leader election, status, finalizers, events, metrics — written from scratch for every CRD.

Orkestra removes the machinery. You keep the business logic.


The migration pack

The from-controller-runtime pack shows the same WebApp operator expressed five ways so you can see what you are choosing between:

ork init --pack from-controller-runtime
OptionGo requiredWhat you own
00 — controller-runtime baselineYes — fullEverything: informers, manager, scheme, main.go
01 — declarativeNoNothing — pure YAML
02 — hybridYes — hook onlyThe 10% templates can’t express
03 — hooks onlyYes — all resourcesAll child resource specs in Go
04 — constructor: minimal migrationYes — full reconcilerReconcile logic; two lines added, nothing changed
05 — constructor: Orkestra resourcesYes — full reconcilerReconcile logic; resource ops simplified

Start at 00 and follow the READMEs — each step removes one layer of machinery.


The mechanical path: ork migrate

ork migrate automates option 04. It takes your reconciler file, makes the minimum change needed to run inside Orkestra, and generates the scaffolding:

ork migrate ./controller/webapp_controller.go -o ./my-operator

Output:

my-operator/
  webapp_controller.go   SetupWithManager removed, constructor injected — Reconcile untouched
  katalog.yaml           constructor Katalog stub — fill in group, kind, plural
  simulate.yaml          simulation stub — fill in expected resources
  e2e.yaml               end-to-end stub — fill in CR assertions
  go.mod                 module file with Orkestra pinned to this CLI version

What the rewrite does

SetupWithManager is removed and replaced with a comment. Before removal, ork migrate scans it to extract For(), Owns(), and Watches() — those become apiTypes, managedResources:, and watch: entries in the generated katalog.yaml.

A constructor is injected — two lines:

func NewWebAppReconciler(kube kubeclient.Interface) domain.Reconciler {
    return domain.ReconcilerFrom(&WebAppReconciler{
        client: kubeclient.ToClient(kube),
    })
}

kubeclient.ToClient returns a client.Client — the same type your struct field already holds. domain.ReconcilerFrom adapts the ctrl.Request signature to Orkestra’s interface. Your Reconcile method body is completely untouched.

ctrl.Result{RequeueAfter: X} is preserved — the bridge forwards it to Orkestra’s workqueue. No changes needed.

Imports are injecteddomain and kubeclient.

What still needs manual review

grep -rn "TODO(ork migrate)" ./my-operator/
  • Set group, kind, plural, location in katalog.yaml
  • Review managedResources: entries — add any resource kinds the operator manages that were not detected from Owns()
  • Fill in resource assertions in simulate.yaml and e2e.yaml
  • Delete main.go, scheme registration, and manager setup

Option 05: Orkestra resources

After completing option 04, you can go one step further and replace the manual Get / IsNotFound / Create / Patch pattern with pkg/resources:

// Before — manual (option 04)
existing := &appsv1.Deployment{}
err := r.client.Get(ctx, client.ObjectKey{Namespace: namespace, Name: name}, existing)
if errors.IsNotFound(err) { return r.client.Create(ctx, desired) }
patch := client.MergeFrom(existing.DeepCopy())
existing.Spec = desired.Spec
return r.client.Patch(ctx, existing, patch)

// After — Orkestra resources (option 05)
return orkdeploy.Update(ctx, r.kube, webapp, spec)

Update handles create-if-absent, drift correction, owner references, and system labels. DeleteIfOwned removes a resource only if this CR owns it.


Where to go next