Where's my client?

4 min read

In controller-runtime a reconciler holds a client: it Gets the CR, Gets what it owns, watches what it doesn’t, and Creates, Updates and Status().Updates its way to the desired state. An Inrun reconciler has no client to reach for, and needs none. The runtime does every read and every write; the reconciler gets a prepared request and returns a result.

Before and after you run

Everything around Reconcile is the runtime’s, in this order:

  1. Trigger. A change to the CR, something it owns, something it observes, or a resync enqueues it — filtered by on: and enqueueGate, mapped to the right CR by keyFrom.
  2. Gate. preReconcile gates and when: decide whether this pass runs.
  3. Gather. Owned resources (.children), other CRDs (.cross), external calls (.external) and enrichment are read.
  4. Render. reconcile.args are rendered against all of it.
  5. Reconcile. Your code: a PreparedRequest in, a Result out.
  6. Apply. The Result’s resources are built and server-side applied with the CR as owner, pruned when dropped, the status merged, labels and annotations set, the requeue or cleanup scheduled.

So the reconciler is the one step that decides. It may do its own business I/O — call a feature-flag service, a billing API — but never cluster I/O.

Reconciler decides, Inrun writes
Every read from and write to the cluster is the runtime’s. Your reconciler gets values in and returns what it wants out, so it never needs a client.

What you’d use the client for

In controller-runtimeIn Inrun
r.Get the CR into its Go typereq.Args().Bind(&in) into your own type; req.Object() for the CR itself
Map spec fields to what the logic needsreconcile.args: templates over the CR, in your names
r.Get a child for its state.children.* in args, and functions like allReplicasReady
Owns(&appsv1.Deployment{})reconcile.managedResources
Watches(...) something you don’t ownobserve.watch: any kind, by namespace or name, with on:, enqueueGate and keyFrom
React to Eventsobserve.events: by reason, action, type or reporting controller
Read another CRD’s CRsobserve.cross: .cross.<as>.status.*
Call something outside Kubernetesreconcile.external: .external.<name>.*
CreateOrUpdate, owner referencesResult.Resources, full or intent form ({"type": "deployment", "fields": {...}})
Status().Update / PatchResult.Status
Patch the CR’s own labels or annotationsResult.Labels, Result.Annotations: a key dropped from a later Result is removed
r.Delete the CR when it expires or is doneResult.Cleanup: deleted after its grace period; a later Result without it withdraws the request
DeletionTimestamp, finalizersreq.Finalizing(); Inrun holds its own finalizer
RequeueAfterResult.RequeueAfter, or reconcile.resync

A reconciler without a client

02-constructor is a complete Go operator that never talks to Kubernetes. The Blueprint maps the spec into args:

reconcile:
  default: false
  managedResources:
    - kind: Deployment
  args:
    image: '{{ .spec.image }}'
    replicas: '{{ .spec.replicas }}'
    flagUrl: '{{ .spec.serviceUrl }}/flags/{{ .metadata.name }}/v2Enabled'
  constructor:
    location: github.com/intentd/inrun-args-constructor/constructor
    function: NewBlockchainNodeReconciler

The reconciler binds them into its own type, decides, and answers with what it wants:

type nodeArgs struct {
	Image    string `json:"image"`
	Replicas int32  `json:"replicas"`
	FlagURL  string `json:"flagUrl"`
}

func (r *BlockchainNodeReconciler) Reconcile(ctx context.Context, req domain.Request) (domain.Result, error) {
	if req.Finalizing() {
		return domain.Result{}, nil // the Deployment is owned by the CR
	}
	var in nodeArgs
	if err := req.Args().Bind(&in); err != nil {
		return domain.Result{}, err
	}
	enabled := "false"
	if r.checkFlag(ctx, in.FlagURL) {
		enabled = "true"
	}
	return domain.Result{
		Resources: []map[string]interface{}{{
			"type": "deployment",
			"fields": map[string]interface{}{
				"name": req.Name(), "image": in.Image, "replicas": in.Replicas,
				"annotations": map[string]interface{}{"feature.demo/v2-enabled": enabled},
			},
		}},
		Status: map[string]interface{}{"phase": "Running", "featureEnabled": enabled},
	}, nil
}

The input type is the reconciler’s, not the CRD’s, so no generated Go type, deepcopy or scheme has to track the CRD — and the CRD is free to change under it. Move a field to a new path, or move every CR to a brand-new CRD with migration, and only the Blueprint’s args mapping follows; the reconciler is not rebuilt, and callers keep sending the same intents. And because the contract is values in, values out, the same logic works as a remote reconciler in any language — or under inrun simulate, with no cluster.

When you do want it

A constructor is still handed a kubeclient.Interface, for what nobody has declared yet. It is an escape hatch, not the path: reaching for it means a read or a write the runtime can’t see, test or prune.