diff --git a/CHANGELOG.md b/CHANGELOG.md index 42ea91582..422a350ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -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: diff --git a/README.md b/README.md index 16394b34a..dd8443c73 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,12 @@ Orkestra

Orkestra

-

A runtime for Kubernetes operators.

-

Declare. Run.

+

Kubernetes operators without the infrastructure.

+

+ Reconciliation as a runtime service.
+ Security as a runtime service.
+ Intent Delivery as a runtime service. +

Release @@ -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 @@ -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 @@ -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. | --- @@ -115,14 +141,14 @@ 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. @@ -130,9 +156,7 @@ ork run ### Control Center -In another terminal: - -```console +```bash ork control ``` > → localhost:8081 · username:password → orkestra @@ -160,23 +184,19 @@ 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 cache — the 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. --- @@ -184,7 +204,8 @@ Six Runtimes. 75 CRDs. One Control Center. | | | |---|---| -| [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 | diff --git a/cmd/cli/migrate.go b/cmd/cli/migrate.go index 99733596f..63a814df7 100644 --- a/cmd/cli/migrate.go +++ b/cmd/cli/migrate.go @@ -16,22 +16,26 @@ import ( var migrateCmd = &cobra.Command{ Use: "migrate ", - 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 { @@ -39,13 +43,24 @@ Examples: 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) } @@ -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") diff --git a/cmd/internal/runtime_konstructor.go b/cmd/internal/runtime_konstructor.go index cec908787..b87741f6b 100644 --- a/cmd/internal/runtime_konstructor.go +++ b/cmd/internal/runtime_konstructor.go @@ -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). @@ -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) } } @@ -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()) } diff --git a/documentation/concepts/conditional/04-conditional-reconciliation.md b/documentation/concepts/conditional/04-conditional-reconciliation.md index f998e848a..661427094 100644 --- a/documentation/concepts/conditional/04-conditional-reconciliation.md +++ b/documentation/concepts/conditional/04-conditional-reconciliation.md @@ -106,6 +106,48 @@ Use `enqueueGate` when you want zero queue pressure for objects that should be c --- +## Sentinels — gate on what changed + +`preReconcile.enqueueGate` and `reconcileGate` evaluate the *current* state of the CR — they answer "does this object satisfy a condition right now?" Sentinels answer a different question: "did a specific thing change between the last version and this version?" + +```yaml +operatorBox: + preReconcile: + sentinels: + - generationChanged + - labelsChanged + enqueueGate: + when: + - field: "{{ generationChanged }}" + equals: "true" +``` + +Sentinels are declared in `preReconcile.sentinels` and computed at the informer level — in the `UpdateFunc`, before the gate is evaluated. Each sentinel compares the old and new object and produces `"true"` or `"false"`. Declared sentinels become template functions available in `enqueueGate` and `reconcileGate` conditions. + +| Sentinel | Fires when | +|---|---| +| `generationChanged` | `.metadata.generation` incremented (spec change on most CRDs) | +| `labelsChanged` | label set differs between old and new object | +| `annotationsChanged` | annotation set differs | +| `deletionStarted` | `deletionTimestamp` was nil, is now set | +| `finalizersChanged` | finalizer list differs | + +A sentinel that is not declared is not available in gate templates — `ork validate` rejects templates that reference undeclared sentinel names. + + +!!! tip "A sentinel is not a Note" + Notes carry arbitrary values into the reconcile context and can be read anywhere in templates. A sentinel is narrower: it answers a yes/no question about what changed between two versions of the object, and it is only available in gate conditions — not in `onCreate`/`onReconcile` templates or `status:` field mappings. Use a Note when you need a value inside reconciliation; use a sentinel when you need to decide whether reconciliation should run at all. + +### Why declare rather than use `.metadata.generation` directly + +You could write `field: "{{ .metadata.generation }}"` and compare it to a static value, but generation is a monotonically increasing counter — there is no "previous value" available inside a stateless template. Sentinels are computed at event time, when both the old and new versions of the object are available side by side. That comparison window is gone by the time the object reaches a gate or reconciler, which is why sentinels must be declared and computed upfront. + +### Sentinel scope + +Sentinels are computed at the same level as `enqueueGate` — at event time, when both the old and new versions of the object are visible. The values travel with the queued item, so both `enqueueGate` and `reconcileGate` see them. A sentinel referenced in a `reconcileGate` reflects what changed when the object was last updated, not a recomputed comparison at reconcile time. + +--- + ## Difference from resource-level conditions | | `preReconcile.enqueueGate` or `reconcileGate` | `onCreate` / `onReconcile` resource `when:` | diff --git a/documentation/concepts/operatorbox/09-retry-backoff.md b/documentation/concepts/operatorbox/09-retry-backoff.md new file mode 100644 index 000000000..a061bc5e0 --- /dev/null +++ b/documentation/concepts/operatorbox/09-retry-backoff.md @@ -0,0 +1,121 @@ +# Retry backoff + +When a reconcile call or an external data fetch fails, Orkestra re-enqueues the item. By default, re-enqueue happens via the workqueue's built-in rate limiter with no additional waiting. `retryBackoff` lets you layer *intra-reconcile* retries on top — so a transient failure is retried a configurable number of times with exponential backoff before the error is returned to the queue. + +There are two places to declare it: + +| Declaration site | What it retries | +|---|---| +| `queue.retryBackoff` | Each call to the reconciler function when it returns an error | +| `external[].retryBackoff` | That specific external call before its error propagates to the reconciler | + +--- + +## Shorthand vs full form + +Both sites accept the same two forms: + +```yaml +# Shorthand — initial delay only; Orkestra supplies max: 30s, multiplier: 2.0, maxAttempts: 3 +queue: + retryBackoff: 5s + +# Full form — explicit control over every parameter +queue: + retryBackoff: + initial: 500ms + max: 30s + multiplier: 2.0 + maxAttempts: 3 +``` + +The shorthand `5s` is equivalent to `initial: 5s` with defaults applied to all other fields. + +--- + +## `queue.retryBackoff` — reconciler retries + +Wraps each call to your reconciler. If the reconciler returns an error, Orkestra waits `initial`, doubles the delay (up to `max`), and retries — up to `maxAttempts` times before returning the error to the workqueue. + +```yaml +operatorBox: + reconciler: + resync: 10m + queue: + retryBackoff: + initial: 500ms + max: 30s + multiplier: 2.0 + maxAttempts: 3 +``` + +With the above, a failing reconcile is attempted 3 times with delays of 500ms and 1s (total ≈ 1.5s) before the error is returned and the item is re-enqueued by the workqueue's rate limiter. + +### Resync interaction + +If `initial`, `max`, and `maxAttempts` are set such that the worst-case retry window exceeds the `resync` period, `ork validate` emits a **warning** (not an error). The warning surfaces the math: + +```text +queue.retryBackoff worst-case delay (150s) exceeds resync (30s) — the queue will +re-enqueue before retries finish; consider reducing maxAttempts or initial delay +``` + +This is a warning because it is not always wrong — a slow external API might justify deep in-call retries. The operator author decides. + +--- + +## `external[].retryBackoff` — per-call retries + +Retries a specific external call before its error reaches the reconciler. Use this when one call is known to be flaky without affecting the rest of the reconcile pipeline: + +```yaml +operatorBox: + onReconcile: + external: + - name: health-check + url: "{{ .spec.serviceUrl }}/health" + retryBackoff: + initial: 1s + max: 10s + multiplier: 1.5 + maxAttempts: 3 + - name: db-query + url: "postgres://{{ .spec.dbHost }}/mydb" + query: "SELECT 1" + retryBackoff: 2s # shorthand — 2s initial, defaults for the rest +``` + +The retry happens *inside* the external call executor before the result is placed in `.external.`. If all attempts fail and `continueOnError: false` (the default), the error is returned to the reconciler. + +--- + +## Fields + +| Field | Type | Default | Description | +|---|---|---|---| +| `initial` | duration | `500ms` | First backoff delay | +| `max` | duration | `30s` | Upper cap — the delay never grows beyond this | +| `multiplier` | float | `2.0` | Factor applied to the delay after each attempt | +| `maxAttempts` | int | `3` | Total calls including the first (1 = no retries) | + +Shorthand (plain duration string) sets `initial` only; all other fields use their defaults. + +--- +!!! tip "Not a substitute for idempotency" + Retries assume your reconciler is idempotent — calling it twice produces the same result as calling it once. If your reconciler creates resources that are not cleaned up on failure, retrying it multiple times can create duplicates. Fix the idempotency first; then add retries. + +## When to use each + +**`queue.retryBackoff`** — use when the reconciler as a whole can be safely retried. Good for operators that call external services that are occasionally unavailable, and where partial completion is not a concern (all resource operations are idempotent). + +**`external[].retryBackoff`** — use when one specific external call is the flaky part and you want to shield the rest of the reconcile from it. More targeted than retrying the whole reconciler. + +--- + +## Where to go next + +- [External calls](07-external/index.md) — full guide to `external:` declarations, result context, and patterns for health gating and config injection +- [Queue](../../reference/schema/02-katalog/14-queue.md) — `maxDepth`, `failureThreshold`, `shared`, and `retryBackoff` reference +- [Reconciler model](../reconciler-model/) — how items move from the informer cache through the workqueue to the reconciler +- [Health subsystem](../health-subsystem/) — how `failureThreshold` and degraded state interact with `dependsOn` + diff --git a/documentation/concepts/operatorbox/watch.md b/documentation/concepts/operatorbox/watch.md new file mode 100644 index 000000000..31ac6b993 --- /dev/null +++ b/documentation/concepts/operatorbox/watch.md @@ -0,0 +1,127 @@ +# Arbitrary Watch + +An operatorBox reconciler normally runs when its own CR changes. `operatorBox.watch` extends this: declare secondary Kubernetes resources that, when they change, re-enqueue the primary CR for reconciliation. + +No Go code is required. The feature is purely declarative. + +--- + +## Why it exists + +Many real operators react to resources they do not own: + +- An app operator that reads a shared `ConfigMap` of feature flags — if the ConfigMap changes, every CR must reconcile. +- A database operator that watches `Nodes` — node capacity changes affect pod scheduling, so each database CR must re-evaluate. +- A workload operator that watches a `Secret` managed by cert-manager — when the Secret rotates, the operator must restart the relevant workload. + +Without arbitrary watch, authors work around this by polling in a hook, or by adding a finalizer or ownerReference to an object they do not logically own. Both are fragile. + +With `operatorBox.watch`, Orkestra manages the secondary informer and routes events back to the right primary CR. + +--- + +## How it works + +For each entry in `operatorBox.watch`, Orkestra starts a dynamic shared informer scoped to that resource type and optional namespace. Events that arrive during the initial cache sync (the List phase) are dropped — the same behavior as controller-runtime's `source.Kind`. Only events that arrive after sync trigger re-enqueues. + +```yaml +operatorBox: + watch: + - apiVersion: v1 + kind: ConfigMap + name: feature-flags + namespace: config + on: [update] +``` + +When the `config/feature-flags` ConfigMap is updated, the informer fires an `UpdateFunc`. Orkestra then resolves which primary CR(s) to enqueue and adds them to the workqueue. + +--- + +## Key resolution + +Orkestra resolves the primary CR key from the watched object using a four-step chain (first match wins): + +### 1. `keyFrom.label` + +The watched object carries a label whose value is the primary CR key. Use this when the object is not owned by the primary CR but is labelled to declare ownership. + +```yaml +watch: + - apiVersion: v1 + kind: Secret + namespace: certs + keyFrom: + label: app.kubernetes.io/cr-owner +``` + +If the Secret has label `app.kubernetes.io/cr-owner: default/myapp`, then `default/myapp` is enqueued. + +### 2. `keyFrom.name` + +A fixed primary CR name. Use this for singleton patterns — a single well-known resource that, when it changes, always means a specific primary CR must reconcile. + +```yaml +watch: + - apiVersion: v1 + kind: ConfigMap + name: global-config + namespace: config + keyFrom: + name: my-operator + namespace: default +``` + +Every update to `global-config` enqueues `default/my-operator`. + +### 3. ownerReference + +No `keyFrom` is set, but the watched object's `ownerReferences` list contains an entry whose `apiVersion` and `kind` match the primary CRD. The named owner is enqueued. + +This is the most common case when the primary CR created the watched object and set an ownerReference. It mirrors controller-runtime's `EnqueueRequestForOwner`. + +### 4. Broadcast + +None of the above matched. Orkestra enqueues all currently known primary CRs of this type. + +This is the right default for truly shared resources — cluster Nodes, cluster-wide ConfigMaps — where a change is relevant to every instance. It mirrors controller-runtime's `EnqueueRequestsFromMapFunc` with a "return all" mapper. + +--- + +## Comparison with controller-runtime + +| Pattern | controller-runtime | Orkestra | +|---|---|---| +| Watch owned objects | `Owns(T)` | ownerReference path (automatic) | +| Watch unowned objects, map to owner | `Watches(T, EnqueueRequestForOwner)` | `watch:` + ownerReference | +| Watch unowned objects, custom key | `Watches(T, EnqueueRequestsFromMapFunc)` | `watch:` + `keyFrom.label` or `keyFrom.name` | +| Watch cluster-wide resource, fan-out | `Watches(T, mapFunc that returns all)` | `watch:` (broadcast is the default fallback) | + +The key difference: controller-runtime requires Go. Orkestra watch is declarative YAML — the informer, the event filter, and the key-resolution strategy are all expressed in the katalog. + +--- + +## Event filtering with `on:` + +By default, all three event types (`create`, `update`, `delete`) trigger re-enqueues. Restrict this with `on:`: + +```yaml +watch: + - apiVersion: apps/v1 + kind: Deployment + on: [update] # only updates re-enqueue; create and delete do not +``` + +--- + +## Interaction with `preReconcile.enqueueGate` + +A watch-triggered enqueue goes through the same `preReconcile.enqueueGate` as any other update event. The gate sees the current state of the primary CR, not the watched object. Sentinels (`generationChanged`, `labelsChanged`, etc.) reflect the primary CR's own metadata delta from the previous reconcile, not the watched object's delta. + +If you need to gate on the watched object's state, use a `when:` condition inside the reconcile template that reads the appropriate cross-CRD or external field. + +--- + +## Schema reference + +→ [operatorBox.watch schema](../../reference/schema/02-katalog/27-watch.md) diff --git a/documentation/concepts/typed-operators/02-constructor.md b/documentation/concepts/typed-operators/02-constructor.md index 77f42baeb..a235bcd2c 100644 --- a/documentation/concepts/typed-operators/02-constructor.md +++ b/documentation/concepts/typed-operators/02-constructor.md @@ -66,18 +66,12 @@ Use `fetch: true` when pulling the constructor from a remote module you have not **String values support Go template expressions**, including strings inside nested maps — the resolver recurses into them. Integers and booleans have no template syntax (YAML parses them as native types) so they are always read as-is. Dynamic string values — those with `{{ }}` — need to be resolved per-CR at reconcile time. The constructor calls `kube.ScopedFor(resolver.TemplateEvaluator())` itself after building its resolver: ```go -func NewPipelineReconciler( - kube kubeclient.KubeClient, - informer cache.SharedIndexInformer, - ev event.Recorder, -) domain.Reconciler { +func NewPipelineReconciler(kube kubeclient.Interface) domain.Reconciler { // Static args are safe to read at construction time — no templates involved. maxRetries := kube.Args().Int("maxRetries") notifyOnSuccess := kube.Args().Bool("notifyOnSuccess") return &PipelineReconciler{ kube: kube, // holds rawArgs; ScopedFor resolves them at reconcile time - informer: informer, - event: ev, maxRetries: maxRetries, notifyOnSuccess: notifyOnSuccess, } @@ -111,22 +105,18 @@ package reconciler import ( "github.com/orkspace/orkestra/pkg/kubeclient" - "github.com/orkspace/orkestra/pkg/event" "github.com/orkspace/orkestra/domain" - "k8s.io/client-go/tools/cache" ) -func NewPipelineReconciler( - kube kubeclient.Kubeclient, - informer cache.SharedIndexInformer, - ev *event.Event, -) domain.Reconciler { - return &PipelineReconciler{kube: kube, informer: informer, event: ev} +func NewPipelineReconciler(kube kubeclient.Interface) domain.Reconciler { + return &PipelineReconciler{kube: kube} } ``` Orkestra calls this function once at startup and uses the returned `domain.Reconciler` for all reconcile events on this CRD. +The informer and event recorder are available via `kube.GetInformer()` and `kube.GetEventRecorder()` — injected by the runtime before the constructor is called. Constructor args are read via `kube.Args()`. + --- ## Two styles of reconcile implementation diff --git a/documentation/concepts/typed-operators/index.md b/documentation/concepts/typed-operators/index.md index b1db66b28..a54beb408 100644 --- a/documentation/concepts/typed-operators/index.md +++ b/documentation/concepts/typed-operators/index.md @@ -1,36 +1,94 @@ # Typed Operators -Orkestra operators are declarative by default — no Go required. Typed operators are the escape hatch for the cases that genuinely need code. - -A typed operator references compiled Go types. Everything else — informers, workqueues, drift correction, status, metrics — works exactly as it does for a pure YAML operator. +Every controller-runtime operator has two layers. + +**Infrastructure** — Manager setup, informer cache, workqueue, predicates, event handlers, retry logic, leader election, metrics, panic recovery. This is the same in every operator. You write it, maintain it, debug it, and upgrade it — for every operator you build. + +**Business logic** — your `Reconcile` function. The part that is actually yours. + +Orkestra separates them. You write `Reconcile(ctx context.Context, key string) error` — the same logic you have today. Orkestra provides the rest: informers, workqueue, worker pool, retry backoff, watch on secondary resources, enqueue and reconcile gates, resync, leader election, metrics, panic recovery. You declare the topology in the Katalog. You never touch the infrastructure again. + +```go +// Your reconciler — untouched. Same struct, same Reconcile signature, same logic. +type PipelineReconciler struct { + client client.Client +} + +func (r *PipelineReconciler) Reconcile(ctx context.Context, req ctrl.Request) (reconcile.Result, error) { + // your logic, unchanged +} + +// The constructor — the only new code. Replaces main.go and SetupWithManager. +// ToClient adapts Orkestra's kube hub to the client.Client your reconciler already uses. +// ReconcilerFrom adapts the controller-runtime signature to Orkestra's interface. +func NewPipelineReconciler(kube kubeclient.Interface) domain.Reconciler { + return domain.ReconcilerFrom(&PipelineReconciler{ + client: kubeclient.ToClient(kube), + }) +} +``` + +```yaml +# The infrastructure — declared, not written. +operatorBox: + reconciler: + workers: 4 + resync: 30s + queue: + retryBackoff: 500ms + watch: + - apiVersion: v1 + kind: ConfigMap + name: shared-config + on: [update] + enqueueGate: + when: + - field: "{{ generationChanged }}" + equals: "true" + preReconcile: + sentinels: [generationChanged] +``` + +Watches on secondary resources, retry backoff, enqueue filtering — declared. Your reconciler sees none of it. It just runs. --- -## When to use typed operators +## Patterns + +→ **[Hooks — hybrid](./01-hooks.md#hybrid)** *(recommended)* — declare everything Orkestra handles well in the Katalog; write Go only for what templates cannot express. Orkestra runs declared templates first, then your hook. The smallest surface area of Go code. + +→ **[Hooks — hooks only](./01-hooks.md#hooks-only)** — the hook manages all child resources in Go. Use when type-safe control over every resource matters more than keeping declarations in YAML. + +→ **[Constructor](./02-constructor.md)** — replace the reconciler entirely. Your Go code owns the full reconcile loop. The right entry point when migrating an existing controller-runtime operator — change the signature, remove the Manager, register the constructor. The rest of your code is unchanged. -Use a typed operator when your reconcile logic needs: +→ **[Mixing all three](./03-mixed.md)** — a declarative operator, a hooks operator, and a constructor operator composed into one runtime from a single Komposer. -- **External calls the `external:` block cannot express** — the `external:` block handles HTTP calls declaratively (GET/POST, bearer tokens, response gating). Use hooks when you need non-HTTP protocols, SDK calls, database connections, or multi-step interactions that can't be expressed as "call a URL and gate on the response". If you need to provision a user inside PostgreSQL, call an AWS SDK, or run a gRPC call, that needs a hook. -- **Complex computed fields** — deriving values from multiple sources, running logic that template expressions cannot express -- **A fully custom reconciler** — integrating an existing operator's logic without rewriting it +→ **[Migrating from controller-runtime](./05-migration.md)** — have a working controller-runtime operator? The `from-controller-runtime` pack shows the same operator expressed five ways, and `ork migrate` automates the constructor path. -If your operator only creates Kubernetes resources and applies rules, stay declarative. +→ **[Reusability](./06-reusability.md)** — one binary, many deployments. The same hook or constructor serves different environments, tiers, and tenants — API type routing via `apiTypes`, behavior routing via `args:`. + +--- !!! note "Templates already see the full spec" Template expressions have access to the complete CR — `{{ .spec.* }}`, `{{ .status.* }}`, `{{ .metadata.* }}` — regardless of whether `apiTypes.location` is set. The template resolver converts any object to `map[string]interface{}` before executing expressions. Setting `location` is for Go code only. ---- +## When to write Go -## The patterns +Stay declarative when your operator creates Kubernetes resources and applies rules. Reach for Go when you need: -→ [Hooks — hybrid](./01-hooks.md#hybrid) **(recommended)** — declare everything Orkestra handles well in the Katalog; write Go only for what templates cannot express. Orkestra runs declared templates first, then the hook. +- **Non-HTTP protocols** — SDK calls, gRPC, database connections, multi-step interactions that can't be expressed as "call a URL and gate on the response" +- **Complex computed fields** — logic that template expressions cannot express +- **An existing reconciler** — integrating a controller-runtime operator without rewriting it -→ [Hooks — hooks only](./01-hooks.md#hooks-only) — the hook manages all child resources in Go. Use when type-safe control over every resource matters more than keeping declarations in YAML. +--- -→ [Constructor](./02-constructor.md) — replace the reconciler entirely. Your Go code owns the full reconcile loop; declared templates are not applied. Use when migrating an existing controller-runtime operator or running a custom state machine. +## The infrastructure you are declaring -→ [Mixing all three](./03-mixed.md) — a declarative operator, a hooks operator, and a constructor operator composed into one runtime from a single Komposer. +The Katalog fields that replace what you used to write: -→ [Migrating from controller-runtime](./05-migration.md) — have a working controller-runtime operator? The `from-controller-runtime` pack shows the same operator expressed five ways, and `ork migrate` automates the constructor path. +- [watch](../operatorbox/watch.md) — secondary resource informers, enqueue filtering, key resolution +- [retryBackoff](../operatorbox/09-retry-backoff.md) — per-call and per-reconciler retry with exponential backoff +- [Conditional reconciliation](../conditional/04-conditional-reconciliation.md) — enqueue gates, reconcile gates, sentinels +- [Profiles](../operatorbox/06-profiles/) — worker count, resync, queue depth — named and reusable across CRDs +- [Reconciler model](../reconciler-model/) — how items move from the informer cache through the workqueue to your Reconcile call -→ [Reusability](./06-reusability.md) — one binary, many deployments. The same hook or constructor serves different environments, tiers, and tenants — API type routing via `apiTypes`, behavior routing via `args:`. diff --git a/documentation/faqs/03-usage.md b/documentation/faqs/03-usage.md index 10db91c29..fff9bb3b4 100644 --- a/documentation/faqs/03-usage.md +++ b/documentation/faqs/03-usage.md @@ -245,7 +245,7 @@ cd 09-hooks ## When do I need a constructor? -When you need to own the full reconcile loop — typically when integrating an existing operator without rewriting it. +When you need to own the full reconcile loop — typically when integrating an existing controller-runtime operator without rewriting it. ```yaml operatorBox: @@ -253,7 +253,9 @@ operatorBox: default: false # GenericReconciler is replaced; constructor owns everything ``` -`reconciler.default: false` is the one field change that replaces the entire reconciler. Your constructor receives Orkestra's `KubeClient` and informer — no `controller-runtime` required. Declarative templates (`onCreate`, `onReconcile`, `status.fields`) are not applied when `reconciler.default: false`; the constructor is responsible for all state. +`reconciler.default: false` is the one field change. Your constructor receives `kubeclient.Interface` — Orkestra's single interface for informer, kube calls, events, and args. If you are migrating from controller-runtime, `kubeclient.ToClient(kube)` returns a `client.Client` so your existing `Reconcile` body compiles unchanged. `domain.ReconcilerFrom` adapts the `ctrl.Request` signature. + +Declarative templates (`onCreate`, `onReconcile`, `status.fields`) are not applied when `reconciler.default: false` — the constructor is responsible for all state. Try it: diff --git a/documentation/faqs/04-ecosystem.md b/documentation/faqs/04-ecosystem.md index 95cc4be11..05369755a 100644 --- a/documentation/faqs/04-ecosystem.md +++ b/documentation/faqs/04-ecosystem.md @@ -117,33 +117,51 @@ manages them. ## I already have a controller-runtime operator. Where do I start? -Pull the migration pack. It scaffolds the exact files you need — the Katalog stub, the constructor wiring, the bundle — pre-filled from a working controller-runtime operator so you can see the delta between what you have and what Orkestra expects: +Pull the migration pack and look at `04-constructor-migration`: ```bash ork init --pack from-controller-runtime -cd from-controller-runtime +cd from-controller-runtime/04-constructor-migration ``` -The pack contains three progressive examples — declarative only, hybrid (declarative + hooks), and hooks-only — so you can pick the migration depth that fits your operator today without rewriting everything at once. +Your `Reconcile` method stays completely unchanged — same signature, same body. Two lines in a constructor wire it into Orkestra: -If you want to understand the conceptual shift first, the [Migration Guide](../guides/migration/index.md) walks through each mode. `ork migrate` automates the mechanical parts. +```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, and metrics. + +Or run `ork migrate` to have the constructor injected automatically — see below. + +If you want to understand the full range of options first, the [Migration Guide](../guides/migration/index.md) walks through each mode from zero-Go declarative to full constructor. --- ## What does `ork migrate` do? -`ork migrate` reads your existing controller-runtime operator and generates the Orkestra scaffolding around it. It inspects your `Reconcile()` method, infers the CRD group/version/kind from your scheme registration, and writes: +`ork migrate` takes your existing controller-runtime reconciler file and produces a ready-to-run Orkestra operator. By default (`--mode toclient`) it leaves your `Reconcile` method completely untouched — no signature change, no body change. It removes `SetupWithManager` and injects the constructor: -- A `katalog.yaml` stub with the CRD entry, operatorBox declaration, and constructor block wired to your existing reconciler -- A `bundle/` directory ready for `ork generate bundle` -- A `simulate.yaml` with a placeholder CR and first-cycle expectations +```go +func NewWebAppReconciler(kube kubeclient.Interface) domain.Reconciler { + return domain.ReconcilerFrom(&WebAppReconciler{ + Client: kubeclient.ToClient(kube), + }) +} +``` -The output is a starting point — not a complete migration. Fields that Orkestra can declare (standard Deployments, Services, ConfigMaps) still need to move from your Go code into the Katalog. `ork migrate` handles the structural scaffolding; you handle the logic separation. +It also writes a `katalog.yaml` stub, `simulate.yaml`, `e2e.yaml`, `go.mod`, `Makefile`, and `Dockerfile` alongside the rewritten file. Search for `TODO(ork migrate)` in the output to see what needs your attention. ```bash -ork migrate --src ./my-operator --out ./my-operator-orkestra +ork migrate ./controller/webapp_controller.go -o ./my-operator ``` +For a full rewrite to native Orkestra style (new Reconcile signature, struct, and call sites), use `--mode native`. + → [ork migrate reference](../reference/cli/migrate.md) · [Migration Guide](../guides/migration/index.md) --- diff --git a/documentation/getting-started/01-learning-to-orkestrate/07-migration.md b/documentation/getting-started/01-learning-to-orkestrate/07-migration.md index 080ef7764..644859e77 100644 --- a/documentation/getting-started/01-learning-to-orkestrate/07-migration.md +++ b/documentation/getting-started/01-learning-to-orkestrate/07-migration.md @@ -25,8 +25,8 @@ ork init --pack from-controller-runtime If your operator only creates Kubernetes resources and applies rules — start with `01-declarative`. You may not need Go at all. -If you have an existing `Reconcile` method you want to keep — start with `04-constructor-migration`. One signature change is all it costs. +If you have an existing `Reconcile` method you want to keep — start with `04-constructor-migration`. Zero changes to your reconciler. Two lines in a constructor are all it costs. -If you want the migration automated — run `ork migrate` in `06-ork-migrate`. +If you want the constructor injected automatically — run `ork migrate` in `06-ork-migrate`. It removes `SetupWithManager` and injects the two-line constructor for you. → [Migration Guide](../../guides/migration/index.md) — detailed page per option diff --git a/documentation/guides/migration/05-constructor.md b/documentation/guides/migration/05-constructor.md index a641cbc7e..2f232eec0 100644 --- a/documentation/guides/migration/05-constructor.md +++ b/documentation/guides/migration/05-constructor.md @@ -1,8 +1,8 @@ -# Constructor — Lift and Change +# Constructor — Zero Changes You have a working controller-runtime operator. The reconcile logic is solid. What you want to remove is the machinery around it — the manager, the workqueue, the scheme registration, the leader election setup, the metrics. Not rewrite the operator from scratch. -Option 04 is the lift-and-change path. The reconcile logic moves across unchanged. Only the signature changes. +Option 04 is the zero-change path. Your `Reconcile` method is completely untouched. Two lines wire it into Orkestra. ```bash ork init --pack from-controller-runtime @@ -13,34 +13,33 @@ cd from-controller-runtime/04-constructor-migration ## What you will learn -- The exact signature change from controller-runtime to Orkestra constructor +- How `kubeclient.ToClient` and `domain.ReconcilerFrom` bridge a controller-runtime reconciler into Orkestra - What `default: false` means in the Katalog — the GenericReconciler is disabled, the constructor owns the loop - What the runtime provides that `ctrl.NewManager` previously handled -- Why `ctrl.Result{RequeueAfter: X}` becomes a plain error return +- How `ork migrate` generates the constructor automatically --- -## The signature change +## The only new code -Before: ```go -Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) +func NewWebAppReconciler(kube kubeclient.Interface) domain.Reconciler { + return domain.ReconcilerFrom(&WebAppReconciler{ + Client: kubeclient.ToClient(kube), + }) +} ``` -After: -```go -Reconcile(ctx context.Context, key string) error` -``` +- `kubeclient.ToClient(kube)` — wraps Orkestra's `kubeclient.Interface` as a `client.Client`. The same type your struct already holds. +- `domain.ReconcilerFrom(r)` — adapts the `ctrl.Request` signature so Orkestra's worker pool can call it. -`key` is `namespace/name` — the same as `req.String()`. Everything inside the method body stays unchanged. - -The struct changes from an embedded `client.Client` to Orkestra's `informer`, `kube`, and `ev` fields. The constructor function (`NewWebAppReconciler`) is what the Katalog references. `SetupWithManager` is removed. +That is all. Your `Reconcile` method, your struct, your `r.Get` / `r.Create` / `r.Status().Update()` calls — unchanged. --- ## What you removed -`ctrl.NewControllerManagedBy`, `SetupWithManager`, scheme registration, `main.go`, leader election setup. Orkestra provides all of it — informer, workqueue, worker pool, panic recovery, Prometheus metrics, health endpoints, leader election. +`SetupWithManager`, `Scheme`, scheme registration, `main.go`, leader election setup. Orkestra provides all of it — informer, workqueue, worker pool, panic recovery, Prometheus metrics, health endpoints, leader election. --- @@ -50,6 +49,8 @@ The struct changes from an embedded `client.Client` to Orkestra's `informer`, `k ork migrate ./controller/webapp_controller.go -o ./output ``` +Default mode (`--mode toclient`) does exactly what this option shows: removes `SetupWithManager`, injects the two-line constructor, leaves everything else untouched. + See [06 — ork migrate](./07-ork-migrate.md) or run option 06 in the pack. --- @@ -62,4 +63,4 @@ cd from-controller-runtime/04-constructor-migration # Follow steps in README ``` -→ [05 — Constructor: Orkestra resources](./06-constructor-resources.md) +→ [06 — Constructor: Orkestra resources](./06-constructor-resources.md) diff --git a/documentation/guides/migration/index.md b/documentation/guides/migration/index.md index e2c929f10..6f3855a4d 100644 --- a/documentation/guides/migration/index.md +++ b/documentation/guides/migration/index.md @@ -24,7 +24,7 @@ Each directory is a self-contained, runnable step. Work through them in order or - How to go declarative — zero Go, no binary — when it fits - How the hybrid pattern (90% declarative, 10% Go hook) works and when to use it - How to drop all declarations and let a Go hook own every resource -- How to lift an existing `Reconcile` method into Orkestra with a one-line signature change +- How to bring an existing `Reconcile` method into Orkestra with zero changes — two lines in a constructor are all it takes - How `pkg/resources` simplifies the Get / Create / Patch pattern - How `ork migrate` automates the constructor path for an existing operator file @@ -38,7 +38,7 @@ Each directory is a self-contained, runnable step. Work through them in order or | Declarative | `01-declarative` | No | Nothing — pure YAML | | Hybrid | `02-hybrid` | Yes — hook only | The 10% templates can't express | | Hooks only | `03-hooks-only` | Yes — all resources | All child resource specs in Go | -| Constructor — lift | `04-constructor-migration` | Yes — full reconciler | Reconcile logic; manager removed | +| Constructor — zero change | `04-constructor-migration` | Yes — full reconciler | Reconcile unchanged; manager removed | | Constructor — resources | `05-constructor-orkestra-resources` | Yes — full reconciler | Reconcile logic; resource ops simplified | | ork migrate | `06-ork-migrate` | — | Automated constructor path from an existing file | @@ -52,7 +52,7 @@ Each directory is a self-contained, runnable step. Work through them in order or | [Declarative](./02-declarative.md) | Zero Go, zero binary — pure Katalog | | [Hybrid](./03-hybrid.md) | 90/10: declare everything Orkestra handles, write Go for the rest | | [Hooks only](./04-hooks-only.md) | When type-safe control over every resource matters more than YAML | -| [Constructor — lift](./05-constructor.md) | One signature change, every resource op untouched | +| [Constructor — zero change](./05-constructor.md) | Zero changes to your reconciler — two lines in a constructor | | [Constructor — resources](./06-constructor-resources.md) | `pkg/resources`: Get / Create / Patch → one Update call | | [ork migrate](./07-ork-migrate.md) | Automate the constructor path for an existing operator file | diff --git a/documentation/reference/cli/migrate.md b/documentation/reference/cli/migrate.md index 66bbd8885..cbf7cb156 100644 --- a/documentation/reference/cli/migrate.md +++ b/documentation/reference/cli/migrate.md @@ -1,6 +1,6 @@ # ork migrate -Rewrite a controller-runtime `Reconcile` method to the Orkestra constructor signature and generate the full operator scaffolding — `katalog.yaml`, `simulate.yaml`, `e2e.yaml`, `go.mod`, `Makefile`, and `Dockerfile` — as a starting point. +Migrate a controller-runtime reconciler to Orkestra. By default, your `Reconcile` method is completely untouched — `ork migrate` removes `SetupWithManager` and injects a two-line constructor. The output also includes full operator scaffolding: `katalog.yaml`, `simulate.yaml`, `e2e.yaml`, `go.mod`, `Makefile`, and `Dockerfile`. ```bash ork migrate [flags] @@ -11,25 +11,29 @@ ork migrate [flags] | Flag | Short | Default | Description | |------|-------|---------|-------------| | `--output` | `-o` | *(none)* | Write all output to this directory (non-destructive; skips confirmation prompt) | +| `--mode` | | `toclient` | Migration mode: `toclient` (default) or `native` (full rewrite) | | `--module` | | *(derived)* | Go module path for the migrated operator (e.g. `github.com/myorg/my-operator`) | -| `--name` | | *(derived)* | Operator name in kebab-case (e.g. `my-operator`). Derived from receiver type if omitted. | +| `--name` | | *(derived)* | Operator name in kebab-case. Derived from receiver type if omitted. | -## Examples +## Modes -```bash -# Non-destructive: write to a new directory -ork migrate ./controller/webapp_controller.go -o ./my-operator +### `--mode toclient` (default) -# Specify module path for go.mod and katalog.yaml location hints -ork migrate ./controller/webapp_controller.go \ - --module github.com/myorg/webapp-operator \ - -o ./webapp-operator +Zero changes to your reconciler. `Reconcile`, struct fields, and all call sites are untouched. Only `SetupWithManager` is removed and a constructor is injected: -# Interactive: replace the file in place after confirmation -ork migrate ./controller/webapp_controller.go +```go +func NewWebAppReconciler(kube kubeclient.Interface) domain.Reconciler { + return domain.ReconcilerFrom(&WebAppReconciler{ + Client: kubeclient.ToClient(kube), + }) +} ``` -## What it rewrites +`kubeclient.ToClient` returns a `client.Client` — the same type your struct already holds. `domain.ReconcilerFrom` adapts the `ctrl.Request` signature. Your reconciler compiles and runs inside Orkestra with no other edits. + +### `--mode native` + +Full rewrite to idiomatic Orkestra style: | Before | After | |--------|-------| @@ -43,6 +47,24 @@ ork migrate ./controller/webapp_controller.go | `SetupWithManager` method | removed with explanation comment | | `ctrl` import | removed | +## Examples + +```bash +# Default (toclient) — zero Reconcile changes +ork migrate ./controller/webapp_controller.go -o ./my-operator + +# Full rewrite to idiomatic Orkestra +ork migrate ./controller/webapp_controller.go --mode native -o ./my-operator + +# Specify module path for go.mod and katalog.yaml location hints +ork migrate ./controller/webapp_controller.go \ + --module github.com/myorg/webapp-operator \ + -o ./webapp-operator + +# Interactive: replace the file in place after confirmation +ork migrate ./controller/webapp_controller.go +``` + ## Output files When `-o` is provided: @@ -66,15 +88,15 @@ Search for `TODO(ork migrate)` in the output: grep -rn "TODO(ork migrate)" ./my-operator/ ``` -Items that need manual attention: - +**toclient mode:** 1. Set `group`, `kind`, `plural`, `location` in `katalog.yaml` -2. Replace the embedded `client.Client` struct field with `kube kubeclient.KubeClient` -3. Update the constructor function to match the Orkestra signature -4. Replace `r.Status().Update()` with `r.kube.PatchStatus()` -5. Fill in resource assertions in `simulate.yaml` and `e2e.yaml` -6. Delete `main.go`, scheme registration, and manager setup -7. Run `go mod tidy` +2. Delete `main.go`, scheme registration, and manager setup +3. Fill in resource assertions in `simulate.yaml` and `e2e.yaml` +4. Run `go mod tidy` + +**native mode (additional):** +1. Replace `r.Status().Update()` with `r.kube.PatchStatus()` +2. Resolve any `RequeueAfter` TODOs — return `err` requeues with backoff → Full review checklist: [pkg/tools/migrate/README.md](https://github.com/orkspace/orkestra/blob/main/pkg/tools/migrate/README.md) diff --git a/documentation/reference/schema/02-katalog/02-crd-entry.md b/documentation/reference/schema/02-katalog/02-crd-entry.md index d624472dd..183af0d84 100644 --- a/documentation/reference/schema/02-katalog/02-crd-entry.md +++ b/documentation/reference/schema/02-katalog/02-crd-entry.md @@ -26,6 +26,10 @@ spec: - pods - events + labels: + app: "{{ .metadata.name }}" + env: production + labelSelector: app: my-operator fieldSelector: @@ -133,6 +137,50 @@ dependsOn: | `maxDepth` | int | `100` (`QUEUE_DEPTH` env) | Max items in the queue before new items are dropped. | | `failureThreshold` | int | `5` (`FAILURE_THRESHOLD` env) | Consecutive reconcile failures before health transitions to degraded. | +## `labels` + +Additional labels to attach to each CR managed by this CRD entry. Labels are applied on every reconcile cycle alongside the standard Orkestra ownership labels. + +```yaml +labels: + app: "{{ .metadata.name }}" + env: production + team: platform +``` + +**Keys** must be valid Kubernetes label keys (static — no template syntax). +**Values** are Go templates resolved against the CR at reconcile time. All CR fields and user-defined notes are available as template variables. + +This is distinct from `labelSelector`, which *filters* which CRs are watched. `labels:` *writes* labels onto CRs that are already being reconciled. + +## `labelSelector` + +Filters which resources this CRD entry watches and reconciles. Only objects whose labels match **all** declared key-value pairs are picked up by the informer. + +```yaml +labelSelector: + app: my-operator + env: production +``` + +**Required for built-in types** (ConfigMap, Pod, Secret, etc.) — without a selector, Orkestra would reconcile every instance of that type in the cluster. Optional for custom CRDs, where it can narrow scope within a group. + +Values are static strings. Template syntax is not supported here. + +## `fieldSelector` + +Filters resources by field values rather than labels. Field selectors are evaluated server-side before the informer pipeline receives any events, reducing watch traffic. + +```yaml +fieldSelector: + metadata.namespace: production + metadata.name: my-config +``` + +Only fields exposed by the Kubernetes API server are valid — arbitrary user-defined fields are not supported. Common uses: restrict by namespace or target a single object by name. + +`fieldSelector` is optional for all types. When omitted, all objects allowed by `labelSelector` and namespace restrictions are watched. + ## `enrich` → [enrich](15-enrich.md) diff --git a/documentation/reference/schema/02-katalog/13-external.md b/documentation/reference/schema/02-katalog/13-external.md index d368fa6ae..238070f6e 100644 --- a/documentation/reference/schema/02-katalog/13-external.md +++ b/documentation/reference/schema/02-katalog/13-external.md @@ -44,6 +44,7 @@ operatorBox: | `sleep` | no | `""` | Delay before this call. Go duration. For development and sequencing async side-effects — not for production rate limiting. | | `fires.reconcile` | no | `true` | When `false`, the call is skipped during reconcile — it only runs at admission time. Applies when the call is declared under `validation.external` or `mutation.external`. No effect on `onReconcile.external` calls. | | `include` | no | — | Path to a YAML file with a top-level `calls:` list. When set, this entry is replaced in-place by the listed calls. Resolved relative to the katalog file. Cleared after expansion. | +| `retryBackoff` | no | — | Retry this specific call with exponential backoff before returning an error. Shorthand (`"2s"`) sets `initial` only; full form: `initial`, `max`, `multiplier`, `maxAttempts`. See [retry backoff](../../concepts/operatorbox/09-retry-backoff.md). | ## Result context diff --git a/documentation/reference/schema/02-katalog/14-queue.md b/documentation/reference/schema/02-katalog/14-queue.md index ba71c4755..89dff9595 100644 --- a/documentation/reference/schema/02-katalog/14-queue.md +++ b/documentation/reference/schema/02-katalog/14-queue.md @@ -23,6 +23,7 @@ crds: | `maxDepth` | int | `100` (`QUEUE_DEPTH` env) | Maximum items the queue holds. When the limit is reached, new reconcile events are **dropped** — not queued, not retried. The resync period will re-enqueue the CR on the next tick. | | `failureThreshold` | int | `5` (`FAILURE_THRESHOLD` env) | Consecutive reconcile failures before the operatorBox transitions to degraded. Resets to zero on the next successful reconcile. | | `shared` | bool | `false` | Use the shared global workqueue instead of an isolated per-CRD queue. Rarely needed. | +| `retryBackoff` | duration or object | — | Intra-reconcile retry backoff. Shorthand (`5s`) sets `initial` only; full form: `initial`, `max`, `multiplier`, `maxAttempts`. See [retry backoff](../../concepts/operatorbox/09-retry-backoff.md). | Defaults are controlled by `QUEUE_DEPTH` and `FAILURE_THRESHOLD` environment variables in the runtime deployment — set them in `values.yaml` under `runtime.config`. diff --git a/documentation/reference/schema/02-katalog/27-watch.md b/documentation/reference/schema/02-katalog/27-watch.md new file mode 100644 index 000000000..4e4c7eaa6 --- /dev/null +++ b/documentation/reference/schema/02-katalog/27-watch.md @@ -0,0 +1,106 @@ +# operatorBox.watch + +`operatorBox.watch` declares secondary Kubernetes resources whose changes should re-enqueue the primary CR. Useful when a CR's reconcile outcome depends on objects it does not own — shared ConfigMaps, cluster-wide Nodes, Secrets managed by another operator. + +No Go code is required. Orkestra creates a dynamic informer per entry and enqueues the relevant primary CR key(s) when an event fires. + +--- + +## Declaration + +```yaml +spec: + crds: + app: + operatorBox: + watch: + - apiVersion: apps/v1 + kind: Deployment + namespace: default + on: [update] + + - apiVersion: v1 + kind: ConfigMap + name: feature-flags + namespace: config + on: [update, delete] + keyFrom: + label: app.kubernetes.io/cr-owner + + - apiVersion: v1 + kind: Node + on: [create, update, delete] +``` + +--- + +## `watch[]` fields + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `apiVersion` | string | yes | API version of the watched resource, e.g. `apps/v1`, `v1`. | +| `kind` | string | yes | Kind of the watched resource, e.g. `Deployment`, `ConfigMap`. | +| `namespace` | string | no | Restrict the watch to this namespace. Omit for cluster-scoped or all-namespace watching. | +| `name` | string | no | Watch a single named instance. When set, the informer scopes to that object. | +| `on` | `[]string` | no | Event types to react to. Values: `create`, `update`, `delete`. Defaults to all three when omitted. | +| `keyFrom` | [WatchKeyFrom](#watchkeyfrom) | no | Override the default key-resolution strategy. See below. | + +Each `(apiVersion, kind, namespace)` combination must be unique across the `watch` list. + +--- + +## Key resolution + +When an event fires on a watched object, Orkestra resolves which primary CR(s) to enqueue using this order (first match wins): + +1. **`keyFrom.label`** — a label on the watched object carries the primary CR key. Useful when the object is not owned by the primary CR but is labelled to indicate which CR it belongs to. + +2. **`keyFrom.name`** — a fixed primary CR name declared in the watch entry. Useful for singleton or well-known CRs. + +3. **ownerReference** — the watched object's `ownerReferences` contains an entry whose `apiVersion` and `kind` match the primary CRD. The referencing CR is enqueued by name. + +4. **broadcast** — none of the above matched. All currently known primary CRs are enqueued. The right default for shared resources (cluster Nodes, shared ConfigMaps) that affect every CR equally. + +--- + +## `WatchKeyFrom` + +Overrides key resolution to steps 1 or 2 above. Exactly one of `label` or `name` must be set. + +```yaml +watch: + - apiVersion: v1 + kind: ConfigMap + keyFrom: + label: app.kubernetes.io/cr-owner # OR + name: my-singleton # but not both + namespace: default # only with name +``` + +| Field | Type | Description | +|-------|------|-------------| +| `label` | string | Label key on the watched object whose value is the primary CR key (e.g. `namespace/name` or bare `name`). | +| `name` | string | Name of the primary CR to enqueue regardless of which watched object changed. | +| `namespace` | string | Namespace of the primary CR. Only meaningful with `name`. Has no effect when `label` is set. | + +### Validation rules + +- Exactly one of `label` or `name` must be set — both or neither is an error. +- `namespace` combined with `label` is rejected: label resolution reads the key from the object, so a namespace restriction has no meaning there. + +--- + +## Interaction with `preReconcile.enqueueGate` + +A watch-triggered enqueue goes through the same `preReconcile.enqueueGate` as a normal update enqueue. If the gate is configured with sentinels, those sentinels reflect the state of the **primary** CR at the time of re-enqueue, not the watched object. + +--- + +## Validation + +`ork validate` enforces: + +- `apiVersion` and `kind` are present on every entry. +- `on:` values are one of `create`, `update`, `delete`. +- No two entries share the same `(apiVersion, kind, namespace)`. +- `keyFrom`, when present, has exactly one of `label` or `name`. diff --git a/domain/reconciler_adapter.go b/domain/reconciler_adapter.go new file mode 100644 index 000000000..8e2a0e796 --- /dev/null +++ b/domain/reconciler_adapter.go @@ -0,0 +1,47 @@ +package domain + +import ( + "context" + + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/cache" + "sigs.k8s.io/controller-runtime/pkg/reconcile" +) + +// ReconcilerFrom wraps a sigs.k8s.io/controller-runtime/pkg/reconcile.Reconciler +// as a domain.Reconciler so it can be returned from a constructor function without +// any changes to the reconciler body. +// +// Orkestra calls Reconcile(ctx, key) where key is "namespace/name". The adapter +// splits the key and builds a reconcile.Request, then discards the returned +// ctrl.Result — Orkestra's operatorBox owns requeue scheduling via its own +// rate-limiting queue. Return an error to trigger a rate-limited retry as normal. +// +// Usage: +// +// func NewMyReconciler(kube kubeclient.Interface) domain.Reconciler { +// return domain.ReconcilerFrom(&MyReconciler{ +// client: kubeclient.ToClient(kube), +// }) +// } +func ReconcilerFrom(r reconcile.Reconciler) Reconciler { + return &ctrlReconcilerAdapter{r: r} +} + +type ctrlReconcilerAdapter struct { + r reconcile.Reconciler +} + +var _ Reconciler = (*ctrlReconcilerAdapter)(nil) + +func (a *ctrlReconcilerAdapter) Reconcile(ctx context.Context, key string) error { + ns, name, err := cache.SplitMetaNamespaceKey(key) + if err != nil { + return err + } + // ctrl.Result is intentionally discarded — Orkestra manages requeue. + _, err = a.r.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{Namespace: ns, Name: name}, + }) + return err +} diff --git a/examples/advanced/09-hooks/go.mod.txt b/examples/advanced/09-hooks/go.mod.txt index a13577a65..846e45176 100644 --- a/examples/advanced/09-hooks/go.mod.txt +++ b/examples/advanced/09-hooks/go.mod.txt @@ -3,7 +3,7 @@ module github.com/orkspace/orkestra-hooks-demo go 1.26.3 require ( - github.com/orkspace/orkestra v0.7.13 + github.com/orkspace/orkestra v0.7.16 k8s.io/apimachinery v0.36.1 ) diff --git a/examples/advanced/10-constructor/go.mod.txt b/examples/advanced/10-constructor/go.mod.txt index 8887f6b2f..77842d246 100644 --- a/examples/advanced/10-constructor/go.mod.txt +++ b/examples/advanced/10-constructor/go.mod.txt @@ -3,7 +3,7 @@ module github.com/orkspace/orkestra-constructor-demo go 1.26.3 require ( - github.com/orkspace/orkestra v0.7.12 + github.com/orkspace/orkestra v0.7.16 k8s.io/api v0.36.1 k8s.io/apimachinery v0.36.1 k8s.io/client-go v0.36.1 diff --git a/examples/advanced/10-constructor/reconciler/pipeline_reconciler.go b/examples/advanced/10-constructor/reconciler/pipeline_reconciler.go index 419dbe7d1..8f5e1fd75 100644 --- a/examples/advanced/10-constructor/reconciler/pipeline_reconciler.go +++ b/examples/advanced/10-constructor/reconciler/pipeline_reconciler.go @@ -33,7 +33,6 @@ import ( apiv1 "github.com/orkspace/orkestra-constructor-demo/api/v1alpha1" "github.com/orkspace/orkestra/domain" - "github.com/orkspace/orkestra/pkg/event" "github.com/orkspace/orkestra/pkg/kubeclient" orkjobs "github.com/orkspace/orkestra/pkg/resources/jobs" orktypes "github.com/orkspace/orkestra/pkg/types" @@ -53,23 +52,12 @@ const ( // // Pending → Running → Succeeded | Failed type PipelineReconciler struct { - informer cache.SharedIndexInformer - kube kubeclient.KubeClient - ev event.Recorder + kube kubeclient.Interface } // NewPipelineReconciler is the constructor function registered in the Katalog. -// Signature matches orktypes.NewReconcilerFunc: (kube, informer, ev) → domain.Reconciler. -func NewPipelineReconciler( - kube kubeclient.KubeClient, - informer cache.SharedIndexInformer, - ev event.Recorder, -) domain.Reconciler { - return &PipelineReconciler{ - informer: informer, - kube: kube, - ev: ev, - } +func NewPipelineReconciler(kube kubeclient.Interface) domain.Reconciler { + return &PipelineReconciler{kube: kube} } // Reconcile is called by Orkestra's worker pool for every queued Pipeline key. @@ -81,7 +69,7 @@ func (r *PipelineReconciler) Reconcile(ctx context.Context, key string) error { } // Read from the informer cache — no API call - raw, exists, err := r.informer.GetIndexer().GetByKey(key) + raw, exists, err := r.kube.GetInformer().GetIndexer().GetByKey(key) if err != nil { return fmt.Errorf("cache lookup %q: %w", key, err) } @@ -155,7 +143,7 @@ func (r *PipelineReconciler) handlePending(ctx context.Context, p *apiv1.Pipelin p.Status.CurrentStep = firstStep.Name p.Status.StartTime = &now - r.ev.Eventf(p, corev1.EventTypeNormal, "PipelineStarted", + r.kube.GetEventRecorder().Eventf(p, corev1.EventTypeNormal, "PipelineStarted", "Pipeline %s/%s started — step: %s", p.Namespace, p.Name, firstStep.Name) return r.patchStatus(ctx, p) @@ -178,7 +166,7 @@ func (r *PipelineReconciler) handleRunning(ctx context.Context, p *apiv1.Pipelin // Check Job outcome if isJobFailed(job) { - r.ev.Eventf(p, corev1.EventTypeWarning, "StepFailed", + r.kube.GetEventRecorder().Eventf(p, corev1.EventTypeWarning, "StepFailed", "Pipeline step %q failed", p.Status.CurrentStep) return r.setPhase(ctx, p, apiv1.PipelinePhaseFailed, fmt.Sprintf("step %q failed", p.Status.CurrentStep)) @@ -209,7 +197,7 @@ func (r *PipelineReconciler) advanceStep(ctx context.Context, p *apiv1.Pipeline) // All steps complete now := metav1.NewTime(time.Now()) p.Status.CompletionTime = &now - r.ev.Eventf(p, corev1.EventTypeNormal, "PipelineSucceeded", + r.kube.GetEventRecorder().Eventf(p, corev1.EventTypeNormal, "PipelineSucceeded", "Pipeline %s/%s completed all %d steps", p.Namespace, p.Name, len(p.Spec.Steps)) return r.setPhase(ctx, p, apiv1.PipelinePhaseSucceeded, "all steps completed") } @@ -231,7 +219,7 @@ func (r *PipelineReconciler) advanceStep(ctx context.Context, p *apiv1.Pipeline) } p.Status.CurrentStep = nextStep.Name - r.ev.Eventf(p, corev1.EventTypeNormal, "StepStarted", + r.kube.GetEventRecorder().Eventf(p, corev1.EventTypeNormal, "StepStarted", "Pipeline advancing to step %q", nextStep.Name) return r.patchStatus(ctx, p) diff --git a/examples/advanced/11-mixed-operator-pattern/10-constructor/reconciler/pipeline_reconciler.go b/examples/advanced/11-mixed-operator-pattern/10-constructor/reconciler/pipeline_reconciler.go index 8d200364c..d8ec5ecc5 100644 --- a/examples/advanced/11-mixed-operator-pattern/10-constructor/reconciler/pipeline_reconciler.go +++ b/examples/advanced/11-mixed-operator-pattern/10-constructor/reconciler/pipeline_reconciler.go @@ -33,7 +33,6 @@ import ( apiv1 "github.com/orkspace/orkestra-mixed-operator-pattern/10-constructor/api/v1alpha1" "github.com/orkspace/orkestra/domain" - "github.com/orkspace/orkestra/pkg/event" "github.com/orkspace/orkestra/pkg/kubeclient" orkjobs "github.com/orkspace/orkestra/pkg/resources/jobs" orktypes "github.com/orkspace/orkestra/pkg/types" @@ -53,23 +52,12 @@ const ( // // Pending → Running → Succeeded | Failed type PipelineReconciler struct { - informer cache.SharedIndexInformer - kube kubeclient.KubeClient - ev event.Recorder + kube kubeclient.Interface } // NewPipelineReconciler is the constructor function registered in the Katalog. -// Signature matches orktypes.NewReconcilerFunc: (kube, informer, ev) → domain.Reconciler. -func NewPipelineReconciler( - kube kubeclient.KubeClient, - informer cache.SharedIndexInformer, - ev event.Recorder, -) domain.Reconciler { - return &PipelineReconciler{ - informer: informer, - kube: kube, - ev: ev, - } +func NewPipelineReconciler(kube kubeclient.Interface) domain.Reconciler { + return &PipelineReconciler{kube: kube} } // Reconcile is called by Orkestra's worker pool for every queued Pipeline key. @@ -81,7 +69,7 @@ func (r *PipelineReconciler) Reconcile(ctx context.Context, key string) error { } // Read from the informer cache — no API call - raw, exists, err := r.informer.GetIndexer().GetByKey(key) + raw, exists, err := r.kube.GetInformer().GetIndexer().GetByKey(key) if err != nil { return fmt.Errorf("cache lookup %q: %w", key, err) } @@ -155,7 +143,7 @@ func (r *PipelineReconciler) handlePending(ctx context.Context, p *apiv1.Pipelin p.Status.CurrentStep = firstStep.Name p.Status.StartTime = &now - r.ev.Eventf(p, corev1.EventTypeNormal, "PipelineStarted", + r.kube.GetEventRecorder().Eventf(p, corev1.EventTypeNormal, "PipelineStarted", "Pipeline %s/%s started — step: %s", p.Namespace, p.Name, firstStep.Name) return r.patchStatus(ctx, p) @@ -178,7 +166,7 @@ func (r *PipelineReconciler) handleRunning(ctx context.Context, p *apiv1.Pipelin // Check Job outcome if isJobFailed(job) { - r.ev.Eventf(p, corev1.EventTypeWarning, "StepFailed", + r.kube.GetEventRecorder().Eventf(p, corev1.EventTypeWarning, "StepFailed", "Pipeline step %q failed", p.Status.CurrentStep) return r.setPhase(ctx, p, apiv1.PipelinePhaseFailed, fmt.Sprintf("step %q failed", p.Status.CurrentStep)) @@ -209,7 +197,7 @@ func (r *PipelineReconciler) advanceStep(ctx context.Context, p *apiv1.Pipeline) // All steps complete now := metav1.NewTime(time.Now()) p.Status.CompletionTime = &now - r.ev.Eventf(p, corev1.EventTypeNormal, "PipelineSucceeded", + r.kube.GetEventRecorder().Eventf(p, corev1.EventTypeNormal, "PipelineSucceeded", "Pipeline %s/%s completed all %d steps", p.Namespace, p.Name, len(p.Spec.Steps)) return r.setPhase(ctx, p, apiv1.PipelinePhaseSucceeded, "all steps completed") } @@ -231,7 +219,7 @@ func (r *PipelineReconciler) advanceStep(ctx context.Context, p *apiv1.Pipeline) } p.Status.CurrentStep = nextStep.Name - r.ev.Eventf(p, corev1.EventTypeNormal, "StepStarted", + r.kube.GetEventRecorder().Eventf(p, corev1.EventTypeNormal, "StepStarted", "Pipeline advancing to step %q", nextStep.Name) return r.patchStatus(ctx, p) diff --git a/examples/advanced/11-mixed-operator-pattern/go.mod.txt b/examples/advanced/11-mixed-operator-pattern/go.mod.txt index 841152aa1..9b22a4489 100644 --- a/examples/advanced/11-mixed-operator-pattern/go.mod.txt +++ b/examples/advanced/11-mixed-operator-pattern/go.mod.txt @@ -3,7 +3,7 @@ module github.com/orkspace/orkestra-mixed-operator-pattern go 1.26.3 require ( - github.com/orkspace/orkestra v0.7.13 + github.com/orkspace/orkestra v0.7.16 k8s.io/api v0.36.1 k8s.io/apimachinery v0.36.1 k8s.io/client-go v0.36.1 diff --git a/examples/from-controller-runtime/02-hybrid/go.mod.txt b/examples/from-controller-runtime/02-hybrid/go.mod.txt index ad77d9e8c..2cd590a30 100644 --- a/examples/from-controller-runtime/02-hybrid/go.mod.txt +++ b/examples/from-controller-runtime/02-hybrid/go.mod.txt @@ -3,7 +3,7 @@ module github.com/orkspace/from-controller-runtime-demo go 1.26.3 require ( - github.com/orkspace/orkestra v0.7.12 + github.com/orkspace/orkestra v0.7.16 k8s.io/apimachinery v0.36.1 ) diff --git a/examples/from-controller-runtime/03-hooks-only/go.mod.txt b/examples/from-controller-runtime/03-hooks-only/go.mod.txt index d135ee758..914e92ca7 100644 --- a/examples/from-controller-runtime/03-hooks-only/go.mod.txt +++ b/examples/from-controller-runtime/03-hooks-only/go.mod.txt @@ -3,7 +3,7 @@ module github.com/orkspace/from-controller-runtime-demo go 1.26.3 require ( - github.com/orkspace/orkestra v0.7.12 + github.com/orkspace/orkestra v0.7.16 k8s.io/apimachinery v0.36.1 ) diff --git a/examples/from-controller-runtime/04-constructor-migration/README.md b/examples/from-controller-runtime/04-constructor-migration/README.md index e08e4713d..a5b8f45a8 100644 --- a/examples/from-controller-runtime/04-constructor-migration/README.md +++ b/examples/from-controller-runtime/04-constructor-migration/README.md @@ -1,6 +1,20 @@ # 04 — Constructor Migration -Your controller-runtime reconcile loop runs inside Orkestra. The logic is unchanged — informer, workqueue, worker pool, leader election, and metrics are provided by the runtime. +Your existing `Reconcile` method runs inside Orkestra unchanged. Compare [reconciler/webapp_reconciler.go](reconciler/webapp_reconciler.go) with [00-controller-runtime-baseline/controller/webapp_controller.go](../00-controller-runtime-baseline/controller/webapp_controller.go). The differences are exactly three: + +1. **`SetupWithManager` is gone.** Orkestra provides the informer, workqueue, worker pool, leader election, panic recovery, and metrics. +2. **`Scheme` is gone.** Orkestra handles scheme registration at startup. +3. **`NewWebAppReconciler` is added.** Two lines wire the reconciler in: + +```go +func NewWebAppReconciler(kube kubeclient.Interface) domain.Reconciler { + return domain.ReconcilerFrom(&WebAppReconciler{ + Client: kubeclient.ToClient(kube), + }) +} +``` + +`kubeclient.ToClient` wraps Orkestra's interface as a `client.Client` — the same type your struct already holds. `domain.ReconcilerFrom` adapts the `ctrl.Request` signature. Nothing inside `Reconcile` changes. --- @@ -11,21 +25,6 @@ Your controller-runtime reconcile loop runs inside Orkestra. The logic is unchan --- -## What Orkestra handles vs what you handle - -| | controller-runtime | Constructor | -|---|---|---| -| Informer + workqueue | `ctrl.NewControllerManagedBy` | runtime | -| Worker pool | 1 goroutine per controller | configurable (`workers: N` in Katalog) | -| Leader election | manager setup in `main.go` | runtime | -| Panic recovery | not included | `safeReconcile` wrapper | -| Prometheus metrics | not included | runtime | -| Scheme registration | `main.go` | not needed | -| Status updates | `Status().Update()` | `kube.PatchStatus()` | -| Reconcile logic | your `Reconcile()` | your `Reconcile()` | - ---- - ## Step 1 — Generate the type registry ```bash @@ -42,8 +41,6 @@ Generates `pkg/typeregistry/zz_generated_typeregistry.go` from your Katalog. Re- make build ``` -Builds the full CLI (validate, simulate, run) and places it at `~/.orkestra/bin/ork`. - --- ## Step 3 — Validate @@ -85,7 +82,7 @@ kubectl get services --- -## Step 6. Build and push the production image +## Step 6 — Build and push the production image ```bash export IMAGE_REPO=ghcr.io/myorg/webapp-constructor @@ -93,11 +90,7 @@ export IMAGE_TAG=1.0.0 make release ``` -`make release` compiles with the `runtime` build tag (no validate/simulate/e2e commands), builds the distroless image, and pushes it. - -## Step 6. Update [values.yaml](values.yaml) with your image - -The e2e gate runs automatically during push and needs to pull your custom runtime image. Update [values.yaml](values.yaml) to point to the image you just built: +## Step 7 — Update [values.yaml](values.yaml) with your image ```yaml runtime: @@ -106,34 +99,13 @@ runtime: tag: 1.0.0 ``` -## Step 7. Push the katalog pattern to the registry +## Step 8 — Push the katalog pattern to the registry ```bash export ORK_REGISTRY=ghcr.io/myorg/katalogs ork push . ``` -> **Note:** `ork push` requires `docker login` to any OCI-compatible registry. - -Simulate and e2e run automatically before the artifact and its dependencies are published. - -**8. Confirm the published artifact** - -```bash -ork inspect webapp-constructor:1.0.0 -``` - -Expected: - -```text -webapp-constructor:1.0.0 - ... - Simulate: ✓ Verified · 4 assertions · 227ms · tested 44s ago - E2E: ✓ Verified · 2 assertions · 20s · tested 18s ago - Typed: ✓ constructor · requires custom runtime image - Runtime: v0.7.7 -``` - --- ## Cleanup diff --git a/examples/from-controller-runtime/04-constructor-migration/go.mod.txt b/examples/from-controller-runtime/04-constructor-migration/go.mod.txt index f12af816f..d259e1b07 100644 --- a/examples/from-controller-runtime/04-constructor-migration/go.mod.txt +++ b/examples/from-controller-runtime/04-constructor-migration/go.mod.txt @@ -3,7 +3,7 @@ module github.com/orkspace/from-controller-runtime-demo go 1.26.3 require ( - github.com/orkspace/orkestra v0.7.12 + github.com/orkspace/orkestra v0.7.16 k8s.io/api v0.36.1 k8s.io/apimachinery v0.36.1 k8s.io/client-go v0.36.1 diff --git a/examples/from-controller-runtime/04-constructor-migration/reconciler/webapp_reconciler.go b/examples/from-controller-runtime/04-constructor-migration/reconciler/webapp_reconciler.go index b58219927..4b3b409e8 100644 --- a/examples/from-controller-runtime/04-constructor-migration/reconciler/webapp_reconciler.go +++ b/examples/from-controller-runtime/04-constructor-migration/reconciler/webapp_reconciler.go @@ -2,130 +2,99 @@ // reconciler/webapp_reconciler.go // -// The WebApp reconciler lifted from controller-runtime into Orkestra. +// The WebApp reconciler from 00-controller-runtime-baseline, migrated to Orkestra. // -// Compare with 00-controller-runtime-baseline/controller/webapp_controller.go. -// The reconcile logic is identical — same Deployment spec, same Service spec, -// same status patch, same IsNotFound guard. The only change is the signature: +// Compare this file with 00-controller-runtime-baseline/controller/webapp_controller.go. +// The differences are exactly three: // -// Before: Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) -// After: Reconcile(ctx context.Context, key string) error +// 1. SetupWithManager is gone — Orkestra provides the informer, workqueue, +// worker pool, leader election, panic recovery, and metrics. // -// key is namespace/name — the same string as req.String(). Everything inside -// the method is unchanged. +// 2. Scheme is gone — Orkestra handles scheme registration. // -// What was removed (owned by Orkestra now): -// - ctrl.NewManager and all setup in main.go -// - ctrl.NewControllerManagedBy / SetupWithManager -// - scheme registration -// - ctrl.Result retry semantics (return nil = done, return error = requeue) +// 3. NewWebAppReconciler is added — two lines wire the reconciler into Orkestra: +// kubeclient.ToClient wraps Orkestra's interface as a client.Client, +// domain.ReconcilerFrom adapts the ctrl.Request signature. // -// Orkestra provides (without you writing any of it): -// - Informer watching the WebApp CRD -// - Workqueue with deduplication and backoff -// - Worker pool (configurable in Katalog: workers: N) -// - safeReconcile panic recovery -// - Prometheus metrics (reconcile total, duration, queue depth) -// - Per-CRD health tracking -// - Leader election -// -// You own (same as before): -// - Reading objects from the informer cache -// - Finalizer management -// - Kubernetes events -// - Status updates -// - All reconcile logic +// Everything else — struct, Reconcile signature, reconcileDeployment, +// reconcileService, r.Get, r.Status().Update — is word for word the same as +// the baseline. Nothing inside Reconcile changed. package reconciler import ( "context" "fmt" - "strings" - apiv1 "github.com/orkspace/from-controller-runtime-demo/api/v1alpha1" - "github.com/orkspace/orkestra/domain" - "github.com/orkspace/orkestra/pkg/event" - "github.com/orkspace/orkestra/pkg/kubeclient" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/util/intstr" - "k8s.io/client-go/tools/cache" - sigs "sigs.k8s.io/controller-runtime/pkg/client" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" + + demov1alpha1 "github.com/orkspace/from-controller-runtime-demo/api/v1alpha1" + "github.com/orkspace/orkestra/domain" + "github.com/orkspace/orkestra/pkg/kubeclient" ) -// WebAppReconciler implements domain.Reconciler for the WebApp CRD. +// WebAppReconciler reconciles a WebApp object. +// Struct is identical to the baseline — embedded client.Client, same fields. +// Scheme is removed: Orkestra registers the scheme at startup. type WebAppReconciler struct { - informer cache.SharedIndexInformer - kube kubeclient.KubeClient - ev event.Recorder + client.Client } -// NewWebAppReconciler is the constructor function registered in the Katalog. -func NewWebAppReconciler( - kube kubeclient.KubeClient, - informer cache.SharedIndexInformer, - ev event.Recorder, -) domain.Reconciler { - return &WebAppReconciler{ - informer: informer, - kube: kube, - ev: ev, - } +// NewWebAppReconciler is the only new code. +// Two lines replace all of main.go, scheme registration, and SetupWithManager. +func NewWebAppReconciler(kube kubeclient.Interface) domain.Reconciler { + return domain.ReconcilerFrom(&WebAppReconciler{ + Client: kubeclient.ToClient(kube), + }) } -// Reconcile is called by Orkestra's worker pool for every queued WebApp key. -// key is namespace/name — same as req.String() in controller-runtime. -func (r *WebAppReconciler) Reconcile(ctx context.Context, key string) error { - raw, exists, err := r.informer.GetIndexer().GetByKey(key) - if err != nil { - return fmt.Errorf("cache lookup %q: %w", key, err) - } - if !exists { - return nil - } +// Reconcile — identical to the baseline. Signature, body, and return types unchanged. +func (r *WebAppReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + logger := log.FromContext(ctx) - webapp, ok := raw.(*apiv1.WebApp) - if !ok { - return fmt.Errorf("unexpected type %T", raw) - } - webapp = webapp.DeepCopyObject().(*apiv1.WebApp) - - if webapp.DeletionTimestamp != nil { - // Owner references clean up Deployment and Service automatically. - return nil + webapp := &demov1alpha1.WebApp{} + if err := r.Get(ctx, req.NamespacedName, webapp); err != nil { + if errors.IsNotFound(err) { + return ctrl.Result{}, nil + } + return ctrl.Result{}, err } if err := r.reconcileDeployment(ctx, webapp); err != nil { - return err + logger.Error(err, "failed to reconcile Deployment") + return ctrl.Result{}, err } + if err := r.reconcileService(ctx, webapp); err != nil { - return err + logger.Error(err, "failed to reconcile Service") + return ctrl.Result{}, err } - r.ev.Eventf(webapp, corev1.EventTypeNormal, "WebAppReconciled", - "WebApp %s/%s reconciled", webapp.Namespace, webapp.Name) + webapp.Status.Phase = "Running" + webapp.Status.Endpoint = fmt.Sprintf("%s.%s.svc.cluster.local", webapp.Name, webapp.Namespace) + webapp.Status.Replicas = webapp.Spec.Replicas + if err := r.Status().Update(ctx, webapp); err != nil { + logger.Error(err, "failed to update WebApp status") + return ctrl.Result{}, err + } - return r.kube.PatchStatus(ctx, webapp, map[string]interface{}{ - "phase": "Running", - "endpoint": fmt.Sprintf("%s-svc.%s.svc.cluster.local", webapp.Name, webapp.Namespace), - "replicas": webapp.Spec.Replicas, - }) + return ctrl.Result{}, nil } -// reconcileDeployment — same logic as the controller-runtime baseline. -// StrategicMergeFrom is used here because Deployment's container list carries -// patchMergeKey:"name" annotations — the API server merges containers by name -// rather than replacing the list wholesale. sigs.StrategicMergeFrom works here too. -func (r *WebAppReconciler) reconcileDeployment(ctx context.Context, webapp *apiv1.WebApp) error { +func (r *WebAppReconciler) reconcileDeployment(ctx context.Context, webapp *demov1alpha1.WebApp) error { replicas := webapp.Spec.Replicas - desired := &appsv1.Deployment{ + deploy := &appsv1.Deployment{ ObjectMeta: metav1.ObjectMeta{ Name: webapp.Name, Namespace: webapp.Namespace, OwnerReferences: []metav1.OwnerReference{ - *metav1.NewControllerRef(webapp, apiv1.GroupVersionKind), + *metav1.NewControllerRef(webapp, demov1alpha1.GroupVersionKind), }, }, Spec: appsv1.DeploymentSpec{ @@ -153,29 +122,25 @@ func (r *WebAppReconciler) reconcileDeployment(ctx context.Context, webapp *apiv } existing := &appsv1.Deployment{} - err := r.kube.Get(ctx, webapp.Namespace, webapp.Name, existing) + err := r.Get(ctx, client.ObjectKey{Name: webapp.Name, Namespace: webapp.Namespace}, existing) if errors.IsNotFound(err) { - return r.kube.Create(ctx, desired) + return r.Create(ctx, deploy) } if err != nil { return err } - patch := sigs.StrategicMergeFrom(existing.DeepCopy()) - existing.Spec = desired.Spec - return r.kube.Patch(ctx, existing, patch) + patch := client.MergeFrom(existing.DeepCopy()) + existing.Spec = deploy.Spec + return r.Patch(ctx, existing, patch) } -// reconcileService — same logic as the controller-runtime baseline. -// MergeFrom (JSON merge patch) is correct here — Service ports have no strategic -// merge key, so replace semantics are what the API server applies anyway. -// sigs.MergeFrom works here too. -func (r *WebAppReconciler) reconcileService(ctx context.Context, webapp *apiv1.WebApp) error { - desired := &corev1.Service{ +func (r *WebAppReconciler) reconcileService(ctx context.Context, webapp *demov1alpha1.WebApp) error { + svc := &corev1.Service{ ObjectMeta: metav1.ObjectMeta{ Name: webapp.Name + "-svc", Namespace: webapp.Namespace, OwnerReferences: []metav1.OwnerReference{ - *metav1.NewControllerRef(webapp, apiv1.GroupVersionKind), + *metav1.NewControllerRef(webapp, demov1alpha1.GroupVersionKind), }, }, Spec: corev1.ServiceSpec{ @@ -190,23 +155,14 @@ func (r *WebAppReconciler) reconcileService(ctx context.Context, webapp *apiv1.W } existing := &corev1.Service{} - err := r.kube.Get(ctx, webapp.Namespace, webapp.Name+"-svc", existing) + err := r.Get(ctx, client.ObjectKey{Name: webapp.Name + "-svc", Namespace: webapp.Namespace}, existing) if errors.IsNotFound(err) { - return r.kube.Create(ctx, desired) + return r.Create(ctx, svc) } if err != nil { return err } - patch := sigs.MergeFrom(existing.DeepCopy()) - existing.Spec.Ports = desired.Spec.Ports - return r.kube.Patch(ctx, existing, patch) -} - -// namespacedName splits a cache key "namespace/name" into its parts. -func namespacedName(key string) (namespace, name string) { - parts := strings.SplitN(key, "/", 2) - if len(parts) == 2 { - return parts[0], parts[1] - } - return "", parts[0] + patch := client.MergeFrom(existing.DeepCopy()) + existing.Spec.Ports = svc.Spec.Ports + return r.Patch(ctx, existing, patch) } diff --git a/examples/from-controller-runtime/05-constructor-orkestra-resources/go.mod.txt b/examples/from-controller-runtime/05-constructor-orkestra-resources/go.mod.txt index e52395d4b..1b6c19829 100644 --- a/examples/from-controller-runtime/05-constructor-orkestra-resources/go.mod.txt +++ b/examples/from-controller-runtime/05-constructor-orkestra-resources/go.mod.txt @@ -3,7 +3,7 @@ module github.com/orkspace/from-controller-runtime-demo go 1.26.3 require ( - github.com/orkspace/orkestra v0.7.12 + github.com/orkspace/orkestra v0.7.16 k8s.io/api v0.36.1 k8s.io/apimachinery v0.36.1 k8s.io/client-go v0.36.1 diff --git a/examples/from-controller-runtime/05-constructor-orkestra-resources/reconciler/webapp_reconciler.go b/examples/from-controller-runtime/05-constructor-orkestra-resources/reconciler/webapp_reconciler.go index 6084ce41a..3a2d98dbe 100644 --- a/examples/from-controller-runtime/05-constructor-orkestra-resources/reconciler/webapp_reconciler.go +++ b/examples/from-controller-runtime/05-constructor-orkestra-resources/reconciler/webapp_reconciler.go @@ -33,38 +33,26 @@ import ( apiv1 "github.com/orkspace/from-controller-runtime-demo/api/v1alpha1" "github.com/orkspace/orkestra/domain" - "github.com/orkspace/orkestra/pkg/event" "github.com/orkspace/orkestra/pkg/kubeclient" orkdeploy "github.com/orkspace/orkestra/pkg/resources/deployments" orksvc "github.com/orkspace/orkestra/pkg/resources/services" orktypes "github.com/orkspace/orkestra/pkg/types" corev1 "k8s.io/api/core/v1" - "k8s.io/client-go/tools/cache" ) // WebAppReconciler implements domain.Reconciler for the WebApp CRD. type WebAppReconciler struct { - informer cache.SharedIndexInformer - kube kubeclient.KubeClient - ev event.Recorder + kube kubeclient.Interface } // NewWebAppReconciler is the constructor function registered in the Katalog. -func NewWebAppReconciler( - kube kubeclient.KubeClient, - informer cache.SharedIndexInformer, - ev event.Recorder, -) domain.Reconciler { - return &WebAppReconciler{ - informer: informer, - kube: kube, - ev: ev, - } +func NewWebAppReconciler(kube kubeclient.Interface) domain.Reconciler { + return &WebAppReconciler{kube: kube} } // Reconcile is called by Orkestra's worker pool for every queued WebApp key. func (r *WebAppReconciler) Reconcile(ctx context.Context, key string) error { - raw, exists, err := r.informer.GetIndexer().GetByKey(key) + raw, exists, err := r.kube.GetInformer().GetIndexer().GetByKey(key) if err != nil { return fmt.Errorf("cache lookup %q: %w", key, err) } @@ -89,7 +77,7 @@ func (r *WebAppReconciler) Reconcile(ctx context.Context, key string) error { return err } - r.ev.Eventf(webapp, corev1.EventTypeNormal, "WebAppReconciled", + r.kube.GetEventRecorder().Eventf(webapp, corev1.EventTypeNormal, "WebAppReconciled", "WebApp %s/%s reconciled", webapp.Namespace, webapp.Name) return r.kube.PatchStatus(ctx, webapp, map[string]interface{}{ diff --git a/examples/from-controller-runtime/07-all-options/go.mod.txt b/examples/from-controller-runtime/07-all-options/go.mod.txt index 15612bb90..cc32ded28 100644 --- a/examples/from-controller-runtime/07-all-options/go.mod.txt +++ b/examples/from-controller-runtime/07-all-options/go.mod.txt @@ -3,7 +3,7 @@ module github.com/orkspace/from-controller-runtime-all-options go 1.26.3 require ( - github.com/orkspace/orkestra v0.7.12 + github.com/orkspace/orkestra v0.7.16 k8s.io/apimachinery v0.36.1 ) diff --git a/examples/from-controller-runtime/07-all-options/options/constructor/reconciler/webapp_reconciler.go b/examples/from-controller-runtime/07-all-options/options/constructor/reconciler/webapp_reconciler.go index 555843b62..81b887618 100644 --- a/examples/from-controller-runtime/07-all-options/options/constructor/reconciler/webapp_reconciler.go +++ b/examples/from-controller-runtime/07-all-options/options/constructor/reconciler/webapp_reconciler.go @@ -44,41 +44,29 @@ import ( apiv1 "github.com/orkspace/from-controller-runtime-all-options/options/constructor/api/v1alpha1" "github.com/orkspace/orkestra/domain" - "github.com/orkspace/orkestra/pkg/event" "github.com/orkspace/orkestra/pkg/kubeclient" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/util/intstr" - "k8s.io/client-go/tools/cache" sigs "sigs.k8s.io/controller-runtime/pkg/client" ) // WebAppReconciler implements domain.Reconciler for the ConstructorApp CRD. type WebAppReconciler struct { - informer cache.SharedIndexInformer - kube kubeclient.KubeClient - ev event.Recorder + kube kubeclient.Interface } // NewWebAppReconciler is the constructor function registered in the Katalog. -func NewWebAppReconciler( - kube kubeclient.KubeClient, - informer cache.SharedIndexInformer, - ev event.Recorder, -) domain.Reconciler { - return &WebAppReconciler{ - informer: informer, - kube: kube, - ev: ev, - } +func NewWebAppReconciler(kube kubeclient.Interface) domain.Reconciler { + return &WebAppReconciler{kube: kube} } // Reconcile is called by Orkestra's worker pool for every queued ConstructorApp key. // key is namespace/name — same as req.String() in controller-runtime. func (r *WebAppReconciler) Reconcile(ctx context.Context, key string) error { - raw, exists, err := r.informer.GetIndexer().GetByKey(key) + raw, exists, err := r.kube.GetInformer().GetIndexer().GetByKey(key) if err != nil { return fmt.Errorf("cache lookup %q: %w", key, err) } @@ -104,7 +92,7 @@ func (r *WebAppReconciler) Reconcile(ctx context.Context, key string) error { return err } - r.ev.Eventf(webapp, corev1.EventTypeNormal, "WebAppReconciled", + r.kube.GetEventRecorder().Eventf(webapp, corev1.EventTypeNormal, "WebAppReconciled", "ConstructorApp %s/%s reconciled", webapp.Namespace, webapp.Name) return r.kube.PatchStatus(ctx, webapp, map[string]interface{}{ diff --git a/examples/from-controller-runtime/07-all-options/options/ork-resources/reconciler/webapp_reconciler.go b/examples/from-controller-runtime/07-all-options/options/ork-resources/reconciler/webapp_reconciler.go index 10b79505c..bb5ead777 100644 --- a/examples/from-controller-runtime/07-all-options/options/ork-resources/reconciler/webapp_reconciler.go +++ b/examples/from-controller-runtime/07-all-options/options/ork-resources/reconciler/webapp_reconciler.go @@ -33,38 +33,26 @@ import ( apiv1 "github.com/orkspace/from-controller-runtime-all-options/options/ork-resources/api/v1alpha1" "github.com/orkspace/orkestra/domain" - "github.com/orkspace/orkestra/pkg/event" "github.com/orkspace/orkestra/pkg/kubeclient" orkdeploy "github.com/orkspace/orkestra/pkg/resources/deployments" orksvc "github.com/orkspace/orkestra/pkg/resources/services" orktypes "github.com/orkspace/orkestra/pkg/types" corev1 "k8s.io/api/core/v1" - "k8s.io/client-go/tools/cache" ) // WebAppReconciler implements domain.Reconciler for the OrkApp CRD. type WebAppReconciler struct { - informer cache.SharedIndexInformer - kube kubeclient.KubeClient - ev event.Recorder + kube kubeclient.Interface } // NewWebAppReconciler is the constructor function registered in the Katalog. -func NewWebAppReconciler( - kube kubeclient.KubeClient, - informer cache.SharedIndexInformer, - ev event.Recorder, -) domain.Reconciler { - return &WebAppReconciler{ - informer: informer, - kube: kube, - ev: ev, - } +func NewWebAppReconciler(kube kubeclient.Interface) domain.Reconciler { + return &WebAppReconciler{kube: kube} } // Reconcile is called by Orkestra's worker pool for every queued OrkApp key. func (r *WebAppReconciler) Reconcile(ctx context.Context, key string) error { - raw, exists, err := r.informer.GetIndexer().GetByKey(key) + raw, exists, err := r.kube.GetInformer().GetIndexer().GetByKey(key) if err != nil { return fmt.Errorf("cache lookup %q: %w", key, err) } @@ -89,7 +77,7 @@ func (r *WebAppReconciler) Reconcile(ctx context.Context, key string) error { return err } - r.ev.Eventf(webapp, corev1.EventTypeNormal, "WebAppReconciled", + r.kube.GetEventRecorder().Eventf(webapp, corev1.EventTypeNormal, "WebAppReconciled", "OrkApp %s/%s reconciled", webapp.Namespace, webapp.Name) return r.kube.PatchStatus(ctx, webapp, map[string]interface{}{ diff --git a/examples/from-controller-runtime/README.md b/examples/from-controller-runtime/README.md index 03cfa16ae..0805d6821 100644 --- a/examples/from-controller-runtime/README.md +++ b/examples/from-controller-runtime/README.md @@ -64,21 +64,19 @@ Both resources — Deployment and Service — are created in Go. No declared tem --- -### [04 — constructor: lift and change signature](../from-controller-runtime/04-constructor-migration/README.md) +### [04 — constructor: zero changes](../from-controller-runtime/04-constructor-migration/README.md) -The migration path. The existing `Reconcile` logic is lifted verbatim. Only the signature changes: +The migration path. The existing `Reconcile` method is completely untouched — same signature, same body, same return types. Two lines wire it into Orkestra: ```go -// Before -func (r *WebAppReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) - -// After — everything inside is identical -func (r *WebAppReconciler) Reconcile(ctx context.Context, key string) error +func NewWebAppReconciler(kube kubeclient.Interface) domain.Reconciler { + return domain.ReconcilerFrom(&WebAppReconciler{ + Client: kubeclient.ToClient(kube), + }) +} ``` -`key` is `namespace/name` — the same as `req.String()`. Remove `SetupWithManager`, scheme registration, and `main.go`. The resource management code (Get / IsNotFound / Create / Patch) stays unchanged. - -What you removed: `ctrl.NewManager`, `SetupWithManager`, scheme registration. Orkestra provides the informer, workqueue, worker pool, leader election, panic recovery, and metrics. +Remove `SetupWithManager`, `Scheme`, and `main.go`. Everything inside `Reconcile` — `r.Get`, `r.Create`, `r.Status().Update()` — stays unchanged. Orkestra provides the informer, workqueue, worker pool, leader election, panic recovery, and metrics. ``` 04-constructor-migration/ @@ -140,7 +138,7 @@ Use this to compare patterns side by side, or as the starting point before distr | **01 declarative** | No | No | Nothing — pure YAML | | **02 hybrid** | Yes — hook only | Yes | The 10% templates can't express | | **03 hooks only** | Yes — all resources | Yes | All child resource specs in Go | -| **04 constructor migration** | Yes — full reconciler | Yes | Reconcile logic; manager removed | +| **04 constructor migration** | Yes — full reconciler | Yes | Reconcile unchanged; manager removed | | **05 constructor resources** | Yes — full reconciler | Yes | Reconcile logic; resource ops simplified | | **06 ork migrate** | Yes — tool generates it | Yes | Starting from an existing operator | | **07 all options** | Depends on options used | Yes | All five patterns in one Komposer | diff --git a/examples/registry-guide/09-hooks-katalog/go.mod.txt b/examples/registry-guide/09-hooks-katalog/go.mod.txt index 5251308d3..901c82a6d 100644 --- a/examples/registry-guide/09-hooks-katalog/go.mod.txt +++ b/examples/registry-guide/09-hooks-katalog/go.mod.txt @@ -3,7 +3,7 @@ module github.com/orkspace/orkestra-registry-guide go 1.26.3 require ( - github.com/orkspace/orkestra v0.7.13 + github.com/orkspace/orkestra v0.7.16 k8s.io/apimachinery v0.36.1 ) diff --git a/examples/registry-guide/11-ork-action/database-operator/go.mod.txt b/examples/registry-guide/11-ork-action/database-operator/go.mod.txt index 5251308d3..901c82a6d 100644 --- a/examples/registry-guide/11-ork-action/database-operator/go.mod.txt +++ b/examples/registry-guide/11-ork-action/database-operator/go.mod.txt @@ -3,7 +3,7 @@ module github.com/orkspace/orkestra-registry-guide go 1.26.3 require ( - github.com/orkspace/orkestra v0.7.13 + github.com/orkspace/orkestra v0.7.16 k8s.io/apimachinery v0.36.1 ) diff --git a/examples/resilience/safe-reconcile/go.mod.txt b/examples/resilience/safe-reconcile/go.mod.txt index 2c19c3587..d751927cd 100644 --- a/examples/resilience/safe-reconcile/go.mod.txt +++ b/examples/resilience/safe-reconcile/go.mod.txt @@ -3,7 +3,7 @@ module github.com/orkspace/safe-reconcile-demo go 1.26.3 require ( - github.com/orkspace/orkestra v0.7.12 + github.com/orkspace/orkestra v0.7.16 k8s.io/apimachinery v0.36.1 ) diff --git a/go.mod b/go.mod index 3d6fcdaa0..089ddc28b 100644 --- a/go.mod +++ b/go.mod @@ -111,6 +111,7 @@ require ( github.com/go-errors/errors v1.4.2 // indirect github.com/go-gorp/gorp/v3 v3.1.0 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-logr/zerologr v1.2.3 // indirect github.com/go-openapi/swag/cmdutils v0.25.4 // indirect github.com/go-openapi/swag/conv v0.25.4 // indirect github.com/go-openapi/swag/fileutils v0.25.4 // indirect diff --git a/go.sum b/go.sum index 5e7a74fb2..8559061ea 100644 --- a/go.sum +++ b/go.sum @@ -230,6 +230,8 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= +github.com/go-logr/zerologr v1.2.3 h1:up5N9vcH9Xck3jJkXzgyOxozT14R47IyDODz8LM1KSs= +github.com/go-logr/zerologr v1.2.3/go.mod h1:BxwGo7y5zgSHYR1BjbnHPyF/5ZjVKfKxAZANVu6E8Ho= github.com/go-openapi/jsonpointer v0.22.1 h1:sHYI1He3b9NqJ4wXLoJDKmUmHkWy/L7rtEo92JUxBNk= github.com/go-openapi/jsonpointer v0.22.1/go.mod h1:pQT9OsLkfz1yWoMgYFy4x3U5GY5nUlsOn1qSBH5MkCM= github.com/go-openapi/jsonreference v0.21.3 h1:96Dn+MRPa0nYAR8DR1E03SblB5FJvh7W6krPI0Z7qMc= diff --git a/pkg/katalog/generate_rbac.go b/pkg/katalog/generate_rbac.go index e82b251ca..60b76eb3a 100644 --- a/pkg/katalog/generate_rbac.go +++ b/pkg/katalog/generate_rbac.go @@ -14,6 +14,10 @@ var defaultVerbs = []string{ "get", "list", "watch", "create", "update", "patch", "delete", } +// watchVerbs is the minimal verb set for secondary watch resources. +// Watch entries only observe — they never write to watched resources. +var watchVerbs = []string{"get", "list", "watch"} + // rbacVerbsFor returns the appropriate verb set for a built-in resource. // // Roles and ClusterRoles require two extra verbs beyond standard CRUD: @@ -167,6 +171,19 @@ func (k *Katalog) GenerateRBACRules() []rbacv1.PolicyRule { }) } } + + // Watch-entry resources — read-only + for _, w := range crd.WatchEntries() { + gvr, ok := k.ResolveGVR(w.ToManagedResource()) + if !ok { + continue + } + rules = append(rules, rbacv1.PolicyRule{ + APIGroups: []string{gvr.Group}, + Resources: []string{gvr.Resource}, + Verbs: watchVerbs, + }) + } } // ─────────────────────────────────────────────── @@ -350,6 +367,19 @@ func (k *Katalog) GenerateRuntimeRBACRules() []rbacv1.PolicyRule { }) } } + + // Watch-entry resources — read-only + for _, w := range crd.WatchEntries() { + gvr, ok := k.ResolveGVR(w.ToManagedResource()) + if !ok { + continue + } + rules = append(rules, rbacv1.PolicyRule{ + APIGroups: []string{gvr.Group}, + Resources: []string{gvr.Resource}, + Verbs: watchVerbs, + }) + } } // ─────────────────────────────────────────────── @@ -728,6 +758,17 @@ func (k *Katalog) GeneratePerCRDRBACRules() map[string][]rbacv1.PolicyRule { } } + // Watch-entry resources — read-only + for _, w := range crd.WatchEntries() { + if gvr, ok := k.ResolveGVR(w.ToManagedResource()); ok { + rules = append(rules, rbacv1.PolicyRule{ + APIGroups: []string{gvr.Group}, + Resources: []string{gvr.Resource}, + Verbs: watchVerbs, + }) + } + } + for _, b := range children.AllBuiltInKindDefs() { if b.Detect != nil && b.Detect(crd) { rules = append(rules, rbacv1.PolicyRule{ diff --git a/pkg/katalog/pre_reconcile.go b/pkg/katalog/pre_reconcile.go index 33306f3d5..cc8a63b79 100644 --- a/pkg/katalog/pre_reconcile.go +++ b/pkg/katalog/pre_reconcile.go @@ -19,7 +19,7 @@ import ( // // preReconcile.external runs first (shared enrichment), then reconcileGate.external, // then conditions are evaluated against the accumulated resolver. -func (k *Katalog) EvaluatePreReconcile(ctx context.Context, crdName string, obj *unstructured.Unstructured, cs kubernetes.Interface) (allowed bool, reason string) { +func (k *Katalog) EvaluatePreReconcile(ctx context.Context, crdName string, obj *unstructured.Unstructured, cs kubernetes.Interface, sentinels map[string]string) (allowed bool, reason string) { if obj == nil { return true, "" } @@ -47,6 +47,9 @@ func (k *Katalog) EvaluatePreReconcile(ctx context.Context, crdName string, obj if intent := orktypes.ServeIntentFromObject(resolver.Data()); intent != nil { resolver = resolver.WithRequest(intent) } + if len(sentinels) > 0 { + resolver = resolver.WithSentinels(rc.DeclaredSentinels(), sentinels) + } gvk := entry.GVKString() @@ -74,7 +77,7 @@ func (k *Katalog) EvaluatePreReconcile(ctx context.Context, crdName string, obj // // preReconcile.external runs first (shared enrichment), then enqueueGate.external, // then conditions are evaluated against the accumulated resolver. -func (k *Katalog) EvaluateEnqueueFilter(ctx context.Context, crdName string, obj domain.Object, cs kubernetes.Interface) bool { +func (k *Katalog) EvaluateEnqueueFilter(ctx context.Context, crdName string, obj domain.Object, cs kubernetes.Interface, sentinels map[string]string) bool { if obj == nil { return true } @@ -102,6 +105,9 @@ func (k *Katalog) EvaluateEnqueueFilter(ctx context.Context, crdName string, obj if intent := orktypes.ServeIntentFromObject(resolver.Data()); intent != nil { resolver = resolver.WithRequest(intent) } + if len(sentinels) > 0 { + resolver = resolver.WithSentinels(rc.DeclaredSentinels(), sentinels) + } gvk := entry.GVKString() diff --git a/pkg/katalog/testdata/validate/invalid/bad-retry-backoff-max-lt-initial.yaml b/pkg/katalog/testdata/validate/invalid/bad-retry-backoff-max-lt-initial.yaml new file mode 100644 index 000000000..0a7153593 --- /dev/null +++ b/pkg/katalog/testdata/validate/invalid/bad-retry-backoff-max-lt-initial.yaml @@ -0,0 +1,16 @@ +apiVersion: orkestra.orkspace.io/v1 +kind: Katalog +metadata: + name: bad-retry-backoff-max-lt-initial + version: 0.1.0 + description: Invalid retryBackoff — max less than initial. +spec: + crds: + widget: + crdFile: ../crd.yaml + operatorBox: + reconciler: + queue: + retryBackoff: + initial: 30s + max: 1s diff --git a/pkg/katalog/testdata/validate/invalid/bad-retry-backoff-negative-multiplier.yaml b/pkg/katalog/testdata/validate/invalid/bad-retry-backoff-negative-multiplier.yaml new file mode 100644 index 000000000..6f7d8501f --- /dev/null +++ b/pkg/katalog/testdata/validate/invalid/bad-retry-backoff-negative-multiplier.yaml @@ -0,0 +1,16 @@ +apiVersion: orkestra.orkspace.io/v1 +kind: Katalog +metadata: + name: bad-retry-backoff-negative-multiplier + version: 0.1.0 + description: Invalid retryBackoff — negative multiplier. +spec: + crds: + widget: + crdFile: ../crd.yaml + operatorBox: + reconciler: + queue: + retryBackoff: + initial: 500ms + multiplier: -1.0 diff --git a/pkg/katalog/testdata/validate/invalid/bad-watch-duplicate.yaml b/pkg/katalog/testdata/validate/invalid/bad-watch-duplicate.yaml new file mode 100644 index 000000000..87dea1456 --- /dev/null +++ b/pkg/katalog/testdata/validate/invalid/bad-watch-duplicate.yaml @@ -0,0 +1,17 @@ +# Triggers: duplicate watch entry (same apiVersion + kind) +apiVersion: orkestra.orkspace.io/v1 +kind: Katalog +metadata: + name: bad-watch-duplicate + version: 0.1.0 + description: watch declares the same resource twice. +spec: + crds: + widget: + crdFile: ../crd.yaml + operatorBox: + watch: + - apiVersion: apps/v1 + kind: Deployment + - apiVersion: apps/v1 + kind: Deployment diff --git a/pkg/katalog/testdata/validate/invalid/bad-watch-undeclared-sentinel-in-gate.yaml b/pkg/katalog/testdata/validate/invalid/bad-watch-undeclared-sentinel-in-gate.yaml new file mode 100644 index 000000000..b77bf6283 --- /dev/null +++ b/pkg/katalog/testdata/validate/invalid/bad-watch-undeclared-sentinel-in-gate.yaml @@ -0,0 +1,19 @@ +# Triggers: enqueueGate template uses a sentinel not declared in preReconcile.sentinels +apiVersion: orkestra.orkspace.io/v1 +kind: Katalog +metadata: + name: bad-watch-undeclared-sentinel-in-gate + version: 0.1.0 + description: gate template references a sentinel that is not declared. +spec: + crds: + widget: + crdFile: ../crd.yaml + operatorBox: + preReconcile: + sentinels: + - generationChanged + enqueueGate: + when: + - field: "{{ labelsChanged }}" + equals: "true" diff --git a/pkg/katalog/testdata/validate/invalid/bad-watch-unknown-on.yaml b/pkg/katalog/testdata/validate/invalid/bad-watch-unknown-on.yaml new file mode 100644 index 000000000..72a806dad --- /dev/null +++ b/pkg/katalog/testdata/validate/invalid/bad-watch-unknown-on.yaml @@ -0,0 +1,17 @@ +# Triggers: watch entry with unknown on: value +apiVersion: orkestra.orkspace.io/v1 +kind: Katalog +metadata: + name: bad-watch-unknown-on + version: 0.1.0 + description: > + watch entry declares an unrecognised on: value. +spec: + crds: + widget: + crdFile: ../crd.yaml + operatorBox: + watch: + - apiVersion: apps/v1 + kind: Deployment + on: [modified] diff --git a/pkg/katalog/testdata/validate/invalid/bad-watch-unknown-sentinel.yaml b/pkg/katalog/testdata/validate/invalid/bad-watch-unknown-sentinel.yaml new file mode 100644 index 000000000..80d5b9797 --- /dev/null +++ b/pkg/katalog/testdata/validate/invalid/bad-watch-unknown-sentinel.yaml @@ -0,0 +1,16 @@ +# Triggers: preReconcile.sentinels contains an unknown sentinel name +apiVersion: orkestra.orkspace.io/v1 +kind: Katalog +metadata: + name: bad-watch-unknown-sentinel + version: 0.1.0 + description: preReconcile.sentinels declares an unknown sentinel. +spec: + crds: + widget: + crdFile: ../crd.yaml + operatorBox: + preReconcile: + sentinels: + - generationChanged + - specChanged diff --git a/pkg/katalog/testdata/validate/valid/retry-backoff-full.yaml b/pkg/katalog/testdata/validate/valid/retry-backoff-full.yaml new file mode 100644 index 000000000..17ecbacdd --- /dev/null +++ b/pkg/katalog/testdata/validate/valid/retry-backoff-full.yaml @@ -0,0 +1,28 @@ +apiVersion: orkestra.orkspace.io/v1 +kind: Katalog +metadata: + name: retry-backoff-full + version: 0.1.0 + description: Valid retryBackoff full form on queue and external. +spec: + crds: + widget: + crdFile: ../crd.yaml + operatorBox: + reconciler: + resync: 10m + queue: + retryBackoff: + initial: 500ms + max: 30s + multiplier: 2.0 + maxAttempts: 3 + onReconcile: + external: + - name: health + url: http://svc/health + retryBackoff: + initial: 1s + max: 10s + multiplier: 1.5 + maxAttempts: 3 diff --git a/pkg/katalog/testdata/validate/valid/retry-backoff-shorthand.yaml b/pkg/katalog/testdata/validate/valid/retry-backoff-shorthand.yaml new file mode 100644 index 000000000..904f01f5a --- /dev/null +++ b/pkg/katalog/testdata/validate/valid/retry-backoff-shorthand.yaml @@ -0,0 +1,15 @@ +apiVersion: orkestra.orkspace.io/v1 +kind: Katalog +metadata: + name: retry-backoff-shorthand + version: 0.1.0 + description: Valid retryBackoff shorthand on queue and external. +spec: + crds: + widget: + crdFile: ../crd.yaml + operatorBox: + reconciler: + resync: 10m + queue: + retryBackoff: 5s diff --git a/pkg/katalog/testdata/validate/valid/watch-entries.yaml b/pkg/katalog/testdata/validate/valid/watch-entries.yaml new file mode 100644 index 000000000..fd7d3c316 --- /dev/null +++ b/pkg/katalog/testdata/validate/valid/watch-entries.yaml @@ -0,0 +1,21 @@ +apiVersion: orkestra.orkspace.io/v1 +kind: Katalog +metadata: + name: watch-entries + version: 0.1.0 + description: Valid operatorBox.watch and preReconcile.sentinels. +spec: + crds: + widget: + crdFile: ../crd.yaml + operatorBox: + preReconcile: + sentinels: + - generationChanged + - labelsChanged + watch: + - apiVersion: apps/v1 + kind: Deployment + - apiVersion: v1 + kind: Node + on: [update] diff --git a/pkg/katalog/validate.go b/pkg/katalog/validate.go index 718ca16a4..39d58755d 100644 --- a/pkg/katalog/validate.go +++ b/pkg/katalog/validate.go @@ -286,6 +286,20 @@ func (k *Katalog) ValidateConfig(kfg *konfig.Konfig) (*Katalog, error) { return nil, err } + // ------------------------------------------------------------------------- + // 34a. Validate watch entries and preReconcile sentinels + // ------------------------------------------------------------------------- + if err := k.validateWatchEntries(); err != nil { + return nil, err + } + + // ------------------------------------------------------------------------- + // 34b. Validate CRD entry labels (static keys, template values) + // ------------------------------------------------------------------------- + if err := k.validateCRDEntryLabels(); err != nil { + return nil, err + } + // ------------------------------------------------------------------------- // 35. Validate envFrom refs (suffix requires keys) // ------------------------------------------------------------------------- @@ -342,5 +356,13 @@ func (k *Katalog) ValidateConfig(kfg *konfig.Konfig) (*Katalog, error) { return nil, err } + // ------------------------------------------------------------------------- + // 46. Validate retryBackoff — hard errors for invalid values; warnings when + // worst-case delay exceeds the effective resync window. + // ------------------------------------------------------------------------- + if err := k.validateRetryBackoff(); err != nil { + return nil, err + } + return k, nil } diff --git a/pkg/katalog/validate_hooks_reconcilers_test.go b/pkg/katalog/validate_hooks_reconcilers_test.go index c59468fbb..4e39d6104 100644 --- a/pkg/katalog/validate_hooks_reconcilers_test.go +++ b/pkg/katalog/validate_hooks_reconcilers_test.go @@ -4,11 +4,9 @@ import ( "testing" "github.com/orkspace/orkestra/domain" - "github.com/orkspace/orkestra/pkg/event" "github.com/orkspace/orkestra/pkg/kubeclient" orktypes "github.com/orkspace/orkestra/pkg/types" "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/client-go/tools/cache" ) // ── Helpers ─────────────────────────────────────────────────────────────────── @@ -20,7 +18,7 @@ func stubHookFn() func() domain.AnyReconcileHooks { } func stubRecFn() orktypes.NewReconcilerFunc { - return func(kubeclient.Interface, cache.SharedIndexInformer, event.Recorder) domain.Reconciler { + return func(kubeclient.Interface) domain.Reconciler { return nil } } diff --git a/pkg/katalog/validate_labels.go b/pkg/katalog/validate_labels.go new file mode 100644 index 000000000..4c6baa79f --- /dev/null +++ b/pkg/katalog/validate_labels.go @@ -0,0 +1,35 @@ +package katalog + +import ( + "fmt" + "strings" + + "k8s.io/apimachinery/pkg/api/validate/content" +) + +// validateCRDEntryLabels validates the labels: block on each CRD entry. +// +// Enforces: +// 1. Label keys must be valid Kubernetes qualified names (static — no templates). +// 2. Label values must be valid Go templates (they are resolved at reconcile time +// against the CR object, so {{ .metadata.name }} etc. are allowed). +func (k *Katalog) validateCRDEntryLabels() error { + funcMap := buildFuncMapForValidation(k.Notes) + for crdName, crd := range k.Enabled() { + if !crd.HasUserLabels() { + continue + } + for key, value := range crd.Labels { + if isTemplate(key) { + return fmt.Errorf("%s CRD %q: labels: key %q must be a static label key, not a template", failureMark(), crdName, key) + } + if errs := content.IsLabelKey(key); len(errs) > 0 { + return fmt.Errorf("%s CRD %q: labels: key %q is not a valid Kubernetes label key: %s", failureMark(), crdName, key, strings.Join(errs, "; ")) + } + if err := validateTemplate("labels", crdName, key, "value", value, funcMap); err != nil { + return err + } + } + } + return nil +} diff --git a/pkg/katalog/validate_retry_backoff.go b/pkg/katalog/validate_retry_backoff.go new file mode 100644 index 000000000..a6623dbba --- /dev/null +++ b/pkg/katalog/validate_retry_backoff.go @@ -0,0 +1,111 @@ +package katalog + +import ( + "fmt" + "time" + + orktypes "github.com/orkspace/orkestra/pkg/types" +) + +// validateRetryBackoff checks that declared retryBackoff configurations are +// internally valid and warns when the worst-case retry window exceeds the +// effective resync period. +// +// Why warn rather than error: a long retry window is not always wrong — an +// external: block retrying a slow API and a short resync window can coexist +// (the queue re-enqueues on the next resync anyway). The warning surfaces the +// math so the user can make an informed choice. +func (k *Katalog) validateRetryBackoff() error { + for name, crd := range k.enabledCRDs { + changed := false + rec := crd.OperatorBox.Reconciler + resync := effectiveResync(rec) + + // queue.retryBackoff — applies to the reconcile loop as a whole. + if rec.HasRetryBackoff() { + rb := rec.Queue.RetryBackoff + if err := validateBackoffConfig("queue.retryBackoff", rb); err != nil { + return fmt.Errorf("%s CRD %q: %w", failureMark(), name, err) + } + if resync > 0 { + if wc := rb.WorstCaseDuration(); wc > resync { + crd.Warnings.AddWarning(fmt.Sprintf( + "queue.retryBackoff worst-case delay (%s) exceeds resync (%s) — "+ + "the queue will re-enqueue before retries finish; "+ + "consider reducing maxAttempts or initial delay", + wc.Round(time.Millisecond), resync.Round(time.Millisecond), + )) + changed = true + } + } + } + + // external[].retryBackoff — applies per external call. + for _, phase := range allExternalPhases(crd) { + for i, ext := range phase { + if ext.RetryBackoff == nil { + continue + } + label := fmt.Sprintf("external[%d] %q retryBackoff", i, ext.Name) + if err := validateBackoffConfig(label, ext.RetryBackoff); err != nil { + return fmt.Errorf("%s CRD %q: %w", failureMark(), name, err) + } + if resync > 0 { + if wc := ext.RetryBackoff.WorstCaseDuration(); wc > resync { + crd.Warnings.AddWarning(fmt.Sprintf( + "external[%d] %q: retryBackoff worst-case delay (%s) exceeds resync (%s) — "+ + "the reconcile will block longer than the resync window for this call", + i, ext.Name, + wc.Round(time.Millisecond), resync.Round(time.Millisecond), + )) + changed = true + } + } + } + } + + if changed { + k.enabledCRDs[name] = crd + } + } + return nil +} + +// validateBackoffConfig returns a hard error for structurally invalid values. +func validateBackoffConfig(label string, rb *orktypes.RetryBackoffConfig) error { + if rb.Multiplier < 0 { + return fmt.Errorf("%s %s: multiplier must be >= 0, got %g", failureMark(), label, rb.Multiplier) + } + if rb.MaxAttempts < 0 { + return fmt.Errorf("%s %s: maxAttempts must be >= 0, got %d", failureMark(), label, rb.MaxAttempts) + } + opts := rb.ToRetryDoOptions() + opts.ApplyDefaults() + if opts.Max < opts.Base { + return fmt.Errorf("%s %s: max (%s) must be >= initial (%s)", failureMark(), label, opts.Max, opts.Base) + } + return nil +} + +// effectiveResync returns the resolved resync duration for this CRD's reconciler. +// By the time validation runs, enrichCRDs has already applied global defaults, +// so Reconciler.Resync.Duration is the effective value. +func effectiveResync(rec *orktypes.ReconcilerConfig) time.Duration { + if rec == nil { + return 0 + } + return rec.Resync.Duration +} + +// allExternalPhases collects all external call lists from onReconcile, onCreate, +// onDelete, and the preReconcile enqueueGate/reconcileGate. +func allExternalPhases(crd orktypes.CRDEntry) [][]orktypes.ExternalCallSpec { + box := &crd.OperatorBox + phases := [][]orktypes.ExternalCallSpec{ + box.OnReconcile.ExternalCalls(), + box.OnCreate.ExternalCalls(), + box.OnDelete.ExternalCalls(), + } + phases = append(phases, box.PreReconcile.GateExternalCalls()...) + return phases +} diff --git a/pkg/katalog/validate_retry_backoff_test.go b/pkg/katalog/validate_retry_backoff_test.go new file mode 100644 index 000000000..a3ab6b2a4 --- /dev/null +++ b/pkg/katalog/validate_retry_backoff_test.go @@ -0,0 +1,160 @@ +package katalog + +import ( + "testing" + "time" + + orktypes "github.com/orkspace/orkestra/pkg/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func katalogWithRetryBackoff(crdName string, rec *orktypes.ReconcilerConfig, box orktypes.OperatorBoxConfig) *Katalog { + box.Reconciler = rec + return &Katalog{ + enabledCRDs: map[string]orktypes.CRDEntry{ + crdName: {OperatorBox: box}, + }, + } +} + +func dur(d time.Duration) orktypes.Duration { return orktypes.Duration{Duration: d} } + +// ── queue.retryBackoff ──────────────────────────────────────────────────────── + +func TestValidateRetryBackoff_NoConfig(t *testing.T) { + k := katalogWithRetryBackoff("myapp", nil, orktypes.OperatorBoxConfig{}) + assert.NoError(t, k.validateRetryBackoff()) +} + +func TestValidateRetryBackoff_ValidShorthand(t *testing.T) { + k := katalogWithRetryBackoff("myapp", &orktypes.ReconcilerConfig{ + Resync: dur(10 * time.Minute), + Queue: orktypes.Queue{RetryBackoff: &orktypes.RetryBackoffConfig{Initial: dur(5 * time.Second)}}, + }, orktypes.OperatorBoxConfig{}) + assert.NoError(t, k.validateRetryBackoff()) +} + +func TestValidateRetryBackoff_ValidFullForm(t *testing.T) { + k := katalogWithRetryBackoff("myapp", &orktypes.ReconcilerConfig{ + Resync: dur(10 * time.Minute), + Queue: orktypes.Queue{RetryBackoff: &orktypes.RetryBackoffConfig{ + Initial: dur(500 * time.Millisecond), + Max: dur(30 * time.Second), + Multiplier: 2.0, + MaxAttempts: 3, + }}, + }, orktypes.OperatorBoxConfig{}) + assert.NoError(t, k.validateRetryBackoff()) +} + +func TestValidateRetryBackoff_NegativeMultiplierErrors(t *testing.T) { + k := katalogWithRetryBackoff("myapp", &orktypes.ReconcilerConfig{ + Queue: orktypes.Queue{RetryBackoff: &orktypes.RetryBackoffConfig{ + Initial: dur(500 * time.Millisecond), + Multiplier: -1.0, + }}, + }, orktypes.OperatorBoxConfig{}) + err := k.validateRetryBackoff() + require.Error(t, err) + assert.Contains(t, err.Error(), "multiplier must be >= 0") +} + +func TestValidateRetryBackoff_MaxLessThanInitialErrors(t *testing.T) { + k := katalogWithRetryBackoff("myapp", &orktypes.ReconcilerConfig{ + Queue: orktypes.Queue{RetryBackoff: &orktypes.RetryBackoffConfig{ + Initial: dur(30 * time.Second), + Max: dur(1 * time.Second), + }}, + }, orktypes.OperatorBoxConfig{}) + err := k.validateRetryBackoff() + require.Error(t, err) + assert.Contains(t, err.Error(), "max") + assert.Contains(t, err.Error(), "must be >= initial") +} + +func TestValidateRetryBackoff_WorstCaseExceedsResyncWarns(t *testing.T) { + // maxAttempts=5, initial=10s, multiplier=2 → delays: 10s+20s+40s+80s = 150s > 30s resync + k := katalogWithRetryBackoff("myapp", &orktypes.ReconcilerConfig{ + Resync: dur(30 * time.Second), + Queue: orktypes.Queue{RetryBackoff: &orktypes.RetryBackoffConfig{ + Initial: dur(10 * time.Second), + Max: dur(5 * time.Minute), + Multiplier: 2.0, + MaxAttempts: 5, + }}, + }, orktypes.OperatorBoxConfig{}) + assert.NoError(t, k.validateRetryBackoff()) + crd := k.enabledCRDs["myapp"] + require.True(t, crd.Warnings.HasWarnings(), "expected a warning about retry window exceeding resync") + assert.Contains(t, crd.Warnings[0], "worst-case delay") +} + +func TestValidateRetryBackoff_WorstCaseWithinResyncNoWarning(t *testing.T) { + // maxAttempts=3, initial=500ms, multiplier=2 → delays: 500ms+1s = 1.5s < 10m resync + k := katalogWithRetryBackoff("myapp", &orktypes.ReconcilerConfig{ + Resync: dur(10 * time.Minute), + Queue: orktypes.Queue{RetryBackoff: &orktypes.RetryBackoffConfig{ + Initial: dur(500 * time.Millisecond), + Max: dur(30 * time.Second), + Multiplier: 2.0, + MaxAttempts: 3, + }}, + }, orktypes.OperatorBoxConfig{}) + assert.NoError(t, k.validateRetryBackoff()) + crd := k.enabledCRDs["myapp"] + assert.False(t, crd.Warnings.HasWarnings()) +} + +// ── external[].retryBackoff ─────────────────────────────────────────────────── + +func TestValidateRetryBackoff_ExternalValidShorthand(t *testing.T) { + k := katalogWithRetryBackoff("myapp", &orktypes.ReconcilerConfig{ + Resync: dur(10 * time.Minute), + }, orktypes.OperatorBoxConfig{ + OnReconcile: &orktypes.HookTemplates{ + External: []orktypes.ExternalCallSpec{ + {Name: "health", URL: "http://svc/health", RetryBackoff: &orktypes.RetryBackoffConfig{ + Initial: dur(1 * time.Second), + }}, + }, + }, + }) + assert.NoError(t, k.validateRetryBackoff()) +} + +func TestValidateRetryBackoff_ExternalNegativeMultiplierErrors(t *testing.T) { + k := katalogWithRetryBackoff("myapp", nil, orktypes.OperatorBoxConfig{ + OnReconcile: &orktypes.HookTemplates{ + External: []orktypes.ExternalCallSpec{ + {Name: "health", URL: "http://svc/health", RetryBackoff: &orktypes.RetryBackoffConfig{ + Multiplier: -2.0, + }}, + }, + }, + }) + err := k.validateRetryBackoff() + require.Error(t, err) + assert.Contains(t, err.Error(), "multiplier must be >= 0") +} + +func TestValidateRetryBackoff_ExternalWorstCaseExceedsResyncWarns(t *testing.T) { + k := katalogWithRetryBackoff("myapp", &orktypes.ReconcilerConfig{ + Resync: dur(5 * time.Second), + }, orktypes.OperatorBoxConfig{ + OnReconcile: &orktypes.HookTemplates{ + External: []orktypes.ExternalCallSpec{ + {Name: "db", URL: "postgres://svc/db", RetryBackoff: &orktypes.RetryBackoffConfig{ + Initial: dur(3 * time.Second), + Max: dur(1 * time.Minute), + Multiplier: 2.0, + MaxAttempts: 4, + }}, + }, + }, + }) + assert.NoError(t, k.validateRetryBackoff()) + crd := k.enabledCRDs["myapp"] + require.True(t, crd.Warnings.HasWarnings()) + assert.Contains(t, crd.Warnings[0], "worst-case delay") +} diff --git a/pkg/katalog/validate_watch.go b/pkg/katalog/validate_watch.go new file mode 100644 index 000000000..b017ca867 --- /dev/null +++ b/pkg/katalog/validate_watch.go @@ -0,0 +1,156 @@ +package katalog + +import ( + "fmt" + "strings" + "text/template" + + orktemplate "github.com/orkspace/orkestra/pkg/resources/template" + orktypes "github.com/orkspace/orkestra/pkg/types" +) + +// validateWatchEntries validates operatorBox.watch and preReconcile.sentinels +// across all enabled CRDs. +// +// Enforces: +// 1. Each watch entry must declare apiVersion and kind. +// 2. Each on: value must be a known WatchEvent (create, update, delete). +// 3. No duplicate watch entries (same apiVersion + kind + namespace + name). +// 4. Each preReconcile.sentinels value must be a known Sentinel. +// 5. Gate templates (enqueueGate/reconcileGate) must compile with the +// declared sentinel FuncMap — undeclared sentinels cause a parse error. +func (k *Katalog) validateWatchEntries() error { + for crdName, crd := range k.Enabled() { + if err := validateCRDWatchEntries(crdName, crd); err != nil { + return err + } + if err := validateCRDSentinels(crdName, crd); err != nil { + return err + } + if err := k.validatePreReconcileGateTemplates(crdName, crd); err != nil { + return err + } + } + return nil +} + +func validateCRDWatchEntries(crdName string, crd orktypes.CRDEntry) error { + entries := crd.WatchEntries() + if len(entries) == 0 { + return nil + } + + type key struct{ apiVersion, kind, namespace, name string } + seen := make(map[key]bool, len(entries)) + + for i, w := range entries { + if w.APIVersion == "" { + return fmt.Errorf("%s crd %q: watch[%d]: apiVersion must not be empty", failureMark(), crdName, i) + } + if w.Kind == "" { + return fmt.Errorf("%s crd %q: watch[%d]: kind must not be empty", failureMark(), crdName, i) + } + + if invalid := w.InvalidOnValues(); len(invalid) > 0 { + return fmt.Errorf("%s crd %q: watch[%d] %s/%s: unknown on: value(s) [%s] — valid values: %s", + failureMark(), crdName, i, w.APIVersion, w.Kind, + strings.Join(invalid, ", "), strings.Join(orktypes.ValidWatchEvents(), ", ")) + } + + if err := validateWatchKeyFrom(crdName, i, w); err != nil { + return err + } + + k := key{w.APIVersion, w.Kind, w.Namespace, w.Name} + if seen[k] { + return fmt.Errorf("%s crd %q: duplicate watch entry %s/%s (namespace=%q name=%q) — watch entries must be unique", + failureMark(), crdName, w.APIVersion, w.Kind, w.Namespace, w.Name) + } + seen[k] = true + } + return nil +} + +func validateWatchKeyFrom(crdName string, idx int, w orktypes.WatchEntry) error { + kf := w.KeyFrom + if kf == nil { + return nil + } + hasLabel := kf.Label != "" + hasName := kf.Name != "" + if hasLabel && hasName { + return fmt.Errorf("%s crd %q: watch[%d] %s/%s: keyFrom must declare exactly one of label or name, not both", + failureMark(), crdName, idx, w.APIVersion, w.Kind) + } + if !hasLabel && !hasName { + return fmt.Errorf("%s crd %q: watch[%d] %s/%s: keyFrom is declared but neither label nor name is set", + failureMark(), crdName, idx, w.APIVersion, w.Kind) + } + if hasLabel && kf.Namespace != "" { + return fmt.Errorf("%s crd %q: watch[%d] %s/%s: keyFrom.namespace has no effect when label is set", + failureMark(), crdName, idx, w.APIVersion, w.Kind) + } + return nil +} + +func validateCRDSentinels(crdName string, crd orktypes.CRDEntry) error { + invalid := crd.OperatorBox.PreReconcile.InvalidSentinels() + if len(invalid) == 0 { + return nil + } + return fmt.Errorf("%s crd %q: preReconcile.sentinels: unknown sentinel(s) [%s] — valid values: %s", + failureMark(), crdName, strings.Join(invalid, ", "), strings.Join(orktypes.ValidSentinels(), ", ")) +} + +// validatePreReconcileGateTemplates parses enqueueGate and reconcileGate templates +// with a FuncMap that includes only the declared sentinels. Any template that +// references an undeclared sentinel name fails to parse — caught here at validate +// time, not at runtime. +func (k *Katalog) validatePreReconcileGateTemplates(crdName string, crd orktypes.CRDEntry) error { + pr := crd.OperatorBox.PreReconcile + if pr == nil { + return nil + } + + // Build the FuncMap: notes (built-ins + user) + declared sentinel stubs. + funcMap := buildFuncMapForValidation(k.Notes) + for name, fn := range orktemplate.SentinelFuncMap(pr.DeclaredSentinels()) { + funcMap[name] = fn + } + + check := func(gate *orktypes.GateConditions, location string) error { + for i, cond := range gate.WhenConditions() { + if err := parseGateTemplate(crdName, location, fmt.Sprintf("when[%d].field", i), cond.Field, funcMap); err != nil { + return err + } + if err := parseGateTemplate(crdName, location, fmt.Sprintf("when[%d].equals", i), cond.Equals, funcMap); err != nil { + return err + } + } + for i, cond := range gate.AnyOfConditions() { + if err := parseGateTemplate(crdName, location, fmt.Sprintf("anyOf[%d].field", i), cond.Field, funcMap); err != nil { + return err + } + if err := parseGateTemplate(crdName, location, fmt.Sprintf("anyOf[%d].equals", i), cond.Equals, funcMap); err != nil { + return err + } + } + return nil + } + + if err := check(pr.EnqueueGate, "preReconcile.enqueueGate"); err != nil { + return err + } + return check(pr.ReconcileGate, "preReconcile.reconcileGate") +} + +func parseGateTemplate(crdName, location, field, expr string, funcMap template.FuncMap) error { + if expr == "" || !isTemplate(expr) { + return nil + } + if _, err := template.New("").Funcs(funcMap).Parse(expr); err != nil { + return fmt.Errorf("%s crd %q: %s %s: invalid template: %s", + failureMark(), crdName, location, field, err.Error()) + } + return nil +} diff --git a/pkg/katalog/validate_watch_test.go b/pkg/katalog/validate_watch_test.go new file mode 100644 index 000000000..c9980209b --- /dev/null +++ b/pkg/katalog/validate_watch_test.go @@ -0,0 +1,209 @@ +package katalog + +import ( + "testing" + + orktypes "github.com/orkspace/orkestra/pkg/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func katalogWithWatch(crdName string, box orktypes.OperatorBoxConfig) *Katalog { + return &Katalog{ + enabledCRDs: map[string]orktypes.CRDEntry{ + crdName: {OperatorBox: box}, + }, + } +} + +func TestValidateWatchEntries_Empty(t *testing.T) { + k := katalogWithWatch("myapp", orktypes.OperatorBoxConfig{}) + assert.NoError(t, k.validateWatchEntries()) +} + +func TestValidateWatchEntries_Valid(t *testing.T) { + k := katalogWithWatch("myapp", orktypes.OperatorBoxConfig{ + Watch: []orktypes.WatchEntry{ + {APIVersion: "apps/v1", Kind: "Deployment"}, + {APIVersion: "v1", Kind: "ConfigMap", Namespace: "default", Name: "shared-config"}, + {APIVersion: "v1", Kind: "Node", On: []string{"update"}}, + }, + }) + assert.NoError(t, k.validateWatchEntries()) +} + +func TestValidateWatchEntries_MissingAPIVersion(t *testing.T) { + k := katalogWithWatch("myapp", orktypes.OperatorBoxConfig{ + Watch: []orktypes.WatchEntry{ + {Kind: "Deployment"}, + }, + }) + err := k.validateWatchEntries() + require.Error(t, err) + assert.Contains(t, err.Error(), "apiVersion must not be empty") +} + +func TestValidateWatchEntries_MissingKind(t *testing.T) { + k := katalogWithWatch("myapp", orktypes.OperatorBoxConfig{ + Watch: []orktypes.WatchEntry{ + {APIVersion: "apps/v1"}, + }, + }) + err := k.validateWatchEntries() + require.Error(t, err) + assert.Contains(t, err.Error(), "kind must not be empty") +} + +func TestValidateWatchEntries_InvalidOnValue(t *testing.T) { + k := katalogWithWatch("myapp", orktypes.OperatorBoxConfig{ + Watch: []orktypes.WatchEntry{ + {APIVersion: "apps/v1", Kind: "Deployment", On: []string{"modified"}}, + }, + }) + err := k.validateWatchEntries() + require.Error(t, err) + assert.Contains(t, err.Error(), "modified") + assert.Contains(t, err.Error(), "create, update, delete") +} + +func TestValidateWatchEntries_DuplicateEntry(t *testing.T) { + k := katalogWithWatch("myapp", orktypes.OperatorBoxConfig{ + Watch: []orktypes.WatchEntry{ + {APIVersion: "apps/v1", Kind: "Deployment"}, + {APIVersion: "apps/v1", Kind: "Deployment"}, + }, + }) + err := k.validateWatchEntries() + require.Error(t, err) + assert.Contains(t, err.Error(), "duplicate watch entry") +} + +func TestValidateWatchEntries_DuplicateWithDifferentNamespace(t *testing.T) { + k := katalogWithWatch("myapp", orktypes.OperatorBoxConfig{ + Watch: []orktypes.WatchEntry{ + {APIVersion: "v1", Kind: "ConfigMap", Namespace: "ns-a"}, + {APIVersion: "v1", Kind: "ConfigMap", Namespace: "ns-b"}, + }, + }) + assert.NoError(t, k.validateWatchEntries()) +} + +func TestValidateWatchEntries_ValidAllOnValues(t *testing.T) { + k := katalogWithWatch("myapp", orktypes.OperatorBoxConfig{ + Watch: []orktypes.WatchEntry{ + {APIVersion: "v1", Kind: "Node", On: []string{"create", "update", "delete"}}, + }, + }) + assert.NoError(t, k.validateWatchEntries()) +} + +func TestValidateSentinels_Valid(t *testing.T) { + k := katalogWithWatch("myapp", orktypes.OperatorBoxConfig{ + PreReconcile: &orktypes.PreReconcileConfig{ + Sentinels: []string{"generationChanged", "labelsChanged"}, + }, + }) + assert.NoError(t, k.validateWatchEntries()) +} + +func TestValidateSentinels_Unknown(t *testing.T) { + k := katalogWithWatch("myapp", orktypes.OperatorBoxConfig{ + PreReconcile: &orktypes.PreReconcileConfig{ + Sentinels: []string{"generationChanged", "specChanged"}, + }, + }) + err := k.validateWatchEntries() + require.Error(t, err) + assert.Contains(t, err.Error(), "specChanged") + assert.Contains(t, err.Error(), "generationChanged, labelsChanged, annotationsChanged") +} + +func TestValidateSentinels_AllValid(t *testing.T) { + k := katalogWithWatch("myapp", orktypes.OperatorBoxConfig{ + PreReconcile: &orktypes.PreReconcileConfig{ + Sentinels: []string{"generationChanged", "labelsChanged", "annotationsChanged"}, + }, + }) + assert.NoError(t, k.validateWatchEntries()) +} + +func TestValidateGateTemplate_DeclaredSentinelParsesOK(t *testing.T) { + k := katalogWithWatch("myapp", orktypes.OperatorBoxConfig{ + PreReconcile: &orktypes.PreReconcileConfig{ + Sentinels: []string{"generationChanged"}, + EnqueueGate: &orktypes.GateConditions{ + When: []orktypes.Condition{ + {Field: `{{ generationChanged }}`, Equals: "true"}, + }, + }, + }, + }) + assert.NoError(t, k.validateWatchEntries()) +} + +func TestValidateGateTemplate_UndeclaredSentinelFails(t *testing.T) { + k := katalogWithWatch("myapp", orktypes.OperatorBoxConfig{ + PreReconcile: &orktypes.PreReconcileConfig{ + Sentinels: []string{"generationChanged"}, + EnqueueGate: &orktypes.GateConditions{ + When: []orktypes.Condition{ + {Field: `{{ labelsChanged }}`, Equals: "true"}, + }, + }, + }, + }) + err := k.validateWatchEntries() + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid template") +} + +func TestValidateKeyFrom_ValidLabel(t *testing.T) { + k := katalogWithWatch("myapp", orktypes.OperatorBoxConfig{ + Watch: []orktypes.WatchEntry{ + {APIVersion: "v1", Kind: "ConfigMap", KeyFrom: &orktypes.WatchKeyFrom{Label: "app.kubernetes.io/cr-owner"}}, + }, + }) + require.NoError(t, k.validateWatchEntries()) +} + +func TestValidateKeyFrom_ValidName(t *testing.T) { + k := katalogWithWatch("myapp", orktypes.OperatorBoxConfig{ + Watch: []orktypes.WatchEntry{ + {APIVersion: "v1", Kind: "Node", KeyFrom: &orktypes.WatchKeyFrom{Name: "my-singleton"}}, + }, + }) + require.NoError(t, k.validateWatchEntries()) +} + +func TestValidateKeyFrom_BothLabelAndName(t *testing.T) { + k := katalogWithWatch("myapp", orktypes.OperatorBoxConfig{ + Watch: []orktypes.WatchEntry{ + {APIVersion: "v1", Kind: "ConfigMap", KeyFrom: &orktypes.WatchKeyFrom{Label: "some-label", Name: "some-name"}}, + }, + }) + err := k.validateWatchEntries() + require.Error(t, err) + assert.Contains(t, err.Error(), "exactly one of label or name") +} + +func TestValidateKeyFrom_NeitherLabelNorName(t *testing.T) { + k := katalogWithWatch("myapp", orktypes.OperatorBoxConfig{ + Watch: []orktypes.WatchEntry{ + {APIVersion: "v1", Kind: "ConfigMap", KeyFrom: &orktypes.WatchKeyFrom{}}, + }, + }) + err := k.validateWatchEntries() + require.Error(t, err) + assert.Contains(t, err.Error(), "neither label nor name") +} + +func TestValidateKeyFrom_NamespaceWithLabelRejected(t *testing.T) { + k := katalogWithWatch("myapp", orktypes.OperatorBoxConfig{ + Watch: []orktypes.WatchEntry{ + {APIVersion: "v1", Kind: "ConfigMap", KeyFrom: &orktypes.WatchKeyFrom{Label: "some-label", Namespace: "default"}}, + }, + }) + err := k.validateWatchEntries() + require.Error(t, err) + assert.Contains(t, err.Error(), "namespace has no effect") +} diff --git a/pkg/kubeclient/ctrlclient.go b/pkg/kubeclient/ctrlclient.go new file mode 100644 index 000000000..4f11554ea --- /dev/null +++ b/pkg/kubeclient/ctrlclient.go @@ -0,0 +1,272 @@ +package kubeclient + +import ( + "context" + "fmt" + + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + sigs "sigs.k8s.io/controller-runtime/pkg/client" +) + +// ToClient wraps a kubeclient.Interface as a sigs.k8s.io/controller-runtime/pkg/client.Client. +// Constructor reconcilers migrated from controller-runtime can call ToClient(kube) and use +// the familiar client.Get / client.Patch / client.Status().Patch patterns without learning +// kubeclient's API. All operations delegate to the underlying Interface — the same dynamic +// client, scheme, and mapper that the declarative reconciler uses. +// +// Unsupported operations (DeleteAllOf, SubResource other than "status") return +// ErrNotSupported so the compile check passes and callers get a clear runtime signal. +func ToClient(k Interface) sigs.Client { + return &ctrlClientAdapter{k: k} +} + +type ctrlClientAdapter struct { + k Interface +} + +var _ sigs.Client = (*ctrlClientAdapter)(nil) + +// ── Reader ──────────────────────────────────────────────────────────────────── + +func (a *ctrlClientAdapter) Get(ctx context.Context, key sigs.ObjectKey, obj sigs.Object, _ ...sigs.GetOption) error { + return a.k.Get(ctx, key.Namespace, key.Name, obj) +} + +func (a *ctrlClientAdapter) List(ctx context.Context, list sigs.ObjectList, opts ...sigs.ListOption) error { + lo := &sigs.ListOptions{} + for _, opt := range opts { + opt.ApplyToList(lo) + } + + gvks, _, err := a.k.Scheme().ObjectKinds(list) + if err != nil { + return fmt.Errorf("ctrlclient List: unknown type %T: %w", list, err) + } + // List types have a "List" suffix in the kind; strip it to get the item GVK + gvk := gvks[0] + mapping, err := a.k.Mapper().RESTMapping(gvk.GroupKind(), gvk.Version) + if err != nil { + return fmt.Errorf("ctrlclient List: no REST mapping for %s: %w", gvk, err) + } + + listOpts := metav1.ListOptions{} + if lo.LabelSelector != nil { + listOpts.LabelSelector = lo.LabelSelector.String() + } + if lo.FieldSelector != nil { + listOpts.FieldSelector = lo.FieldSelector.String() + } + + var ul *unstructured.UnstructuredList + if lo.Namespace != "" { + ul, err = a.k.DynamicClient().Resource(mapping.Resource).Namespace(lo.Namespace).List(ctx, listOpts) + } else { + ul, err = a.k.DynamicClient().Resource(mapping.Resource).List(ctx, listOpts) + } + if err != nil { + return err + } + + // Convert each item individually then re-assemble into the typed list. + // FromUnstructured on ul.Object alone would restore list metadata but leave + // Items empty — the dynamic client stores items as separate Unstructured values. + items := make([]interface{}, len(ul.Items)) + for i := range ul.Items { + out := map[string]interface{}{} + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(ul.Items[i].Object, &out); err != nil { + return fmt.Errorf("ctrlclient List: convert item %d: %w", i, err) + } + items[i] = out + } + + listMap, err := runtime.DefaultUnstructuredConverter.ToUnstructured(list) + if err != nil { + return fmt.Errorf("ctrlclient List: marshal list shell: %w", err) + } + // Copy list metadata from the server response, then set items. + for k, v := range ul.Object { + if k != "items" { + listMap[k] = v + } + } + listMap["items"] = items + + return runtime.DefaultUnstructuredConverter.FromUnstructured(listMap, list) +} + +// ── Writer ──────────────────────────────────────────────────────────────────── + +func (a *ctrlClientAdapter) Create(ctx context.Context, obj sigs.Object, _ ...sigs.CreateOption) error { + return a.k.Create(ctx, obj) +} + +func (a *ctrlClientAdapter) Delete(ctx context.Context, obj sigs.Object, opts ...sigs.DeleteOption) error { + do := &sigs.DeleteOptions{} + for _, opt := range opts { + opt.ApplyToDelete(do) + } + + mapping, err := a.restMappingFor(obj) + if err != nil { + return err + } + + delOpts := metav1.DeleteOptions{} + ns := obj.GetNamespace() + name := obj.GetName() + + if ns == "" { + err = a.k.DynamicClient().Resource(mapping.Resource).Delete(ctx, name, delOpts) + } else { + err = a.k.DynamicClient().Resource(mapping.Resource).Namespace(ns).Delete(ctx, name, delOpts) + } + return err +} + +func (a *ctrlClientAdapter) Update(ctx context.Context, obj sigs.Object, _ ...sigs.UpdateOption) error { + mapping, err := a.restMappingFor(obj) + if err != nil { + return err + } + + raw, err := runtime.DefaultUnstructuredConverter.ToUnstructured(obj) + if err != nil { + return fmt.Errorf("ctrlclient Update: convert to unstructured: %w", err) + } + u := &unstructured.Unstructured{Object: raw} + ns := obj.GetNamespace() + + if ns == "" { + _, err = a.k.DynamicClient().Resource(mapping.Resource).Update(ctx, u, metav1.UpdateOptions{}) + } else { + _, err = a.k.DynamicClient().Resource(mapping.Resource).Namespace(ns).Update(ctx, u, metav1.UpdateOptions{}) + } + return err +} + +func (a *ctrlClientAdapter) Patch(ctx context.Context, obj sigs.Object, patch sigs.Patch, _ ...sigs.PatchOption) error { + // sigs.Patch == kubeclient.Patch (type alias), passes straight through. + return a.k.Patch(ctx, obj, patch) +} + +func (a *ctrlClientAdapter) DeleteAllOf(_ context.Context, _ sigs.Object, _ ...sigs.DeleteAllOfOption) error { + return fmt.Errorf("ctrlclient: DeleteAllOf is not supported by the kubeclient adapter") +} + +func (a *ctrlClientAdapter) Apply(_ context.Context, _ runtime.ApplyConfiguration, _ ...sigs.ApplyOption) error { + return fmt.Errorf("ctrlclient: Apply (ApplyConfiguration) is not supported; use Patch with a SSA patch instead") +} + +// ── StatusClient ────────────────────────────────────────────────────────────── + +func (a *ctrlClientAdapter) Status() sigs.SubResourceWriter { + return &ctrlStatusAdapter{k: a.k} +} + +// ── SubResourceClientConstructor ───────────────────────────────────────────── + +// SubResource returns a client for the named subresource. Only "status" is +// supported — Patch on the returned client routes to the /status subresource +// via the dynamic client. All other methods and all other subresource names +// return an error. +func (a *ctrlClientAdapter) SubResource(_ string) sigs.SubResourceClient { + return &ctrlStatusAdapter{k: a.k} +} + +// ── Scheme / Mapper / GVK ───────────────────────────────────────────────────── + +func (a *ctrlClientAdapter) Scheme() *runtime.Scheme { return a.k.Scheme() } + +func (a *ctrlClientAdapter) RESTMapper() apimeta.RESTMapper { return a.k.Mapper() } + +func (a *ctrlClientAdapter) GroupVersionKindFor(obj runtime.Object) (schema.GroupVersionKind, error) { + gvks, _, err := a.k.Scheme().ObjectKinds(obj) + if err != nil { + return schema.GroupVersionKind{}, err + } + return gvks[0], nil +} + +func (a *ctrlClientAdapter) IsObjectNamespaced(obj runtime.Object) (bool, error) { + gvks, _, err := a.k.Scheme().ObjectKinds(obj) + if err != nil { + return false, err + } + mapping, err := a.k.Mapper().RESTMapping(gvks[0].GroupKind(), gvks[0].Version) + if err != nil { + return false, err + } + return mapping.Scope.Name() == apimeta.RESTScopeNameNamespace, nil +} + +// ── helpers ─────────────────────────────────────────────────────────────────── + +func (a *ctrlClientAdapter) restMappingFor(obj runtime.Object) (*apimeta.RESTMapping, error) { + gvks, _, err := a.k.Scheme().ObjectKinds(obj) + if err != nil { + return nil, fmt.Errorf("ctrlclient: unknown type %T: %w", obj, err) + } + mapping, err := a.k.Mapper().RESTMapping(gvks[0].GroupKind(), gvks[0].Version) + if err != nil { + return nil, fmt.Errorf("ctrlclient: no REST mapping for %s: %w", gvks[0], err) + } + return mapping, nil +} + +// ── status adapter ──────────────────────────────────────────────────────────── + +// ctrlStatusAdapter implements sigs.SubResourceWriter for the /status subresource. +// Patch delegates to the dynamic client so the caller can use sigs.MergeFrom directly. +type ctrlStatusAdapter struct { + k Interface +} + +var _ sigs.SubResourceClient = (*ctrlStatusAdapter)(nil) + +func (s *ctrlStatusAdapter) Get(_ context.Context, _ sigs.Object, _ sigs.Object, _ ...sigs.SubResourceGetOption) error { + return fmt.Errorf("ctrlclient SubResource.Get is not supported") +} + +func (s *ctrlStatusAdapter) Patch(ctx context.Context, obj sigs.Object, patch sigs.Patch, _ ...sigs.SubResourcePatchOption) error { + gvks, _, err := s.k.Scheme().ObjectKinds(obj) + if err != nil { + return fmt.Errorf("ctrlclient Status.Patch: unknown type %T: %w", obj, err) + } + mapping, err := s.k.Mapper().RESTMapping(gvks[0].GroupKind(), gvks[0].Version) + if err != nil { + return fmt.Errorf("ctrlclient Status.Patch: no REST mapping for %s: %w", gvks[0], err) + } + + data, err := patch.Data(obj) + if err != nil { + return fmt.Errorf("ctrlclient Status.Patch: compute patch: %w", err) + } + + ns := obj.GetNamespace() + name := obj.GetName() + + if ns == "" { + _, err = s.k.DynamicClient().Resource(mapping.Resource). + Patch(ctx, name, patch.Type(), data, metav1.PatchOptions{}, "status") + } else { + _, err = s.k.DynamicClient().Resource(mapping.Resource).Namespace(ns). + Patch(ctx, name, patch.Type(), data, metav1.PatchOptions{}, "status") + } + return err +} + +func (s *ctrlStatusAdapter) Create(_ context.Context, _ sigs.Object, _ sigs.Object, _ ...sigs.SubResourceCreateOption) error { + return fmt.Errorf("ctrlclient Status.Create is not supported") +} + +func (s *ctrlStatusAdapter) Update(ctx context.Context, obj sigs.Object, _ ...sigs.SubResourceUpdateOption) error { + return s.Patch(ctx, obj, sigs.MergeFrom(obj.DeepCopyObject().(sigs.Object))) +} + +func (s *ctrlStatusAdapter) Apply(_ context.Context, _ runtime.ApplyConfiguration, _ ...sigs.SubResourceApplyOption) error { + return fmt.Errorf("ctrlclient Status.Apply is not supported") +} diff --git a/pkg/kubeclient/fixture/02-constructor/constructor/blockchainnode_reconciler.go b/pkg/kubeclient/fixture/02-constructor/constructor/blockchainnode_reconciler.go index 8abac255e..f75ddf963 100644 --- a/pkg/kubeclient/fixture/02-constructor/constructor/blockchainnode_reconciler.go +++ b/pkg/kubeclient/fixture/02-constructor/constructor/blockchainnode_reconciler.go @@ -10,35 +10,23 @@ import ( apiv1 "github.com/orkspace/orkestra-args-constructor/api/v1alpha1" "github.com/orkspace/orkestra/domain" - "github.com/orkspace/orkestra/pkg/event" "github.com/orkspace/orkestra/pkg/kubeclient" orkdeploy "github.com/orkspace/orkestra/pkg/resources/deployments" orktmpl "github.com/orkspace/orkestra/pkg/resources/template" - "k8s.io/client-go/tools/cache" ) // BlockchainNodeReconciler implements domain.Reconciler for the BlockchainNode CRD. type BlockchainNodeReconciler struct { - informer cache.SharedIndexInformer - kube kubeclient.Interface - ev event.Recorder + kube kubeclient.Interface } // NewBlockchainNodeReconciler is the constructor function registered in the Katalog. -func NewBlockchainNodeReconciler( - kube kubeclient.Interface, - informer cache.SharedIndexInformer, - ev event.Recorder, -) domain.Reconciler { - return &BlockchainNodeReconciler{ - informer: informer, - kube: kube, - ev: ev, - } +func NewBlockchainNodeReconciler(kube kubeclient.Interface) domain.Reconciler { + return &BlockchainNodeReconciler{kube: kube} } func (r *BlockchainNodeReconciler) Reconcile(ctx context.Context, key string) error { - raw, exists, err := r.informer.GetIndexer().GetByKey(key) + raw, exists, err := r.kube.GetInformer().GetIndexer().GetByKey(key) if err != nil { return fmt.Errorf("cache lookup %q: %w", key, err) } diff --git a/pkg/kubeclient/fixture/02-constructor/pkg/typeregistry/zz_generated_typeregistry.go b/pkg/kubeclient/fixture/02-constructor/pkg/typeregistry/zz_generated_typeregistry.go index f238dd211..94c7b2964 100644 --- a/pkg/kubeclient/fixture/02-constructor/pkg/typeregistry/zz_generated_typeregistry.go +++ b/pkg/kubeclient/fixture/02-constructor/pkg/typeregistry/zz_generated_typeregistry.go @@ -9,14 +9,12 @@ package typeregistry import ( "github.com/orkspace/orkestra/domain" - "github.com/orkspace/orkestra/pkg/event" "github.com/orkspace/orkestra/pkg/kubeclient" "github.com/orkspace/orkestra/pkg/logger" orktypes "github.com/orkspace/orkestra/pkg/types" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/client-go/tools/cache" bcnodev1 "github.com/orkspace/orkestra-args-constructor/api/v1alpha1" bnconstructor "github.com/orkspace/orkestra-args-constructor/constructor" @@ -76,8 +74,8 @@ func RegisterRuntimeObjects() { // BlockchainNode — custom reconciler constructor // Calls bnconstructor.NewBlockchainNodeReconciler() to build the user's reconciler. orktypes.ReconcilerRegistry[schema.GroupVersionKind{Group: "demo.orkestra.io", Version: "v1alpha1", Kind: "BlockchainNode"}] = - func(kube kubeclient.Interface, inf cache.SharedIndexInformer, ev event.Recorder) domain.Reconciler { - return bnconstructor.NewBlockchainNodeReconciler(kube, inf, ev) + func(kube kubeclient.Interface) domain.Reconciler { + return bnconstructor.NewBlockchainNodeReconciler(kube) } logger.Debug(). diff --git a/pkg/kubeclient/fixture/03-hooks-targets/constructor/blockchainappwithtargets_reconciler.go b/pkg/kubeclient/fixture/03-hooks-targets/constructor/blockchainappwithtargets_reconciler.go index 0470922b1..c591b82b7 100644 --- a/pkg/kubeclient/fixture/03-hooks-targets/constructor/blockchainappwithtargets_reconciler.go +++ b/pkg/kubeclient/fixture/03-hooks-targets/constructor/blockchainappwithtargets_reconciler.go @@ -6,11 +6,9 @@ import ( apiv1 "github.com/orkspace/orkestra-args-hooks-targets/api/v1alpha1" "github.com/orkspace/orkestra/domain" - "github.com/orkspace/orkestra/pkg/event" "github.com/orkspace/orkestra/pkg/kubeclient" orkdeploy "github.com/orkspace/orkestra/pkg/resources/deployments" orktmpl "github.com/orkspace/orkestra/pkg/resources/template" - "k8s.io/client-go/tools/cache" ) // BlockchainAppWithTargetsReconciler is the per-target constructor reconciler @@ -18,27 +16,17 @@ import ( // (declared in katalog serve.target..operatorBox.reconciler.constructor.args) // rather than calling a live feature-flag endpoint. type BlockchainAppWithTargetsReconciler struct { - informer cache.SharedIndexInformer - kube kubeclient.Interface - ev event.Recorder + kube kubeclient.Interface } // NewBlockchainAppWithTargetsReconciler is the constructor registered in // serve.target.v2-ctor.operatorBox.reconciler.constructor. -func NewBlockchainAppWithTargetsReconciler( - kube kubeclient.Interface, - informer cache.SharedIndexInformer, - ev event.Recorder, -) domain.Reconciler { - return &BlockchainAppWithTargetsReconciler{ - informer: informer, - kube: kube, - ev: ev, - } +func NewBlockchainAppWithTargetsReconciler(kube kubeclient.Interface) domain.Reconciler { + return &BlockchainAppWithTargetsReconciler{kube: kube} } func (r *BlockchainAppWithTargetsReconciler) Reconcile(ctx context.Context, key string) error { - raw, exists, err := r.informer.GetIndexer().GetByKey(key) + raw, exists, err := r.kube.GetInformer().GetIndexer().GetByKey(key) if err != nil { return fmt.Errorf("cache lookup %q: %w", key, err) } diff --git a/pkg/kubeclient/fixture/03-hooks-targets/pkg/typeregistry/zz_generated_typeregistry.go b/pkg/kubeclient/fixture/03-hooks-targets/pkg/typeregistry/zz_generated_typeregistry.go index 908a71e97..7eefa74a9 100644 --- a/pkg/kubeclient/fixture/03-hooks-targets/pkg/typeregistry/zz_generated_typeregistry.go +++ b/pkg/kubeclient/fixture/03-hooks-targets/pkg/typeregistry/zz_generated_typeregistry.go @@ -9,14 +9,12 @@ package typeregistry import ( "github.com/orkspace/orkestra/domain" - "github.com/orkspace/orkestra/pkg/event" "github.com/orkspace/orkestra/pkg/kubeclient" "github.com/orkspace/orkestra/pkg/logger" orktypes "github.com/orkspace/orkestra/pkg/types" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/client-go/tools/cache" bcappwithtargetsv1 "github.com/orkspace/orkestra-args-hooks-targets/api/v1alpha1" bcctor "github.com/orkspace/orkestra-args-hooks-targets/constructor" @@ -105,8 +103,8 @@ func RegisterRuntimeObjects() { if orktypes.TargetReconcilerRegistry[gvk] == nil { orktypes.TargetReconcilerRegistry[gvk] = map[string]orktypes.NewReconcilerFunc{} } - orktypes.TargetReconcilerRegistry[gvk]["v2-ctor"] = func(kube kubeclient.Interface, inf cache.SharedIndexInformer, ev event.Recorder) domain.Reconciler { - return bcctor.NewBlockchainAppWithTargetsReconciler(kube, inf, ev) + orktypes.TargetReconcilerRegistry[gvk]["v2-ctor"] = func(kube kubeclient.Interface) domain.Reconciler { + return bcctor.NewBlockchainAppWithTargetsReconciler(kube) } } diff --git a/pkg/kubeclient/fixture/04-ctrlruntime/Makefile b/pkg/kubeclient/fixture/04-ctrlruntime/Makefile new file mode 100644 index 000000000..d8e56882e --- /dev/null +++ b/pkg/kubeclient/fixture/04-ctrlruntime/Makefile @@ -0,0 +1,109 @@ +# ── controller-runtime style constructor — WebApp ───────────────────────────── +BINARY_NAME ?= ork +DEV_OUTPUT_DIR ?= $(HOME)/.orkestra/bin +PROD_OUTPUT_DIR ?= $(HOME)/.orkestra/bin/runtime +KATALOG ?= katalog.yaml + +IMAGE_REPO ?= myorg/webapp-operator +IMAGE_TAG ?= latest +IMAGE ?= $(IMAGE_REPO):$(IMAGE_TAG) +BUILD_TAGS ?= + +GOOS ?= linux +GOARCH ?= amd64 +CGO_ENABLED = 0 + +ORK_LDFLAGS := -X github.com/orkspace/orkestra/pkg/version.Version=$(GIT_VERSION) \ + -X github.com/orkspace/orkestra/pkg/version.Commit=$(GIT_COMMIT) \ + -X github.com/orkspace/orkestra/pkg/version.Date=$(GIT_DATE) + +# ── registry ────────────────────────────────────────────────────────────────── +# Generates pkg/typeregistry/zz_generated_typeregistry.go. +# Re-run whenever you change apiTypes fields in katalog.yaml. +.PHONY: registry +registry: + @[ -f go.mod.txt ] && mv go.mod.txt go.mod || true + @[ -f go.sum.txt ] && mv go.sum.txt go.sum || true + @find . -name "*.go" | xargs grep -l "^//go:build ignore$$" 2>/dev/null | while read f; do tail -n +3 "$$f" > "$$f.tmp" && mv "$$f.tmp" "$$f"; done || true + ork generate registry --file $(KATALOG) + +# ── build (development) ─────────────────────────────────────────────────────── +.PHONY: build +build: + @[ -f go.mod.txt ] && mv go.mod.txt go.mod || true + @[ -f go.sum.txt ] && mv go.sum.txt go.sum || true + @find . -name "*.go" | xargs grep -l "^//go:build ignore$$" 2>/dev/null | while read f; do tail -n +3 "$$f" > "$$f.tmp" && mv "$$f.tmp" "$$f"; done || true + @mkdir -p $(DEV_OUTPUT_DIR) + go mod tidy + gofmt -w . + go build \ + -ldflags "$(ORK_LDFLAGS)" \ + -o $(DEV_OUTPUT_DIR)/$(BINARY_NAME) ./cmd/orkestra + @echo "✅ Development build: $(DEV_OUTPUT_DIR)/$(BINARY_NAME)" + +# ── build-runtime (production) ──────────────────────────────────────────────── +.PHONY: build-runtime +build-runtime: + @[ -f go.mod.txt ] && mv go.mod.txt go.mod || true + @[ -f go.sum.txt ] && mv go.sum.txt go.sum || true + @find . -name "*.go" | xargs grep -l "^//go:build ignore$$" 2>/dev/null | while read f; do tail -n +3 "$$f" > "$$f.tmp" && mv "$$f.tmp" "$$f"; done || true + @mkdir -p $(PROD_OUTPUT_DIR) + go mod tidy + gofmt -w . + GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build \ + -tags "runtime" \ + -ldflags "$(ORK_LDFLAGS)" \ + -o $(PROD_OUTPUT_DIR)/$(BINARY_NAME) ./cmd/orkestra + @echo "✅ Production runtime build: $(PROD_OUTPUT_DIR)/$(BINARY_NAME)" + @echo " This binary only supports 'ork run' — perfect for production." + +# ── validate ────────────────────────────────────────────────────────────────── +.PHONY: validate +validate: + $(DEV_OUTPUT_DIR)/$(BINARY_NAME) validate -f $(KATALOG) + +# ── simulate ────────────────────────────────────────────────────────────────── +.PHONY: simulate +simulate: + $(DEV_OUTPUT_DIR)/$(BINARY_NAME) simulate + +# ── e2e ─────────────────────────────────────────────────────────────────────── +.PHONY: e2e +e2e: + $(DEV_OUTPUT_DIR)/$(BINARY_NAME) e2e + +# ── docker / push / release ─────────────────────────────────────────────────── +.PHONY: docker +docker: build-runtime + @cp $(PROD_OUTPUT_DIR)/$(BINARY_NAME) ./$(BINARY_NAME) + docker build -t $(IMAGE) . + @rm -f ./$(BINARY_NAME) + @echo "✅ Docker image built: $(IMAGE)" + +.PHONY: push +push: + docker push $(IMAGE) + +.PHONY: release +release: docker push + +# ── clean ───────────────────────────────────────────────────────────────────── +.PHONY: clean +clean: + @rm -f $(DEV_OUTPUT_DIR)/$(BINARY_NAME) + @rm -rf $(PROD_OUTPUT_DIR) + @echo "✅ Removed all local builds" + +# ── help ────────────────────────────────────────────────────────────────────── +.PHONY: help +help: + @echo " registry generate type registry from Katalog" + @echo " build compile full development CLI (all commands)" + @echo " build-runtime compile production binary (only 'ork run')" + @echo " validate run katalog validation (using development binary)" + @echo " e2e run end‑to‑end tests (using development binary)" + @echo " simulate run simulation tests (using development binary)" + @echo " docker build-runtime + copy into Docker image" + @echo " push push Docker image to registry" + @echo " release docker + push" + @echo " clean remove all local builds" diff --git a/pkg/kubeclient/fixture/04-ctrlruntime/README.md b/pkg/kubeclient/fixture/04-ctrlruntime/README.md new file mode 100644 index 000000000..f8340c039 --- /dev/null +++ b/pkg/kubeclient/fixture/04-ctrlruntime/README.md @@ -0,0 +1,67 @@ +# controller-runtime style constructor — WebApp + +The reconciler is written in pure controller-runtime style. It holds a +`client.Client`, receives a `reconcile.Request`, and calls `client.Get` / +`client.Create` / `client.Patch` exactly as it would in a kubebuilder project. + +Two adapter calls at the constructor boundary wire it into Orkestra: + +```go +func NewWebAppReconciler(kube kubeclient.Interface) domain.Reconciler { + return domain.ReconcilerFrom(&WebAppReconciler{ + client: kubeclient.ToClient(kube), + }) +} +``` + +- `kubeclient.ToClient(kube)` — wraps Orkestra's `kubeclient.Interface` as a + `sigs.k8s.io/controller-runtime/pkg/client.Client` +- `domain.ReconcilerFrom(r)` — wraps the `ctrl.Request` reconciler signature + as a `domain.Reconciler` so Orkestra's operatorBox can call it + +The `Reconcile` method is completely untouched. No signature change, no return +type change, no call-site rewrites. Bring an existing controller-runtime +reconciler, write two lines in a constructor, and it runs in Orkestra. + +**Requirement:** `ork` CLI — install from [orkestra-install](https://github.com/orkspace/orkestra#getting-started) + +--- + +## Step 1 — Generate the registry + +```bash +make registry +``` + +## Step 2 — Build + +```bash +make clean && make build +ork validate katalog.yaml +ork simulate +``` + +## Step 3 — Run + +```bash +ork run + +kubectl get deployment 04-ctrlruntime-demo -o wide +kubectl get webapp 04-ctrlruntime-demo -o jsonpath='{.status.phase}' && echo +``` + +## E2E + +```bash +make docker push IMAGE_REPO=yourregistry/webapp-operator IMAGE_TAG=latest + +ork e2e \ + --set runtime.image.repository=yourregistry/webapp-operator \ + --set runtime.image.tag=latest +``` + +## Cleanup + +```bash +./cleanup.sh +``` diff --git a/pkg/kubeclient/fixture/04-ctrlruntime/api/v1alpha1/types.go b/pkg/kubeclient/fixture/04-ctrlruntime/api/v1alpha1/types.go new file mode 100644 index 000000000..489cd81f5 --- /dev/null +++ b/pkg/kubeclient/fixture/04-ctrlruntime/api/v1alpha1/types.go @@ -0,0 +1,90 @@ +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +// WebAppSpec defines the desired state of WebApp. +type WebAppSpec struct { + // Image is the container image to deploy. + Image string `json:"image"` + + // Replicas is the desired number of pods. Defaults to 1. + // +optional + // +kubebuilder:default=1 + Replicas int32 `json:"replicas,omitempty"` + + // Port is the container port to expose. Defaults to 80. + // +optional + // +kubebuilder:default=80 + Port int32 `json:"port,omitempty"` +} + +// WebAppStatus defines the observed state of WebApp. +type WebAppStatus struct { + Phase string `json:"phase,omitempty"` + Endpoint string `json:"endpoint,omitempty"` + Replicas int32 `json:"replicas,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status + +// WebApp is the Schema for the webapps API. +type WebApp struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec WebAppSpec `json:"spec,omitempty"` + Status WebAppStatus `json:"status,omitempty"` +} + +func (in *WebApp) DeepCopyObject() runtime.Object { + out := new(WebApp) + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = in.Spec + out.Status = in.Status + return out +} + +// +kubebuilder:object:root=true + +// WebAppList contains a list of WebApp. +type WebAppList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []WebApp `json:"items"` +} + +func (in *WebAppList) DeepCopyObject() runtime.Object { + out := new(WebAppList) + *out = *in + if in.Items != nil { + out.Items = make([]WebApp, len(in.Items)) + for i := range in.Items { + out.Items[i] = *in.Items[i].DeepCopyObject().(*WebApp) + } + } + return out +} + +var GroupVersionKind = schema.GroupVersionKind{ + Group: "migration.demo.orkestra.io", + Version: "v1alpha1", + Kind: "WebApp", +} + +var SchemeGroupVersion = schema.GroupVersion{ + Group: "migration.demo.orkestra.io", + Version: "v1alpha1", +} + +func AddToScheme(s *runtime.Scheme) error { + s.AddKnownTypes(SchemeGroupVersion, &WebApp{}, &WebAppList{}) + metav1.AddToGroupVersion(s, SchemeGroupVersion) + return nil +} diff --git a/pkg/kubeclient/fixture/04-ctrlruntime/cmd/orkestra/main.go b/pkg/kubeclient/fixture/04-ctrlruntime/cmd/orkestra/main.go new file mode 100644 index 000000000..6231bbffa --- /dev/null +++ b/pkg/kubeclient/fixture/04-ctrlruntime/cmd/orkestra/main.go @@ -0,0 +1,25 @@ +// Code generated by "ork generate registry" on 2026-08-20T13:32:48Z. DO NOT EDIT. +// Re-generate by running: ork generate registry --file +package main + +import ( + "context" + + "github.com/orkspace/orkestra/cmd/cli" + "github.com/orkspace/orkestra/pkg/konfig" + "github.com/orkspace/orkestra/pkg/logger" + "github.com/orkspace/orkestra/pkg/utils" + + _ "github.com/orkspace/orkestra-ctrlruntime-constructor/pkg/typeregistry" +) + +func main() { + kfg, err := konfig.Init() + if err != nil { + logger.Fatal().AnErr("failed to load configurations", err) + utils.Exit(err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + cli.Execute(kfg, ctx) +} diff --git a/pkg/kubeclient/fixture/04-ctrlruntime/constructor/webapp_reconciler.go b/pkg/kubeclient/fixture/04-ctrlruntime/constructor/webapp_reconciler.go new file mode 100644 index 000000000..8cbd938f2 --- /dev/null +++ b/pkg/kubeclient/fixture/04-ctrlruntime/constructor/webapp_reconciler.go @@ -0,0 +1,120 @@ +package constructor + +import ( + "context" + "fmt" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + webappv1 "github.com/orkspace/orkestra-ctrlruntime-constructor/api/v1alpha1" + "github.com/orkspace/orkestra/domain" + "github.com/orkspace/orkestra/pkg/kubeclient" +) + +// WebAppReconciler is written in pure controller-runtime style. +// It holds a client.Client and knows nothing about Orkestra's internals. +type WebAppReconciler struct { + client client.Client +} + +// NewWebAppReconciler is the Orkestra constructor function. +// It wires the controller-runtime reconciler into Orkestra without touching +// the reconciler body: ToClient adapts the kubeclient, ReconcilerFrom adapts +// the reconcile.Reconciler signature. +func NewWebAppReconciler(kube kubeclient.Interface) domain.Reconciler { + return domain.ReconcilerFrom(&WebAppReconciler{ + client: kubeclient.ToClient(kube), + }) +} + +func (r *WebAppReconciler) Reconcile(ctx context.Context, req ctrl.Request) (reconcile.Result, error) { + log := ctrl.LoggerFrom(ctx).WithValues("webapp", req.NamespacedName) + log.Info("reconciling") + + webapp := &webappv1.WebApp{} + if err := r.client.Get(ctx, req.NamespacedName, webapp); err != nil { + if errors.IsNotFound(err) { + log.Info("not found — deleted") + return ctrl.Result{}, nil + } + return ctrl.Result{}, err + } + + if err := r.reconcileDeployment(ctx, webapp); err != nil { + log.Error(err, "reconcileDeployment failed") + return ctrl.Result{}, err + } + + base := webapp.DeepCopyObject().(client.Object) + webapp.Status.Phase = "Running" + webapp.Status.Endpoint = fmt.Sprintf("%s.%s.svc.cluster.local", webapp.Name, webapp.Namespace) + webapp.Status.Replicas = webapp.Spec.Replicas + if err := r.client.Status().Patch(ctx, webapp, client.MergeFrom(base)); err != nil { + return ctrl.Result{}, err + } + + log.Info("done", "phase", webapp.Status.Phase, "endpoint", webapp.Status.Endpoint, "replicas", webapp.Status.Replicas) + return ctrl.Result{}, nil +} + +func (r *WebAppReconciler) reconcileDeployment(ctx context.Context, webapp *webappv1.WebApp) error { + log := ctrl.LoggerFrom(ctx).WithValues("webapp", webapp.Name, "namespace", webapp.Namespace) + + replicas := webapp.Spec.Replicas + if replicas == 0 { + replicas = 1 + } + + desired := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: webapp.Name, + Namespace: webapp.Namespace, + OwnerReferences: []metav1.OwnerReference{ + *metav1.NewControllerRef(webapp, webappv1.GroupVersionKind), + }, + }, + Spec: appsv1.DeploymentSpec{ + Replicas: &replicas, + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": webapp.Name}, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"app": webapp.Name}, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: webapp.Name, + Image: webapp.Spec.Image, + Ports: []corev1.ContainerPort{ + {ContainerPort: webapp.Spec.Port}, + }, + }, + }, + }, + }, + }, + } + + existing := &appsv1.Deployment{} + err := r.client.Get(ctx, client.ObjectKey{Name: webapp.Name, Namespace: webapp.Namespace}, existing) + if errors.IsNotFound(err) { + log.Info("creating deployment", "image", webapp.Spec.Image, "replicas", replicas) + return r.client.Create(ctx, desired) + } + if err != nil { + return err + } + + log.Info("patching deployment", "image", webapp.Spec.Image, "replicas", replicas) + patch := client.MergeFrom(existing.DeepCopy()) + existing.Spec = desired.Spec + return r.client.Patch(ctx, existing, patch) +} diff --git a/pkg/kubeclient/fixture/04-ctrlruntime/cr-webapp.yaml b/pkg/kubeclient/fixture/04-ctrlruntime/cr-webapp.yaml new file mode 100644 index 000000000..9b4ff162c --- /dev/null +++ b/pkg/kubeclient/fixture/04-ctrlruntime/cr-webapp.yaml @@ -0,0 +1,9 @@ +apiVersion: migration.demo.orkestra.io/v1alpha1 +kind: WebApp +metadata: + name: 04-ctrlruntime-demo + namespace: default +spec: + image: nginx:latest + replicas: 2 + port: 80 diff --git a/pkg/kubeclient/fixture/04-ctrlruntime/crd-webapp.yaml b/pkg/kubeclient/fixture/04-ctrlruntime/crd-webapp.yaml new file mode 100644 index 000000000..5ce966774 --- /dev/null +++ b/pkg/kubeclient/fixture/04-ctrlruntime/crd-webapp.yaml @@ -0,0 +1,55 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: webapps.migration.demo.orkestra.io +spec: + group: migration.demo.orkestra.io + versions: + - name: v1alpha1 + served: true + storage: true + subresources: + status: {} + additionalPrinterColumns: + - name: Image + type: string + jsonPath: .spec.image + - name: Replicas + type: integer + jsonPath: .spec.replicas + - name: Phase + type: string + jsonPath: .status.phase + - name: Age + type: date + jsonPath: .metadata.creationTimestamp + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + required: [image] + properties: + image: + type: string + description: Container image to deploy. + replicas: + type: integer + minimum: 1 + default: 1 + description: Desired number of pods. + port: + type: integer + minimum: 1 + maximum: 65535 + default: 80 + description: Container port to expose. + status: + type: object + x-kubernetes-preserve-unknown-fields: true + names: + kind: WebApp + plural: webapps + singular: webapp + scope: Namespaced diff --git a/pkg/kubeclient/fixture/04-ctrlruntime/e2e.yaml b/pkg/kubeclient/fixture/04-ctrlruntime/e2e.yaml new file mode 100644 index 000000000..0874d823c --- /dev/null +++ b/pkg/kubeclient/fixture/04-ctrlruntime/e2e.yaml @@ -0,0 +1,49 @@ +apiVersion: orkestra.orkspace.io/v1 +kind: E2E +metadata: + name: webapp-ctrlruntime-e2e + description: > + Run with: ork e2e + + Verifies that a controller-runtime reconciler plugged in via + domain.ReconcilerFrom and kubeclient.ToClient reconciles a WebApp + into a live Deployment end-to-end on a real cluster. + +spec: + katalog: ./katalog.yaml + crd: ./crd-webapp.yaml + cr: ./cr-webapp.yaml + + cluster: + provider: kind + name: ork-ctrlruntime + reuse: false + + expect: + - name: Deployment created and ready + after: cr-applied + timeout: 90s + resources: + - kind: Deployment + namespace: default + name: 04-ctrlruntime-demo + ready: true + + - name: WebApp status phase Running + after: cr-applied + timeout: 30s + kubectl: + get: + - kind: WebApp + name: 04-ctrlruntime-demo + namespace: default + field: .status.phase + equals: Running + + - name: Deployment removed on delete + after: cr-deleted + timeout: 30s + resources: + - kind: Deployment + namespace: default + count: 0 diff --git a/pkg/kubeclient/fixture/04-ctrlruntime/go.mod b/pkg/kubeclient/fixture/04-ctrlruntime/go.mod new file mode 100644 index 000000000..17a0fe140 --- /dev/null +++ b/pkg/kubeclient/fixture/04-ctrlruntime/go.mod @@ -0,0 +1,235 @@ +module github.com/orkspace/orkestra-ctrlruntime-constructor + +go 1.26.6 + +require ( + github.com/orkspace/orkestra v0.0.0 + k8s.io/api v0.36.1 + k8s.io/apimachinery v0.36.1 + sigs.k8s.io/controller-runtime v0.24.1 +) + +require ( + cel.dev/expr v0.25.1 // indirect + cloud.google.com/go v0.123.0 // indirect + cloud.google.com/go/auth v0.20.0 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect + cloud.google.com/go/iam v1.7.0 // indirect + cloud.google.com/go/monitoring v1.24.3 // indirect + cloud.google.com/go/pubsub v1.50.2 // indirect + cloud.google.com/go/pubsub/v2 v2.4.0 // indirect + cloud.google.com/go/storage v1.62.1 // indirect + dario.cat/mergo v1.0.1 // indirect + filippo.io/edwards25519 v1.1.1 // indirect + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 // indirect + github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 // indirect + github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/servicebus/armservicebus v1.2.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/sql/armsql v1.2.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1 // indirect + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect + github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect + github.com/BurntSushi/toml v1.6.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 // indirect + github.com/MakeNowJust/heredoc v1.0.0 // indirect + github.com/Masterminds/goutils v1.1.1 // indirect + github.com/Masterminds/semver/v3 v3.5.0 // indirect + github.com/Masterminds/sprig/v3 v3.3.0 // indirect + github.com/Masterminds/squirrel v1.5.4 // indirect + github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect + github.com/aws/aws-sdk-go-v2 v1.41.7 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect + github.com/aws/aws-sdk-go-v2/config v1.32.18 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.17 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 // indirect + github.com/aws/aws-sdk-go-v2/service/rds v1.118.2 // indirect + github.com/aws/aws-sdk-go-v2/service/route53 v1.62.7 // indirect + github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.0 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 // indirect + github.com/aws/smithy-go v1.25.1 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/blang/semver/v4 v4.0.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/chai2010/gettext-go v1.0.2 // indirect + github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect + github.com/containerd/containerd v1.7.33 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/platforms v1.0.0-rc.1 // indirect + github.com/cyphar/filepath-securejoin v0.6.1 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/emicklei/go-restful/v3 v3.13.0 // indirect + github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect + github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect + github.com/evanphx/json-patch v5.9.11+incompatible // indirect + github.com/evanphx/json-patch/v5 v5.9.11 // indirect + github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f // indirect + github.com/fatih/color v1.18.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.13 // indirect + github.com/go-errors/errors v1.4.2 // indirect + github.com/go-gorp/gorp/v3 v3.1.0 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-logr/zerologr v1.2.3 // indirect + github.com/go-openapi/jsonpointer v0.22.1 // indirect + github.com/go-openapi/jsonreference v0.21.3 // indirect + github.com/go-openapi/swag v0.25.4 // indirect + github.com/go-openapi/swag/cmdutils v0.25.4 // indirect + github.com/go-openapi/swag/conv v0.25.4 // indirect + github.com/go-openapi/swag/fileutils v0.25.4 // indirect + github.com/go-openapi/swag/jsonname v0.25.4 // indirect + github.com/go-openapi/swag/jsonutils v0.25.4 // indirect + github.com/go-openapi/swag/loading v0.25.4 // indirect + github.com/go-openapi/swag/mangling v0.25.4 // indirect + github.com/go-openapi/swag/netutils v0.25.4 // indirect + github.com/go-openapi/swag/stringutils v0.25.4 // indirect + github.com/go-openapi/swag/typeutils v0.25.4 // indirect + github.com/go-openapi/swag/yamlutils v0.25.4 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.2 // indirect + github.com/go-sql-driver/mysql v1.9.3 // indirect + github.com/gobwas/glob v0.2.3 // indirect + github.com/golang-jwt/jwt/v5 v5.3.0 // indirect + github.com/golang/snappy v0.0.4 // indirect + github.com/google/btree v1.1.3 // indirect + github.com/google/gnostic-models v0.7.0 // indirect + github.com/google/s2a-go v0.1.9 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.15 // indirect + github.com/googleapis/gax-go/v2 v2.22.0 // indirect + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect + github.com/gosuri/uitable v0.0.4 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/huandu/xstrings v1.5.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.9.2 // indirect + github.com/jmoiron/sqlx v1.4.0 // indirect + github.com/joho/godotenv v1.5.1 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.18.1 // indirect + github.com/kylelemons/godebug v1.1.0 // indirect + github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect + github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/lib/pq v1.10.9 // indirect + github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/go-wordwrap v1.0.1 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/moby/spdystream v0.5.1 // indirect + github.com/moby/term v0.5.2 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect + github.com/montanaflynn/stats v0.7.1 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/peterbourgon/diskv v2.0.1+incompatible // indirect + github.com/pierrec/lz4/v4 v4.1.15 // indirect + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/prometheus/client_golang v1.23.2 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.67.5 // indirect + github.com/prometheus/otlptranslator v1.0.0 // indirect + github.com/prometheus/procfs v0.19.2 // indirect + github.com/redis/go-redis/v9 v9.14.1 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/robfig/cron/v3 v3.0.1 // indirect + github.com/rs/zerolog v1.35.1 // indirect + github.com/rubenv/sql-migrate v1.8.1 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect + github.com/segmentio/kafka-go v0.4.51 // indirect + github.com/shopspring/decimal v1.4.0 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/cobra v1.10.2 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect + github.com/x448/float16 v0.8.4 // indirect + github.com/xdg-go/pbkdf2 v1.0.0 // indirect + github.com/xdg-go/scram v1.1.2 // indirect + github.com/xdg-go/stringprep v1.0.4 // indirect + github.com/xlab/treeprint v1.2.0 // indirect + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect + go.mongodb.org/mongo-driver v1.17.9 // indirect + go.opencensus.io v0.24.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/detectors/gcp v1.43.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect + go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/otel/metric v1.43.0 // indirect + go.opentelemetry.io/otel/sdk v1.43.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.53.0 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/term v0.44.0 // indirect + golang.org/x/text v0.39.0 // indirect + golang.org/x/time v0.15.0 // indirect + gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect + google.golang.org/api v0.280.0 // indirect + google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 // indirect + google.golang.org/grpc v1.82.1 // indirect + google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect + gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + helm.sh/helm/v3 v3.20.2 // indirect + k8s.io/apiextensions-apiserver v0.36.1 // indirect + k8s.io/apiserver v0.36.1 // indirect + k8s.io/cli-runtime v0.36.1 // indirect + k8s.io/client-go v0.36.1 // indirect + k8s.io/component-base v0.36.1 // indirect + k8s.io/klog/v2 v2.140.0 // indirect + k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect + k8s.io/kubectl v0.36.1 // indirect + k8s.io/streaming v0.36.1 // indirect + k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect + oras.land/oras-go/v2 v2.6.2 // indirect + sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect + sigs.k8s.io/kustomize/api v0.21.1 // indirect + sigs.k8s.io/kustomize/kyaml v0.21.1 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.2 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect +) + +replace github.com/orkspace/orkestra => ../../../.. diff --git a/pkg/kubeclient/fixture/04-ctrlruntime/go.sum b/pkg/kubeclient/fixture/04-ctrlruntime/go.sum new file mode 100644 index 000000000..cfda0c6e5 --- /dev/null +++ b/pkg/kubeclient/fixture/04-ctrlruntime/go.sum @@ -0,0 +1,748 @@ +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= +cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= +cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA= +cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +cloud.google.com/go/iam v1.7.0 h1:JD3zh0C6LHl16aCn5Akff0+GELdp1+4hmh6ndoFLl8U= +cloud.google.com/go/iam v1.7.0/go.mod h1:tetWZW1PD/m6vcuY2Zj/aU0eCHNPuxedbnbRTyKXvdY= +cloud.google.com/go/kms v1.26.0 h1:cK9mN2cf+9V63D3H1f6koxTatWy39aTI/hCjz1I+adU= +cloud.google.com/go/kms v1.26.0/go.mod h1:pHKOdFJm63hxBsiPkYtowZPltu9dW0MWvBa6IA4HM58= +cloud.google.com/go/logging v1.13.2 h1:qqlHCBvieJT9Cdq4QqYx1KPadCQ2noD4FK02eNqHAjA= +cloud.google.com/go/logging v1.13.2/go.mod h1:zaybliM3yun1J8mU2dVQ1/qDzjbOqEijZCn6hSBtKak= +cloud.google.com/go/longrunning v0.9.0 h1:0EzbDEGsAvOZNbqXopgniY0w0a1phvu5IdUFq8grmqY= +cloud.google.com/go/longrunning v0.9.0/go.mod h1:pkTz846W7bF4o2SzdWJ40Hu0Re+UoNT6Q5t+igIcb8E= +cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE= +cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI= +cloud.google.com/go/pubsub v1.50.2 h1:54Up97HnThdP4H8jjWJSSQ/mnYG2EKon7ZSNETRq0tM= +cloud.google.com/go/pubsub v1.50.2/go.mod h1:jyCWeZdGFqd4mitSsBERnJcpqaHBsxQoPkNvjj4sp0w= +cloud.google.com/go/pubsub/v2 v2.4.0 h1:oMKNiBQpXImRWnHYla9uSU66ZzByZwBSCJOEs/pTKVg= +cloud.google.com/go/pubsub/v2 v2.4.0/go.mod h1:2lS/XQKq5qtOMs6kHBK+WX1ytUC36kLl2ig3zqsGUx8= +cloud.google.com/go/storage v1.62.1 h1:Os0G3XbUbjZumkpDUf2Y0rLoXJTCF1kU2kWUujKYXD8= +cloud.google.com/go/storage v1.62.1/go.mod h1:cpYz/kRVZ+UQAF1uHeea10/9ewcRbxGoGNKsS9daSXA= +cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U= +cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s= +dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= +dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +filippo.io/edwards25519 v1.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw= +filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 h1:jHb/wfvRikGdxMXYV3QG/SzUOPYN9KEUUuC0Yd0/vC0= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1/go.mod h1:pzBXCYn05zvYIrwLgtK8Ap8QcjRg+0i76tMQdWN6wOk= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0= +github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY= +github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v2 v2.0.0 h1:PTFGRSlMKCQelWwxUyYVEUqseBJVemLyqWJjvMyt0do= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v2 v2.0.0/go.mod h1:LRr2FzBTQlONPPa5HREE5+RjSCTXl7BwOvYOaWTqCaI= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.0 h1:2qsIIvxVT+uE6yrNldntJKlLRgxGbZ85kgtz5SNBhMw= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.0/go.mod h1:AW8VEadnhw9xox+VaVd9sP7NjzOAnaZBLRH6Tq3cJ38= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0 h1:Dd+RhdJn0OTtVGaeDLZpcumkIVCtA/3/Fo42+eoYvVM= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0/go.mod h1:5kakwfW5CjC9KK+Q4wjXAg+ShuIm2mBMua0ZFj2C8PE= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/servicebus/armservicebus v1.2.0 h1:jngSeKBnzC7qIk3rvbWHsLI7eeasEucORHWr2CHX0Yg= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/servicebus/armservicebus v1.2.0/go.mod h1:1YXAxWw6baox+KafeQU2scy21/4IHvqXoIJuCpcvpMQ= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/sql/armsql v1.2.0 h1:S087deZ0kP1RUg4pU7w9U9xpUedTCbOtz+mnd0+hrkQ= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/sql/armsql v1.2.0/go.mod h1:B4cEyXrWBmbfMDAPnpJ1di7MAt5DKP57jPEObAvZChg= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1 h1:/Zt+cDPnpC3OVDm/JKLOs7M2DKmLRIIp3XIx9pHHiig= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1/go.mod h1:Ng3urmn6dYe8gnbCMoHHVl5APYz2txho3koEkV2o2HA= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM= +github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= +github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs= +github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= +github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0 h1:rIkQfkCOVKc1OiRCNcSDD8ml5RJlZbH/Xsq7lbpynwc= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0/go.mod h1:RD2SsorTmYhF6HkTmDw7KmPYQk8OBYwTkuasChwv7R4= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 h1:UnDZ/zFfG1JhH/DqxIZYU/1CUAlTUScoXD/LcM2Ykk8= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0/go.mod h1:IA1C1U7jO/ENqm/vhi7V9YYpBsp+IMyqNrEN94N7tVc= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0 h1:7t/qx5Ost0s0wbA/VDrByOooURhp+ikYwv20i9Y07TQ= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 h1:0s6TxfCu2KHkkZPnBfsQ2y5qia0jl3MMrmBhu3nCOYk= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc= +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= +github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= +github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= +github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= +github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= +github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM= +github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= +github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8= +github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 h1:gx1AwW1Iyk9Z9dD9F4akX5gnN3QZwUB20GGKH/I+Rho= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10/go.mod h1:qqY157uZoqm5OXq/amuaBJyC9hgBCBQnsaWnPe905GY= +github.com/aws/aws-sdk-go-v2/config v1.32.18 h1:Hcia46bxhGgF3BaSnG8nSNCWmqTK6bj9xN9/FJ3WK6Q= +github.com/aws/aws-sdk-go-v2/config v1.32.18/go.mod h1:zEjCAYmxqDadH1WX8CdBvmLKhUEUVFgKRQG38zjDmrY= +github.com/aws/aws-sdk-go-v2/credentials v1.19.17 h1:gP2nkGsS+KMvF/jfFz2Vv2qiiOqWKyPACSzPsqHgoW8= +github.com/aws/aws-sdk-go-v2/credentials v1.19.17/go.mod h1:Bsew3S/moG5iT77giPj1q8wb/s0RE5/QfH+ASjYtuQc= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 h1:UuSfcORqNSz/ey3VPRS8TcVH2Ikf0/sC+Hdj400QI6U= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23/go.mod h1:+G/OSGiOFnSOkYloKj/9M35s74LgVAdJBSD5lsFfqKg= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 h1:ieLCO1JxUWuxTZ1cRd0GAaeX7O6cIxnwk7tc1LsQhC4= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15/go.mod h1:e3IzZvQ3kAWNykvE0Tr0RDZCMFInMvhku3qNpcIQXhM= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 h1:pbrxO/kuIwgEsOPLkaHu0O+m4fNgLU8B3vxQ+72jTPw= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23/go.mod h1:/CMNUqoj46HpS3MNRDEDIwcgEnrtZlKRaHNaHxIFpNA= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 h1:03xatSQO4+AM1lTAbnRg5OK528EUg744nW7F73U8DKw= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23/go.mod h1:M8l3mwgx5ToK7wot2sBBce/ojzgnPzZXUV445gTSyE8= +github.com/aws/aws-sdk-go-v2/service/rds v1.118.2 h1:pkEeQneYFpTAnGhyqSbyp/DlCPPJTGt0GkWahlLYzMA= +github.com/aws/aws-sdk-go-v2/service/rds v1.118.2/go.mod h1:7gS+cGrKF0mH253QHFlStmx79ws+DlNk+04ZRfmw3U0= +github.com/aws/aws-sdk-go-v2/service/route53 v1.62.7 h1:twRRMmtSITnt/rrp+D7UDLzE5pKMZe759aalkUdN+OY= +github.com/aws/aws-sdk-go-v2/service/route53 v1.62.7/go.mod h1:ztM1lr+sRoCAI8336ZUvlRPbToue0d3gE/wd6jomSJ8= +github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0 h1:etqBTKY581iwLL/H/S2sVgk3C9lAsTJFeXWFDsDcWOU= +github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0/go.mod h1:L2dcoOgS2VSgbPLvpak2NyUPsO1TBN7M45Z4H7DlRc4= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 h1:TdJ+HdzOBhU8+iVAOGUTU63VXopcumCOF1paFulHWZc= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.11/go.mod h1:R82ZRExE/nheo0N+T8zHPcLRTcH8MGsnR3BiVGX0TwI= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 h1:7byT8HUWrgoRp6sXjxtZwgOKfhss5fW6SkLBtqzgRoE= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.17/go.mod h1:xNWknVi4Ezm1vg1QsB/5EWpAJURq22uqd38U8qKvOJc= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.0 h1:nDARhv/oF55bcxF7rCI/4PDxOKnVXVWwDuDwCs2I2SQ= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.0/go.mod h1:4vIRDq+CJB2xFAXZ+YgGUTiEft7oAQlhIs71xcSeuVg= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 h1:F/M5Y9I3nwr2IEpshZgh1GeHpOItExNM9L1euNuh/fk= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio= +github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI= +github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= +github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= +github.com/bshuster-repo/logrus-logstash-hook v1.0.0 h1:e+C0SB5R1pu//O4MQ3f9cFuPGoOVeF2fE4Og9otCc70= +github.com/bshuster-repo/logrus-logstash-hook v1.0.0/go.mod h1:zsTqEiSzDgAa/8GZR7E1qaXrhYNDKBYy5/dWPTIflbk= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chai2010/gettext-go v1.0.2 h1:1Lwwip6Q2QGsAdl/ZKPCwTe9fe0CjlUbqj5bFNSjIRk= +github.com/chai2010/gettext-go v1.0.2/go.mod h1:y+wnP2cHYaVj19NZhYKAwEMH2CI1gNHeQQ+5AjwawxA= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= +github.com/containerd/containerd v1.7.33 h1:iAkYGC/ifR/V+0eR4iXWHNGYUF0DF2PmGV5iz4Irj5M= +github.com/containerd/containerd v1.7.33/go.mod h1:gSbSCVjPCdkfJCjyrzz7aRC+xFlqVbatNpfHfVCYGUM= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v1.0.0-rc.1 h1:83KIq4yy1erSRgOVHNk1HYdPvzdJ5CnsWaRoJX4C41E= +github.com/containerd/platforms v1.0.0-rc.1/go.mod h1:J71L7B+aiM5SdIEqmd9wp6THLVRzJGXfNuWCZCllLA4= +github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA= +github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.19 h1:tUN6H7LWqNx4hQVxomd0CVsDwaDr9gaRQaI4GpSmrsA= +github.com/creack/pty v1.1.19/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= +github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/distribution/distribution/v3 v3.0.0 h1:q4R8wemdRQDClzoNNStftB2ZAfqOiN6UX90KJc4HjyM= +github.com/distribution/distribution/v3 v3.0.0/go.mod h1:tRNuFoZsUdyRVegq8xGNeds4KLjwLCRin/tTo6i1DhU= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8= +github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo= +github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c h1:+pKlWGMw7gf6bQ+oDZB4KHQFypsfjYlq/C4rfL7D3g8= +github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c/go.mod h1:Uw6UezgYA44ePAFQYUehOuCzmy5zmg/+nl2ZfMWGkpA= +github.com/docker/go-metrics v0.0.1 h1:AgB/0SvBxihN0X8OR4SjsblXkbMvalQ8cjmtKQ2rQV8= +github.com/docker/go-metrics v0.0.1/go.mod h1:cG1hvH2utMXtqgqqYE9plW6lDxS3/5ayHzueweSI3Vw= +github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= +github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= +github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= +github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ= +github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= +github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= +github.com/evanphx/json-patch v5.9.11+incompatible h1:ixHHqfcGvxhWkniF1tWxBHA0yb4Z+d1UQi45df52xW8= +github.com/evanphx/json-patch v5.9.11+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= +github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= +github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f h1:Wl78ApPPB2Wvf/TIe2xdyJxTlb6obmF18d8QdkxNDu4= +github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f/go.mod h1:OSYXu++VVOHnXeitef/D8n/6y4QV8uLHSFXX4NeXMGc= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/foxcpp/go-mockdns v1.2.0 h1:omK3OrHRD1IWJz1FuFBCFquhXslXoF17OvBS6JPzZF0= +github.com/foxcpp/go-mockdns v1.2.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= +github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= +github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= +github.com/go-gorp/gorp/v3 v3.1.0 h1:ItKF/Vbuj31dmV4jxA1qblpSwkl9g1typ24xoe70IGs= +github.com/go-gorp/gorp/v3 v3.1.0/go.mod h1:dLEjIyyRNiXvNZ8PSmzpt1GsWAUK8kjVhEpjH8TixEw= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= +github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= +github.com/go-logr/zerologr v1.2.3 h1:up5N9vcH9Xck3jJkXzgyOxozT14R47IyDODz8LM1KSs= +github.com/go-logr/zerologr v1.2.3/go.mod h1:BxwGo7y5zgSHYR1BjbnHPyF/5ZjVKfKxAZANVu6E8Ho= +github.com/go-openapi/jsonpointer v0.22.1 h1:sHYI1He3b9NqJ4wXLoJDKmUmHkWy/L7rtEo92JUxBNk= +github.com/go-openapi/jsonpointer v0.22.1/go.mod h1:pQT9OsLkfz1yWoMgYFy4x3U5GY5nUlsOn1qSBH5MkCM= +github.com/go-openapi/jsonreference v0.21.3 h1:96Dn+MRPa0nYAR8DR1E03SblB5FJvh7W6krPI0Z7qMc= +github.com/go-openapi/jsonreference v0.21.3/go.mod h1:RqkUP0MrLf37HqxZxrIAtTWW4ZJIK1VzduhXYBEeGc4= +github.com/go-openapi/swag v0.25.4 h1:OyUPUFYDPDBMkqyxOTkqDYFnrhuhi9NR6QVUvIochMU= +github.com/go-openapi/swag v0.25.4/go.mod h1:zNfJ9WZABGHCFg2RnY0S4IOkAcVTzJ6z2Bi+Q4i6qFQ= +github.com/go-openapi/swag/cmdutils v0.25.4 h1:8rYhB5n6WawR192/BfUu2iVlxqVR9aRgGJP6WaBoW+4= +github.com/go-openapi/swag/cmdutils v0.25.4/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0= +github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4= +github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU= +github.com/go-openapi/swag/fileutils v0.25.4 h1:2oI0XNW5y6UWZTC7vAxC8hmsK/tOkWXHJQH4lKjqw+Y= +github.com/go-openapi/swag/fileutils v0.25.4/go.mod h1:cdOT/PKbwcysVQ9Tpr0q20lQKH7MGhOEb6EwmHOirUk= +github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI= +github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag= +github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA= +github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4 h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4/go.mod h1:Mt0Ost9l3cUzVv4OEZG+WSeoHwjWLnarzMePNDAOBiM= +github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s= +github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE= +github.com/go-openapi/swag/mangling v0.25.4 h1:2b9kBJk9JvPgxr36V23FxJLdwBrpijI26Bx5JH4Hp48= +github.com/go-openapi/swag/mangling v0.25.4/go.mod h1:6dxwu6QyORHpIIApsdZgb6wBk/DPU15MdyYj/ikn0Hg= +github.com/go-openapi/swag/netutils v0.25.4 h1:Gqe6K71bGRb3ZQLusdI8p/y1KLgV4M/k+/HzVSqT8H0= +github.com/go-openapi/swag/netutils v0.25.4/go.mod h1:m2W8dtdaoX7oj9rEttLyTeEFFEBvnAx9qHd5nJEBzYg= +github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8= +github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0= +github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw= +github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE= +github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw= +github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc= +github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4= +github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg= +github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls= +github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ= +github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= +github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= +github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= +github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= +github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc= +github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0= +github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc= +github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.15 h1:xolVQTEXusUcAA5UgtyRLjelpFFHWlPQ4XfWGc7MBas= +github.com/googleapis/enterprise-certificate-proxy v0.3.15/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= +github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4= +github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY= +github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE= +github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= +github.com/gosuri/uitable v0.0.4 h1:IG2xLKRvErL3uhY6e1BylFzG+aJiwQviDDTfOKeKTpY= +github.com/gosuri/uitable v0.0.4/go.mod h1:tKR86bXuXPZazfOTG1FIzvjIdXzd0mo4Vtn16vt0PJo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZBeBuvMkmuI2V3Fak= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/golang-lru/arc/v2 v2.0.5 h1:l2zaLDubNhW4XO3LnliVj0GXO3+/CGNJAg1dcN2Fpfw= +github.com/hashicorp/golang-lru/arc/v2 v2.0.5/go.mod h1:ny6zBSQZi2JxIeYcv7kt2sH2PXJtirBN7RDhRpxPkxU= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= +github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= +github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= +github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= +github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= +github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co= +github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw= +github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o= +github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk= +github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhnIaL+V+BEER86oLrvS+kWobKpbJuye0= +github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A= +github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/miekg/dns v1.1.61 h1:nLxbwF3XxhwVSm8g9Dghm9MHPaUZuqhPiGL+675ZmEs= +github.com/miekg/dns v1.1.61/go.mod h1:mnAarhS3nWaW+NVP2wTkYVIZyHNJ098SJZUki3eykwQ= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= +github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/moby/spdystream v0.5.1 h1:9sNYeYZUcci9R6/w7KDaFWEWeV4LStVG78Mpyq/Zm/Y= +github.com/moby/spdystream v0.5.1/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 h1:n6/2gBQ3RWajuToeY6ZtZTIKv2v7ThUy5KKusIT0yc0= +github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00/go.mod h1:Pm3mSP3c5uWn86xMLZ5Sa7JB9GsEZySvHYXCTK4E9q4= +github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE= +github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI= +github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE= +github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= +github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI= +github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= +github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5 h1:Ii+DKncOVM8Cu1Hc+ETb5K+23HdAMvESYE3ZJ5b5cMI= +github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5/go.mod h1:iIss55rKnNBTvrwdmkUpLnDpZoAHvWaiq5+iMmen4AE= +github.com/pierrec/lz4/v4 v4.1.15 h1:MO0/ucJhngq7299dKLwIMtgTfbkoSPF6AoMYDd8Q4q0= +github.com/pierrec/lz4/v4 v4.1.15/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/poy/onpar v1.1.2 h1:QaNrNiZx0+Nar5dLgTVp5mXkyoVFIbepjyEoGSnhbAY= +github.com/poy/onpar v1.1.2/go.mod h1:6X8FLNoxyr9kkmnlqpK6LSoiOtrO6MICtWwEuWkLjzg= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= +github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= +github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos= +github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM= +github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= +github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= +github.com/redis/go-redis/extra/rediscmd/v9 v9.0.5 h1:EaDatTxkdHG+U3Bk4EUr+DZ7fOGwTfezUiUJMaIcaho= +github.com/redis/go-redis/extra/rediscmd/v9 v9.0.5/go.mod h1:fyalQWdtzDBECAQFBJuQe5bzQ02jGd5Qcbgb97Flm7U= +github.com/redis/go-redis/extra/redisotel/v9 v9.5.3 h1:kuvuJL/+MZIEdvtb/kTBRiRgYaOmx1l+lYJyVdrRUOs= +github.com/redis/go-redis/extra/redisotel/v9 v9.5.3/go.mod h1:7f/FMrf5RRRVHXgfk7CzSVzXHiWeuOQUu2bsVqWoa+g= +github.com/redis/go-redis/v9 v9.14.1 h1:nDCrEiJmfOWhD76xlaw+HXT0c9hfNWeXgl0vIRYSDvQ= +github.com/redis/go-redis/v9 v9.14.1/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI= +github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= +github.com/rubenv/sql-migrate v1.8.1 h1:EPNwCvjAowHI3TnZ+4fQu3a915OpnQoPAjTXCGOy2U0= +github.com/rubenv/sql-migrate v1.8.1/go.mod h1:BTIKBORjzyxZDS6dzoiw6eAFYJ1iNlGAtjn4LGeVjS8= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/segmentio/kafka-go v0.4.51 h1:JgDPPG75tC1rWIS2Me6MwcvXJ6f49UQ4HjAOef71Hno= +github.com/segmentio/kafka-go v0.4.51/go.mod h1:Y1gn60kzLEEaW28YshXyk2+VCUKbJ3Qr6DrnT3i4+9E= +github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= +github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= +github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY= +github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= +github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= +github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.einride.tech/aip v0.83.0 h1:TI21IdeOnLTwZEJ3BxtImIZk6bsN2Q+sd0x99SLiQ+M= +go.einride.tech/aip v0.83.0/go.mod h1:E8+wdTApA70odnpFzJgsGogHozC2JCIhFJBKPr8bVig= +go.mongodb.org/mongo-driver v1.17.9 h1:IexDdCuuNJ3BHrELgBlyaH9p60JXAvdzWR128q+U5tU= +go.mongodb.org/mongo-driver v1.17.9/go.mod h1:LlOhpH5NUEfhxcAwG0UEkMqwYcc4JU18gtCdGudk/tQ= +go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= +go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/bridges/prometheus v0.57.0 h1:UW0+QyeyBVhn+COBec3nGhfnFe5lwB0ic1JBVjzhk0w= +go.opentelemetry.io/contrib/bridges/prometheus v0.57.0/go.mod h1:ppciCHRLsyCio54qbzQv0E4Jyth/fLWDTJYfvWpcSVk= +go.opentelemetry.io/contrib/detectors/gcp v1.43.0 h1:62yY3dT7/ShwOxzA0RsKRgshBmfElKI4d/Myu2OxDFU= +go.opentelemetry.io/contrib/detectors/gcp v1.43.0/go.mod h1:RyaZMFY7yi1kAs45S6mbFGz8O8rqB0dTY14uzvG4LCs= +go.opentelemetry.io/contrib/exporters/autoexport v0.57.0 h1:jmTVJ86dP60C01K3slFQa2NQ/Aoi7zA+wy7vMOKD9H4= +go.opentelemetry.io/contrib/exporters/autoexport v0.57.0/go.mod h1:EJBheUMttD/lABFyLXhce47Wr6DPWYReCzaZiXadH7g= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.8.0 h1:WzNab7hOOLzdDF/EoWCt4glhrbMPVMOO5JYTmpz36Ls= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.8.0/go.mod h1:hKvJwTzJdp90Vh7p6q/9PAOd55dI6WA6sWj62a/JvSs= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.8.0 h1:S+LdBGiQXtJdowoJoQPEtI52syEP/JYBUpjO49EQhV8= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.8.0/go.mod h1:5KXybFvPGds3QinJWQT7pmXf+TN5YIa7CNYObWRkj50= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.32.0 h1:j7ZSD+5yn+lo3sGV69nW04rRR0jhYnBwjuX3r0HvnK0= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.32.0/go.mod h1:WXbYJTUaZXAbYd8lbgGuvih0yuCfOFC5RJoYnoLcGz8= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.32.0 h1:t/Qur3vKSkUCcDVaSumWF2PKHt85pc7fRvFuoVT8qFU= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.32.0/go.mod h1:Rl61tySSdcOJWoEgYZVtmnKdA0GeKrSqkHC1t+91CH8= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0/go.mod h1:bTdK1nhqF76qiPoCCdyFIV+N/sRHYXYCTQc+3VCi3MI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0 h1:DvJDOPmSWQHWywQS6lKL+pb8s3gBLOZUtw4N+mavW1I= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0/go.mod h1:EtekO9DEJb4/jRyN4v4Qjc2yA7AtfCBuz2FynRUWTXs= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.37.0 h1:bDMKF3RUSxshZ5OjOTi8rsHGaPKsAt76FaqgvIUySLc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.37.0/go.mod h1:dDT67G/IkA46Mr2l9Uj7HsQVwsjASyV9SjGofsiUZDA= +go.opentelemetry.io/otel/exporters/prometheus v0.60.0 h1:cGtQxGvZbnrWdC2GyjZi0PDKVSLWP/Jocix3QWfXtbo= +go.opentelemetry.io/otel/exporters/prometheus v0.60.0/go.mod h1:hkd1EekxNo69PTV4OWFGZcKQiIqg0RfuWExcPKFvepk= +go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.8.0 h1:CHXNXwfKWfzS65yrlB2PVds1IBZcdsX8Vepy9of0iRU= +go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.8.0/go.mod h1:zKU4zUgKiaRxrdovSS2amdM5gOc59slmo/zJwGX+YBg= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 h1:TC+BewnDpeiAmcscXbGMfxkO+mwYUwE/VySwvw88PfA= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0/go.mod h1:J/ZyF4vfPwsSr9xJSPyQ4LqtcTPULFR64KwTikGLe+A= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.32.0 h1:cC2yDI3IQd0Udsux7Qmq8ToKAx1XCilTQECZ0KDZyTw= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.32.0/go.mod h1:2PD5Ex6z8CFzDbTdOlwyNIUywRr1DN0ospafJM1wJ+s= +go.opentelemetry.io/otel/log v0.11.0 h1:c24Hrlk5WJ8JWcwbQxdBqxZdOK7PcP/LFtOtwpDTe3Y= +go.opentelemetry.io/otel/log v0.11.0/go.mod h1:U/sxQ83FPmT29trrifhQg+Zj2lo1/IPN1PF6RTFqdwc= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/log v0.8.0 h1:zg7GUYXqxk1jnGF/dTdLPrK06xJdrXgqgFLnI4Crxvs= +go.opentelemetry.io/otel/sdk/log v0.8.0/go.mod h1:50iXr0UVwQrYS45KbruFrEt4LvAdCaWWgIrsN3ZQggo= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= +go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= +golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= +gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/api v0.280.0 h1:F4OfEHZhZh6a7uTufJAXXVd/2TQ8EjM4vZH+jX/vFYk= +google.golang.org/api v0.280.0/go.mod h1:oGKmPZRDoD3vdkf6MA7F4VNkR1rxCiuaPSkhsf3EolU= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= +google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 h1:yQugLulqltosq0B/f8l4w9VryjV+N/5gcW0jQ3N8Qec= +google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 h1:seT2EwLWM78plQ7wcDfuWBc/4FAEAXDDiaSol4ku4qo= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= +google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= +google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= +gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +helm.sh/helm/v3 v3.20.2 h1:binM4rvPx5DcNsa1sIt7UZi55lRbu3pZUFmQkSoRh48= +helm.sh/helm/v3 v3.20.2/go.mod h1:Fl1kBaWCpkUrM6IYXPjQ3bdZQfFrogKArqptvueZ6Ww= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +k8s.io/api v0.36.1 h1:XbL/EMj8K2aJpJtePmqUyQMsM0D4QI2pvl7YKJ20FTY= +k8s.io/api v0.36.1/go.mod h1:KOWo4ey3TINlXjeHVuwB3i+tXXnu+UcwFBHlI/9dvEo= +k8s.io/apiextensions-apiserver v0.36.1 h1:6JfYmPUsuUIHuN+3QxutXYWj492RqF5fBSx67GYK5Ks= +k8s.io/apiextensions-apiserver v0.36.1/go.mod h1:pLzZin90riwisdzKwv/GoTwENooytoIx5zWJb4Hkby8= +k8s.io/apimachinery v0.36.1 h1:G63Gjx2W+q0YD+72Vo8oY0nDnePVwnuzTmmy5ENrVSA= +k8s.io/apimachinery v0.36.1/go.mod h1:ibYOR00vW/I1kzvi5SF0dRuJ52BvKtfvRdOn35GPQ+8= +k8s.io/apiserver v0.36.1 h1:iMS5V+rPUertv5P9RaqJgmHHTuh4quWpoxchvMUY+JY= +k8s.io/apiserver v0.36.1/go.mod h1:Cby1PbLWztu0GDOxoO6iFOyyqIsziHNEW+w9zVQ22Kw= +k8s.io/cli-runtime v0.36.1 h1:yuC/BGnnj1YYPh6D1P+pZnzinCs6DvMq86yAeNqoqzM= +k8s.io/cli-runtime v0.36.1/go.mod h1:ZQWHGt8xAF7KnviB79vX0lYNyUUqKIpU+LQg7exuFAw= +k8s.io/client-go v0.36.1 h1:FN/K8QIT2CEDt+2WB2HnWrUANZ50AP5GII43/SP2JR0= +k8s.io/client-go v0.36.1/go.mod h1:s6rAnCtTGYDQnpNjEhSaISV+2O8jwruZ6m3QOYBFbtU= +k8s.io/component-base v0.36.1 h1:iG6GsELftXqTNG9HG6kiVjatSgAw1sf5pJ6R5a6N0kA= +k8s.io/component-base v0.36.1/go.mod h1:nf9XPlntRdqO6WMeEWAA5F93Y4ICZQdeT9GeqLDB3JI= +k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= +k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= +k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a h1:xCeOEAOoGYl2jnJoHkC3hkbPJgdATINPMAxaynU2Ovg= +k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0= +k8s.io/kubectl v0.36.1 h1:96HqS9twIdHM0MlJLTwbo14b9kUKPkOzZ4tlRDLv4qI= +k8s.io/kubectl v0.36.1/go.mod h1:/DGPAIewKsFWF9VFgGvkPhao2Ev4SNuE3BioZo8yPbk= +k8s.io/streaming v0.36.1 h1:L+K68n4Gg940BGNNYtUBvL1WTLL0YnKT3s+P1MNAmR4= +k8s.io/streaming v0.36.1/go.mod h1:z6fV3D+NVkoeqRMtWwlUZK6U17SY/LqNzOxWL6GyR/s= +k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU= +k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= +oras.land/oras-go/v2 v2.6.2 h1:N04RXngAp1LJKTG6ifz3xHPipasEkWr+hFmInja5YKo= +oras.land/oras-go/v2 v2.6.2/go.mod h1:PlTtg4JTDJkDe8yVHpM2wz7/YDc00GVas+i4jAW2TZ4= +sigs.k8s.io/controller-runtime v0.24.1 h1:miPEwrmirImAvgME1L9qebGHrOnGJoVmVdtOU9fRfo4= +sigs.k8s.io/controller-runtime v0.24.1/go.mod h1:vFkfY5fGt5xAC/sKb8IBFKgWPNKG9OUG29dR8Y2wImw= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/kustomize/api v0.21.1 h1:lzqbzvz2CSvsjIUZUBNFKtIMsEw7hVLJp0JeSIVmuJs= +sigs.k8s.io/kustomize/api v0.21.1/go.mod h1:f3wkKByTrgpgltLgySCntrYoq5d3q7aaxveSagwTlwI= +sigs.k8s.io/kustomize/kyaml v0.21.1 h1:IVlbmhC076nf6foyL6Taw4BkrLuEsXUXNpsE+ScX7fI= +sigs.k8s.io/kustomize/kyaml v0.21.1/go.mod h1:hmxADesM3yUN2vbA5z1/YTBnzLJ1dajdqpQonwBL1FQ= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2 h1:kwVWMx5yS1CrnFWA/2QHyRVJ8jM6dBA80uLmm0wJkk8= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/pkg/kubeclient/fixture/04-ctrlruntime/katalog.yaml b/pkg/kubeclient/fixture/04-ctrlruntime/katalog.yaml new file mode 100644 index 000000000..833f4b862 --- /dev/null +++ b/pkg/kubeclient/fixture/04-ctrlruntime/katalog.yaml @@ -0,0 +1,39 @@ +apiVersion: orkestra.orkspace.io/v1 +kind: Katalog +metadata: + name: webapp-ctrlruntime + author: orkspace + version: 0.1.0 + description: > + WebApp operator — reconciler written in controller-runtime style. + The constructor wraps it with domain.ReconcilerFrom and kubeclient.ToClient + so it plugs into Orkestra without rewriting the reconciler body. + +spec: + crds: + webapp: + crdFile: ./crd-webapp.yaml + crFiles: + - ./cr-webapp.yaml + + apiTypes: + group: migration.demo.orkestra.io + version: v1alpha1 + kind: WebApp + plural: webapps + object: WebApp + objectList: WebAppList + location: github.com/orkspace/orkestra-ctrlruntime-constructor/api/v1alpha1 + alias: webappv1 + + operatorBox: + reconciler: + default: false + constructor: + location: github.com/orkspace/orkestra-ctrlruntime-constructor/constructor + function: NewWebAppReconciler + alias: webappconstructor + resources: + - kind: Deployment + workers: 2 + resync: 30s diff --git a/pkg/kubeclient/fixture/04-ctrlruntime/pkg/typeregistry/zz_generated_typeregistry.go b/pkg/kubeclient/fixture/04-ctrlruntime/pkg/typeregistry/zz_generated_typeregistry.go new file mode 100644 index 000000000..c534fff5c --- /dev/null +++ b/pkg/kubeclient/fixture/04-ctrlruntime/pkg/typeregistry/zz_generated_typeregistry.go @@ -0,0 +1,86 @@ +// pkg/typeregistry/zz_generated_typeregistry.go +// Code generated by "ork generate registry" on 2026-08-20T13:32:48Z. DO NOT EDIT. +// Re-generate by running: ork generate registry --file +// +// This file registers compiled Go types and external functions. +// Dynamic template CRDs do not appear here — they are handled at runtime +// by GenericReconciler without any code generation. +package typeregistry + +import ( + "github.com/orkspace/orkestra/domain" + "github.com/orkspace/orkestra/pkg/kubeclient" + "github.com/orkspace/orkestra/pkg/logger" + orktypes "github.com/orkspace/orkestra/pkg/types" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + + webappv1 "github.com/orkspace/orkestra-ctrlruntime-constructor/api/v1alpha1" + webappconstructor "github.com/orkspace/orkestra-ctrlruntime-constructor/constructor" +) + +// init runs before main(). It calls RegisterRuntimeObjects to populate the +// GVK-keyed registries and appends AddToScheme functions to SchemeAdderFns so +// that NewSchemeRegistry can find them through the internal pkg/typeregistry stub. +// +// A blank import of this package from main.go is the only wiring needed: +// +// import _ "myapp/pkg/typeregistry" +// +// "ork generate registry -k " populates this +// +// No explicit call to RegisterRuntimeObjects or RegisterTypedScheme is required. +func init() { + logger.Debug().Msg("typeregistry.init() started") + RegisterRuntimeObjects() + orktypes.SchemeAdderFns = append(orktypes.SchemeAdderFns, func(s *runtime.Scheme) error { + s.AddKnownTypeWithName(schema.GroupVersionKind{Group: "migration.demo.orkestra.io", Version: "v1alpha1", Kind: "WebApp"}, &webappv1.WebApp{}) + s.AddKnownTypeWithName(schema.GroupVersionKind{Group: "migration.demo.orkestra.io", Version: "v1alpha1", Kind: "WebAppList"}, &webappv1.WebAppList{}) + metav1.AddToGroupVersion(s, schema.GroupVersion{Group: "migration.demo.orkestra.io", Version: "v1alpha1"}) + return nil + }) + logger.Debug().Int("length", len(orktypes.SchemeAdderFns)).Msg("typeregistry.init() finished") +} + +// RegisterRuntimeObjects populates ObjectRegistry, ListRegistry, HookRegistry, +// and ReconcilerRegistry. Called by init() — do not call directly. +// +// Object/List entries — factory functions for typed CRDs. +// +// Used by the informer to create zero-value instances for cache storage +// and type assertion during reconciliation. +// +// Hook entries — factory functions for Go hook implementations. +// +// Called by addHooks() during Katalog validation to wire HookFactory +// onto the CRD entry. GenericReconciler calls HookFactory() once at +// startCRDWorkers time to obtain the typed hooks. +// +// Reconciler entries — constructor functions for custom reconcilers. +// +// Called by addReconcilers() during Katalog validation to wire Constructor +// onto the CRD entry. DependencyKordinator calls Constructor() once at +// startCRDWorkers time to build the reconciler. +func RegisterRuntimeObjects() { + logger.Debug().Msg("RegisterRuntimeObjects called") + + // WebApp — typed CRD object and list factories + orktypes.ObjectRegistry[schema.GroupVersionKind{Group: "migration.demo.orkestra.io", Version: "v1alpha1", Kind: "WebApp"}] = + func() runtime.Object { return &webappv1.WebApp{} } + orktypes.ListRegistry[schema.GroupVersionKind{Group: "migration.demo.orkestra.io", Version: "v1alpha1", Kind: "WebApp"}] = + func() runtime.Object { return &webappv1.WebAppList{} } + + // WebApp — custom reconciler constructor + // Calls webappconstructor.NewWebAppReconciler() to build the user's reconciler. + orktypes.ReconcilerRegistry[schema.GroupVersionKind{Group: "migration.demo.orkestra.io", Version: "v1alpha1", Kind: "WebApp"}] = + func(kube kubeclient.Interface) domain.Reconciler { + return webappconstructor.NewWebAppReconciler(kube) + } + + logger.Debug(). + Int("objectRegistrySize", len(orktypes.ObjectRegistry)). + Int("listRegistrySize", len(orktypes.ListRegistry)). + Int("reconcilerRegistrySize", len(orktypes.ReconcilerRegistry)). + Msg("Runtime objects registered") +} diff --git a/pkg/kubeclient/fixture/04-ctrlruntime/simulate.yaml b/pkg/kubeclient/fixture/04-ctrlruntime/simulate.yaml new file mode 100644 index 000000000..e526a32a0 --- /dev/null +++ b/pkg/kubeclient/fixture/04-ctrlruntime/simulate.yaml @@ -0,0 +1,26 @@ +apiVersion: orkestra.orkspace.io/v1 +kind: Simulate +metadata: + name: webapp-ctrlruntime-sim + description: > + Run with: ork simulate + + The reconciler is written in controller-runtime style: it receives a + reconcile.Request, fetches the WebApp via client.Get, and creates a + Deployment via client.Create / client.Patch. kubeclient.ToClient and + domain.ReconcilerFrom bridge the two APIs at the constructor boundary — + the reconciler body never changes. + +spec: + katalog: ./katalog.yaml + cr: ./cr-webapp.yaml + cycles: 3 + + expect: + steady: true + noErrors: true + ops: + - cycle: 1 + verb: apply + resource: deployments + name: 04-ctrlruntime-demo diff --git a/pkg/kubeclient/fixture/04-ctrlruntime/values.yaml b/pkg/kubeclient/fixture/04-ctrlruntime/values.yaml new file mode 100644 index 000000000..b74b15b50 --- /dev/null +++ b/pkg/kubeclient/fixture/04-ctrlruntime/values.yaml @@ -0,0 +1,4 @@ +runtime: + image: + repository: ghcr.io/orkspace/orkestra/pkg/kubeclient/fixture/04-ctrlruntime + tag: latest diff --git a/pkg/kubeclient/fixture/pkg/typeregistry/zz_generated_typeregistry.go b/pkg/kubeclient/fixture/pkg/typeregistry/zz_generated_typeregistry.go index e21a612fc..6ace6bcea 100644 --- a/pkg/kubeclient/fixture/pkg/typeregistry/zz_generated_typeregistry.go +++ b/pkg/kubeclient/fixture/pkg/typeregistry/zz_generated_typeregistry.go @@ -9,14 +9,12 @@ package typeregistry import ( "github.com/orkspace/orkestra/domain" - "github.com/orkspace/orkestra/pkg/event" "github.com/orkspace/orkestra/pkg/kubeclient" "github.com/orkspace/orkestra/pkg/logger" orktypes "github.com/orkspace/orkestra/pkg/types" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/client-go/tools/cache" bcnodev1 "github.com/orkspace/orkestra-args-constructor/api/v1alpha1" bnconstructor "github.com/orkspace/orkestra-args-constructor/constructor" @@ -97,8 +95,8 @@ func RegisterRuntimeObjects() { // BlockchainNode — custom reconciler constructor // Calls bnconstructor.NewBlockchainNodeReconciler() to build the user's reconciler. orktypes.ReconcilerRegistry[schema.GroupVersionKind{Group: "demo.orkestra.io", Version: "v1alpha1", Kind: "BlockchainNode"}] = - func(kube kubeclient.Interface, inf cache.SharedIndexInformer, ev event.Recorder) domain.Reconciler { - return bnconstructor.NewBlockchainNodeReconciler(kube, inf, ev) + func(kube kubeclient.Interface) domain.Reconciler { + return bnconstructor.NewBlockchainNodeReconciler(kube) } logger.Debug(). diff --git a/pkg/kubeclient/interface.go b/pkg/kubeclient/interface.go index 08867c5c5..7a9574875 100644 --- a/pkg/kubeclient/interface.go +++ b/pkg/kubeclient/interface.go @@ -9,9 +9,17 @@ import ( "k8s.io/client-go/dynamic" "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" + "k8s.io/client-go/tools/cache" sigs "sigs.k8s.io/controller-runtime/pkg/client" ) +// EventRecorder is a minimal interface for recording Kubernetes events. +// pkg/event.Recorder satisfies this — kubeclient does not import pkg/event +// directly to avoid an import cycle (pkg/event imports pkg/kubeclient). +type EventRecorder interface { + Eventf(obj runtime.Object, eventType, reason, messageFmt string, args ...interface{}) +} + // Interface is the interface every registry function depends on. // *Kubeclient satisfies this with real clients. // *simulate.FakeKubeclient satisfies this with k8s.io/client-go/kubernetes/fake. @@ -30,6 +38,22 @@ type Interface interface { // Used by the runtime to inject per-CRD args before calling a hook or constructor. WithArgs(args Args) Interface + // WithInformer returns a copy of this Interface with the primary CRD informer + // attached. Called by the runtime before invoking a constructor function. + WithInformer(inf cache.SharedIndexInformer) Interface + + // WithEventRecorder returns a copy of this Interface with the event recorder + // attached. Called by the runtime before invoking a constructor function. + WithEventRecorder(ev EventRecorder) Interface + + // GetInformer returns the primary CRD's SharedIndexInformer. + // Available inside constructor functions — nil if called outside that context. + GetInformer() cache.SharedIndexInformer + + // GetEventRecorder returns the event recorder for this CRD. + // Available inside constructor functions — nil if called outside that context. + GetEventRecorder() EventRecorder + // ScopedFor evaluates any template expressions in the rawArgs using eval and // returns a copy of this Interface with the resolved args attached. // Called by GenericReconciler after building the resolver so hook authors see diff --git a/pkg/kubeclient/kubeclient.go b/pkg/kubeclient/kubeclient.go index 77071c539..50bcea271 100644 --- a/pkg/kubeclient/kubeclient.go +++ b/pkg/kubeclient/kubeclient.go @@ -21,6 +21,7 @@ import ( "k8s.io/client-go/kubernetes/fake" "k8s.io/client-go/rest" "k8s.io/client-go/restmapper" + "k8s.io/client-go/tools/cache" "k8s.io/client-go/tools/clientcmd" ) @@ -46,6 +47,13 @@ type Kubeclient struct { // Set by ScopedFor; nil means fall back to rawArgs as-is. args Args + // informer is the primary CRD's SharedIndexInformer, injected by the runtime + // before the constructor is called. Accessible via Informer(). + informer cache.SharedIndexInformer + // eventRecorder is the event recorder for this CRD, injected by the runtime + // before the constructor is called. Accessible via GetEventRecorder(). + eventRecorder EventRecorder + // Testing FakeClientset kubernetes.Interface } @@ -240,3 +248,31 @@ func (k *Kubeclient) ScopedFor(eval func(string) (string, bool)) Interface { cp.args = ResolveArgsMap(k.rawArgs, eval) return &cp } + +// WithInformer returns a copy of this Interface with the primary CRD informer +// attached. Called by the runtime before invoking a constructor function. +func (k *Kubeclient) WithInformer(inf cache.SharedIndexInformer) Interface { + cp := *k + cp.informer = inf + return &cp +} + +// WithEventRecorder returns a copy of this Interface with the event recorder +// attached. Called by the runtime before invoking a constructor function. +func (k *Kubeclient) WithEventRecorder(ev EventRecorder) Interface { + cp := *k + cp.eventRecorder = ev + return &cp +} + +// GetInformer returns the primary CRD's SharedIndexInformer. +// Available inside constructor functions — nil if called outside that context. +func (k *Kubeclient) GetInformer() cache.SharedIndexInformer { + return k.informer +} + +// GetEventRecorder returns the event recorder for this CRD. +// Available inside constructor functions — nil if called outside that context. +func (k *Kubeclient) GetEventRecorder() EventRecorder { + return k.eventRecorder +} diff --git a/pkg/labels/manager.go b/pkg/labels/manager.go index 0b9286659..d4dc71885 100644 --- a/pkg/labels/manager.go +++ b/pkg/labels/manager.go @@ -242,6 +242,33 @@ func (m *Manager) EnsureStrictModeExemptLabel(obj domain.Object, strictModeEnabl return true } +// EnsureUserLabels merges user-defined labels onto obj. +// Values are expected to have been resolved from templates before this call. +// Existing keys set by the user are overwritten; keys absent from extra are +// left untouched. Returns true if any label was added or changed. +// +// The caller must persist any change via kube.PatchLabels. +func (m *Manager) EnsureUserLabels(obj domain.Object, extra map[string]string) bool { + if len(extra) == 0 { + return false + } + lbls := obj.GetLabels() + if lbls == nil { + lbls = make(map[string]string, len(extra)) + } + changed := false + for k, v := range extra { + if lbls[k] != v { + lbls[k] = v + changed = true + } + } + if changed { + obj.SetLabels(lbls) + } + return changed +} + // ── Getters ─────────────────────────────────────────────────────────────────── func (m *Manager) IsStandalone() bool { diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go index 5ac8f54bc..e1009a7b6 100644 --- a/pkg/logger/logger.go +++ b/pkg/logger/logger.go @@ -4,8 +4,10 @@ import ( "context" "strings" + "github.com/go-logr/zerologr" "github.com/rs/zerolog" "github.com/rs/zerolog/log" + ctrllog "sigs.k8s.io/controller-runtime/pkg/log" ) func init() { @@ -34,6 +36,11 @@ func Init(level string) { default: zerolog.SetGlobalLevel(zerolog.InfoLevel) } + + // Wire zerolog as the backend for logr / controller-runtime log. + // Constructor writers calling ctrl/log.FromContext(ctx) or logr.FromContext(ctx) + // get a logger that emits through zerolog automatically. + ctrllog.SetLogger(zerologr.New(&log.Logger)) } func FromContext(ctx context.Context) *zerolog.Logger { diff --git a/pkg/registry/simulate/harness.go b/pkg/registry/simulate/harness.go index ece10afd5..9c897c8e8 100644 --- a/pkg/registry/simulate/harness.go +++ b/pkg/registry/simulate/harness.go @@ -228,7 +228,7 @@ func Run(ctx context.Context, kat *katalog.Katalog, crdName string, cr *unstruct // so hook BindToObjectHooks type-assertions and cross: lookups both work. var r domain.Reconciler if factoryFn, ok := orktypes.ReconcilerRegistry[gvk]; ok { - r = factoryFn(fakeKube, informer, event.Discard()) + r = factoryFn(fakeKube.WithInformer(informer).WithEventRecorder(event.Discard())) } else { r = reconciler.NewGenericReconciler( effectiveCRDEntry, diff --git a/pkg/registry/simulate/harness_envtest.go b/pkg/registry/simulate/harness_envtest.go index 4f8929a88..de9b626cb 100644 --- a/pkg/registry/simulate/harness_envtest.go +++ b/pkg/registry/simulate/harness_envtest.go @@ -233,7 +233,7 @@ func RunWithEnvtest(ctx context.Context, kat *katalog.Katalog, crdName string, var r domain.Reconciler if factoryFn, ok := orktypes.ReconcilerRegistry[gvk]; ok { - r = factoryFn(recKube, inf, event.Discard()) + r = factoryFn(recKube.WithInformer(inf).WithEventRecorder(event.Discard())) } else { r = reconciler.NewGenericReconciler( crdEntry, diff --git a/pkg/registry/simulate/kubeclient.go b/pkg/registry/simulate/kubeclient.go index b7b275a0c..c6f74f1cb 100644 --- a/pkg/registry/simulate/kubeclient.go +++ b/pkg/registry/simulate/kubeclient.go @@ -21,6 +21,7 @@ import ( "k8s.io/client-go/kubernetes/fake" "k8s.io/client-go/rest" k8stesting "k8s.io/client-go/testing" + "k8s.io/client-go/tools/cache" sigs "sigs.k8s.io/controller-runtime/pkg/client" ) @@ -51,8 +52,10 @@ type FakeKubeclient struct { scheme *runtime.Scheme shared *fakeShared - rawArgs map[string]interface{} - args kubeclient.Args + rawArgs map[string]interface{} + args kubeclient.Args + informer cache.SharedIndexInformer + eventRecorder kubeclient.EventRecorder } // dynamicObjects seeds the fake dynamic client's tracker at construction — @@ -144,6 +147,21 @@ func (f *FakeKubeclient) ScopedFor(eval func(string) (string, bool)) kubeclient. return &cp } +func (f *FakeKubeclient) WithInformer(inf cache.SharedIndexInformer) kubeclient.Interface { + cp := *f + cp.informer = inf + return &cp +} + +func (f *FakeKubeclient) WithEventRecorder(ev kubeclient.EventRecorder) kubeclient.Interface { + cp := *f + cp.eventRecorder = ev + return &cp +} + +func (f *FakeKubeclient) GetInformer() cache.SharedIndexInformer { return f.informer } +func (f *FakeKubeclient) GetEventRecorder() kubeclient.EventRecorder { return f.eventRecorder } + // AdvanceCycle increments the cycle counter. Call between simulated reconciles. func (f *FakeKubeclient) AdvanceCycle() { f.shared.mu.Lock() diff --git a/pkg/resources/template/resolver.go b/pkg/resources/template/resolver.go index 4751b9324..53ac10fac 100644 --- a/pkg/resources/template/resolver.go +++ b/pkg/resources/template/resolver.go @@ -42,6 +42,47 @@ type Resolver struct { mergedFuncs template.FuncMap } +// WithSentinels adds the declared sentinel names to the resolver's FuncMap. +// Each sentinel is a no-arg function that returns its computed string value. +// +// Pass nil for values at validate time — sentinels return "" (stub for parse). +// Pass the computed values map at runtime — built by the informer UpdateFunc. +// +// Sentinels layer on top of any existing mergedFuncs (including user notes). +// Templates that reference an undeclared sentinel name fail to parse — this is +// how `ork validate` catches misuse without executing templates. +func (r *Resolver) WithSentinels(declared []string, values map[string]string) *Resolver { + if len(declared) == 0 { + return r + } + base := orkNotes + if r.mergedFuncs != nil { + base = r.mergedFuncs + } + merged := make(template.FuncMap, len(base)+len(declared)) + for k, v := range base { + merged[k] = v + } + for _, name := range declared { + n := name // capture + merged[n] = func() string { return values[n] } + } + r.mergedFuncs = merged + return r +} + +// SentinelFuncMap returns a FuncMap containing stubs for each declared sentinel. +// All stubs return "" — only template parsing is checked, not execution. +// Used by validators that need to parse gate templates without a full Resolver. +func SentinelFuncMap(declared []string) template.FuncMap { + fm := make(template.FuncMap, len(declared)) + for _, name := range declared { + n := name + fm[n] = func() string { return "" } + } + return fm +} + // WithProfiles attaches a user-defined profile registry to the resolver. // Call this after NewResolver when the katalog declares a profiles: block. func (r *Resolver) WithProfiles(reg orktypes.ProfileRegistry) *Resolver { diff --git a/pkg/runtime/informer/enqueue_filter.go b/pkg/runtime/informer/enqueue_filter.go index 596737f41..052ce3692 100644 --- a/pkg/runtime/informer/enqueue_filter.go +++ b/pkg/runtime/informer/enqueue_filter.go @@ -16,6 +16,7 @@ package informer import ( "github.com/orkspace/orkestra/domain" "github.com/orkspace/orkestra/pkg/logger" + "github.com/orkspace/orkestra/pkg/runtime/sentinel" "k8s.io/client-go/tools/cache" ) @@ -33,6 +34,51 @@ func (f *Factory) RegisterEnqueueFilter(gvkStr string, fn func(domain.Object) bo f.enqueueFilters[gvkStr] = fn } +// RegisterUpdateEnqueueFilter registers sentinel configuration for a GVK. +// declared is the list of sentinel names from preReconcile.sentinels; gate +// decides whether to enqueue and receives the already-computed sentinel values. +// Sentinel computation (old vs new comparison) happens inside handleUpdateEvent — +// the caller only passes configuration, not computation. +// Only one config per GVK — subsequent calls overwrite. +func (f *Factory) RegisterUpdateEnqueueFilter(gvkStr string, declared []string, gate func(domain.Object, map[string]string) bool) { + if gate == nil { + return + } + f.mu.Lock() + defer f.mu.Unlock() + f.updateFilters[gvkStr] = &updateFilterCfg{declared: declared, gate: gate} +} + +// updateEnqueueAllowed evaluates the registered update config for the given GVK. +// Sentinel computation happens here using oldObj and newObj — the result is passed +// to the gate function. Returns (true, nil, false) when no config is registered. +func (f *Factory) updateEnqueueAllowed(gvkStr string, oldObj, newObj interface{}) (bool, map[string]string, bool) { + f.mu.RLock() + cfg, ok := f.updateFilters[gvkStr] + f.mu.RUnlock() + if !ok { + return true, nil, false + } + + oldDomain, okOld := toDomainObject(oldObj) + newDomain, okNew := toDomainObject(newObj) + if !okOld || !okNew { + return true, nil, true + } + sentinels := sentinel.Compute(cfg.declared, oldDomain, newDomain) + allowed := cfg.gate(newDomain, sentinels) + return allowed, sentinels, true +} + +// toDomainObject unwraps a cache tombstone and asserts to domain.Object. +func toDomainObject(obj interface{}) (domain.Object, bool) { + if tombstone, ok := obj.(cache.DeletedFinalStateUnknown); ok { + obj = tombstone.Obj + } + d, ok := obj.(domain.Object) + return d, ok +} + // enqueueAllowed evaluates the registered enqueue filter for the given GVK. // Returns true when the event should proceed to the queue (no filter, or filter passes). // Returns false when the event should be silently dropped. @@ -50,11 +96,7 @@ func (f *Factory) enqueueAllowed(gvkStr string, obj interface{}) bool { // Unwrap tombstone — produced when a deletion event arrives after the object // has already been removed from the cache. - if ts, ok := obj.(cache.DeletedFinalStateUnknown); ok { - obj = ts.Obj - } - - domObj, ok := obj.(domain.Object) + domObj, ok := toDomainObject(obj) if !ok { return true // can't evaluate — let it through } diff --git a/pkg/runtime/informer/factory.go b/pkg/runtime/informer/factory.go index a4cc0f2e3..7d5327c64 100644 --- a/pkg/runtime/informer/factory.go +++ b/pkg/runtime/informer/factory.go @@ -65,15 +65,16 @@ func (f *Factory) getOrCreate( inf := cache.NewSharedIndexInformer(lw, obj, resync, cache.Indexers{}) + gvkStr := gvk.String() // Ensure GVK is normalized for all CRDs inf.AddEventHandler(cache.ResourceEventHandlerFuncs{ AddFunc: func(obj interface{}) { normalizeInformerObject(obj, gvk) f.handleEvent(obj) }, - UpdateFunc: func(_, newObj interface{}) { + UpdateFunc: func(oldObj, newObj interface{}) { normalizeInformerObject(newObj, gvk) - f.handleEvent(newObj) + f.handleUpdateEvent(gvkStr, oldObj, newObj) }, DeleteFunc: func(obj interface{}) { normalizeInformerObject(obj, gvk) diff --git a/pkg/runtime/informer/fixture/watch/README.md b/pkg/runtime/informer/fixture/watch/README.md new file mode 100644 index 000000000..1e9f6b2d1 --- /dev/null +++ b/pkg/runtime/informer/fixture/watch/README.md @@ -0,0 +1,18 @@ +# watch fixture + +Living fixture for `operatorBox.watch`. A WatchProbe CR watches a shared +ConfigMap (`shared-config`) it does not own. When the ConfigMap changes, +the secondary informer resolves the primary CR key via `keyFrom.name` and +re-enqueues it — no ownerReference required. + +Key points demonstrated: + +- `operatorBox.watch` sets up a dynamic informer for the watched resource. +- `keyFrom.name` and `keyFrom.namespace` maps the watched object's events to a fixed primary CR key (singleton pattern). +- Events during the initial cache sync are dropped — only real changes trigger re-enqueues. +- The primary CR's reconciler runs as normal; no Go code is needed to wire the watch. + +```bash +ork validate pkg/runtime/informer/fixture/watch/katalog.yaml +ork e2e -f pkg/runtime/informer/fixture/watch/e2e.yaml +``` diff --git a/pkg/runtime/informer/fixture/watch/configmap-shared.yaml b/pkg/runtime/informer/fixture/watch/configmap-shared.yaml new file mode 100644 index 000000000..614a3517f --- /dev/null +++ b/pkg/runtime/informer/fixture/watch/configmap-shared.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: shared-config + namespace: default +data: + version: "1" diff --git a/pkg/runtime/informer/fixture/watch/cr.yaml b/pkg/runtime/informer/fixture/watch/cr.yaml new file mode 100644 index 000000000..074b006e8 --- /dev/null +++ b/pkg/runtime/informer/fixture/watch/cr.yaml @@ -0,0 +1,6 @@ +apiVersion: watch.orkestra.io/v1alpha1 +kind: WatchProbe +metadata: + name: probe-alpha + namespace: default +spec: {} diff --git a/pkg/runtime/informer/fixture/watch/crd.yaml b/pkg/runtime/informer/fixture/watch/crd.yaml new file mode 100644 index 000000000..07bc1f724 --- /dev/null +++ b/pkg/runtime/informer/fixture/watch/crd.yaml @@ -0,0 +1,27 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: watchprobes.watch.orkestra.io +spec: + group: watch.orkestra.io + names: + kind: WatchProbe + plural: watchprobes + singular: watchprobe + scope: Namespaced + versions: + - name: v1alpha1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + x-kubernetes-preserve-unknown-fields: true + status: + type: object + x-kubernetes-preserve-unknown-fields: true + subresources: + status: {} diff --git a/pkg/runtime/informer/fixture/watch/e2e.yaml b/pkg/runtime/informer/fixture/watch/e2e.yaml new file mode 100644 index 000000000..96ad157db --- /dev/null +++ b/pkg/runtime/informer/fixture/watch/e2e.yaml @@ -0,0 +1,119 @@ +apiVersion: orkestra.orkspace.io/v1 +kind: E2E +metadata: + name: watch-probe-e2e + description: > + Living fixture for operatorBox.watch. A WatchProbe CR watches a shared + ConfigMap (shared-config) it does not own. Patching the ConfigMap triggers + a re-enqueue of the WatchProbe — verifying the secondary informer routes + events back to the primary CR without an ownerReference. + +spec: + katalog: ./katalog.yaml + crd: ./crd.yaml + cr: ./cr.yaml + + cluster: + provider: kind + name: ork-watch-probe + reuse: false + + expect: + - name: WatchProbe reconciled — output ConfigMap created + after: cr-applied + timeout: 60s + kubectl: + apply: + - file: ./configmap-shared.yaml + get: + - kind: WatchProbe + name: probe-alpha + namespace: default + field: .status.phase + equals: Watching + resources: + - kind: ConfigMap + name: probe-alpha-output + namespace: default + + - name: Operator reports watch-probe as healthy + after: cr-applied + timeout: 60s + kubectl: + port-forward: + - namespace: orkestra-system + leaderElection: + lease: orkestra-konductor + port: 8080 + path: /katalog/watch-probe/health + jq: state + equals: healthy + + - name: ConfigMap patched — WatchProbe re-enqueued, health remains healthy + after: cr-applied + timeout: 60s + wait: 3s + kubectl: + patch: + - kind: ConfigMap + name: shared-config + namespace: default + patch: '{"data":{"version":"2"}}' + get: + - kind: WatchProbe + name: probe-alpha + namespace: default + field: .status.phase + equals: Watching + resources: + - kind: ConfigMap + name: probe-alpha-output + namespace: default + + - name: Operator still healthy after re-enqueue + after: cr-applied + timeout: 60s + kubectl: + port-forward: + - namespace: orkestra-system + leaderElection: + lease: orkestra-konductor + port: 8080 + path: /katalog/watch-probe/health + jq: state + equals: healthy + + - name: Cleanup verified + after: cr-deleted + timeout: 30s + resources: + - kind: WatchProbe + name: probe-alpha + namespace: default + count: 0 + - kind: ConfigMap + name: probe-alpha-output + namespace: default + count: 0 + + onFailure: + kubectl: + get: + - kind: WatchProbe + name: probe-alpha + namespace: default + - kind: ConfigMap + name: shared-config + namespace: default + - kind: ConfigMap + name: probe-alpha-output + namespace: default + logs: + - leaderElection: + lease: orkestra-konductor + namespace: orkestra-system + since: 2m + commands: + - kubectl get watchprobes -A -o wide + - kubectl get configmaps -n default + - kubectl get pods -n orkestra-system -o wide diff --git a/pkg/runtime/informer/fixture/watch/katalog.yaml b/pkg/runtime/informer/fixture/watch/katalog.yaml new file mode 100644 index 000000000..1a8404d28 --- /dev/null +++ b/pkg/runtime/informer/fixture/watch/katalog.yaml @@ -0,0 +1,48 @@ +apiVersion: orkestra.orkspace.io/v1 +kind: Katalog +metadata: + name: watch-probe + author: orkspace + version: 0.1.0 + description: > + Living fixture for operatorBox.watch. A WatchProbe CR watches a shared + ConfigMap it does not own. When the ConfigMap changes, the WatchProbe is + re-enqueued and reconciles — no ownerReference required. Key resolution + uses keyFrom.name (singleton pattern). + +spec: + crds: + watch-probe: + labels: + watchprobe-tester: "{{ .metadata.name }}" + crdFile: crd.yaml + crFiles: + - cr.yaml + setup: + - configmap-shared.yaml + operatorBox: + reconciler: + resync: 30s + watch: + - apiVersion: v1 + kind: ConfigMap + name: shared-config + namespace: default + on: [update] + keyFrom: + name: probe-alpha + namespace: default + + onReconcile: + configMaps: + - name: "{{ .metadata.name }}-output" + namespace: "{{ .metadata.namespace }}" + reconcile: true + data: + observed: "{{ .metadata.name }}" + phase: active + + status: + fields: + - path: phase + value: Watching diff --git a/pkg/runtime/informer/helper.go b/pkg/runtime/informer/helper.go index 59fa44778..e79e43179 100644 --- a/pkg/runtime/informer/helper.go +++ b/pkg/runtime/informer/helper.go @@ -66,6 +66,51 @@ func (f *Factory) handleEvent(obj interface{}) { wq.Enqueue(obj, gvkStr) } +// handleUpdateEvent routes an update event for oldObj→newObj to the correct queue. +// When a sentinel-aware update filter is registered for the GVK, it is evaluated +// first — both oldObj and newObj are available here for sentinel computation. +// If the filter passes, EnqueueWithSentinels carries the sentinel map through. +// When no update filter is registered, falls through to the standard enqueue path. +func (f *Factory) handleUpdateEvent(gvkStr string, oldObj, newObj interface{}) { + <-f.ready + + namespace := extractNamespace(newObj) + if !f.namespaceAllowed(gvkStr, namespace) { + logger.Debug(). + Str("gvk", gvkStr). + Str("namespace", namespace). + Msg("informer: update dropped — namespace not allowed") + return + } + + allowed, sentinels, hasUpdateFilter := f.updateEnqueueAllowed(gvkStr, oldObj, newObj) + if hasUpdateFilter { + if !allowed { + return + } + wq, ok := f.queueRegistry.For(gvkStr) + if !ok { + logger.Warn().Str("gvk", gvkStr).Msg("no per-CRD queue — falling back to default queue") + f.defaultWq.EnqueueWithSentinels(newObj, gvkStr, sentinels) + return + } + wq.EnqueueWithSentinels(newObj, gvkStr, sentinels) + return + } + + // No update filter — standard path (same as handleEvent). + if !f.enqueueAllowed(gvkStr, newObj) { + return + } + wq, ok := f.queueRegistry.For(gvkStr) + if !ok { + logger.Warn().Str("gvk", gvkStr).Msg("no per-CRD queue — falling back to default queue") + f.defaultWq.Enqueue(newObj, gvkStr) + return + } + wq.Enqueue(newObj, gvkStr) +} + // newListWatch returns a ListWatch for the given object type. // Both List and Watch block on f.ready so they never run before Start(). // When opts.Namespace is set (Tier 1 single-namespace filter), the ListerWatcher diff --git a/pkg/runtime/informer/type.go b/pkg/runtime/informer/type.go index 58dde840d..30984a766 100644 --- a/pkg/runtime/informer/type.go +++ b/pkg/runtime/informer/type.go @@ -85,6 +85,20 @@ type Factory struct { // enqueueAllowed unwraps tombstones and asserts to domain.Object before // calling the function — works for both dynamic and typed CRDs. enqueueFilters map[string]func(domain.Object) bool + + // updateFilters maps GVK string to a sentinel-aware update config. + // The factory computes sentinels from declared names at event time and + // calls gate to decide whether to enqueue. Splitting the two means + // runtime_konstructor.go only passes configuration; computation stays here. + updateFilters map[string]*updateFilterCfg +} + +// updateFilterCfg holds the configuration registered for a sentinel-aware GVK. +// declared is captured at startup; gate is evaluated at event time with the +// already-computed sentinel values so the closure never needs to call sentinel.Compute. +type updateFilterCfg struct { + declared []string + gate func(newObj domain.Object, sentinels map[string]string) bool } func SharedInformerFactory( @@ -108,5 +122,6 @@ func SharedInformerFactory( ready: make(chan struct{}), namespaceFilters: make(map[string]*NamespaceFilter), enqueueFilters: make(map[string]func(domain.Object) bool), + updateFilters: make(map[string]*updateFilterCfg), } } diff --git a/pkg/runtime/kordinator/dependency_kordinator.go b/pkg/runtime/kordinator/dependency_kordinator.go index b1b5c3cbf..2671c6cfd 100644 --- a/pkg/runtime/kordinator/dependency_kordinator.go +++ b/pkg/runtime/kordinator/dependency_kordinator.go @@ -607,6 +607,9 @@ func (k *DependencyKordinator) startCRDWorkers(ctx context.Context, gvk string, k.runWorkerForGVK(crdCtx, gvk, id) }(workerID) } + + // Start secondary watch informers for each operatorBox.watch entry. + k.startWatchInformers(crdCtx, entry.CRD) } // stopCRDWorkers cancels the CRD context and waits for all workers to drain. diff --git a/pkg/runtime/kordinator/pre_reconcile.go b/pkg/runtime/kordinator/pre_reconcile.go index 645af9caa..14afbbf3f 100644 --- a/pkg/runtime/kordinator/pre_reconcile.go +++ b/pkg/runtime/kordinator/pre_reconcile.go @@ -34,11 +34,12 @@ func (k *Kontroller) evaluatePreReconcileCheck( ctx context.Context, obj *unstructured.Unstructured, crdName string, + sentinels map[string]string, ) (gated bool, reason string) { if k.kat == nil || obj == nil { return false, "" } - allowed, reason := k.kat.EvaluatePreReconcile(ctx, crdName, obj, k.kube.Clientset()) + allowed, reason := k.kat.EvaluatePreReconcile(ctx, crdName, obj, k.kube.Clientset(), sentinels) return !allowed, reason } diff --git a/pkg/runtime/kordinator/watch_informer.go b/pkg/runtime/kordinator/watch_informer.go new file mode 100644 index 000000000..516dac574 --- /dev/null +++ b/pkg/runtime/kordinator/watch_informer.go @@ -0,0 +1,208 @@ +// pkg/runtime/kordinator/watch_informer.go +// +// Secondary watch informers for operatorBox.watch entries. +// +// When a CRD declares operatorBox.watch, Orkestra sets up a dynamic informer +// for each listed resource. When a watched resource changes, the handler +// resolves the relevant primary CR key(s) and enqueues them — no Go required +// from the constructor author. +// +// Key resolution order (first match wins): +// 1. keyFrom.label — the watched object has a label whose value is the primary CR key. +// 2. keyFrom.name — a fixed primary CR name declared in the watch entry. +// 3. ownerReference — the watched object is owned by a primary CR of this CRD. +// 4. broadcast — none of the above matched; enqueue all known primary CRs. +// Right for shared resources (ConfigMap, Secret) that affect every CR equally. +package kordinator + +import ( + "context" + + "github.com/orkspace/orkestra/pkg/kubeclient" + "github.com/orkspace/orkestra/pkg/logger" + "github.com/orkspace/orkestra/pkg/runtime/queue" + orktypes "github.com/orkspace/orkestra/pkg/types" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/tools/cache" +) + +// startWatchInformers creates one dynamic informer per watch: entry on the CRD. +// Called from startCRDWorkers after the worker pool is started. +// Informers run within crdCtx and stop when the primary CRD stops. +func (k *DependencyKordinator) startWatchInformers(ctx context.Context, crd orktypes.CRDEntry) { + if !crd.WithWatchEntries() { + return + } + + primaryGVK := crd.GVKString() + wq, ok := k.queueReg.For(primaryGVK) + if !ok { + logger.Warn().Str("gvk", primaryGVK).Msg("watch: no queue registered for primary CRD — skipping") + return + } + + for _, watchEntry := range crd.WatchEntries() { + watchEntry := watchEntry + + gvr, ok := k.kat.ResolveGVR(watchEntry.ToManagedResource()) + if !ok { + logger.Warn(). + Str("apiVersion", watchEntry.APIVersion). + Str("kind", watchEntry.Kind). + Str("primary", crd.APITypes.Kind). + Msg("watch: cannot resolve GVR — entry skipped") + continue + } + + lw := k.kube.NewDynamicListerWatcher(watchEntryToCRDInfo(watchEntry, gvr), kubeclient.ListOptions{}) + inf := cache.NewSharedIndexInformer( + lw, + &unstructured.Unstructured{}, + 0, // no resync — primary CRD resync handles re-queuing + cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, + ) + + // captured so each handler can check HasSynced; events fired during the + // initial List phase (before sync) are dropped — same as controller-runtime. + localInf := inf + _, _ = inf.AddEventHandler(cache.ResourceEventHandlerFuncs{ + AddFunc: func(obj interface{}) { + if !localInf.HasSynced() || !watchEntry.WatchesOn(string(orktypes.WatchEventCreate)) { + return + } + k.resolveAndEnqueue(obj, watchEntry, crd, primaryGVK, wq) + }, + UpdateFunc: func(_, newObj interface{}) { + if !localInf.HasSynced() || !watchEntry.WatchesOn(string(orktypes.WatchEventUpdate)) { + return + } + k.resolveAndEnqueue(newObj, watchEntry, crd, primaryGVK, wq) + }, + DeleteFunc: func(obj interface{}) { + if !localInf.HasSynced() || !watchEntry.WatchesOn(string(orktypes.WatchEventDelete)) { + return + } + if ts, ok := obj.(cache.DeletedFinalStateUnknown); ok { + obj = ts.Obj + } + k.resolveAndEnqueue(obj, watchEntry, crd, primaryGVK, wq) + }, + }) + + go inf.Run(ctx.Done()) + logger.Info(). + Str("primary", crd.APITypes.Kind). + Str("watched", watchEntry.Kind). + Str("gvr", gvr.String()). + Msg("watch: secondary informer started") + } +} + +// resolveAndEnqueue resolves the primary CR key(s) from a watched resource event +// and adds them to the primary CRD's workqueue. +// +// Resolution order: +// 1. keyFrom.label — label on the watched object carries the key +// 2. keyFrom.name — fixed named primary CR +// 3. ownerReference — owner of the watched object matches the primary CRD +// 4. broadcast — no match found; enqueue all known primary CRs +func (k *DependencyKordinator) resolveAndEnqueue(obj interface{}, w orktypes.WatchEntry, crd orktypes.CRDEntry, primaryGVK string, wq *queue.Workqueue) { + u, ok := watchedToUnstructured(obj) + if !ok { + return + } + + primaryKind := crd.APITypes.Kind + + // 1. keyFrom.label — read key from a label on the watched object. + if kf := w.KeyFrom; kf != nil && kf.Label != "" { + key, ok := u.GetLabels()[kf.Label] + if ok && key != "" { + wq.EnqueueKey(key, primaryGVK) + logger.Debug(). + Str("primary", primaryKind). + Str("key", key). + Str("label", kf.Label). + Msg("watch: enqueued via keyFrom.label") + return + } + // Label declared but absent on this object — fall through to broadcast. + } + + // 2. keyFrom.name — fixed primary CR key regardless of which object changed. + if kf := w.KeyFrom; kf != nil && kf.Name != "" { + wq.EnqueueKey(kf.Key(), primaryGVK) + logger.Debug(). + Str("primary", primaryKind). + Str("key", kf.Key()). + Msg("watch: enqueued via keyFrom.name") + return + } + + // 3. ownerReference — enqueue the specific primary CR if the watched object + // is owned by one. + primaryAPIVersion := crd.APIVersion() + for _, ref := range u.GetOwnerReferences() { + if ref.APIVersion == primaryAPIVersion && ref.Kind == primaryKind { + ns := u.GetNamespace() + key := ref.Name + if ns != "" { + key = ns + "/" + ref.Name + } + wq.EnqueueKey(key, primaryGVK) + logger.Debug(). + Str("primary", primaryKind). + Str("key", key). + Str("watched", u.GetKind()). + Msg("watch: enqueued via ownerReference") + return + } + } + + // 4. Broadcast — no specific match; enqueue all known primary CRs. + registered := k.informerFactory.Registered() + entry, ok := registered[primaryGVK] + if !ok || entry == nil { + return + } + for _, item := range entry.Informer.GetIndexer().List() { + o, ok := item.(metav1.Object) + if !ok { + continue + } + key, err := cache.MetaNamespaceKeyFunc(o) + if err != nil { + continue + } + wq.EnqueueKey(key, primaryGVK) + } + logger.Debug(). + Str("primary", primaryKind). + Str("watched", u.GetKind()). + Msg("watch: broadcasted to all primary CRs") +} + +// watchedToUnstructured unwraps a cache tombstone and asserts to *unstructured.Unstructured. +func watchedToUnstructured(obj interface{}) (*unstructured.Unstructured, bool) { + if ts, ok := obj.(cache.DeletedFinalStateUnknown); ok { + obj = ts.Obj + } + u, ok := obj.(*unstructured.Unstructured) + return u, ok +} + +// watchEntryToCRDInfo converts a WatchEntry + resolved GVR to a kubeclient.CRDInfo +// for NewDynamicListerWatcher. Namespace is set to the entry's declared namespace; +// Namespaced is true when a namespace is declared (restricts the watch to that +// namespace), false for a cluster-scoped watch (all namespaces). +func watchEntryToCRDInfo(w orktypes.WatchEntry, gvr schema.GroupVersionResource) kubeclient.CRDInfo { + return kubeclient.CRDInfo{ + Group: gvr.Group, + Version: gvr.Version, + Plural: gvr.Resource, + Namespace: w.Namespace, + Namespaced: w.Namespace != "", + } +} diff --git a/pkg/runtime/kordinator/worker.go b/pkg/runtime/kordinator/worker.go index b8a50ff95..bb07a1ca6 100644 --- a/pkg/runtime/kordinator/worker.go +++ b/pkg/runtime/kordinator/worker.go @@ -138,7 +138,11 @@ func (k *Kontroller) processItemForGVK(ctx context.Context, gvk string, item que if entry, ok := k.katalog.Get(gvk); ok { if entry.CRD.HasAnyReconcileGate() { obj := k.objectFromCache(entry, item.Key) - if gated, reason := k.evaluatePreReconcileCheck(ctx, obj, entry.CRD.Name); gated { + var sentinelMap map[string]string + if item.SentinelMap != nil { + sentinelMap = *item.SentinelMap + } + if gated, reason := k.evaluatePreReconcileCheck(ctx, obj, entry.CRD.Name, sentinelMap); gated { k.crdHealthMap[gvk].RecordGated(reason) wq.Queue.Forget(item) return diff --git a/pkg/runtime/queue/queue.go b/pkg/runtime/queue/queue.go index 7da2dfdd9..bb55dadcc 100644 --- a/pkg/runtime/queue/queue.go +++ b/pkg/runtime/queue/queue.go @@ -14,6 +14,14 @@ import ( type QueueItem struct { Key string GVK string + // SentinelMap carries event-time sentinel values computed in the informer's + // UpdateFunc (oldObj vs newObj). Both enqueueGate and reconcileGate share the + // same preReconcile context — reconcileGate rebuilds the resolver from this map + // after dequeue, when oldObj is no longer available. + // nil when no preReconcile.sentinels are declared (common case — deduplication + // behaviour is unchanged). Non-nil items dedup by pointer identity, meaning + // each sentinel-bearing enqueue is treated as a distinct work item. + SentinelMap *map[string]string } type Workqueue struct { @@ -57,6 +65,50 @@ func (q *Workqueue) Enqueue(obj interface{}, gvk string) { } q.Queue.Add(QueueItem{Key: key, GVK: gvk}) +} + +// EnqueueKey adds a pre-computed key directly to the workqueue. +// Used when the key is resolved from an ownerReference or another indirect source +// rather than from the object itself. +func (q *Workqueue) EnqueueKey(key, gvk string) { + if limit := q.maxDepth.Load(); limit > 0 && int32(q.Queue.Len()) >= limit { + logger.Warn(). + Str("key", key). + Str("gvk", gvk). + Int32("limit", limit). + Int("depth", q.Queue.Len()). + Msg("enqueue: queue depth limit reached — item dropped") + return + } + q.Queue.Add(QueueItem{Key: key, GVK: gvk}) +} + +// EnqueueWithSentinels adds a key to the workqueue alongside the sentinel values +// computed at event time (oldObj vs newObj in the informer UpdateFunc). +// The sentinel map is passed as a pointer so the item remains comparable — two +// sentinel-bearing enqueues for the same key are treated as distinct items. +func (q *Workqueue) EnqueueWithSentinels(obj interface{}, gvk string, sentinels map[string]string) { + if tombstone, ok := obj.(cache.DeletedFinalStateUnknown); ok { + obj = tombstone.Obj + } + + key, err := cache.MetaNamespaceKeyFunc(obj) + if err != nil { + logger.Error().Err(err).Str("gvk", gvk).Msg("enqueue: failed to get key") + return + } + + if limit := q.maxDepth.Load(); limit > 0 && int32(q.Queue.Len()) >= limit { + logger.Warn(). + Str("key", key). + Str("gvk", gvk). + Int32("limit", limit). + Int("depth", q.Queue.Len()). + Msg("enqueue: queue depth limit reached — item dropped") + return + } + + q.Queue.Add(QueueItem{Key: key, GVK: gvk, SentinelMap: &sentinels}) logger.Debug().Str("key", key).Str("gvk", gvk).Msg("enqueued") } diff --git a/pkg/runtime/reconciler/generic.go b/pkg/runtime/reconciler/generic.go index 292cfa865..8484f5820 100644 --- a/pkg/runtime/reconciler/generic.go +++ b/pkg/runtime/reconciler/generic.go @@ -477,6 +477,20 @@ func (r *GenericReconciler[PTR]) reconcileCore(ctx context.Context, key string) labelMgr.EnsureStrictModeExemptLabel(obj, effectiveStrict) } + // User-defined labels from CRDEntry.Labels — values are templates resolved + // against the current CR. Keys must be static valid label identifiers. + if r.crd.HasUserLabels() { + resolved := make(map[string]string, len(r.crd.Labels)) + for k, v := range r.crd.Labels { + val, err := resolver.Resolve(v) + if err != nil { + return fmt.Errorf("labels: CRD %q: key %q: %w", r.crd.Name, k, err) + } + resolved[k] = val + } + labelMgr.EnsureUserLabels(obj, resolved) + } + // One atomic patch: diff serverLabels → desired. No-op if nothing changed. if err := r.kube.PatchLabels(ctx, obj, serverLabels, obj.GetLabels()); err != nil { return err diff --git a/pkg/runtime/sentinel/README.md b/pkg/runtime/sentinel/README.md new file mode 100644 index 000000000..8969a0140 --- /dev/null +++ b/pkg/runtime/sentinel/README.md @@ -0,0 +1,58 @@ +# sentinel — event-time gate values + +Sentinels are booleans computed at informer `UpdateFunc` time by comparing `oldObj` and `newObj`. They are carried through the queue so that `enqueueGate` and `reconcileGate` can both ask "did the generation change?" without `oldObj` being available at dequeue time. + +> [!IMPORTANT] +> This package is the canonical home for sentinel names. It imports only stdlib and `k8s.io/apimachinery` so it can be imported by `pkg/types` (for YAML constants) and `pkg/runtime/informer` (for `Compute`) without creating an import cycle. Do not add dependencies outside those two — move computation to the caller instead. + +> [!TIP] +> **A sentinel is not a Note.** Notes carry arbitrary values into the reconcile context and are available everywhere in templates — `onCreate`, `onReconcile`, `status.fields`, `normalize`, rules. A sentinel answers a single yes/no question about what changed between two versions of the object, and is only available in gate conditions. Use a Note when you need a value inside reconciliation; use a sentinel when you need to decide whether reconciliation should run at all. → [`pkg/note`](../../note/README.md) + +--- + +## YAML usage + +Declare which sentinels to compute on `preReconcile.sentinels`. They are then available as gate conditions on `enqueueGate` and `reconcileGate`. + +```yaml +operatorBox: + preReconcile: + sentinels: [generationChanged, labelsChanged] + enqueueGate: + sentinels: [generationChanged] # skip the queue unless spec changed +``` + +On secondary watches, `enqueueGate.sentinels` works the same way: + +```yaml +operatorBox: + watch: + - apiVersion: apps/v1 + kind: Deployment + enqueueGate: + sentinels: [generationChanged] +``` + +--- + +## Built-in sentinels + +| Name | True when | +|------|-----------| +| `generationChanged` | `old.generation != new.generation` — spec change | +| `labelsChanged` | label map differs between old and new | +| `annotationsChanged` | annotation map differs between old and new | +| `deletionStarted` | `DeletionTimestamp` transitions from nil to non-nil | +| `finalizersChanged` | finalizer list differs between old and new | + +--- + +## Documents + +| File | What it covers | +|------|----------------| +| [01-sentinel-names.md](docs/01-sentinel-names.md) | Each sentinel in detail — what it tests, when to use it, common patterns | +| [02-compute.md](docs/02-compute.md) | How `Compute` runs at event time, what `QueueItem.SentinelMap` carries, and how gates read it | +| [03-adding-a-sentinel.md](docs/03-adding-a-sentinel.md) | Step-by-step: constant, registration, computation, tests, documentation | + +Read [01-sentinel-names.md](docs/01-sentinel-names.md) when choosing which sentinels to declare for a gate. Read [02-compute.md](docs/02-compute.md) when tracing how a sentinel value flows from the informer event to a gate evaluation. Read [03-adding-a-sentinel.md](docs/03-adding-a-sentinel.md) when extending the sentinel set. diff --git a/pkg/runtime/sentinel/docs/01-sentinel-names.md b/pkg/runtime/sentinel/docs/01-sentinel-names.md new file mode 100644 index 000000000..764c9c380 --- /dev/null +++ b/pkg/runtime/sentinel/docs/01-sentinel-names.md @@ -0,0 +1,89 @@ +# Sentinel Names + +Each sentinel is a named boolean computed at `UpdateFunc` time. The result is a string `"true"` or `"false"` stored in `QueueItem.SentinelMap` so it survives the queue. + +--- + +## `generationChanged` + +```go +old.GetGeneration() != new.GetGeneration() +``` + +The Kubernetes API server increments `metadata.generation` whenever the object's `spec` changes (for resources that track this). A `metadata.labels` or `metadata.annotations` change does not increment it. + +**Use for**: skipping reconciliation when only non-spec fields changed — annotations written by the reconciler itself, status updates, or label patches that do not affect the desired state. + +```yaml +preReconcile: + sentinels: [generationChanged] + enqueueGate: + sentinels: [generationChanged] +``` + +**Note**: not all resources increment `generation`. Built-in types like `ConfigMap` and `Secret` do not. For those resources, `generationChanged` is always `"false"` on update events. + +--- + +## `labelsChanged` + +```go +!reflect.DeepEqual(old.GetLabels(), new.GetLabels()) +``` + +True when the label map differs — any key added, removed, or changed in value. + +**Use for**: re-running label-driven logic when a user re-labels a CR mid-lifecycle, or when a controller patches labels on a child resource you watch. + +--- + +## `annotationsChanged` + +```go +!reflect.DeepEqual(old.GetAnnotations(), new.GetAnnotations()) +``` + +True when the annotation map differs. + +**Use for**: annotations used as side-channel signals — e.g. a deployment tool writing a `deploy-timestamp` annotation that should trigger a reconcile. + +Be careful: if your reconciler writes annotations back to the CR, every reconcile produces an update event. Without an additional guard (such as gating on `generationChanged` as well), this can produce a reconcile loop. + +--- + +## `deletionStarted` + +```go +old.GetDeletionTimestamp() == nil && new.GetDeletionTimestamp() != nil +``` + +True exactly once: the first event after a `kubectl delete` reaches the API server and `DeletionTimestamp` is set. By the time the object is dequeued for reconciliation, `DeletionTimestamp` is already non-nil — but this sentinel captures the transition at event time. + +**Use for**: immediate enqueue of deletion logic without waiting for the normal reconcile cycle, or as a gate to short-circuit expensive reconcile work when the object is already terminating. + +--- + +## `finalizersChanged` + +```go +!reflect.DeepEqual(old.GetFinalizers(), new.GetFinalizers()) +``` + +True when the finalizer list differs — any finalizer added or removed. + +**Use for**: detecting when a finalizer has been removed externally (e.g. by a user force-removing it) so the reconciler can react before the object disappears. + +--- + +## Combining sentinels in a gate + +`enqueueGate.sentinels` is a list. The gate fires when **any** listed sentinel is `"true"`: + +```yaml +enqueueGate: + sentinels: [generationChanged, labelsChanged] +``` + +To require both, use a template expression in `enqueueGate.when` instead — sentinels are available as template variables in that context. + +→ [02-compute.md](02-compute.md) — how sentinels flow from the informer event to the gate diff --git a/pkg/runtime/sentinel/docs/02-compute.md b/pkg/runtime/sentinel/docs/02-compute.md new file mode 100644 index 000000000..9491c127f --- /dev/null +++ b/pkg/runtime/sentinel/docs/02-compute.md @@ -0,0 +1,67 @@ +# Compute — how sentinels flow through the system + +## The problem + +`enqueueGate` runs inside the informer's `UpdateFunc`, where both `oldObj` and `newObj` are available. `reconcileGate` runs at dequeue time, where only the current object is available — `oldObj` is gone. + +Sentinels solve this by computing the comparison at event time and carrying the result through the queue so both gates can use the same values. + +--- + +## The flow + +``` +informer UpdateFunc + │ + ├── sentinel.Compute(declared, oldObj, newObj) + │ returns map[string]string{"generationChanged": "true", ...} + │ + ├── QueueItem{Key: "ns/name", SentinelMap: result} + │ enqueued with the sentinel values attached + │ + └── enqueueGate evaluated here (oldObj still in scope) + ↓ +workqueue + ↓ +kordinator dequeue + │ + └── reconcileGate evaluated here + SentinelMap is read from QueueItem + oldObj is not available — sentinels carry the event-time result +``` + +--- + +## `Compute` + +```go +func Compute(declared []string, oldObj, newObj metav1.Object) map[string]string +``` + +Only the sentinels listed in `declared` are computed. If `declared` is empty, `Compute` returns `nil` immediately — no allocation on the common path. + +The result maps each declared name to `"true"` or `"false"`. An unknown name maps to `""` (empty string — not `"false"`). Validators reject unknown names before runtime so this case does not occur in practice. + +--- + +## Where `declared` comes from + +The runtime collects sentinel names from two places in the Katalog: + +- `operatorBox.preReconcile.sentinels` — primary CRD event sentinels +- `operatorBox.watch[*].enqueueGate.sentinels` — per-watch-entry sentinels + +Both are passed to `Compute` at event time for the relevant watch source. + +--- + +## Import boundaries + +This package imports only `reflect` and `k8s.io/apimachinery/pkg/apis/meta/v1`. + +- `pkg/types` imports this package for the typed `Sentinel` constants (no cycle: `sentinel` does not import `pkg/types`). +- `pkg/runtime/informer` imports this package for `Compute` (no cycle: `sentinel` does not import `pkg/runtime`). + +If you add a new sentinel that requires a type from outside stdlib or apimachinery, move the computation into the informer package and keep this package as the name registry only. + +→ [03-adding-a-sentinel.md](03-adding-a-sentinel.md) — step-by-step guide to extending the sentinel set diff --git a/pkg/runtime/sentinel/docs/03-adding-a-sentinel.md b/pkg/runtime/sentinel/docs/03-adding-a-sentinel.md new file mode 100644 index 000000000..e74a6a3cb --- /dev/null +++ b/pkg/runtime/sentinel/docs/03-adding-a-sentinel.md @@ -0,0 +1,103 @@ +# Adding a New Sentinel + +Sentinels are computed in `sentinel.go` by comparing `oldObj` and `newObj` at `UpdateFunc` time. Adding a new one is four steps in the same file, plus a test and a doc entry. + +--- + +## 1. Declare the constant + +```go +const ( + GenerationChanged Sentinel = "generationChanged" + LabelsChanged Sentinel = "labelsChanged" + // ... + MyNewSentinel Sentinel = "myNewSentinel" // ← add here +) +``` + +The constant name is PascalCase. The string value is camelCase — this is what users write in YAML. + +--- + +## 2. Register it in `ValidSentinels` and `IsValid` + +```go +func ValidSentinels() []string { + return []string{ + string(GenerationChanged), + // ... + string(MyNewSentinel), // ← add here, in declaration order + } +} + +func IsValid(s string) bool { + switch Sentinel(s) { + case GenerationChanged, LabelsChanged, AnnotationsChanged, + DeletionStarted, FinalizersChanged, + MyNewSentinel: // ← add here + return true + } + return false +} +``` + +`ValidSentinels()` is used by validators to produce error messages with the full list. `IsValid` is the runtime check. + +--- + +## 3. Implement the comparison in `computeOne` + +```go +func computeOne(name string, old, new metav1.Object) string { + switch Sentinel(name) { + // ...existing cases... + case MyNewSentinel: + return boolStr( /* compare old and new */ ) + default: + return "" + } +} +``` + +The comparison must use only fields available on `metav1.Object` (the interface). If you need fields from a concrete type (`*appsv1.Deployment`, for example), you cannot add this sentinel here — keep this package stdlib+apimachinery only. Move the computation into `pkg/runtime/informer` instead and keep the name constant here. + +--- + +## 4. Write the tests + +Add test cases to `sentinel_test.go` following the existing pattern. Cover: + +- The sentinel is `"true"` when the condition holds +- The sentinel is `"false"` when the condition does not hold +- The sentinel is not computed when not declared (use `TestCompute_OnlyDeclaredAreComputed` as reference) + +```go +func TestCompute_MyNewSentinel_True(t *testing.T) { + old := &metav1.ObjectMeta{/* state before */} + new := &metav1.ObjectMeta{/* state after */} + result := Compute([]string{string(MyNewSentinel)}, old, new) + assert.Equal(t, "true", result[string(MyNewSentinel)]) +} + +func TestCompute_MyNewSentinel_False(t *testing.T) { + old := &metav1.ObjectMeta{/* same state */} + new := &metav1.ObjectMeta{/* same state */} + result := Compute([]string{string(MyNewSentinel)}, old, new) + assert.Equal(t, "false", result[string(MyNewSentinel)]) +} +``` + +--- + +## 5. Document it + +Add an entry to [01-sentinel-names.md](01-sentinel-names.md) following the existing format: + +- The comparison expression +- What it tests +- When to use it +- Any edge cases or gotchas (e.g. resources that do not increment `generation`) + +The sentinel will not be in the schema reference or the user-facing validator error messages until it appears in `ValidSentinels()` — that is already handled by step 2. + +→ [README](../README.md) — package overview, YAML usage, and the sentinel vs Note distinction diff --git a/pkg/runtime/sentinel/sentinel.go b/pkg/runtime/sentinel/sentinel.go new file mode 100644 index 000000000..37f70f990 --- /dev/null +++ b/pkg/runtime/sentinel/sentinel.go @@ -0,0 +1,94 @@ +// Package sentinel computes event-time sentinel values for preReconcile gates. +// +// Sentinels are computed in the informer's UpdateFunc by comparing oldObj and +// newObj. The result is carried through QueueItem.SentinelMap so both +// enqueueGate and reconcileGate can share the same preReconcile resolver +// without oldObj being available at dequeue time. +// +// This package is the canonical home for sentinel names. pkg/types imports it +// for the typed Sentinel constants; pkg/runtime/informer imports it for Compute. +// The package itself imports only stdlib and k8s apimachinery so neither +// direction creates an import cycle. +package sentinel + +import ( + "reflect" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// Sentinel is the string type for event-time sentinel names declared under +// preReconcile.sentinels and used in enqueueGate/reconcileGate templates. +type Sentinel string + +const ( + GenerationChanged Sentinel = "generationChanged" + LabelsChanged Sentinel = "labelsChanged" + AnnotationsChanged Sentinel = "annotationsChanged" + // DeletionStarted is true when the object's DeletionTimestamp transitions + // from nil to non-nil — i.e. the moment a delete is issued. Only computable + // at event time (old vs new); by reconcile time the timestamp is already set. + DeletionStarted Sentinel = "deletionStarted" + // FinalizersChanged is true when the finalizer list differs between old and new. + FinalizersChanged Sentinel = "finalizersChanged" +) + +// ValidSentinels returns all known sentinel names in declaration order. +func ValidSentinels() []string { + return []string{ + string(GenerationChanged), + string(LabelsChanged), + string(AnnotationsChanged), + string(DeletionStarted), + string(FinalizersChanged), + } +} + +// IsValid reports whether s is a known sentinel name. +func IsValid(s string) bool { + switch Sentinel(s) { + case GenerationChanged, LabelsChanged, AnnotationsChanged, + DeletionStarted, FinalizersChanged: + return true + } + return false +} + +// Compute returns the sentinel values for the declared names by comparing +// oldObj and newObj at UpdateFunc time. +// +// Returns nil when declared is empty (common path — no allocation). +func Compute(declared []string, oldObj, newObj metav1.Object) map[string]string { + if len(declared) == 0 { + return nil + } + result := make(map[string]string, len(declared)) + for _, name := range declared { + result[name] = computeOne(name, oldObj, newObj) + } + return result +} + +func computeOne(name string, old, new metav1.Object) string { + switch Sentinel(name) { + case GenerationChanged: + return boolStr(old.GetGeneration() != new.GetGeneration()) + case LabelsChanged: + return boolStr(!reflect.DeepEqual(old.GetLabels(), new.GetLabels())) + case AnnotationsChanged: + return boolStr(!reflect.DeepEqual(old.GetAnnotations(), new.GetAnnotations())) + case DeletionStarted: + return boolStr(old.GetDeletionTimestamp() == nil && new.GetDeletionTimestamp() != nil) + case FinalizersChanged: + return boolStr(!reflect.DeepEqual(old.GetFinalizers(), new.GetFinalizers())) + default: + return "" + } +} + +func boolStr(v bool) string { + if v { + return "true" + } + return "false" +} diff --git a/pkg/runtime/sentinel/sentinel_test.go b/pkg/runtime/sentinel/sentinel_test.go new file mode 100644 index 000000000..6a3be78ea --- /dev/null +++ b/pkg/runtime/sentinel/sentinel_test.go @@ -0,0 +1,100 @@ +package sentinel + +import ( + "testing" + + "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func obj(gen int64, labels, annotations map[string]string) metav1.Object { + o := &metav1.ObjectMeta{ + Generation: gen, + Labels: labels, + Annotations: annotations, + } + return o +} + +func TestCompute_Empty(t *testing.T) { + result := Compute(nil, obj(1, nil, nil), obj(2, nil, nil)) + assert.Nil(t, result) +} + +func TestCompute_GenerationChanged(t *testing.T) { + result := Compute([]string{string(GenerationChanged)}, + obj(1, nil, nil), obj(2, nil, nil)) + assert.Equal(t, "true", result[string(GenerationChanged)]) +} + +func TestCompute_GenerationUnchanged(t *testing.T) { + result := Compute([]string{string(GenerationChanged)}, + obj(5, nil, nil), obj(5, nil, nil)) + assert.Equal(t, "false", result[string(GenerationChanged)]) +} + +func TestCompute_LabelsChanged(t *testing.T) { + result := Compute([]string{string(LabelsChanged)}, + obj(1, map[string]string{"a": "1"}, nil), + obj(1, map[string]string{"a": "2"}, nil)) + assert.Equal(t, "true", result[string(LabelsChanged)]) +} + +func TestCompute_LabelsUnchanged(t *testing.T) { + result := Compute([]string{string(LabelsChanged)}, + obj(1, map[string]string{"a": "1"}, nil), + obj(1, map[string]string{"a": "1"}, nil)) + assert.Equal(t, "false", result[string(LabelsChanged)]) +} + +func TestCompute_AnnotationsChanged(t *testing.T) { + result := Compute([]string{string(AnnotationsChanged)}, + obj(1, nil, map[string]string{"x": "old"}), + obj(1, nil, map[string]string{"x": "new"})) + assert.Equal(t, "true", result[string(AnnotationsChanged)]) +} + +func TestCompute_OnlyDeclaredAreComputed(t *testing.T) { + result := Compute([]string{string(GenerationChanged)}, + obj(1, map[string]string{"a": "1"}, nil), + obj(2, map[string]string{"b": "2"}, nil)) + assert.Equal(t, "true", result[string(GenerationChanged)]) + _, hasLabels := result[string(LabelsChanged)] + assert.False(t, hasLabels) +} + +func TestCompute_DeletionStarted(t *testing.T) { + now := metav1.Now() + old := &metav1.ObjectMeta{} + new := &metav1.ObjectMeta{DeletionTimestamp: &now} + result := Compute([]string{string(DeletionStarted)}, old, new) + assert.Equal(t, "true", result[string(DeletionStarted)]) +} + +func TestCompute_DeletionStarted_AlreadyDeleting(t *testing.T) { + now := metav1.Now() + old := &metav1.ObjectMeta{DeletionTimestamp: &now} + new := &metav1.ObjectMeta{DeletionTimestamp: &now} + result := Compute([]string{string(DeletionStarted)}, old, new) + assert.Equal(t, "false", result[string(DeletionStarted)]) +} + +func TestCompute_FinalizersChanged(t *testing.T) { + old := &metav1.ObjectMeta{Finalizers: []string{"orkestra.io/protect"}} + new := &metav1.ObjectMeta{} + result := Compute([]string{string(FinalizersChanged)}, old, new) + assert.Equal(t, "true", result[string(FinalizersChanged)]) +} + +func TestCompute_FinalizersUnchanged(t *testing.T) { + old := &metav1.ObjectMeta{Finalizers: []string{"orkestra.io/protect"}} + new := &metav1.ObjectMeta{Finalizers: []string{"orkestra.io/protect"}} + result := Compute([]string{string(FinalizersChanged)}, old, new) + assert.Equal(t, "false", result[string(FinalizersChanged)]) +} + +func TestCompute_UnknownSentinelReturnsEmpty(t *testing.T) { + result := Compute([]string{"specChanged"}, + obj(1, nil, nil), obj(2, nil, nil)) + assert.Equal(t, "", result["specChanged"]) +} diff --git a/pkg/tools/generate/registry_template.go b/pkg/tools/generate/registry_template.go index 1d669c535..7e05455b1 100644 --- a/pkg/tools/generate/registry_template.go +++ b/pkg/tools/generate/registry_template.go @@ -64,8 +64,6 @@ import ( {{ if .NeedsSchemeImports }}metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" {{ end }}{{ if or .NeedsHookImports .NeedsRecImports }}"github.com/orkspace/orkestra/domain" {{ end }}{{ if .NeedsRecImports }}"github.com/orkspace/orkestra/pkg/kubeclient" - "github.com/orkspace/orkestra/pkg/event" - "k8s.io/client-go/tools/cache" {{ end }} {{ range .Imports }} {{ .Alias }} "{{ .Location }}" {{ end }}) @@ -129,8 +127,8 @@ func RegisterRuntimeObjects() { // {{ .Kind }} — custom reconciler constructor // Calls {{ .Alias }}.{{ .Function }}() to build the user's reconciler. orktypes.ReconcilerRegistry[schema.GroupVersionKind{Group: "{{ .Group }}", Version: "{{ .Version }}", Kind: "{{ .Kind }}"}] = - func(kube kubeclient.Interface, inf cache.SharedIndexInformer, ev event.Recorder) domain.Reconciler { - return {{ .Alias }}.{{ .Function }}(kube, inf, ev) + func(kube kubeclient.Interface) domain.Reconciler { + return {{ .Alias }}.{{ .Function }}(kube) } {{ end }}{{ end }} {{ if .TargetHookEntries }}{{ range .TargetHookEntries }} @@ -154,8 +152,8 @@ func RegisterRuntimeObjects() { if orktypes.TargetReconcilerRegistry[gvk] == nil { orktypes.TargetReconcilerRegistry[gvk] = map[string]orktypes.NewReconcilerFunc{} } - orktypes.TargetReconcilerRegistry[gvk]["{{ .TargetName }}"] = func(kube kubeclient.Interface, inf cache.SharedIndexInformer, ev event.Recorder) domain.Reconciler { - return {{ .Alias }}.{{ .Function }}(kube, inf, ev) + orktypes.TargetReconcilerRegistry[gvk]["{{ .TargetName }}"] = func(kube kubeclient.Interface) domain.Reconciler { + return {{ .Alias }}.{{ .Function }}(kube) } } {{ end }}{{ end }} diff --git a/pkg/tools/migrate/README.md b/pkg/tools/migrate/README.md index 2c3ae7325..9fd9edfd2 100644 --- a/pkg/tools/migrate/README.md +++ b/pkg/tools/migrate/README.md @@ -1,106 +1,111 @@ # pkg/tools/migrate -`migrate` rewrites a controller-runtime `Reconcile` method to the Orkestra constructor signature. It is invoked by `ork migrate` and produces a rewritten Go file plus the full Orkestra scaffolding — `katalog.yaml`, `simulate.yaml`, `e2e.yaml`, `go.mod`, `Makefile`, and `Dockerfile` — as a starting point. +`migrate` rewrites a controller-runtime reconciler file for Orkestra. It is invoked by `ork migrate` and produces a rewritten Go file plus full scaffolding — `katalog.yaml`, `simulate.yaml`, `e2e.yaml`, `go.mod`, `Makefile`, and `Dockerfile` — as a ready-to-run starting point. --- ## Try it first -Before reading further, pull the migration pack and explore the full before/after: - ```bash ork init --pack from-controller-runtime ``` -This gives you eight progressive examples — from the raw controller-runtime baseline (`00-controller-runtime-baseline`) through five migration options to the automated `ork migrate` output (`06-ork-migrate`). The step-by-step narrative is in `documentation/guides/migration/`. +Eight progressive examples — from the raw controller-runtime baseline through five migration options to the automated `ork migrate` output. The step-by-step narrative is in `documentation/guides/migration/`. --- -## Usage +## Two modes -```bash -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 # prompts before replacing in place +### `--mode toclient` (default) + +Zero changes to your reconciler. `Reconcile`, struct fields, and all call sites are untouched. Only `SetupWithManager` is removed and a constructor is injected: + +```go +func NewWebAppReconciler(kube kubeclient.Interface) domain.Reconciler { + return domain.ReconcilerFrom(&WebAppReconciler{ + client: kubeclient.ToClient(kube), + }) +} ``` ---- +`ToClient` returns the same `client.Client` your reconciler already holds. `ReconcilerFrom` adapts the `ctrl.Request` signature to Orkestra's interface. Your reconciler compiles and runs inside Orkestra with no other edits. -## What it rewrites +### `--mode native` -| Before (controller-runtime) | After (Orkestra constructor) | -|-----------------------------|------------------------------| +Full rewrite to idiomatic Orkestra style: + +| Before | After | +|--------|-------| | `Reconcile(ctx, req ctrl.Request) (ctrl.Result, error)` | `Reconcile(ctx context.Context, key string) error` | | `return ctrl.Result{}, err` | `return err` | -| `return ctrl.Result{}, nil` | `return nil` | | `req.NamespacedName` | `client.ObjectKey{Namespace: namespace, Name: name}` | -| `req.String()` | `key` | +| `r.client.Get(ctx, key, obj)` | `r.kube.Get(ctx, namespace, name, obj)` | | `r.Status().Update(...)` | flagged with `// TODO(ork migrate):` | | `ctrl.Result{RequeueAfter: X}` | flagged with `// TODO(ork migrate):` | | `SetupWithManager` | removed with explanation comment | -| `ctrl` import | removed | -| logging imports | left untouched — keep your logger | -`r.client.Patch(ctx, obj, client.MergeFrom(...))` lines pass through unchanged and compile as-is — `kubeclient.Patch` is a type alias for `sigs.k8s.io/controller-runtime/pkg/client.Patch`, so existing patch calls work without modification. The only change needed is the method receiver: `r.client` → `r.kube`. +More invasive; produces fully idiomatic Orkestra code. --- -## What it generates +## Usage + +```bash +# Default (toclient) — zero Reconcile changes +ork migrate ./controller/webapp_controller.go -o ./my-operator -| File | Description | -|------|-------------| -| `.go` | Rewritten source with `TODO(ork migrate):` markers for manual review | -| `katalog.yaml` | Constructor Katalog stub — fill in group, kind, location | -| `simulate.yaml` | Simulation stub — fill in expected resource kinds | -| `e2e.yaml` | E2E test stub — fill in CR name, resource assertions | -| `go.mod` | Module file with Orkestra dependency pinned to the CLI version | -| `Makefile` | Standard typed operator Makefile — registry, build, build-runtime, docker, release | -| `Dockerfile` | Distroless production image — same as all typed examples | +# Full rewrite +ork migrate ./controller/webapp_controller.go --mode native -o ./out + +# In-place — prompts before replacing +ork migrate ./controller/webapp_controller.go +``` --- ## Review checklist -After running `ork migrate`, search for `TODO(ork migrate)` in the output directory: +After running `ork migrate`, search for `TODO(ork migrate)`: ```bash grep -rn "TODO(ork migrate)" . ``` -Work through each marker in order: - +**toclient mode:** +- [ ] Add `domain` and `kubeclient` imports where flagged - [ ] Set `group`, `kind`, `plural`, `location` in `katalog.yaml` -- [ ] Replace the embedded `client.Client` struct field with `kube kubeclient.KubeClient` -- [ ] Update `NewXxx` constructor to accept `(kube kubeclient.KubeClient, informer cache.SharedIndexInformer, ev event.Recorder)` -- [ ] Rename `r.client` → `r.kube` at all call sites (patch lines compile unchanged — only the receiver name changes) -- [ ] Replace `r.Status().Update()` with `r.kube.PatchStatus(ctx, obj, gvr, map[string]interface{}{...})` -- [ ] Add `github.com/orkspace/orkestra/domain` and `pkg/kubeclient` imports +- [ ] Delete `main.go` and scheme registration — Orkestra provides the runtime - [ ] Fill in resource assertions in `simulate.yaml` and `e2e.yaml` -- [ ] Delete `main.go`, scheme registration, and manager setup — Orkestra provides the informer, workqueue, and worker pool ---- +**native mode (additional):** +- [ ] Replace `r.Status().Update()` with `r.kube.PatchStatus(ctx, obj, map[string]interface{}{...})` +- [ ] Resolve any `RequeueAfter` TODOs — return `err` requeues with backoff -## What Orkestra hands you for free +--- -When a constructor reconciler runs inside Orkestra, you keep your existing logic and gain: +## What Orkestra provides for free | Concern | Orkestra | |---------|----------| | Informer watching your CRD | ✓ | | Workqueue with dedup and backoff | ✓ | | Worker pool | ✓ | -| Panic recovery (`safeReconcile`) | ✓ | +| Panic recovery | ✓ | +| Arbitrary watch | ✓ | +| Conditional reconciliation | ✓ | | Leader election | ✓ | | Prometheus metrics | ✓ | | Health tracking | ✓ | | `ork control` UI | ✓ | +And more. + --- ## Developer documentation | I want to… | Go to | |-----------|-------| -| Understand the full signature change and what the output looks like | [docs/01-output.md](docs/01-output.md) | +| Understand what the output looks like | [docs/01-output.md](docs/01-output.md) | | See a before/after of the generated files | [docs/02-generated-files.md](docs/02-generated-files.md) | | Understand what the tool cannot auto-fix | [docs/03-limitations.md](docs/03-limitations.md) | diff --git a/pkg/tools/migrate/docs/01-output.md b/pkg/tools/migrate/docs/01-output.md index 158dfcfe4..fa1ee8084 100644 --- a/pkg/tools/migrate/docs/01-output.md +++ b/pkg/tools/migrate/docs/01-output.md @@ -1,20 +1,50 @@ # Output — the rewritten file -`ork migrate` performs a mechanical rewrite of the `Reconcile` method. The output is structured so you can review and complete it — not run it as-is. +## toclient mode (default) -## Signature change +`ork migrate` (without `--mode`) produces the minimum change needed to run your reconciler inside Orkestra. Only two things change: + +### SetupWithManager is removed + +Replaced with a comment: + +```go +// SetupWithManager removed — Orkestra provides the informer, workqueue, +// worker pool, leader election, panic recovery, and metrics. +// Delete this file's main.go and scheme registration too. +``` + +### A constructor is injected + +```go +func NewWebAppReconciler(kube kubeclient.Interface) domain.Reconciler { + return domain.ReconcilerFrom(&WebAppReconciler{ + client: kubeclient.ToClient(kube), + }) +} +``` + +`kubeclient.ToClient` returns a `client.Client` — the same type your struct field already holds. `domain.ReconcilerFrom` adapts the `ctrl.Request` signature to Orkestra's interface. Your `Reconcile` method body is completely untouched. + +--- + +## native mode (`--mode native`) + +Full mechanical rewrite to idiomatic Orkestra style. + +### Signature change ```go // Before -func (r *WebAppReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { +func (r *WebAppReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) // After -func (r *WebAppReconciler) Reconcile(ctx context.Context, key string) error { +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, which already manages concurrency, retries, and leader election. -## Return statements +### Return statements Every `ctrl.Result` is collapsed: @@ -37,7 +67,7 @@ return nil Return an error to trigger a retry. Orkestra's backoff policy applies automatically. -## req.NamespacedName +### req.NamespacedName When the body uses `req.NamespacedName`, the tool injects a key split at the top of `Reconcile` and replaces usages: @@ -50,17 +80,7 @@ namespace, name := parts[0], parts[1] r.Get(ctx, client.ObjectKey{Namespace: namespace, Name: name}, webapp) ``` -## SetupWithManager - -The method is removed and replaced with a comment: - -```go -// SetupWithManager removed — Orkestra provides the informer, workqueue, -// worker pool, leader election, panic recovery, and metrics. -// Delete this file's main.go and scheme registration too. -``` - -## r.Status().Update() +### r.Status().Update() Flagged inline — Orkestra uses a different status API: @@ -68,10 +88,14 @@ Flagged inline — Orkestra uses a different status API: nil /* TODO(ork migrate): replace with r.kube.PatchStatus(ctx, obj, GroupVersionResource, map[string]interface{}{...}) */ ``` -## TODO markers +### TODO markers All items that need human review are marked `// TODO(ork migrate):`. After migration: ```bash grep -rn "TODO(ork migrate)" ./my-operator/ ``` + +--- + +Next: [Generated files](02-generated-files.md) diff --git a/pkg/tools/migrate/docs/02-generated-files.md b/pkg/tools/migrate/docs/02-generated-files.md index 90eb43bff..a37ca75fe 100644 --- a/pkg/tools/migrate/docs/02-generated-files.md +++ b/pkg/tools/migrate/docs/02-generated-files.md @@ -89,3 +89,7 @@ Run `go mod tidy` to resolve indirect dependencies. ## The rewritten reconciler Same filename as the input. See [01-output.md](01-output.md) for what changed. + +--- + +Next: [Limitations](03-limitations.md) diff --git a/pkg/tools/migrate/docs/03-limitations.md b/pkg/tools/migrate/docs/03-limitations.md index 9ac1c863a..27eb29dd9 100644 --- a/pkg/tools/migrate/docs/03-limitations.md +++ b/pkg/tools/migrate/docs/03-limitations.md @@ -1,41 +1,22 @@ # Limitations -`ork migrate` is a mechanical starting point. It handles the deterministic rewrites and flags the rest. These are the cases that require manual attention. +`ork migrate` is a mechanical starting point. It handles the deterministic rewrites and flags the rest. -## Not automatically rewritten +## toclient mode -### Embedded client.Client +### r.Get, r.Create, r.Patch via embedded client in sub-methods -The standard controller-runtime struct pattern: +`ork migrate --mode toclient` preserves your struct and all call sites. If your reconciler uses `client.Client` via embedding (promoted methods on the struct), the struct itself and sub-method calls remain unchanged and compile without modification. -```go -type WebAppReconciler struct { - client.Client // embedded - Scheme *runtime.Scheme -} -``` +If you later want to migrate those calls to Orkestra's `kubeclient.Interface`, do it by hand or run `--mode native`. -The tool does not rewrite the struct. After migration, replace it with Orkestra's interfaces: +### r.Status().Update() -```go -type WebAppReconciler struct { - informer cache.SharedIndexInformer - kube kubeclient.KubeClient - ev event.Recorder -} -``` +`toclient` mode leaves `Status().Update()` in place — it compiles and works via `client.Client`. The TODO flag is not applied in this mode. -And update the constructor function signature: +--- -```go -func NewWebAppReconciler( - kube kubeclient.KubeClient, - informer cache.SharedIndexInformer, - ev event.Recorder, -) domain.Reconciler { - return &WebAppReconciler{kube: kube, informer: informer, ev: ev} -} -``` +## native mode ### r.Get, r.Create, r.Patch inside sub-methods @@ -47,12 +28,12 @@ r.Get(ctx, client.ObjectKey{...}, existing) r.Create(ctx, desired) r.Patch(ctx, existing, patch) -// After — manual Get/Create/Patch -r.kube.Get(ctx, namespace, name, existing) // or use informer cache +// After — kubeclient +r.kube.Get(ctx, namespace, name, existing) r.kube.Create(ctx, desired) r.kube.Patch(ctx, existing, patch) -// After — Orkestra resources (05-constructor-orkestra-resources style) +// After — Orkestra managed resources orkdeploy.Update(ctx, r.kube, owner, spec) ``` @@ -72,16 +53,16 @@ r.kube.PatchStatus(ctx, webapp, apiv1.GroupVersionResource, map[string]interface The tool removes `RequeueAfter` and flags it. If you need time-based requeue, return an error — Orkestra's exponential backoff will retry. For periodic reconciliation, use an `external:` schedule or a `when:` condition. -### kubebuilder RBAC markers - -Comments like `// +kubebuilder:rbac:groups=...` are left as-is. They have no effect in Orkestra — RBAC is declared in the Katalog's `resources:` list and generated via `ork generate rbac`. +--- -### main.go, scheme registration, manager setup +## Out of scope in both modes -These are separate files — the tool only touches the file you pass it. Delete them manually after migration. +- **kubebuilder RBAC markers** — `// +kubebuilder:rbac:groups=...` are left as-is. They have no effect in Orkestra — declare resources in the Katalog's `resources:` list and generate RBAC via `ork generate rbac`. +- **main.go, scheme registration, manager setup** — these are separate files; the tool only touches the file you pass it. Delete them manually. +- **Webhooks** — `SetupWebhookWithManager` and admission handlers are not touched. +- **Multi-file operators** — the tool processes one file at a time. Run it on each reconciler file separately. +- **Finalizer logic** — the tool does not analyse `DeletionTimestamp` handling or finalizer add/remove patterns. -## Out of scope by design +--- -- Webhooks — `SetupWebhookWithManager` and admission handlers are not touched. -- Multi-file operators — the tool processes one file at a time. Run it on each reconciler file separately. -- Finalizer logic — the tool does not analyse `DeletionTimestamp` handling or finalizer add/remove patterns. +Back: [README](../README.md) diff --git a/pkg/tools/migrate/migrate.go b/pkg/tools/migrate/migrate.go index aedd9d2fd..be96b201f 100644 --- a/pkg/tools/migrate/migrate.go +++ b/pkg/tools/migrate/migrate.go @@ -16,6 +16,23 @@ import ( "strings" ) +// Mode controls how much of the source file migrate rewrites. +type Mode string + +const ( + // ModeNative rewrites the full controller-runtime signature to Orkestra's + // native style: Reconcile(ctx, key string) error, struct fields replaced, + // call sites adapted. Most invasive; produces fully idiomatic Orkestra code. + ModeNative Mode = "native" + + // ModeToClient is the minimal migration path. The Reconcile signature, + // struct fields, and call sites are left completely unchanged. Only + // SetupWithManager is removed and a constructor using kubeclient.ToClient + // and domain.ReconcilerFrom is injected. Two lines of new code; zero + // changes to existing reconciler logic. + ModeToClient Mode = "toclient" +) + // Result holds the output of a migration rewrite. type Result struct { // Source is the rewritten Go source, gofmt-formatted. @@ -26,6 +43,8 @@ type Result struct { PkgName string // Warnings are patterns flagged but not automatically rewritten. Warnings []string + // Mode is the migration mode used to produce this result. + Mode Mode } // replacement is a byte-range substitution to apply to source text. @@ -36,8 +55,19 @@ type replacement struct { } // Rewrite parses src, locates a controller-runtime Reconcile method, and -// returns the source with the Orkestra constructor signature applied. -func Rewrite(src []byte) (*Result, error) { +// returns the source rewritten according to mode. +// +// ModeToClient is the recommended starting point: zero changes to Reconcile +// or call sites, only SetupWithManager removed and a ToClient constructor +// injected. ModeNative performs the full signature and call-site rewrite. +func Rewrite(src []byte, mode Mode) (*Result, error) { + if mode == ModeToClient { + return rewriteToClient(src) + } + return rewriteNative(src) +} + +func rewriteNative(src []byte) (*Result, error) { fset := token.NewFileSet() f, err := parser.ParseFile(fset, "", src, parser.ParseComments) if err != nil { @@ -212,9 +242,120 @@ func Rewrite(src []byte) (*Result, error) { res.Source = formatted } + res.Mode = ModeNative return res, nil } +// rewriteToClient performs the minimal migration: removes SetupWithManager and +// injects a constructor using kubeclient.ToClient + domain.ReconcilerFrom. +// The Reconcile signature, struct fields, and all call sites are untouched. +func rewriteToClient(src []byte) (*Result, error) { + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, "", src, parser.ParseComments) + if err != nil { + return nil, fmt.Errorf("parse: %w", err) + } + + res := &Result{PkgName: f.Name.Name, Mode: ModeToClient} + + fn := findReconcile(f) + if fn == nil { + return nil, fmt.Errorf("no Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) found") + } + if len(fn.Recv.List) > 0 { + res.ReceiverType = typeName(fn.Recv.List[0].Type) + } + + var reps []replacement + + // Remove SetupWithManager. + for _, decl := range f.Decls { + setupFn, ok := decl.(*ast.FuncDecl) + if !ok || setupFn.Name.Name != "SetupWithManager" || setupFn.Recv == nil { + continue + } + reps = append(reps, replacement{ + start: off(fset, setupFn.Pos()), + end: off(fset, setupFn.End()), + text: "// SetupWithManager removed — Orkestra provides the informer, workqueue,\n" + + "// worker pool, leader election, panic recovery, and metrics.\n" + + "// Delete this file's main.go and scheme registration too.", + }) + res.Warnings = append(res.Warnings, "SetupWithManager removed — delete main.go and scheme registration") + } + + result := applyReplacements(src, reps) + + // Inject constructor using ToClient + ReconcilerFrom. + if res.ReceiverType != "" { + constructorName := "New" + res.ReceiverType + // Only inject if not already present. + hasConstructor := false + for _, decl := range f.Decls { + fn, ok := decl.(*ast.FuncDecl) + if ok && fn.Recv == nil && fn.Name.Name == constructorName { + hasConstructor = true + break + } + } + if !hasConstructor { + constructor := fmt.Sprintf(` +// %s is the Orkestra constructor. It replaces SetupWithManager — no other +// changes to the reconciler are needed. ToClient returns the same client.Client +// your reconciler already uses; ReconcilerFrom adapts the signature. +func %s(kube kubeclient.Interface) domain.Reconciler { + return domain.ReconcilerFrom(&%s{ + client: kubeclient.ToClient(kube), + }) +} +`, constructorName, constructorName, res.ReceiverType) + result = append(result, []byte(constructor)...) + } + } + + result = rewriteImportsToClient(result) + + formatted, fmtErr := format.Source(result) + if fmtErr != nil { + res.Warnings = append(res.Warnings, fmt.Sprintf("gofmt failed (%v) — check output manually", fmtErr)) + res.Source = result + } else { + res.Source = formatted + } + + return res, nil +} + +// rewriteImportsToClient removes the ctrl import (no longer needed for +// SetupWithManager) and injects ToClient/ReconcilerFrom import hints. +func rewriteImportsToClient(src []byte) []byte { + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, "", src, parser.ParseComments) + if err != nil { + return src + } + + var reps []replacement + for _, imp := range f.Imports { + path := strings.Trim(imp.Path.Value, `"`) + if path == "sigs.k8s.io/controller-runtime" { + start := off(fset, imp.Pos()) + end := off(fset, imp.End()) + if end < len(src) && src[end] == '\n' { + end++ + } + reps = append(reps, replacement{start: start, end: end, text: ""}) + } + } + + result := applyReplacements(src, reps) + result = injectImport(result, + "// TODO(ork migrate): add these imports:\n"+ + "// \"github.com/orkspace/orkestra/domain\"\n"+ + "// \"github.com/orkspace/orkestra/pkg/kubeclient\"") + return result +} + // rewriteKubeCalls rewrites r.Get/Create/Patch (and r..Get/Create/Patch) // to r.kube.* with kubeclient signatures: // diff --git a/pkg/tools/migrate/migrate_test.go b/pkg/tools/migrate/migrate_test.go index 70bbb3c1e..cbe1e9758 100644 --- a/pkg/tools/migrate/migrate_test.go +++ b/pkg/tools/migrate/migrate_test.go @@ -56,7 +56,7 @@ func (r *WebAppReconciler) SetupWithManager(mgr ctrl.Manager) error { ` func TestRewrite_SignatureChange(t *testing.T) { - res, err := Rewrite([]byte(baseline)) + res, err := Rewrite([]byte(baseline), ModeNative) if err != nil { t.Fatalf("Rewrite: %v", err) } @@ -75,7 +75,7 @@ func TestRewrite_SignatureChange(t *testing.T) { } func TestRewrite_ReturnCollapse(t *testing.T) { - res, err := Rewrite([]byte(baseline)) + res, err := Rewrite([]byte(baseline), ModeNative) if err != nil { t.Fatalf("Rewrite: %v", err) } @@ -95,7 +95,7 @@ func TestRewrite_ReturnCollapse(t *testing.T) { } func TestRewrite_ReqNamespacedName(t *testing.T) { - res, err := Rewrite([]byte(baseline)) + res, err := Rewrite([]byte(baseline), ModeNative) if err != nil { t.Fatalf("Rewrite: %v", err) } @@ -115,7 +115,7 @@ func TestRewrite_ReqNamespacedName(t *testing.T) { } func TestRewrite_ReqString(t *testing.T) { - res, err := Rewrite([]byte(baseline)) + res, err := Rewrite([]byte(baseline), ModeNative) if err != nil { t.Fatalf("Rewrite: %v", err) } @@ -128,7 +128,7 @@ func TestRewrite_ReqString(t *testing.T) { } func TestRewrite_SetupWithManagerRemoved(t *testing.T) { - res, err := Rewrite([]byte(baseline)) + res, err := Rewrite([]byte(baseline), ModeNative) if err != nil { t.Fatalf("Rewrite: %v", err) } @@ -145,7 +145,7 @@ func TestRewrite_SetupWithManagerRemoved(t *testing.T) { } func TestRewrite_StructRewritten(t *testing.T) { - res, err := Rewrite([]byte(baseline)) + res, err := Rewrite([]byte(baseline), ModeNative) if err != nil { t.Fatalf("Rewrite: %v", err) } @@ -167,7 +167,7 @@ func TestRewrite_StructRewritten(t *testing.T) { } func TestRewrite_ConstructorGenerated(t *testing.T) { - res, err := Rewrite([]byte(baseline)) + res, err := Rewrite([]byte(baseline), ModeNative) if err != nil { t.Fatalf("Rewrite: %v", err) } @@ -183,7 +183,7 @@ func TestRewrite_ConstructorGenerated(t *testing.T) { } func TestRewrite_StatusUpdateFlagged(t *testing.T) { - res, err := Rewrite([]byte(baseline)) + res, err := Rewrite([]byte(baseline), ModeNative) if err != nil { t.Fatalf("Rewrite: %v", err) } @@ -205,7 +205,7 @@ func TestRewrite_StatusUpdateFlagged(t *testing.T) { } func TestRewrite_ReceiverType(t *testing.T) { - res, err := Rewrite([]byte(baseline)) + res, err := Rewrite([]byte(baseline), ModeNative) if err != nil { t.Fatalf("Rewrite: %v", err) } @@ -218,7 +218,7 @@ func TestRewrite_ReceiverType(t *testing.T) { } func TestRewrite_NoCtrlImport(t *testing.T) { - res, err := Rewrite([]byte(baseline)) + res, err := Rewrite([]byte(baseline), ModeNative) if err != nil { t.Fatalf("Rewrite: %v", err) } @@ -245,7 +245,7 @@ func (r *MyReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Re return ctrl.Result{RequeueAfter: 30 * time.Second}, nil } ` - res, err := Rewrite([]byte(src)) + res, err := Rewrite([]byte(src), ModeNative) if err != nil { t.Fatalf("Rewrite: %v", err) } @@ -265,6 +265,63 @@ func (r *MyReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Re } } +func TestRewrite_ToClientMode_ReconcileUnchanged(t *testing.T) { + const src = `package controller + +import ( + "context" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +type WebAppReconciler struct { + client client.Client +} + +func (r *WebAppReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + return ctrl.Result{}, nil +} + +func (r *WebAppReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr).For(&WebApp{}).Complete(r) +} +` + res, err := Rewrite([]byte(src), ModeToClient) + if err != nil { + t.Fatalf("Rewrite ModeToClient: %v", err) + } + out := string(res.Source) + + // Reconcile signature must be untouched. + if !strings.Contains(out, "req ctrl.Request") { + t.Error("expected Reconcile signature to be unchanged (req ctrl.Request still present)") + } + if strings.Contains(out, "key string") { + t.Error("expected Reconcile signature NOT to be rewritten to (ctx, key string)") + } + + // Constructor injected. + if !strings.Contains(out, "func NewWebAppReconciler(kube kubeclient.Interface)") { + t.Error("expected ToClient constructor to be injected") + } + if !strings.Contains(out, "kubeclient.ToClient(kube)") { + t.Error("expected kubeclient.ToClient in constructor") + } + if !strings.Contains(out, "domain.ReconcilerFrom") { + t.Error("expected domain.ReconcilerFrom in constructor") + } + + // SetupWithManager removed. + if strings.Contains(out, "SetupWithManager") && !strings.Contains(out, "removed") { + t.Error("expected SetupWithManager to be removed or replaced with comment") + } + + // Mode recorded. + if res.Mode != ModeToClient { + t.Errorf("expected Mode ModeToClient, got %q", res.Mode) + } +} + func TestRewrite_NoReconcileMethod(t *testing.T) { src := `package controller @@ -272,7 +329,7 @@ type MyReconciler struct{} func (r *MyReconciler) DoSomething() {} ` - _, err := Rewrite([]byte(src)) + _, err := Rewrite([]byte(src), ModeNative) if err == nil { t.Error("expected error when no Reconcile method found") } diff --git a/pkg/types/external.go b/pkg/types/external.go index d92d9338a..85bc311d5 100644 --- a/pkg/types/external.go +++ b/pkg/types/external.go @@ -193,6 +193,11 @@ type ExternalCallSpec struct { // When set, this entry is replaced in-place by the listed calls. // Resolved relative to the katalog file's directory. Cleared after expansion. Include string `yaml:"include,omitempty" json:"include,omitempty"` + + // RetryBackoff configures how many times and how long to wait between + // retries for this specific external call before returning an error. + // Shorthand ("3s") sets initial only; full form gives full control. + RetryBackoff *RetryBackoffConfig `yaml:"retryBackoff,omitempty" json:"retryBackoff,omitempty"` } // ExternalCallResult is the result of one HTTP call, injected into the resolver diff --git a/pkg/types/func.go b/pkg/types/func.go index 8120a2ccd..167de3abb 100644 --- a/pkg/types/func.go +++ b/pkg/types/func.go @@ -2,13 +2,14 @@ package types import ( "github.com/orkspace/orkestra/domain" - "github.com/orkspace/orkestra/pkg/event" "github.com/orkspace/orkestra/pkg/kubeclient" - "k8s.io/client-go/tools/cache" ) -type NewReconcilerFunc func( - kube kubeclient.Interface, - inf cache.SharedIndexInformer, - ev event.Recorder, -) domain.Reconciler +// NewReconcilerFunc is the constructor signature every custom reconciler must match. +// It lives in pkg/types (not domain) to avoid an import cycle: domain must not +// import kubeclient, but the constructor function takes kubeclient.Interface. +// +// The runtime injects the primary CRD's informer and event recorder into kube +// before calling the constructor — access them via kube.GetInformer() and +// kube.GetEventRecorder(). Constructor args are available via kube.Args(). +type NewReconcilerFunc func(kube kubeclient.Interface) domain.Reconciler diff --git a/pkg/types/hook_methods.go b/pkg/types/hook_methods.go index f20eddca6..5c2e72ddc 100644 --- a/pkg/types/hook_methods.go +++ b/pkg/types/hook_methods.go @@ -31,6 +31,15 @@ func (h HookTemplates) IsEmpty() bool { h.Docker == nil } +// ExternalCalls returns the external call specs declared in this hook phase. +// Returns nil when the receiver is nil or has no external declarations. +func (h *HookTemplates) ExternalCalls() []ExternalCallSpec { + if h == nil { + return nil + } + return h.External +} + // HasAnyHooks reports whether this CRD declares any onCreate, onReconcile, or onDelete hooks. func (c *CRDEntry) HasAnyHookTemplates() bool { return c.HasOnCreate() || c.HasOnReconcile() || c.HasOnDelete() diff --git a/pkg/types/katalog.go b/pkg/types/katalog.go index f5334d7a4..c8d27e349 100644 --- a/pkg/types/katalog.go +++ b/pkg/types/katalog.go @@ -698,7 +698,6 @@ func (d *KatalogDeprecation) DaysUntilEOL(today time.Time) int { return days } - // KatalogSources declares where to load CRD definitions from. // Sources are loaded before spec.crds — inline CRDs are merged last // and win on name conflict (allowing local overrides of remote definitions). diff --git a/pkg/types/methods.go b/pkg/types/methods.go index 8470f44ca..abd7423bd 100644 --- a/pkg/types/methods.go +++ b/pkg/types/methods.go @@ -245,6 +245,22 @@ func (c *CRDEntry) ConstructorManagedResources() []ManagedResource { return c.OperatorBox.Reconciler.ConstructorDecl.Resources } +// WithWatchEntries reports whether this CRD declares any secondary watch entries. +func (c *CRDEntry) WithWatchEntries() bool { + return len(c.OperatorBox.Watch) > 0 +} + +// WithSentinels reports whether this CRD declares any preReconcile sentinels. +func (c *CRDEntry) WithSentinels() bool { + return len(c.OperatorBox.PreReconcile.DeclaredSentinels()) > 0 +} + +// WatchEntries returns the secondary watch entries declared under operatorBox.watch. +// Returns nil when no watch entries are declared. +func (c *CRDEntry) WatchEntries() []WatchEntry { + return c.OperatorBox.Watch +} + // HasTemplates reports whether this CRD declares any declarative hook templates. // Used by `ork generate` to determine whether to emit generated runtime hooks. func (c *CRDEntry) HasTemplates() bool { @@ -758,6 +774,11 @@ func IsValidServiceType(t string) bool { } } +// HasUserLabels reports whether the CRD entry declares any user-defined labels. +func (e CRDEntry) HasUserLabels() bool { + return len(e.Labels) > 0 +} + // IsValidProtocol reports whether the provided protocol is valid. // Accepted values (case‑insensitive): // - TCP diff --git a/pkg/types/types.go b/pkg/types/types.go index b05ef3c79..8e48f0edc 100644 --- a/pkg/types/types.go +++ b/pkg/types/types.go @@ -160,6 +160,11 @@ type Queue struct { // FailureThreshold — consecutive failures before CRD health degrades. // 0 → uses FAILURE_THRESHOLD env var. FailureThreshold int `yaml:"failureThreshold,omitempty" json:"failureThreshold,omitempty" validate:"omitempty,gte=0"` + + // RetryBackoff — per-CRD backoff applied inside the reconcile loop when + // the reconciler returns an error before re-enqueuing. Shorthand ("5s") + // sets initial only; full form controls initial/max/multiplier/maxAttempts. + RetryBackoff *RetryBackoffConfig `yaml:"retryBackoff,omitempty" json:"retryBackoff,omitempty"` } // IsEmpty reports whether the queue configuration has no meaningful settings. @@ -177,5 +182,13 @@ func (q *Queue) IsEmpty() bool { if q.FailureThreshold != 0 { return false } + if q.RetryBackoff != nil { + return false + } return true } + +// HasRetryBackoff reports whether a retryBackoff is declared on this queue. +func (q *Queue) HasRetryBackoff() bool { + return q != nil && q.RetryBackoff != nil +} diff --git a/pkg/types/types_crd_entry.go b/pkg/types/types_crd_entry.go index 16ff1ffd8..da744689a 100644 --- a/pkg/types/types_crd_entry.go +++ b/pkg/types/types_crd_entry.go @@ -156,7 +156,11 @@ type CRDEntry struct { // ── OperatorBox ──────────────────────────────────────────────────── OperatorBox OperatorBoxConfig `yaml:"operatorBox,omitempty" json:"operatorBox,omitempty"` - // Labels Labels `yaml:"labels,omitempty" json:"labels,omitempty" validate:"omitempty"` + // Labels specifies additional metadata labels to attach to the CR of this CRD entry. + // These labels can be used for organization, watch filtering, or identification purposes. + // They are not used for reconciliation filtering (see LabelSelector for that). + Labels Labels `yaml:"labels,omitempty" json:"labels,omitempty" validate:"omitempty"` + // LabelSelector filters which resources this CRD entry reconciles. // Only resources whose labels match ALL declared key-value pairs are watched. // Required for built-in types (ConfigMap, Pod, etc.) — without a selector, diff --git a/pkg/types/types_operatorbox.go b/pkg/types/types_operatorbox.go index 5a2e84eff..fc8471eb6 100644 --- a/pkg/types/types_operatorbox.go +++ b/pkg/types/types_operatorbox.go @@ -3,6 +3,7 @@ package types import ( "github.com/orkspace/orkestra/domain" + "github.com/orkspace/orkestra/pkg/runtime/sentinel" ) // ── PreReconcileConfig ──────────────────────────────────────────────────────────── @@ -49,6 +50,194 @@ func (g *GateConditions) AnyOfConditions() []Condition { return g.AnyOf } +// ExternalCalls returns the external calls declared on this gate, or nil when +// the gate is nil or has no external declarations. +func (g *GateConditions) ExternalCalls() []ExternalCallSpec { + if g == nil { + return nil + } + return g.External +} + +// ── Watch event types ───────────────────────────────────────────────────── + +// WatchEvent is the string type for watch event types used in WatchEntry.On. +type WatchEvent string + +const ( + WatchEventCreate WatchEvent = "create" + WatchEventUpdate WatchEvent = "update" + WatchEventDelete WatchEvent = "delete" +) + +// ValidWatchEvents returns all known watch event values in declaration order. +func ValidWatchEvents() []string { + return []string{ + string(WatchEventCreate), + string(WatchEventUpdate), + string(WatchEventDelete), + } +} + +// IsValidWatchEvent reports whether s is a known watch event type. +func IsValidWatchEvent(s string) bool { + switch WatchEvent(s) { + case WatchEventCreate, WatchEventUpdate, WatchEventDelete: + return true + } + return false +} + +// ── Sentinel names ──────────────────────────────────────────────────────── + +// Sentinel is the string type for event-time sentinel names declared under +// preReconcile.sentinels and used in enqueueGate/reconcileGate templates. +// The canonical type and constants live in pkg/runtime/sentinel; these are +// re-exported here so callers only need to import pkg/types. +type Sentinel = sentinel.Sentinel + +const ( + SentinelGenerationChanged = sentinel.GenerationChanged + SentinelLabelsChanged = sentinel.LabelsChanged + SentinelAnnotationsChanged = sentinel.AnnotationsChanged + SentinelDeletionStarted = sentinel.DeletionStarted + SentinelFinalizersChanged = sentinel.FinalizersChanged +) + +// ValidSentinels returns all known sentinel names in declaration order. +func ValidSentinels() []string { return sentinel.ValidSentinels() } + +// IsValidSentinel reports whether s is a known sentinel name. +func IsValidSentinel(s string) bool { return sentinel.IsValid(s) } + +// ── WatchEntry ──────────────────────────────────────────────────────────────── + +// WatchEntry declares a secondary Kubernetes resource Orkestra should watch. +// When the resource changes, Orkestra resolves the relevant primary CR key(s) +// and enqueues them — no Go required. +// +// Key resolution: if the changed object has an ownerReference pointing to a +// primary CR, that CR is enqueued. Otherwise all known CRs of the primary kind +// are enqueued (shared-resource broadcast). +// +// YAML: +// +// operatorBox: +// watch: +// - apiVersion: apps/v1 +// kind: Deployment +// - apiVersion: v1 +// kind: ConfigMap +// namespace: my-operator-system +// name: shared-config +// - apiVersion: v1 +// kind: Node +// on: [update] +type WatchEntry struct { + // APIVersion is the Kubernetes API version of the resource to watch. + // e.g. "apps/v1", "v1", "networking.k8s.io/v1" + APIVersion string `yaml:"apiVersion" json:"apiVersion" validate:"required"` + + // Kind is the Kubernetes Kind of the resource to watch. + // e.g. "Deployment", "ConfigMap", "Node" + Kind string `yaml:"kind" json:"kind" validate:"required"` + + // Namespace restricts the watch to a single namespace. + // When empty the watch is cluster-scoped (all namespaces). + Namespace string `yaml:"namespace,omitempty" json:"namespace,omitempty"` + + // Name restricts the watch to a single named resource. + // When set only events for that specific object trigger the enqueue. + // Typically used for well-known shared resources (a specific ConfigMap or Secret). + Name string `yaml:"name,omitempty" json:"name,omitempty"` + + // On declares which event types trigger the enqueue. + // Valid values: WatchEventCreate, WatchEventUpdate, WatchEventDelete. + // When empty all three event types are watched. + On []string `yaml:"on,omitempty" json:"on,omitempty"` + + // EnqueueGate declares conditions evaluated before enqueueing when the watch + // fires. Sentinels (e.g. generationChanged) are computed against the watched + // resource's oldObj / newObj at UpdateFunc time and are valid here. + // When nil all events that pass the On filter are enqueued. + EnqueueGate *GateConditions `yaml:"enqueueGate,omitempty" json:"enqueueGate,omitempty"` + + // KeyFrom overrides the default key resolution (ownerReference → broadcast). + // Declare when the standard mechanisms do not express the mapping you need. + // When nil the runtime checks ownerReferences first; if none match the primary + // CRD it broadcasts to all known primary CRs. + KeyFrom *WatchKeyFrom `yaml:"keyFrom,omitempty" json:"keyFrom,omitempty"` +} + +// WatchKeyFrom overrides the default ownerReference → broadcast key resolution +// for a watch: entry. Exactly one of Label or Name must be set. +// +// keyFrom: +// label: "app.kubernetes.io/cr-owner" # label on the watched object carries the key +// +// keyFrom: +// name: "my-singleton-cr" # always enqueue this named primary CR +// namespace: "my-namespace" # optional; omit for cluster-scoped CRDs +type WatchKeyFrom struct { + // Label names a label on the watched object whose value is the primary CR key. + // The value must be a valid Kubernetes key: "namespace/name" or bare "name". + // Mutually exclusive with Name. + Label string `yaml:"label,omitempty" json:"label,omitempty"` + + // Name is a fixed primary CR name to enqueue regardless of which watched + // object changed. Use for singleton operators (one CR per cluster). + // Mutually exclusive with Label. + Name string `yaml:"name,omitempty" json:"name,omitempty"` + + // Namespace qualifies Name. Omit for cluster-scoped primary CRDs. + // Ignored when Label is set. + Namespace string `yaml:"namespace,omitempty" json:"namespace,omitempty"` +} + +// Key returns the enqueue key for the fixed-name variant. +func (kf *WatchKeyFrom) Key() string { + if kf.Namespace != "" { + return kf.Namespace + "/" + kf.Name + } + return kf.Name +} + +// WatchesOn reports whether the entry should fire for the given event type. +func (w WatchEntry) WatchesOn(event string) bool { + if len(w.On) == 0 { + return true + } + for _, e := range w.On { + if e == event { + return true + } + } + return false +} + +// InvalidOnValues returns any On values that are not valid WatchEvent constants. +// Returns nil when all values are valid. +func (w WatchEntry) InvalidOnValues() []string { + var invalid []string + for _, e := range w.On { + if !IsValidWatchEvent(e) { + invalid = append(invalid, e) + } + } + return invalid +} + +// ToManagedResource converts the entry to a ManagedResource suitable for +// ResolveGVR — used for RBAC generation. +func (w WatchEntry) ToManagedResource() ManagedResource { + return ManagedResource{ + APIVersion: w.APIVersion, + Kind: w.Kind, + } +} + +// ── PreReconcileConfig ──────────────────────────────────────────────────────────── + // PreReconcileConfig groups the two pre-reconcile gates under operatorBox.preReconcile. // // YAML: @@ -72,6 +261,18 @@ type PreReconcileConfig struct { // available in both enqueueGate and reconcileGate field expressions. External []ExternalCallSpec `yaml:"external,omitempty" json:"external,omitempty"` + // Sentinels declares the event-time values this operator uses in gate conditions. + // Each sentinel is computed by the informer's UpdateFunc against oldObj/newObj + // and carried through the queue entry so both enqueueGate and reconcileGate + // can reference it. The informer computes only declared sentinels. + // + // Valid values: SentinelGenerationChanged, SentinelLabelsChanged, SentinelAnnotationsChanged, + // SentinelDeletionStarted, SentinelFinalizersChanged. + // + // ork validate fails if a sentinel is used in a gate template but not declared + // here, or if a sentinel is used outside the preReconcile context. + Sentinels []string `yaml:"sentinels,omitempty" json:"sentinels,omitempty"` + // EnqueueGate declares informer-level gate conditions evaluated in handleEvent // before the object enters the work queue. When the gate fires the object is // silently dropped — it never reaches the kordinator or reconciler. @@ -84,6 +285,30 @@ type PreReconcileConfig struct { ReconcileGate *GateConditions `yaml:"reconcileGate,omitempty" json:"reconcileGate,omitempty"` } +// DeclaredSentinels returns the sentinel names declared under preReconcile.sentinels. +// Returns nil when no sentinels are declared. Safe on nil receiver. +func (r *PreReconcileConfig) DeclaredSentinels() []string { + if r == nil { + return nil + } + return r.Sentinels +} + +// InvalidSentinels returns any sentinel values that are not valid Sentinel constants. +// Returns nil when all values are valid. Safe on nil receiver. +func (r *PreReconcileConfig) InvalidSentinels() []string { + if r == nil { + return nil + } + var invalid []string + for _, s := range r.Sentinels { + if !IsValidSentinel(s) { + invalid = append(invalid, s) + } + } + return invalid +} + // HasPreReconcileConditions reports whether reconcileGate has any when/anyOf conditions declared. func (r *PreReconcileConfig) HasPreReconcileConditions() bool { return r != nil && (r.ReconcileGate.HasConditions() || r.EnqueueGate.HasConditions()) @@ -114,6 +339,22 @@ func (r *PreReconcileConfig) HasReconcileGateExternal() bool { return r != nil && r.ReconcileGate != nil && len(r.ReconcileGate.External) > 0 } +// GateExternalCalls returns all external calls declared across enqueueGate and +// reconcileGate. Returns nil when the receiver is nil or neither gate has calls. +func (r *PreReconcileConfig) GateExternalCalls() [][]ExternalCallSpec { + if r == nil { + return nil + } + var phases [][]ExternalCallSpec + if calls := r.EnqueueGate.ExternalCalls(); len(calls) > 0 { + phases = append(phases, calls) + } + if calls := r.ReconcileGate.ExternalCalls(); len(calls) > 0 { + phases = append(phases, calls) + } + return phases +} + // WhenConditions returns the reconcileGate AND conditions, safe on nil receiver. func (r *PreReconcileConfig) WhenConditions() []Condition { if r == nil { @@ -202,6 +443,11 @@ func (r *ReconcilerConfig) HasHooksDecl() bool { return r.Hooks != nil } +// HasRetryBackoff reports whether a retryBackoff is declared on this reconciler's queue. +func (r *ReconcilerConfig) HasRetryBackoff() bool { + return r != nil && r.Queue.HasRetryBackoff() +} + // HasConstructorDecl reports whether a constructor declaration exists. func (r *ReconcilerConfig) HasConstructorDecl() bool { if r == nil { @@ -310,6 +556,13 @@ type OperatorBoxConfig struct { // Read before any resource groups — results available as .cross..status.* Cross []CrossCRDDeclaration `yaml:"cross,omitempty" json:"cross,omitempty"` + // Watch declares secondary Kubernetes resources Orkestra should watch. + // When a watched resource changes, Orkestra resolves the relevant primary + // CR key(s) and enqueues them. The reconciler runs normally — the watched + // resource's current state is available via .children.* as usual. + // nil → no secondary watches; only the primary CRD informer is active. + Watch []WatchEntry `yaml:"watch,omitempty" json:"watch,omitempty"` + // Autoscale declares runtime autoscale behavior for this operatorbox. // When declared, the autoscaler evaluates conditions on a ticker and applies // or restores worker/queue/resync overrides automatically. diff --git a/pkg/types/types_time.go b/pkg/types/types_time.go index bc2124cfb..af19d4cb3 100644 --- a/pkg/types/types_time.go +++ b/pkg/types/types_time.go @@ -1,9 +1,11 @@ package types import ( + "fmt" "time" "github.com/orkspace/orkestra/pkg/utils" + "gopkg.in/yaml.v3" ) // TimeWindow declares a clock-based active window. @@ -57,3 +59,71 @@ func (d *Duration) UnmarshalYAML(unmarshal func(interface{}) error) error { func (d Duration) MarshalYAML() (interface{}, error) { return d.Duration.String(), nil } + +// RetryBackoffConfig is declared under queue.retryBackoff or external[].retryBackoff. +// Shorthand: a plain duration string sets Initial only. +// Full form: set Initial, Max, Multiplier, and MaxAttempts individually. +type RetryBackoffConfig struct { + // Initial is the first backoff delay. Default: 500ms. + Initial Duration `yaml:"initial,omitempty" json:"initial,omitempty"` + // Max caps the delay so it does not grow unboundedly. Default: 30s. + Max Duration `yaml:"max,omitempty" json:"max,omitempty"` + // Multiplier scales the delay after each attempt. Default: 2.0. + Multiplier float64 `yaml:"multiplier,omitempty" json:"multiplier,omitempty"` + // MaxAttempts is the total number of calls including the first. Default: 3. + MaxAttempts int `yaml:"maxAttempts,omitempty" json:"maxAttempts,omitempty"` +} + +// UnmarshalYAML allows RetryBackoffConfig to be written as either a plain duration +// string (shorthand for initial only) or the full struct form. +// +// retryBackoff: 5s # shorthand — initial: 5s, defaults for the rest +// retryBackoff: # full form +// initial: 100ms +// max: 10m +// multiplier: 2.0 +// maxAttempts: 5 +func (r *RetryBackoffConfig) UnmarshalYAML(value *yaml.Node) error { + if value.Kind == yaml.ScalarNode { + d, err := utils.ParseTimeDuration(value.Value) + if err != nil { + return fmt.Errorf("retryBackoff: %w", err) + } + r.Initial = Duration{d} + return nil + } + // Full struct form — avoid infinite recursion with alias type. + type plain RetryBackoffConfig + return value.Decode((*plain)(r)) +} + +// ToRetryDoOptions converts the declaration into utils.RetryDoOptions, applying defaults. +func (r *RetryBackoffConfig) ToRetryDoOptions() utils.RetryDoOptions { + if r == nil { + return utils.RetryDoOptions{} + } + return utils.RetryDoOptions{ + Base: r.Initial.Duration, + Max: r.Max.Duration, + Multiplier: r.Multiplier, + MaxAttempts: r.MaxAttempts, + } +} + +// WorstCaseDuration returns the maximum wall time a full retry sequence can +// take, assuming no jitter. Used by the validator to compare against resync. +func (r *RetryBackoffConfig) WorstCaseDuration() time.Duration { + opts := r.ToRetryDoOptions() + opts.ApplyDefaults() + delay := opts.Base + var total time.Duration + for i := 1; i < opts.MaxAttempts; i++ { + total += delay + next := time.Duration(float64(delay) * opts.Multiplier) + if next > opts.Max { + next = opts.Max + } + delay = next + } + return total +} diff --git a/pkg/utils/retry.go b/pkg/utils/retry.go new file mode 100644 index 000000000..7f5e86b09 --- /dev/null +++ b/pkg/utils/retry.go @@ -0,0 +1,72 @@ +package utils + +import ( + "context" + "time" +) + +const DefaultRetryAttempts = 3 + +// RetryDoOptions controls the behaviour of Do. +type RetryDoOptions struct { + // MaxAttempts is the total number of calls to fn (including the first). Default: 3. + MaxAttempts int + // Base is the initial backoff duration. Default: 500ms. + Base time.Duration + // Max caps the backoff so it does not grow unboundedly. Default: 30s. + Max time.Duration + // Multiplier scales the delay after each failed attempt. Default: 2.0. + Multiplier float64 + // Retryable, when set, is called on every error. Returning false stops + // retrying immediately and returns the error to the caller. nil means + // every error is retryable. + Retryable func(error) bool +} + +func (o *RetryDoOptions) ApplyDefaults() { + if o.MaxAttempts <= 0 { + o.MaxAttempts = DefaultRetryAttempts + } + if o.Base <= 0 { + o.Base = 500 * time.Millisecond + } + if o.Max <= 0 { + o.Max = 30 * time.Second + } + if o.Multiplier <= 0 { + o.Multiplier = 2.0 + } +} + +// Do calls fn up to opts.MaxAttempts times with exponential backoff and ±50% +// jitter between attempts. ctx cancellation is honoured between attempts. If +// opts.Retryable is set and returns false the error is returned immediately +// without further attempts. +func Do[T any](ctx context.Context, opts RetryDoOptions, fn func(ctx context.Context) (T, error)) (T, error) { + opts.ApplyDefaults() + delay := opts.Base + var zero T + for attempt := 1; attempt <= opts.MaxAttempts; attempt++ { + val, err := fn(ctx) + if err == nil { + return val, nil + } + if opts.Retryable != nil && !opts.Retryable(err) { + return zero, err + } + if attempt == opts.MaxAttempts { + return zero, err + } + select { + case <-ctx.Done(): + return zero, ctx.Err() + case <-time.After(Jitter(delay)): + } + next := time.Duration(float64(delay) * opts.Multiplier) + if next > opts.Max { + next = opts.Max + } + delay = next + } + return zero, nil +} diff --git a/pkg/utils/retry_test.go b/pkg/utils/retry_test.go new file mode 100644 index 000000000..84e125f25 --- /dev/null +++ b/pkg/utils/retry_test.go @@ -0,0 +1,79 @@ +package utils + +import ( + "context" + "errors" + "testing" + "time" +) + +var errTransient = errors.New("transient") +var errFatal = errors.New("fatal") + +func TestDo_succeedsFirstAttempt(t *testing.T) { + calls := 0 + val, err := Do(context.Background(), RetryDoOptions{}, func(_ context.Context) (int, error) { + calls++ + return 42, nil + }) + if err != nil || val != 42 || calls != 1 { + t.Fatalf("got val=%d err=%v calls=%d", val, err, calls) + } +} + +func TestDo_retriesAndSucceeds(t *testing.T) { + calls := 0 + val, err := Do(context.Background(), RetryDoOptions{MaxAttempts: 3, Base: time.Millisecond}, func(_ context.Context) (int, error) { + calls++ + if calls < 3 { + return 0, errTransient + } + return 7, nil + }) + if err != nil || val != 7 || calls != 3 { + t.Fatalf("got val=%d err=%v calls=%d", val, err, calls) + } +} + +func TestDo_exhaustsAttempts(t *testing.T) { + calls := 0 + _, err := Do(context.Background(), RetryDoOptions{MaxAttempts: 3, Base: time.Millisecond}, func(_ context.Context) (int, error) { + calls++ + return 0, errTransient + }) + if err == nil || calls != 3 { + t.Fatalf("expected error after 3 attempts, got err=%v calls=%d", err, calls) + } +} + +func TestDo_nonRetryableStopsImmediately(t *testing.T) { + calls := 0 + _, err := Do(context.Background(), RetryDoOptions{ + MaxAttempts: 5, + Base: time.Millisecond, + Retryable: func(e error) bool { return !errors.Is(e, errFatal) }, + }, func(_ context.Context) (int, error) { + calls++ + return 0, errFatal + }) + if !errors.Is(err, errFatal) || calls != 1 { + t.Fatalf("expected fatal after 1 call, got err=%v calls=%d", err, calls) + } +} + +func TestDo_ctxCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + calls := 0 + _, err := Do(ctx, RetryDoOptions{MaxAttempts: 3, Base: time.Millisecond}, func(_ context.Context) (int, error) { + calls++ + return 0, errTransient + }) + // First call runs, then ctx is checked before the sleep — should not hit attempt 2. + if calls > 1 { + t.Fatalf("expected at most 1 call with cancelled ctx, got %d", calls) + } + if err == nil { + t.Fatal("expected non-nil error") + } +} diff --git a/website/hugo.toml b/website/hugo.toml index dc53e1e59..ac4fa8153 100644 --- a/website/hugo.toml +++ b/website/hugo.toml @@ -15,7 +15,7 @@ enableGitInfo = true startLevel = 2 [params] - description = "The Declarative Control Plane for Kubernetes operators" + description = "Kubernetes operators without the infrastructure." github = "https://github.com/orkspace/orkestra" controlcenter = "https://cc.orkestra.sh" version = "v1" diff --git a/website/layouts/index.html b/website/layouts/index.html index 0c9cfafa4..7c3a5b192 100644 --- a/website/layouts/index.html +++ b/website/layouts/index.html @@ -17,14 +17,14 @@ - Open Source · Zero Boilerplate + Open Source · Apache 2.0

- Kubernetes operators.
- Without the Go. + Kubernetes operators
+ without the infrastructure.

- Write a YAML file. Get a production-grade Kubernetes operator — with built-in reconciliation, drift correction, status propagation, and full observability. + Reconciliation as a runtime service. Security as a runtime service. Intent Delivery as a runtime service. Declare operator behavior — or keep your existing Reconcile function — and the runtime handles the rest.

@@ -43,27 +43,24 @@

- -
10 lines vs 400+
+
Infrastructure removed
hello-website/katalog.yaml - Complete operator · 10 lines + Complete operator · No Go required
apiVersion: orkestra.orkspace.io/v1
 kind: Katalog
 metadata:
   name: hello-website
-  author: orkspace
-  version: 0.1.0
-  description: One CRD. One Deployment. Full operator.
 
 spec:
   crds:
@@ -72,50 +69,34 @@ 

operatorBox: onCreate: deployments: - - image: "{{ "{{" }} .spec.image {{ "}}" }}"

+ - image: "{{ "{{" }} .spec.image {{ "}}" }}" + replicas: "{{ "{{" }} .spec.replicas {{ "}}" }}" + reconcile: true
-
+
- controllers/website_controller.go - ~400 lines — and growing + webapp_reconciler.go + Reconcile untouched · Two lines added
-
// This is just the Reconcile function.
-// You still need: main.go, types, RBAC,
-// CRD schema, Dockerfile, Helm chart...
+          
// Your Reconcile method: completely untouched.
+// Same signature. Same body. Same r.Get, r.Status().Update().
 
-func (r *WebsiteReconciler) Reconcile(
+func (r *WebAppReconciler) Reconcile(
   ctx context.Context,
   req ctrl.Request,
 ) (ctrl.Result, error) {
-
-  website := &appsv1.Website{}
-  if err := r.Get(ctx, req.NamespacedName, website); err != nil {
-    return ctrl.Result{}, client.IgnoreNotFound(err)
-  }
-
-  deploy := r.buildDeployment(website)
-  existing := &appsv1.Deployment{}
-  err := r.Get(ctx, types.NamespacedName{
-    Name: deploy.Name, Namespace: deploy.Namespace,
-  }, existing)
-  if errors.IsNotFound(err) {
-    if err := r.Create(ctx, deploy); err != nil {
-      return ctrl.Result{}, err
-    }
-  } else if err != nil {
-    return ctrl.Result{}, err
-  } else {
-    existing.Spec = deploy.Spec
-    if err := r.Update(ctx, existing); err != nil {
-      return ctrl.Result{}, err
-    }
-  }
-
-  // update status, handle finalizers,
-  // emit events, set owner refs...
-  // 300+ more lines
+  // ... your logic, unchanged ...
   return ctrl.Result{}, nil
+}
+
+// Two lines replace SetupWithManager, Scheme, and main.go.
+// Orkestra provides everything else.
+
+func NewWebAppReconciler(kube kubeclient.Interface) domain.Reconciler {
+  return domain.ReconcilerFrom(&WebAppReconciler{
+    Client: kubeclient.ToClient(kube),
+  })
 }
@@ -143,13 +124,116 @@

- 0 - lines of Go required + 2 lines + to migrate from controller-runtime
- 100% - declarative + 0 + infrastructure files to write +
+ + + + +
+
+
+ +

Start fresh or bring what you have

+

Every Kubernetes operator carries reconciliation infrastructure, security infrastructure, and intent delivery infrastructure. Orkestra absorbs all three — whether you are writing a new operator or migrating an existing one.

+
+ +
+ + +
+
+
+ + +
+ +
+
+ webapp_reconciler.go + Reconcile untouched · Two lines added +
+
// Before: SetupWithManager, Scheme, main.go,
+// informer setup, leader election lease,
+// health endpoints, RBAC ClusterRoles,
+// admission webhook server + TLS...
+
+// After: two lines. Reconcile is untouched.
+
+func NewWebAppReconciler(
+  kube kubeclient.Interface,
+) domain.Reconciler {
+  return domain.ReconcilerFrom(
+    &WebAppReconciler{
+      Client: kubeclient.ToClient(kube),
+    },
+  )
+}
+
+// Or: ork migrate ./controller/webapp_controller.go
+
+ +
+
+ intent.yaml + No apiVersion · No kind · No kubectl +
+
# The developer knows their vocabulary.
+# The gateway knows the CRD.
+# Neither has to learn the other's language.
+
+target: app
+name: payments-api
+repository: myorg/payments-api
+environment: staging
+team: payments
+
+
+ +
@@ -276,136 +360,67 @@

Live production visibility

- -
-
-
-
- -

Multiple CRDs, dependencies, status — still just YAML

-

Most real operators manage several related resources with startup ordering and status propagation. Orkestra handles all of it from a single Katalog file.

-
    -
  • - - Dependency ordering — database starts before application -
  • -
  • - - Status propagationphase, endpoint written automatically -
  • -
  • - - Drift correctionreconcile: true keeps resources in sync -
  • -
  • - - Resync interval — periodic reconciliation out of the box -
  • -
- - Learning to Orkestrate - - -
- -
-
-
- - - - katalog.yaml -
-
spec:
-  crds:
-
-    network:
-      operatorBox:
-        reconciler:
-          workers: 2
-
-    database:
-      operatorBox:
-        reconciler:
-          workers: 5
-      dependsOn:
-        network:
-          condition: healthy
-
-    application:
-      operatorBox:
-        reconciler:
-          workers: 3
-      dependsOn:
-        database:
-          condition: healthy
-
-
-
-
-
-
- -

Everything a production operator needs

-

One runtime. Every CRD pattern. No boilerplate.

+ +

Three kinds of infrastructure. Gone.

+

Everything normally surrounding Reconcile() — the cost of entry — is now the runtime's job.

- +
-

Declarative CRDs

-

Define your API schema in YAML. Orkestra auto-generates the CRD, registers it with the API server, and starts reconciling immediately.

+

Reconciliation infrastructure

+

Informers, workqueues, worker pools, leader election, retries, backoff, finalizers, status patching, panic recovery — declared in a Katalog, managed by the runtime. Per-CRD. Isolated.

Katalog schema
- +
-

Zero Boilerplate

-

No informers, workqueues, RBAC manifests, or controller-manager setup. Orkestra generates and manages all of it from your Katalog.

- Quick start +

Security infrastructure

+

Admission webhooks, validation rules, mutation rules, RBAC — declared in the Katalog. No webhook server to write. No TLS to manage. ork generate rbac derives ClusterRoles automatically.

+ Security docs
- +
-

Security Built-in

-

Deletion protection, namespace restrictions, admission webhooks, and minimal RBAC derived directly from your Katalog — no separate security layer to configure.

- Security docs +

Intent delivery infrastructure

+

serve.enabled: true opens any operator to callers who don't know Kubernetes. No apiVersion. No kind. No YAML. The gateway builds the CR, routes fields, translates values, and stamps provenance.

+ Gateway docs
-

Drift Correction

-

Mark any managed resource with reconcile: true — Orkestra detects and corrects configuration drift on every reconcile cycle.

+

Drift correction

+

Mark any managed resource with reconcile: true — Orkestra detects and corrects configuration drift on every reconcile cycle. No manual enforcement code.

OperatorBox schema
- +
-

Status Propagation

-

Declare status fields as template expressions. Orkestra evaluates and writes them after every successful reconcile — no custom code needed.

- Status schema +

Migration from controller-runtime

+

Your Reconcile method stays completely unchanged. Two lines in a constructor wire it into Orkestra. ork migrate injects the constructor automatically and scaffolds the full operator project.

+ Migration guide
-

Motif & Komposer

-

Compose reusable Motifs into Katalogs. Assemble full platform control planes from building blocks via the Orkestra Registry.

+

OCI distribution

+

Package operators as OCI artifacts. Publish with quality gates baked in — simulate status, e2e status, intent status. Distribute via any OCI registry or discover via Artifact Hub.

Orkestra Registry
@@ -417,29 +432,29 @@

Motif & Komposer

-

From zero to operator in three steps

+

From operator to running in three steps

01
-

Write your Katalog

-

Define the CRD schema, the resources it manages, lifecycle hooks, and status fields — all in a single YAML file. No Go required.

+

Declare or migrate

+

Write a Katalog to declare new operator behavior — or run ork migrate against an existing controller-runtime file. Either way, the reconciliation, security, and delivery infrastructure is declared, not written.

02

Run the Orkestra CLI

-

ork run
Orkestra registers the CRD, starts the controller, and launches Control Center automatically.

+

ork run
Orkestra registers the CRD, starts the controller, and launches Control Center automatically. No cluster? ork run --dev provisions one.

03
-

Orkestra manages everything

-

Apply a CR and watch Orkestra reconcile child resources, emit events, update status conditions, and self-heal on drift — all from your YAML declaration.

+

The runtime manages everything

+

Apply a CR and watch Orkestra reconcile child resources, emit events, update status, enforce admission rules, correct drift, and expose health and metrics — all from your declaration.

@@ -450,8 +465,8 @@

Orkestra manages everything

-

Ready to build your first operator?

-

We're in early access and iterating fast. Start with the getting-started guide — and tell us what doesn't work.

+

The infrastructure is the runtime's job.

+

We're in early access and iterating fast. Start with the getting-started guide — or bring your existing operator and run ork migrate.

Read the Docs diff --git a/website/layouts/partials/footer.html b/website/layouts/partials/footer.html index d1856dea2..c55d6f3fe 100644 --- a/website/layouts/partials/footer.html +++ b/website/layouts/partials/footer.html @@ -5,7 +5,7 @@ Orkestra Orkestra - +