v0.7.16 — Per-Target OperatorBox, MuxReconciler, and controller-runtime Compatibility

7 min read

Per-target operatorBox

Each serve.target.entries entry can now declare its own operatorBox — resources, lifecycle hooks, and preReconcile gates (enqueueGate / reconcileGate). The reconciler selects the active box from serve-alias / serve-target annotations on the CR at reconcile time.

serve:
  target:
    entries:
      v2-enabled:
        operatorBox:
          enqueueGate: "{{ isBusinessHours }}"
          reconciler:
            hooks: true
      v2-ctor:
        operatorBox:
          reconciler:
            default: false   # use a dedicated constructor from ReconcilerRegistry

reconciler.default: false wires the target’s constructor from ReconcilerRegistry at load time. A missing registry entry is a load-time error — the Runtime refuses to start rather than falling back silently.

Surface switches are detected via orkestra.orkspace.io/last-surface and cleaned up with a label-selector sweep (SweepOwnedNamespaced / SweepOwnedClusterScoped) rather than template expansion, which is immune to spec fields being cleared before cleanup runs. keepPreviousSurface: true skips the sweep when set.

EffectiveOwnerKey stamps and checks ownership as <name>.<alias> for target-mode CRs, allowing per-surface resource isolation.


MuxReconciler and pkg/intent/target/

MuxReconciler holds one domain.Reconciler per registered target. At reconcile time the kordinator reads the target annotation and dispatches to the matching reconciler, falling back to the CRD-level reconciler when no target-specific one is registered. The kordinator sees one reconciler; the routing is internal.

Target resolution and CR construction move into a dedicated pkg/intent/target/ package, separating the intent layer from the gateway API package. target.go and its tests follow.


ork serve apply --override

Routing conflict detection (409) is still enforced when a target switch is attempted without an explicit override. Pass --override (or ?override=true on the API) to route to the new target and trigger surface cleanup of the old one.


Fixture: 03-hooks-targets

pkg/kubeclient/fixture/03-hooks-targets — three targets on one CRD proving each dispatch path end-to-end:

  • v2-enabled — hooks with an enqueueGate (business hours), featureEnabled: true
  • v2-disabled — same hook binary, featureEnabled: false, no gate
  • v2-ctorreconciler.default: false, dedicated constructor

Documentation

documentation/concepts/reusability/ — new section covering Reusability and Composition in Orkestra.


lifecycle: block — maturity, deprecation, compatibility, and platform policy

A new top-level lifecycle: block on every Katalog and Komposer file replaces the old metadata.deprecation: approach with a first-class lifecycle model.

lifecycle:
  maturity: beta          # alpha | beta | stable | deprecated

  deprecation:
    message: "Replaced by task-runner"
    migratedTo: task-runner:v1.0.0
    timeline:
      from: "2026-01-01"
      to:   "2027-01-01"

  compatibility:
    orkestra: ">= 0.7.0"
    kubernetes: ">= 1.28"

Maturity — four levels: alpha, beta, stable, deprecated. The presence of a deprecation: block is the primary signal; maturity: deprecated without a block emits a warning rather than an error. maturity: deprecated with a block is always valid.

Deprecation — a deprecated Katalog always blocks startup when run directly. Consumers acknowledge it via lifecycle.accept.patterns on their Komposer, not by a field on the Katalog itself.

Compatibility — declares minimum orkestra and kubernetes semver constraints. ork validate rejects patterns that declare versions below the installed runtime.

Kind boundarylifecycle.accept.patterns belongs on a Komposer. Declaring lifecycle.accept on a Katalog is a validation error.


Komposer-level lifecycle acceptance — lifecycle.accept.patterns

Komposers accept deprecated Katalogs at the point of composition:

lifecycle:
  accept:
    patterns:
      - name: webapp-operator
        version: ">= 1.0.0, < 2.0.0"   # optional semver range
      - name: cache-operator

version: scopes acceptance to a semver range. Acceptance without a range applies to any version of that pattern.


Platform policy — policy.lifecycle.minMaturity

Operators can declare a minimum maturity floor for all imported patterns:

policy:
  lifecycle:
    minMaturity: beta   # alpha | beta | stable; deprecated is rejected

ork validate rejects any Katalog whose maturity is below the declared floor. minMaturity: deprecated is itself a validation error — the policy is a quality floor, not a filter.

policy: is structured as policy.<area>.* so security, registry, and user-defined policy categories can grow alongside lifecycle: without flattening.


controller-runtime compatibility

kubeclient.Interface is now the single injection point for constructor-based reconcilers. It composes informer access, kube calls, event recording, and args — replacing the previous three-parameter constructor signature.

kubeclient.ToClient(kube) wraps kubeclient.Interface as a client.Client, so existing controller-runtime reconcilers plug in without any changes inside Reconcile. domain.ReconcilerFrom adapts the ctrl.Request signature. Two lines in a constructor replace SetupWithManager, Scheme, and main.go.

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

