Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 58 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
## v0.7.16 — Per-Target OperatorBox and MuxReconciler
## v0.7.16 — Per-Target OperatorBox, MuxReconciler, and controller-runtime Compatibility [UNRELEASED]

### Per-target operatorBox

Expand Down Expand Up @@ -120,6 +120,63 @@ policy:

---

### 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`.

```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.

```yaml
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.

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

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

---

### Registry guide examples 13–16

Four new self-contained steps extend the registry guide:
Expand Down
83 changes: 52 additions & 31 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,12 @@
<img src="./documentation/assets/logo.png" alt="Orkestra" height="96" />

<h1>Orkestra</h1>
<p><strong>A runtime for Kubernetes operators.</strong></p>
<h3><em>Declare. Run.</em></h3>
<p><strong>Kubernetes operators without the infrastructure.</strong></p>
<p>
Reconciliation as a runtime service.<br/>
Security as a runtime service.<br/>
Intent Delivery as a runtime service.
</p>

<p>
<a href="https://github.com/orkspace/orkestra/releases"><img src="https://img.shields.io/github/v/release/orkspace/orkestra" alt="Release" /></a>
Expand All @@ -23,17 +27,43 @@

---

You have a **CRD**. Kubernetes stores it, validates it, and serves it.
Every Kubernetes operator carries three kinds of infrastructure no one wanted to build:

The only missing piece is something that **watches** it and **acts** on it.
- **Reconciliation infrastructure** — informers, workqueues, worker pools, leader election, retries, backoff, finalizers, status patching, panic recovery
- **Security infrastructure** — admission webhooks, validation rules, mutation rules, RBAC generation, TLS management
- **Intent delivery infrastructure** — CR construction, caller interfaces, field routing, value translation, schema evolution

Traditionally that means **Go**: informers, workqueues, reconcile loops, code generation, Dockerfiles, Helm charts — a software project per operator. Most engineers never start. Teams that do spend weeks before the first CR reconciles.
None of this is the reason the operator exists. All of it is the cost of entry.

**Orkestra removes that entirely.**
Orkestra absorbs all three. You declare behavior — or keep your existing `Reconcile` function — and the runtime handles the rest.

---

## Declare
## If you already have a controller-runtime operator

Two lines. Your `Reconcile` method is completely untouched.

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

Remove `SetupWithManager`, `Scheme`, and `main.go`. Orkestra provides the informer, workqueue, worker pool, leader election, panic recovery, metrics, retries, health endpoints, and admission webhooks. Or run `ork migrate` to have the constructor injected automatically:

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

