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: lift and changeYes — full reconcilerReconcile logic; manager removed
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, rewrites the signature, and generates the scaffolding:

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

Output:

my-operator/
  webapp_controller.go   rewritten — signature changed, ctrl.Result collapsed
  katalog.yaml           constructor Katalog stub — fill in group, kind, location
  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

Signature change — the only mechanical step:

// Before
func (r *WebAppReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error)

// After
func (r *WebAppReconciler) Reconcile(ctx context.Context, key string) error

key is namespace/name — the same as req.String(). Orkestra calls this from its worker pool.

Return values collapsed:

return ctrl.Result{}, err   →   return err
return ctrl.Result{}, nilreturn nil

Struct and constructor rewritten:

The embedded client.Client is replaced with Orkestra’s interfaces, and a constructor function is generated:

// Before
type WebAppReconciler struct {
    client.Client
    Scheme *runtime.Scheme
}

// After
type WebAppReconciler struct {
    informer cache.SharedIndexInformer
    kube     kubeclient.KubeClient
    ev       event.Recorder
}

func NewWebAppReconciler(
    kube kubeclient.KubeClient,
    informer cache.SharedIndexInformer,
    ev event.Recorder,
) domain.Reconciler {
    return &WebAppReconciler{kube: kube, informer: informer, ev: ev}
}

SetupWithManager removed:

// Replaced with a comment:
// SetupWithManager removed — Orkestra provides the informer, workqueue,
// worker pool, leader election, panic recovery, and metrics.

What still needs manual review:

  • r.Get, r.Create, r.Patch in sub-methods — change to r.kube.* calls
  • r.Status().Update() — flagged // TODO(ork migrate): — replace with r.kube.PatchStatus()
  • ctrl.Result{RequeueAfter: X} — flagged — Orkestra uses exponential backoff; return an error to retry
  • Fill in group, kind, location in katalog.yaml
  • Delete main.go, scheme registration, and manager setup

Search after migration:

grep -rn "TODO(ork migrate)" ./my-operator/

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.kube.Get(ctx, namespace, name, existing)
if errors.IsNotFound(err) { return r.kube.Create(ctx, desired) }
patch := sigs.MergeFrom(existing.DeepCopy())
existing.Spec = desired.Spec
return r.kube.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.