Where's my client?
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:
- Trigger. A change to the CR, something it owns, something it
observes, or a resync enqueues it — filtered byon:andenqueueGate, mapped to the right CR bykeyFrom. - Gate.
preReconcilegates andwhen:decide whether this pass runs. - Gather. Owned resources (
.children), other CRDs (.cross), external calls (.external) and enrichment are read. - Render.
reconcile.argsare rendered against all of it. - Reconcile. Your code: a
PreparedRequestin, aResultout. - 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.
What you’d use the client for
| In controller-runtime | In Inrun |
|---|---|
r.Get the CR into its Go type | req.Args().Bind(&in) into your own type; req.Object() for the CR itself |
| Map spec fields to what the logic needs | reconcile.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 own | observe.watch: any kind, by namespace or name, with on:, enqueueGate and keyFrom |
| React to Events | observe.events: by reason, action, type or reporting controller |
| Read another CRD’s CRs | observe.cross: .cross.<as>.status.* |
| Call something outside Kubernetes | reconcile.external: .external.<name>.* |
CreateOrUpdate, owner references | Result.Resources, full or intent form ({"type": "deployment", "fields": {...}}) |
Status().Update / Patch | Result.Status |
| Patch the CR’s own labels or annotations | Result.Labels, Result.Annotations: a key dropped from a later Result is removed |
r.Delete the CR when it expires or is done | Result.Cleanup: deleted after its grace period; a later Result without it withdraws the request |
DeletionTimestamp, finalizers | req.Finalizing(); Inrun holds its own finalizer |
RequeueAfter | Result.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.