→ [Migration Guide](https://orkestra.sh/docs/guides/migration) · [ork migrate reference](https://orkestra.sh/docs/reference/cli/migrate)

---

## If you are starting from scratch

No Go required. Declare what the operator should do:

```yaml
apiVersion: orkestra.orkspace.io/v1
Expand All @@ -59,20 +89,14 @@ spec:
reconcile: true
```

That is the whole operator.

## Run

```console
```bash
ork run
```

Orkestra reads the Katalog, applies the CRD and CR, starts the operator, creates the Deployment and Service, sets owner references on both, writes status, emits Kubernetes events, corrects drift, and exposes health, metrics, and a control center.
Orkestra reads the Katalog, installs the CRD, starts the operator, creates the Deployment and Service, sets owner references, writes status, emits events, corrects drift, and exposes health, metrics, and a control center.

Not a single line of Go.

*Your CRD is enough. The rest is just a Katalog.*

---

## What every CRD gets
Expand All @@ -92,9 +116,11 @@ Every CRD declared in a Katalog becomes a complete, isolated operator. Nothing t
| **Status** | `Ready` condition + your own status fields written after every reconcile. |
| **Health API** | `/katalog/{crd}/health`, `/katalog/{crd}/cr`, `/metrics` — per CRD. |
| **Prometheus metrics** | Reconcile totals, queue depth, error rate — labeled by GVK. |
| **Admission webhooks** | Validation and mutation declared in the Katalog. No webhook server to write or deploy. |
| **RBAC** | `ork generate rbac` derives ClusterRoles from the Katalog. No manual authoring. |
| **Deletion protection** | Orkestra and everything it manages cannot be accidentally `kubectl delete`. |
| **Control Center** | Realtime visibility per CRD, per Katalog, across instances. Auto-generated operator docs — overview, reconcile mode, child resources, kubectl reference, access control. |
| **Developer portal** | `serve.enabled: true` on any CRD surfaces a self-service form in the Control Center. Users submit CRs through a browser — no kubectl, no YAML. |
| **Developer portal** | `serve.enabled: true` on any CRD surfaces a self-service form in the Control Center. Callers submit intent in their vocabulary — no kubectl, no YAML, no Kubernetes knowledge required. |

---

Expand All @@ -115,24 +141,22 @@ curl -sSL https://get.orkestra.sh | bash
> Extract the archives and add the folder containing `ork.exe` and `orkcc.exe` to your `PATH`.

### Initialize and run
```console
```bash
ork init
ork run
```

> No cluster? Add `--dev` to create a temporary kind cluster. Requires Docker.

`ork init` scaffolds a `katalog.yaml`, `crd.yaml`, and `cr.yaml` in the current directory — like `terraform init`.
`ork init` scaffolds a `katalog.yaml`, `crd.yaml`, and `cr.yaml` in the current directory.

**→ [Learning to Orkestrate](https://orkestra.sh/docs/getting-started/learning-to-orkestrate)** — the guided path from first operator to full platform. Every capability has a runnable example.

---

### Control Center

In another terminal:

```console
```bash
ork control
```
> → localhost:8081 · username:password → orkestra
Expand Down Expand Up @@ -160,31 +184,28 @@ Six Runtimes. 75 CRDs. One Control Center.
| **Lines of Go** | 400+ per operator | 0 |
| **Adding a new CRD** | Days to weeks | Minutes |

79 MB is a live measurement from a 10-CRD runtime (`process_resident_memory_bytes` from the `/metrics` endpoint — [raw scrape](./documentation/assets/controlcenter/public/metrics.txt)). The memory reduction works because Orkestra pays the cost of client-go, leader election, and health servers once per runtime. Per-CRD cost is a goroutine pool and an in-memory cache. Isolation works the same way `kube-controller-manager` isolates Deployment, StatefulSet, and Job controllers — dedicated informer, queue, and worker pool per CRD. A panic in one is caught by `safeReconcile`; the others keep running. The Control Center aggregates all runtimes into a single dashboard.
79 MB is a live measurement from a 10-CRD runtime (`process_resident_memory_bytes` from the `/metrics` endpoint — [raw scrape](./documentation/assets/controlcenter/public/metrics.txt)). The reduction works because Orkestra pays the cost of client-go, leader election, and health servers once per runtime. Per-CRD cost is a goroutine pool and an in-memory cachethe same isolation model as `kube-controller-manager`. A panic in one CRD is caught by `safeReconcile`; the others keep running.

---

## What Orkestra is not

**Not an operator framework — an operator runtime.** A framework gives you libraries and conventions. Orkestra gives you a runtime with platform tools to build, test, evaluate, visualize, and operate operators and control planes.

**Not an operator — a runtime for operators.** Each CRD in a Katalog becomes its own operator. Orkestra is the runtime that runs them all.

**Not a developer portal by default — but every operator can become one.** `serve.enabled: true` on any CRD exposes a self-service form in the Control Center. Users submit CRs through a browser without kubectl or YAML. The developer portal is the operator — Orkestra just surfaces it.
**Not an operator framework — an operator runtime.** A framework gives you libraries and conventions. Orkestra gives you a runtime: the reconciliation loop, security layer, and delivery surface are the runtime's job. You write the behavior.

**Not a replacement for Go.** Hooks and constructors exist for exactly this reason. ~90% of operators are declarative; ~10% need code. Orkestra handles the 90% and gives the 10% a clean interface — the same informer, queue, health, and metrics infrastructure, with a single function to implement.
**Not a replacement for Go.** Hooks and constructors exist for exactly this reason. ~90% of operators are declarative; ~10% need code. Orkestra handles the 90% and gives the 10% a clean seam — the same informer, queue, health, and metrics infrastructure, with a single function to implement.

**Not GitOps.** Katalogs define long-lived API contracts and are resolved at startup. Silently reloading them mid-flight is dangerous. Treat Katalog changes like any other runtime change — deploy through a pipeline.
**Not GitOps.** Katalogs define long-lived API contracts resolved at startup. Treat Katalog changes like any other runtime change — deploy through a pipeline.

**Not a product — a primitive layer.** Notes, autoscaler, serve mode, Katalogs — none of these are products. They are primitives ready for composition. What you build on top of them is.
**Not a product — a primitive layer.** Notes, autoscaler, serve mode, Katalogs — none of these are products. They are primitives ready for composition.

---

## Documentation

| | |
|---|---|
| [Why Orkestra](https://orkestra.sh/blog/why-orkestra) | What Orkestra is, how it works, and why it’s different |
| [Migration Guide](https://orkestra.sh/docs/guides/migration) | Bring an existing controller-runtime operator into Orkestra — zero changes to your reconciler |
| [Why Orkestra](https://orkestra.sh/blog/why-orkestra) | What Orkestra is, how it works, and why it's different |
| [Foundations](https://orkestra.sh/docs/foundations) | The decisions that shaped the design — and why they hold |
| [Trust and Failure Model](https://orkestra.sh/publications/trust-and-failure-model) | What happens when things go wrong |
| [Getting Started](https://orkestra.sh/docs/getting-started) | First operator in under an hour |
Expand Down
38 changes: 27 additions & 11 deletions cmd/cli/migrate.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,36 +16,51 @@ import (

var migrateCmd = &cobra.Command{
Use: "migrate <file>",
Short: "Migrate a controller-runtime Reconcile method to the Orkestra constructor signature",
Long: `Parses a Go file containing a controller-runtime Reconcile method and rewrites it
to the Orkestra constructor signature:
Short: "Migrate a controller-runtime operator to Orkestra",
Long: `Migrates a controller-runtime reconciler to Orkestra. Your Reconcile logic
is untouched — Orkestra takes over the infrastructure.

Before: Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error)
After: Reconcile(ctx context.Context, key string) error
Default mode (--mode toclient): zero changes to your Reconcile signature or
call sites. SetupWithManager is removed; a two-line constructor using
kubeclient.ToClient and domain.ReconcilerFrom is injected. Your reconciler
compiles and runs inside Orkestra with no other edits.

With -o, the rewritten file and scaffolding (katalog.yaml, simulate.yaml,
e2e.yaml, go.mod) are written to the output directory. Without -o, the
original file is replaced after confirmation.
ork migrate ./controller/webapp_controller.go -o ./my-operator

The output directory receives the rewritten file plus scaffolding:
katalog.yaml, simulate.yaml, e2e.yaml, go.mod, Makefile, Dockerfile.

The output is a starting point — review TODO(ork migrate) comments before running.
For a full rewrite to idiomatic Orkestra style (new Reconcile signature,
struct fields, call sites), use --mode native.

Examples:
ork migrate ./controller/webapp_controller.go -o ./my-operator
ork migrate ./controller/webapp_controller.go --module github.com/myorg/my-operator -o ./out
ork migrate ./controller/webapp_controller.go --mode native -o ./out
ork migrate ./controller/webapp_controller.go # prompts before replacing`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
inputPath := args[0]
outputDir, _ := cmd.Flags().GetString("output")
modulePath, _ := cmd.Flags().GetString("module")
operatorName, _ := cmd.Flags().GetString("name")
modeFlag, _ := cmd.Flags().GetString("mode")

var mode migrate.Mode
switch modeFlag {
case "native":
mode = migrate.ModeNative
case "toclient", "":
mode = migrate.ModeToClient
default:
return fmt.Errorf("unknown --mode %q: valid values are toclient, native", modeFlag)
}

src, err := os.ReadFile(inputPath)
if err != nil {
return fmt.Errorf("read %s: %w", inputPath, err)
}

res, err := migrate.Rewrite(src)
res, err := migrate.Rewrite(src, mode)
if err != nil {
return fmt.Errorf("migrate: %w", err)
}
Expand Down Expand Up @@ -161,6 +176,7 @@ func init() {
migrateCmd.Flags().StringP("output", "o", "", "Write output to this directory (non-destructive; skips confirmation)")
migrateCmd.Flags().String("module", "", "Go module path for the migrated operator (e.g. github.com/myorg/my-operator)")
migrateCmd.Flags().String("name", "", "Operator name in kebab-case (e.g. my-operator); derived from receiver type if omitted")
migrateCmd.Flags().String("mode", "toclient", "Migration mode: toclient (default, zero Reconcile changes) or native (full rewrite)")

// Shadow global flags so they don't appear under `ork migrate`
shadowGlobalCommandFlags(migrateCmd, "file")
Expand Down
50 changes: 36 additions & 14 deletions cmd/internal/runtime_konstructor.go
Original file line number Diff line number Diff line change
Expand Up @@ -317,14 +317,31 @@ func konstructRuntime(kfg *konfig.Konfig, m *merger.Merger, ctx context.Context)
}

// ── Enqueue filter — Tier 2b (pre-enqueue condition gate) ─────────────
// Register when any operatorBox (CRD-level or per-target) declares an
// enqueueGate. EvaluateEnqueueFilter resolves the effective box at runtime.
if crd.HasAnyEnqueueGate() {
crdNameForFilter := crd.Name
katForFilter := kat
cs := kube.Clientset()
// Two paths depending on whether sentinels are declared:
//
// 1. No sentinels — single-object filter; only newObj available.
// EvaluateEnqueueFilter evaluates enqueueGate with no sentinel values.
//
// 2. Sentinels declared — update filter; oldObj and newObj both available.
// Sentinels are computed here and carried through the QueueItem so
// reconcileGate can rebuild the same preReconcile resolver after dequeue.
// Also covers CRDs with sentinels but no enqueueGate — sentinel map
// is still computed and forwarded for reconcileGate use.
crdNameForFilter := crd.Name
katForFilter := kat
cs := kube.Clientset()

if crd.WithSentinels() {
declared := crd.OperatorBox.PreReconcile.DeclaredSentinels()
infFactory.RegisterUpdateEnqueueFilter(gvk, declared, func(new domain.Object, sentinels map[string]string) bool {
return katForFilter.EvaluateEnqueueFilter(ctx, crdNameForFilter, new, cs, sentinels)
})
logger.Debug().
Str("crd", crd.APITypes.Kind).
Msg("informer: sentinel update filter registered (Tier 2b)")
} else if crd.HasAnyEnqueueGate() {
infFactory.RegisterEnqueueFilter(gvk, func(obj domain.Object) bool {
return katForFilter.EvaluateEnqueueFilter(ctx, crdNameForFilter, obj, cs)
return katForFilter.EvaluateEnqueueFilter(ctx, crdNameForFilter, obj, cs, nil)
})
logger.Debug().
Str("crd", crd.APITypes.Kind).
Expand Down Expand Up @@ -421,14 +438,17 @@ func konstructRuntime(kfg *konfig.Konfig, m *merger.Merger, ctx context.Context)

logger.Debug().Str("gvk", gvk).Msg("wiring custom reconciler factory")

// Attach constructor.args to a copy of the kube client; the constructor reads them via kube.Args().
var ctorKube kubeclient.Interface = kube
// Attach constructor.args, informer, and event recorder to a copy of the
// kube client. Constructor authors access them via kube.GetInformer() etc.
var ctorKube kubeclient.Interface = kube.
WithInformer(infCopy).
WithEventRecorder(ev)
if args := crd.ConstructorArgs(); len(args) > 0 {
ctorKube = kube.WithArgs(kubeclient.Args(args))
ctorKube = ctorKube.WithArgs(kubeclient.Args(args))
}

factory = func() domain.Reconciler {
return crd.OperatorBox.Constructor(ctorKube, infCopy, ev)
return crd.OperatorBox.Constructor(ctorKube)
}
}

Expand All @@ -442,11 +462,13 @@ func konstructRuntime(kfg *konfig.Konfig, m *merger.Merger, ctx context.Context)
factory = func() domain.Reconciler {
targets := make(map[string]domain.Reconciler, len(crdCopy.TargetReconcilerFactories))
for targetName, ctor := range crdCopy.TargetReconcilerFactories {
var targetKube kubeclient.Interface = kube
var targetKube kubeclient.Interface = kube.
WithInformer(infCopy).
WithEventRecorder(ev)
if args := crdCopy.TargetConstructorArgs(targetName); len(args) > 0 {
targetKube = kube.WithArgs(kubeclient.Args(args))
targetKube = targetKube.WithArgs(kubeclient.Args(args))
}
targets[targetName] = ctor(targetKube, infCopy, ev)
targets[targetName] = ctor(targetKube)
}
return orktarget.NewMuxReconciler(infCopy, targets, baseFactory())
}
Expand Down
Loading
Loading