ork migrate defaults to --mode toclient — zero changes to Reconcile, constructor injected automatically.


watch: block

operatorBox.watch declares secondary Kubernetes resources the informer should watch. When a watched resource changes, Orkestra resolves the owning primary CR and enqueues it — no Go required. Supports per-entry event filters (on:) and enqueue gates.

operatorBox:
  watch:
    - apiVersion: v1
      kind: ConfigMap
      name: shared-config
      on: [update]
    - apiVersion: apps/v1
      kind: Deployment
      enqueueGate:
        sentinels: [generationChanged]

queue.retryBackoff: on operatorBox

operatorBox.reconciler.queue.retryBackoff declares the per-CRD backoff applied between failed reconcile attempts. Accepts a plain duration (shorthand for initial only) or the full form.

operatorBox:
  reconciler:
    queue:
      retryBackoff: 5s          # shorthand — initial: 5s, defaults for the rest

      # full form:
      retryBackoff:
        initial: 1s
        max: 30s
        multiplier: 2.0
        maxAttempts: 5

Breaking: anyOf:or: everywhere

anyOf: is renamed to or: across the entire schema. Semantics unchanged — reads naturally alongside when:: “when this AND this, or this OR this.”


Breaking: domain.Reconciler interface — key stringRequest / Result

The Reconcile method signature changed:

// before
Reconcile(ctx context.Context, key string) error

// after
Reconcile(ctx context.Context, req domain.Request) (domain.Result, error)

domain.Request carries req.Key (the old string) and req.NamespacedName for convenience. domain.Result.RequeueAfter lets a reconciler schedule a precise per-object re-enqueue after a successful reconcile — the kordinator calls queue.AddAfter when it is non-zero.

The domain.ReconcilerFrom bridge now forwards ctrl.Result.RequeueAfter from a wrapped reconcile.Reconciler, so migrated operators that returned ctrl.Result{RequeueAfter: time.Until(cert.Expiry)} have that timing honored without any code change.


failPolicy: on enqueueGate and reconcileGate

Controls what a gate does when it cannot evaluate its conditions — for example when an external: call fails or times out.

preReconcile:
  reconcileGate:
    failPolicy: closed   # hold back on evaluation failure
    external:
      - name: dep
        url: "{{ .spec.dependencyUrl }}/health"
    when:
      - field: external.dep.status
        equals: "200"
ValueBehaviour
open (default)Gate passes on failure — object is enqueued / reconciled as normal.
closedGate holds on failure — object is dropped from the queue / withheld from the reconciler.

The validator warns when external: is declared on a gate without an explicit failPolicy.


ToClient reads from the informer store

kubeclient.ToClient(kube) now serves client.Get and client.List from the informer store for any type that has a registered informer (the primary CRD and every watch: entry). Types without an informer fall through to a live API call with a debug log. This restores the cache-backed read behaviour that controller-runtime’s mgr.GetClient() provided before migration.

Declare a watch: entry for any secondary resource type the reconciler reads — the declaration registers both the re-enqueue watch and the cache.


requeue: — per-object requeue scheduling for declarative operators

Orkestra’s resync fires all objects on a uniform interval. requeue: adds per-object, per-state requeue timing — each object schedules its own next reconcile based on its own fields. This makes declarative operators precise where resync is blunt.

operatorBox:
  reconciler:
    requeue:
      after: "{{ timeUntil .status.certExpiry }}"   # per-object, from its own state
      when:
        - field: status.phase
          equals: "Active"

Evaluated after a successful reconcile only — errors go through queue.retryBackoff. after: takes a plain duration string or a template expression; the full post-reconcile resolver is available (.spec, .status, .children, .external).


Breaking: hooks.resources / constructor.resourcesmanagedResources

The resources: key under reconciler.hooks and reconciler.constructor is renamed to managedResources:. This aligns the YAML key with the Go method naming (AllManagedResources, HookManagedResources, ConstructorManagedResources) and removes the ambiguity between managed resources (RBAC + implicit informer) and the watch: block (secondary informers for re-enqueue).

Update all Katalog files:

# before
hooks:
  resources:
    - kind: Deployment

# after
hooks:
  managedResources:
    - kind: Deployment

Registry guide examples 13–16

Four new self-contained steps extend the registry guide:

  • 13-deprecation-accept — accept a deprecated Katalog via Komposer lifecycle.accept.patterns; scoped version acceptance
  • 14-lifecycle-maturity — maturity progression from alpha through stable; ork inspect output at each stage
  • 15-lifecycle-compatibility — declaring orkestra and kubernetes version constraints; validation rejection behaviour
  • 16-komposer-acceptlifecycle.accept.patterns on a Komposer composing both a deprecated and an alpha Katalog; scoped version: range