diff --git a/CHANGELOG.md b/CHANGELOG.md index 422a350ca..61ef992b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -## v0.7.16 — Per-Target OperatorBox, MuxReconciler, and controller-runtime Compatibility [UNRELEASED] +## v0.7.16 — Per-Target OperatorBox, MuxReconciler, and controller-runtime Compatibility ### Per-target operatorBox @@ -177,6 +177,101 @@ operatorBox: --- +### Breaking: `anyOf:` → `or:` everywhere + +`anyOf:` is renamed to `or:` across the entire schema. Semantics unchanged — reads naturally alongside `when:`: "when this AND this, or this OR this." + +--- + +### Breaking: `domain.Reconciler` interface — `key string` → `Request` / `Result` + +The `Reconcile` method signature changed: + +```go +// before +Reconcile(ctx context.Context, key string) error + +// after +Reconcile(ctx context.Context, req domain.Request) (domain.Result, error) +``` + +`domain.Request` carries `req.Key` (the old string) and `req.NamespacedName` for convenience. `domain.Result.RequeueAfter` lets a reconciler schedule a precise per-object re-enqueue after a successful reconcile — the kordinator calls `queue.AddAfter` when it is non-zero. + +The `domain.ReconcilerFrom` bridge now forwards `ctrl.Result.RequeueAfter` from a wrapped `reconcile.Reconciler`, so migrated operators that returned `ctrl.Result{RequeueAfter: time.Until(cert.Expiry)}` have that timing honored without any code change. + +--- + +### `failPolicy:` on `enqueueGate` and `reconcileGate` + +Controls what a gate does when it cannot evaluate its conditions — for example when an `external:` call fails or times out. + +```yaml +preReconcile: + reconcileGate: + failPolicy: closed # hold back on evaluation failure + external: + - name: dep + url: "{{ .spec.dependencyUrl }}/health" + when: + - field: external.dep.status + equals: "200" +``` + +| Value | Behaviour | +|-------|-----------| +| `open` (default) | Gate passes on failure — object is enqueued / reconciled as normal. | +| `closed` | Gate holds on failure — object is dropped from the queue / withheld from the reconciler. | + +The validator warns when `external:` is declared on a gate without an explicit `failPolicy`. + +--- + +### `ToClient` reads from the informer store + +`kubeclient.ToClient(kube)` now serves `client.Get` and `client.List` from the informer store for any type that has a registered informer (the primary CRD and every `watch:` entry). Types without an informer fall through to a live API call with a debug log. This restores the cache-backed read behaviour that controller-runtime's `mgr.GetClient()` provided before migration. + +Declare a `watch:` entry for any secondary resource type the reconciler reads — the declaration registers both the re-enqueue watch and the cache. + +--- + +### `requeue:` — per-object requeue scheduling for declarative operators + +Orkestra's resync fires all objects on a uniform interval. `requeue:` adds per-object, per-state requeue timing — each object schedules its own next reconcile based on its own fields. This makes declarative operators precise where resync is blunt. + +```yaml +operatorBox: + reconciler: + requeue: + after: "{{ timeUntil .status.certExpiry }}" # per-object, from its own state + when: + - field: status.phase + equals: "Active" +``` + +Evaluated after a successful reconcile only — errors go through `queue.retryBackoff`. `after:` takes a plain duration string or a template expression; the full post-reconcile resolver is available (`.spec`, `.status`, `.children`, `.external`). + +--- + +### Breaking: `hooks.resources` / `constructor.resources` → `managedResources` + +The `resources:` key under `reconciler.hooks` and `reconciler.constructor` is renamed to `managedResources:`. This aligns the YAML key with the Go method naming (`AllManagedResources`, `HookManagedResources`, `ConstructorManagedResources`) and removes the ambiguity between managed resources (RBAC + implicit informer) and the `watch:` block (secondary informers for re-enqueue). + +Update all Katalog files: + +```yaml +# before +hooks: + resources: + - kind: Deployment + +# after +hooks: + managedResources: + - kind: Deployment +``` + +--- + ### Registry guide examples 13–16 Four new self-contained steps extend the registry guide: @@ -375,7 +470,7 @@ operatorBox: when: - field: "{{ .spec.enabled }}" equals: "true" - anyOf: + or: - field: "{{ .spec.environment }}" equals: "production" - field: "{{ .spec.environment }}" @@ -411,7 +506,7 @@ Calls run in order — shared first, then gate-level. Each call's results are in Both gates use the full resolver chain (`.spec`, `.metadata`, serve intent, profiles, notes). Logic lives in `pkg/katalog` (`EvaluatePreReconcile`, `EvaluateEnqueueFilter`) and is called via registered closures so neither the informer factory nor the kordinator has a direct katalog dependency. -`EvaluateWhen` renamed to `EvaluateConditions` — the function evaluates both `when:` (AND) and `anyOf:` (OR), so the name now reflects what it does. +`EvaluateWhen` renamed to `EvaluateConditions` — the function evaluates both `when:` (AND) and `or:` (OR), so the name now reflects what it does. **`gated` state in Control Center** — separate from healthy/degraded. Purple badge with gate reason. `StatusCounts.Gated` propagates through the full CC chain. @@ -1143,11 +1238,11 @@ serve: # message: "Branch / Tag is required", action: deny } ``` -The synthesized rule inherits the field's own `when:`/`anyOf:`, so a field required only under one branch of a discriminator (e.g. `workloadType: app`) stays conditionally required — not unconditionally — matching what a static CRD schema's `required: [...]` list can't express. +The synthesized rule inherits the field's own `when:`/`or:`, so a field required only under one branch of a discriminator (e.g. `workloadType: app`) stays conditionally required — not unconditionally — matching what a static CRD schema's `required: [...]` list can't express. ### Fix: `operator: in` was never evaluated in `validation.rules` -`operator: in` was defined for `when:`/`anyOf:` conditions but missing from the separate rule-evaluation switch in both the reconciler and the admission webhook — a `validation.rules` entry using it silently always passed instead of checking comma-separated membership. Both now evaluate it. +`operator: in` was defined for `when:`/`or:` conditions but missing from the separate rule-evaluation switch in both the reconciler and the admission webhook — a `validation.rules` entry using it silently always passed instead of checking comma-separated membership. Both now evaluate it. The reconciler and the webhook no longer maintain separate copies of validation-rule evaluation, shorthand resolution, and field lookup — all now shared from `pkg/types` (`EvaluateValidationRule`, `ResolveValidationOp`, `ResolveScalarField`). That duplication is exactly how `operator: in` went unimplemented in both places at once. @@ -1214,11 +1309,11 @@ Every `onCreate`/`onReconcile`/`onDelete` resource declaration (deployments, ser ### New condition operators: `gte`, `lte`, `between`, `notBetween`, `notIn`, `notContains`, `regex` -Available in both `when:`/`anyOf:` and `validation.rules`, with shorthand fields matching each operator name. Also fixes a real bug: `validation.rules`' `gt`/`lt` were accidentally inclusive when used explicitly (`Min`/`Max` shared their evaluation case) — `Min`/`Max` now resolve to the new `gte`/`lte` unchanged, and explicit `gt`/`lt` are properly strict. An unknown `operator:` value in `validation.rules`/`mutation.rules` is now rejected at katalog-load time instead of silently never matching. +Available in both `when:`/`or:` and `validation.rules`, with shorthand fields matching each operator name. Also fixes a real bug: `validation.rules`' `gt`/`lt` were accidentally inclusive when used explicitly (`Min`/`Max` shared their evaluation case) — `Min`/`Max` now resolve to the new `gte`/`lte` unchanged, and explicit `gt`/`lt` are properly strict. An unknown `operator:` value in `validation.rules`/`mutation.rules` is now rejected at katalog-load time instead of silently never matching. ### `operator: unique` — designed and deferred until stable, now implemented -`unique` was designed and declared as a valid operator from its introduction, with enforcement deliberately deferred until it could be checked safely — a rule using it silently always passed in the meantime, in both `validation.rules` and `when:`/`anyOf:`. Now implemented at both enforcement points: +`unique` was designed and declared as a valid operator from its introduction, with enforcement deliberately deferred until it could be checked safely — a rule using it silently always passed in the meantime, in both `validation.rules` and `when:`/`or:`. Now implemented at both enforcement points: - **Reconcile time** — the reconciler injects a live checker (`template.Resolver.WithUniquenessChecker`) that lists other instances of the CRD via the API server and denies/gates on a matching field value, excluding the CR under evaluation. Authoritative — immune to cache staleness. - **Admission time** — the gateway injects its own checker (`pkg/gateway/webhook/uniqueness.go`), backed by an HTTP call to the runtime's own `GET /katalog/{crd}/cr?field=` endpoint (new `?field=` support) instead of a live `List()` — the runtime already has this data in its informer cache. Deliberately a fast, best-effort early-rejection layer, not a second source of truth: the cache can be momentarily stale, so a duplicate can still slip past admission in a race, but it's caught on the next reconcile regardless — the reconcile-time guarantee never depends on admission catching it first. @@ -1227,7 +1322,7 @@ Available in both `when:`/`anyOf:` and `validation.rules`, with shorthand fields ### e2e output assertions now use the stable `Condition` evaluator — formerly deferred until stable -e2e's shell-command and kubectl-output assertions (`equals`, `contains`, `regex`, `oneOf`, …) were kept hand-rolled and separate from `when:`/`anyOf:` until the shared `Condition`/`EvaluateOneCond` evaluator stabilized. Now unified — `pkg/registry/e2e`'s assertion logic delegates to `EvaluateOneCond` per field, so e2e assertions gain every `when:`/`anyOf:` operator (`gte`, `between`, `regex`, …) for free and can no longer drift from that behavior. Field names and error messages are unchanged. +e2e's shell-command and kubectl-output assertions (`equals`, `contains`, `regex`, `oneOf`, …) were kept hand-rolled and separate from `when:`/`or:` until the shared `Condition`/`EvaluateOneCond` evaluator stabilized. Now unified — `pkg/registry/e2e`'s assertion logic delegates to `EvaluateOneCond` per field, so e2e assertions gain every `when:`/`or:` operator (`gte`, `between`, `regex`, …) for free and can no longer drift from that behavior. Field names and error messages are unchanged. ### `serve.fields`/`serve labels/annotations` `link:` — a clean display field, decoupled from the evaluation expression @@ -1357,7 +1452,7 @@ notes: ### `notPrefix`/`notSuffix` condition operators -`when:`/`anyOf:`/`validation.rules` had `prefix`/`suffix` and `notEquals`/`notContains`/`notIn`, but no negated prefix/suffix — a real gap, since RE2 (Go's regex engine) can't express "does not end with X" as a pattern either (no lookahead/lookbehind), leaving no shorthand way to write a rule like "reject `:latest` image tags". `notPrefix`/`notSuffix` close it, evaluated identically everywhere `Condition`/`ValidationRule` already are. +`when:`/`or:`/`validation.rules` had `prefix`/`suffix` and `notEquals`/`notContains`/`notIn`, but no negated prefix/suffix — a real gap, since RE2 (Go's regex engine) can't express "does not end with X" as a pattern either (no lookahead/lookbehind), leaving no shorthand way to write a rule like "reject `:latest` image tags". `notPrefix`/`notSuffix` close it, evaluated identically everywhere `Condition`/`ValidationRule` already are. ### `gateway.api.auth.include:` — external token file @@ -1526,7 +1621,7 @@ When `field:` is a template expression, the resolved value is used directly in t ### Conditional validation and mutation rules -`when:` and `anyOf:` conditions are now supported on `validation.rules` and `mutation.rules` entries. A rule whose conditions do not match is skipped entirely — no violation is recorded, no log entry is emitted. +`when:` and `or:` conditions are now supported on `validation.rules` and `mutation.rules` entries. A rule whose conditions do not match is skipped entirely — no violation is recorded, no log entry is emitted. ```yaml validation: @@ -1543,7 +1638,7 @@ validation: operator: exists message: "spec.repoURL is required for app and monitoring workloads" action: deny - anyOf: + or: - field: spec.workloadType equals: app - field: spec.workloadType @@ -1552,11 +1647,11 @@ validation: Conditions are evaluated using the same `EvaluateConditions` engine as template `when:` blocks. Works for both typed and unstructured CRDs — the typed CRD limitation (previously documented as "use Go hooks") is removed. Both `applyReconcileTimeValidation` and `applyReconcileTimeMutation` now use `resolver.Data()` which handles typed CRDs via JSON round-trip. -Admission webhook rules honour `when:` and `anyOf:` in the same way as reconcile-time rules. +Admission webhook rules honour `when:` and `or:` in the same way as reconcile-time rules. ### `serve.fields..required` — browser-native form field enforcement -Setting `required: true` on an serve field marks it as mandatory in the Control Center form. The browser enforces it natively — the label shows an asterisk and the form cannot be submitted while the field is empty. Fields hidden by a `when:` or `anyOf:` condition are automatically excluded from browser constraint validation. +Setting `required: true` on an serve field marks it as mandatory in the Control Center form. The browser enforces it natively — the label shows an asterisk and the form cannot be submitted while the field is empty. Fields hidden by a `when:` or `or:` condition are automatically excluded from browser constraint validation. ```yaml serve: @@ -1624,7 +1719,7 @@ hooks: featureEnabled: '{{ .external.flags.body }}' ``` -All `external:` capabilities available to declarative operators — `when:`, `anyOf:`, `continueOnError`, timeout — apply to hook external calls without additional plumbing. Constructor authors who need external data make the call themselves and pass the URL via `args:`. +All `external:` capabilities available to declarative operators — `when:`, `or:`, `continueOnError`, timeout — apply to hook external calls without additional plumbing. Constructor authors who need external data make the call themselves and pass the URL via `args:`. ### Cluster-scoped GC and always-on finalizer diff --git a/cmd/cli/admission_eval.go b/cmd/cli/admission_eval.go index 7a463a2f2..a63d7cbaf 100644 --- a/cmd/cli/admission_eval.go +++ b/cmd/cli/admission_eval.go @@ -79,7 +79,7 @@ func evalAdmissionValidation(obj map[string]interface{}, crd *orktypes.CRDEntry, } result := admissionValidationResult{total: len(crd.Validation.Rules)} for _, rule := range crd.Validation.Rules { - if !orktypes.EvaluateConditions(obj, rule.When, rule.AnyOf, eval) { + if !orktypes.EvaluateConditions(obj, rule.When, rule.Or, eval) { result.passed++ continue } @@ -105,7 +105,7 @@ func evalAdmissionMutation(obj map[string]interface{}, crd *orktypes.CRDEntry, r return result } for _, rule := range crd.Mutation.Rules { - if !orktypes.EvaluateConditions(obj, rule.When, rule.AnyOf, eval) { + if !orktypes.EvaluateConditions(obj, rule.When, rule.Or, eval) { continue } field := rule.Field diff --git a/cmd/cli/helper.go b/cmd/cli/helper.go index 263cb93ec..ff84947bf 100644 --- a/cmd/cli/helper.go +++ b/cmd/cli/helper.go @@ -442,9 +442,9 @@ func printCRDDetail(crd orktypes.CRDEntry, g *katalog.DependencyGraph) { printConditionLine(" ", cond) } } - if len(a.Conditions.AnyOf) > 0 { - fmt.Printf(" AnyOf (OR):\n") - for _, cond := range a.Conditions.AnyOf { + if len(a.Conditions.Or) > 0 { + fmt.Printf(" Or (OR):\n") + for _, cond := range a.Conditions.Or { printConditionLine(" ", cond) } } diff --git a/cmd/cli/init_packs.go b/cmd/cli/init_packs.go index 4277bfe87..a850c0f9a 100644 --- a/cmd/cli/init_packs.go +++ b/cmd/cli/init_packs.go @@ -27,7 +27,7 @@ var Packs = map[string]Pack{ }, "intermediate": { Name: "intermediate", - Description: "Multi-resource patterns, when/anyOf, Komposer basics.", + Description: "Multi-resource patterns, when/or, Komposer basics.", Path: "intermediate", Order: 2, }, diff --git a/cmd/controlcenter/cc/assets/templates/idp_form.html b/cmd/controlcenter/cc/assets/templates/idp_form.html index 8b4c66d01..0e32d1010 100644 --- a/cmd/controlcenter/cc/assets/templates/idp_form.html +++ b/cmd/controlcenter/cc/assets/templates/idp_form.html @@ -275,7 +275,7 @@

Create {{ .Kind }}

+ {{ if .OrJSON }}data-or='{{ .OrJSON }}'{{ end }}> {{ if eq .InputType "checkbox" }}
@@ -392,14 +392,14 @@

Create {{ .Kind }}

function fieldVisible(wrap) { var when = wrap.dataset.when ? JSON.parse(wrap.dataset.when) : null; - var anyOf = wrap.dataset.anyof ? JSON.parse(wrap.dataset.anyof) : null; + var or = wrap.dataset.or ? JSON.parse(wrap.dataset.or) : null; if (when && when.length && !when.every(evalCond)) return false; - if (anyOf && anyOf.length && !anyOf.some(evalCond)) return false; + if (or && or.length && !or.some(evalCond)) return false; return true; } function applyVisibility() { - document.querySelectorAll('.idp-field[data-when], .idp-field[data-anyof]').forEach(function (wrap) { + document.querySelectorAll('.idp-field[data-when], .idp-field[data-or]').forEach(function (wrap) { wrap.style.display = fieldVisible(wrap) ? '' : 'none'; }); // A category heading with no currently-visible fields underneath is just diff --git a/cmd/controlcenter/cc/controlcenter.go b/cmd/controlcenter/cc/controlcenter.go index 6bd7d6338..e4269d93c 100644 --- a/cmd/controlcenter/cc/controlcenter.go +++ b/cmd/controlcenter/cc/controlcenter.go @@ -1022,7 +1022,7 @@ type serveFieldHint struct { Required bool `json:"required"` Disabled string `json:"disabled"` When []json.RawMessage `json:"when"` - AnyOf []json.RawMessage `json:"anyOf"` + Or []json.RawMessage `json:"or"` Type string `json:"type"` Enum []string `json:"enum"` } @@ -1048,15 +1048,15 @@ func buildServeField(name string, hint serveFieldHint) ServeField { Category: hint.Category, Disabled: hint.Disabled, } - // Encode when/anyOf as JSON strings for the template to embed as data attributes. + // Encode when/or as JSON strings for the template to embed as data attributes. if len(hint.When) > 0 { if b, err := json.Marshal(hint.When); err == nil { f.WhenJSON = string(b) } } - if len(hint.AnyOf) > 0 { - if b, err := json.Marshal(hint.AnyOf); err == nil { - f.AnyOfJSON = string(b) + if len(hint.Or) > 0 { + if b, err := json.Marshal(hint.Or); err == nil { + f.OrJSON = string(b) } } switch { diff --git a/cmd/controlcenter/cc/idp_fields_test.go b/cmd/controlcenter/cc/idp_fields_test.go index a7f294551..8f14f197f 100644 --- a/cmd/controlcenter/cc/idp_fields_test.go +++ b/cmd/controlcenter/cc/idp_fields_test.go @@ -53,10 +53,10 @@ func TestBuildServeField_Required(t *testing.T) { } } -func TestBuildServeField_WhenAnyOfEncodedAsJSON(t *testing.T) { +func TestBuildServeField_WhenOrEncodedAsJSON(t *testing.T) { hint := serveFieldHint{ - When: []json.RawMessage{json.RawMessage(`{"field":"spec.workloadType","equals":"app"}`)}, - AnyOf: []json.RawMessage{json.RawMessage(`{"field":"spec.workloadType","equals":"cert"}`)}, + When: []json.RawMessage{json.RawMessage(`{"field":"spec.workloadType","equals":"app"}`)}, + Or: []json.RawMessage{json.RawMessage(`{"field":"spec.workloadType","equals":"cert"}`)}, } f := buildServeField("repoURL", hint) @@ -68,12 +68,12 @@ func TestBuildServeField_WhenAnyOfEncodedAsJSON(t *testing.T) { t.Errorf("WhenJSON decoded to %v, want the single when: condition", when) } - var anyOf []map[string]string - if err := json.Unmarshal([]byte(f.AnyOfJSON), &anyOf); err != nil { - t.Fatalf("AnyOfJSON did not round-trip as JSON: %v", err) + var or []map[string]string + if err := json.Unmarshal([]byte(f.OrJSON), &or); err != nil { + t.Fatalf("OrJSON did not round-trip as JSON: %v", err) } - if len(anyOf) != 1 || anyOf[0]["equals"] != "cert" { - t.Errorf("AnyOfJSON decoded to %v, want the single anyOf: condition", anyOf) + if len(or) != 1 || or[0]["equals"] != "cert" { + t.Errorf("OrJSON decoded to %v, want the single or: condition", or) } } diff --git a/cmd/controlcenter/cc/types.go b/cmd/controlcenter/cc/types.go index 6b888de0f..edc380522 100644 --- a/cmd/controlcenter/cc/types.go +++ b/cmd/controlcenter/cc/types.go @@ -267,7 +267,7 @@ type ServeField struct { Required bool Category string // section heading for visual grouping WhenJSON string // JSON array of Condition — all must be true (AND) - AnyOfJSON string // JSON array of Condition — at least one must be true (OR) + OrJSON string // JSON array of Condition — at least one must be true (OR) Disabled string // non-empty → greyed-out field with this message } diff --git a/cmd/controlcenter/docs/06-idp-form.md b/cmd/controlcenter/docs/06-idp-form.md index 7696ebaaf..0eec326a0 100644 --- a/cmd/controlcenter/docs/06-idp-form.md +++ b/cmd/controlcenter/docs/06-idp-form.md @@ -39,7 +39,7 @@ Browser GET /controlcenter/katalog/{kat}/crd/{crd}/cr/create field.order → sort order (0/unset sorts last) field.category → section heading (fields with no category group under a single default "Fields" section) - field.when/anyOf → data-when/data-anyof — evaluated client-side to + field.when/or → data-when/data-or — evaluated client-side to show/hide the field as the form is filled Browser POST /controlcenter/katalog/{kat}/crd/{crd}/cr/create diff --git a/cmd/internal/runtime_konstructor.go b/cmd/internal/runtime_konstructor.go index b87741f6b..bb2a01a1f 100644 --- a/cmd/internal/runtime_konstructor.go +++ b/cmd/internal/runtime_konstructor.go @@ -93,7 +93,7 @@ // After reconcileImpl: // patchStatusWithChildren(ctx, obj, err) // ├── ReadChildren → .children.* (API server, parallel, RV="0") -// ├── resolveStatusFields(when:, anyOf:, template expressions) +// ├── resolveStatusFields(when:, or:, template expressions) // └── PATCH /status package internal @@ -442,7 +442,9 @@ func konstructRuntime(kfg *konfig.Konfig, m *merger.Merger, ctx context.Context) // kube client. Constructor authors access them via kube.GetInformer() etc. var ctorKube kubeclient.Interface = kube. WithInformer(infCopy). - WithEventRecorder(ev) + WithEventRecorder(ev). + WithStoreFor(infFactory.StoreFor). + WithIndexerFor(infFactory.IndexerFor) if args := crd.ConstructorArgs(); len(args) > 0 { ctorKube = ctorKube.WithArgs(kubeclient.Args(args)) } @@ -464,7 +466,9 @@ func konstructRuntime(kfg *konfig.Konfig, m *merger.Merger, ctx context.Context) for targetName, ctor := range crdCopy.TargetReconcilerFactories { var targetKube kubeclient.Interface = kube. WithInformer(infCopy). - WithEventRecorder(ev) + WithEventRecorder(ev). + WithStoreFor(infFactory.StoreFor). + WithIndexerFor(infFactory.IndexerFor) if args := crdCopy.TargetConstructorArgs(targetName); len(args) > 0 { targetKube = targetKube.WithArgs(kubeclient.Args(args)) } diff --git a/documentation/concepts/conditional/01-resource-conditions.md b/documentation/concepts/conditional/01-resource-conditions.md index d781851c2..62ad8483b 100644 --- a/documentation/concepts/conditional/01-resource-conditions.md +++ b/documentation/concepts/conditional/01-resource-conditions.md @@ -1,6 +1,6 @@ # Resource Conditions -Attach a `when:` or `anyOf:` block to any resource declaration under `onCreate`, `onReconcile`, or `onDelete`. Orkestra evaluates it before creating, updating, or skipping that resource. +Attach a `when:` or `or:` block to any resource declaration under `onCreate`, `onReconcile`, or `onDelete`. Orkestra evaluates it before creating, updating, or skipping that resource. --- @@ -42,10 +42,10 @@ Both must be true for the resource to be created. --- -## OR conditions with `anyOf:` +## OR conditions with `or:` ```yaml -anyOf: +or: - field: spec.environment equals: "production" - field: spec.forceExpose diff --git a/documentation/concepts/conditional/04-conditional-reconciliation.md b/documentation/concepts/conditional/04-conditional-reconciliation.md index 661427094..fdc44395f 100644 --- a/documentation/concepts/conditional/04-conditional-reconciliation.md +++ b/documentation/concepts/conditional/04-conditional-reconciliation.md @@ -51,7 +51,7 @@ With this configuration: All condition operators available in resource-level `when:` blocks are available here — `equals`, `contains`, `exists`, `gt`/`lt`, `in`, `regex`, and more. See the [full operator reference](../../reference/schema/02-katalog/06-when-conditions.md#operators). -`anyOf:` (OR semantics) is also supported alongside `when:` (AND semantics): +`or:` (OR semantics) is also supported alongside `when:` (AND semantics): ```yaml preReconcile: @@ -59,7 +59,7 @@ preReconcile: when: - field: "{{ .spec.enabled }}" equals: "true" - anyOf: + or: - field: "{{ .spec.environment }}" equals: "production" - field: "{{ .spec.environment }}" @@ -106,6 +106,31 @@ Use `enqueueGate` when you want zero queue pressure for objects that should be c --- +## `failPolicy` — gate behaviour on evaluation failure + +When a gate includes `external:` calls, the evaluation can fail — the endpoint is down, the call times out. `failPolicy:` controls what the gate does in that case. + +```yaml +preReconcile: + reconcileGate: + failPolicy: closed # evaluation failure → hold back, do not reconcile + external: + - name: depHealth + url: "{{ .spec.dependencyUrl }}/health" + when: + - field: external.depHealth.status + equals: "200" +``` + +| Value | Behaviour | +|-------|-----------| +| `open` (default) | Evaluation failure passes the gate — CR is enqueued or reconciled as normal. Safe default for `enqueueGate`. | +| `closed` | Evaluation failure holds the gate — CR is dropped or held back. Use on `reconcileGate` when reconciling against an unknown state is worse than skipping a cycle. | + +`ork validate` warns when `external:` calls are declared without an explicit `failPolicy:` — the default `open` may not be the intent for `reconcileGate`. + +--- + ## 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?" diff --git a/documentation/concepts/conditional/index.md b/documentation/concepts/conditional/index.md index 5afa8cce3..04bd828f7 100644 --- a/documentation/concepts/conditional/index.md +++ b/documentation/concepts/conditional/index.md @@ -2,7 +2,7 @@ Conditionals are the logic layer in Orkestra. They let you express *when* something should happen — without writing Go code. -A conditional is a `when:` or `anyOf:` block attached to a resource, a status field, or a hook. Orkestra evaluates it on every reconcile. If the condition passes, the block runs. If it fails, the block is skipped cleanly — no error, no partial state. +A conditional is a `when:` or `or:` block attached to a resource, a status field, or a hook. Orkestra evaluates it on every reconcile. If the condition passes, the block runs. If it fails, the block is skipped cleanly — no error, no partial state. --- @@ -25,14 +25,14 @@ This service is created only when `exposePublicly` is true **and** `environment` --- -## `anyOf:` — OR semantics +## `or:` — OR semantics At least one condition must pass. ```yaml services: - name: "{{ .metadata.name }}-svc" - anyOf: + or: - field: spec.environment equals: "production" - field: spec.forceExpose diff --git a/documentation/concepts/conversion/01-normalize.md b/documentation/concepts/conversion/01-normalize.md index 96ebb0427..b76f217b4 100644 --- a/documentation/concepts/conversion/01-normalize.md +++ b/documentation/concepts/conversion/01-normalize.md @@ -140,4 +140,4 @@ This runs the CronJob operator accepting both schedule formats. Apply both CRs a ## Where to go next - **[conversion.paths:](./02-conversion-paths.md)** — when the API server needs to know about two versions -- **[Conditionals](../conditional/index.md)** — `when:` and `anyOf:` in depth +- **[Conditionals](../conditional/index.md)** — `when:` and `or:` in depth diff --git a/documentation/concepts/e2e/index.md b/documentation/concepts/e2e/index.md index 22771f3cb..29fdfcb09 100644 --- a/documentation/concepts/e2e/index.md +++ b/documentation/concepts/e2e/index.md @@ -82,7 +82,7 @@ imports: - ./01-multi-region/e2e.yaml - ./03-cross-crd/e2e.yaml - ./04-once-secret/e2e.yaml - - ./05-anyof/e2e.yaml + - ./05-or/e2e.yaml ``` One `ork e2e` runs the whole suite — one cluster, four tests, sequential. The suite file has no test of its own; it exists only to compose. diff --git a/documentation/concepts/index.md b/documentation/concepts/index.md index 527938c74..047205699 100644 --- a/documentation/concepts/index.md +++ b/documentation/concepts/index.md @@ -80,7 +80,7 @@ The [Reconciler Model](reconciler-model/) is how a CR becomes a running resource ## Ordered Deletion -[Ordered Deletion](ordered-deletion/) controls the sequence in which child resources are torn down when a CR is deleted. Two models: hard ordered (finalizer held, sequential groups) and condition-based (non-blocking, `when:`/`anyOf:` conditions). +[Ordered Deletion](ordered-deletion/) controls the sequence in which child resources are torn down when a CR is deleted. Two models: hard ordered (finalizer held, sequential groups) and condition-based (non-blocking, `when:`/`or:` conditions). → [Read: Ordered Deletion](ordered-deletion/) @@ -152,7 +152,7 @@ The [Health Subsystem](health-subsystem/) is Orkestra's liveness, readiness, and ## Conditionals -[Conditionals](conditional/) are the logic layer — `when:` and `anyOf:` blocks that control when a resource is created, when a status field is written, and how multi-phase async workflows sequence themselves. Works in Katalogs and Motifs. +[Conditionals](conditional/) are the logic layer — `when:` and `or:` blocks that control when a resource is created, when a status field is written, and how multi-phase async workflows sequence themselves. Works in Katalogs and Motifs. → [Read: Conditionals](conditional/) diff --git a/documentation/concepts/lifecycle/index.md b/documentation/concepts/lifecycle/index.md index 8f8cd5f77..fe3b1822f 100644 --- a/documentation/concepts/lifecycle/index.md +++ b/documentation/concepts/lifecycle/index.md @@ -1,10 +1,8 @@ # Lifecycle in Orkestra -OLM exists because operators were binaries. When the unit of distribution is a binary, lifecycle management becomes a separate problem — you need a separate system to package, version, upgrade, and deprecate it. OLM is that system for the Kubernetes operator world. +An Orkestra operator is a Katalog — a YAML artifact, not a binary. That changes where lifecycle management lives. Maturity, compatibility, deprecation, and deletion protection are fields in the Katalog itself. They travel with the artifact from first push to final end-of-life, and are enforced by the same tooling that validates and runs it. -Orkestra removes the problem at the root. The operator is not a binary — it is a pattern. Patterns are data. Data's lifecycle is not an external concern to manage. It is a built-in property of the artifact. - -!!! tip "Lifecycle follows production" +!!! tip "Lifecycle follows the artifact" The same model that bakes tests into the artifact at publish time is the model that governs maturity, upgrade, compatibility, deprecation, and deletion. There is no separate lifecycle system to install. You write patterns, and the lifecycle comes with them. --- @@ -239,8 +237,3 @@ Per-CRD overrides let you opt individual CRDs out of CR-level or CRD-level prote --- -## Why this matters - -OLM solved the right problem for its era. When operators were binaries, lifecycle management had to be external — and OLM built exactly the system that required: its own controllers, its own CRDs, its own installation lifecycle. The overhead was the necessary cost of the binary constraint. - -Orkestra makes the question moot. The operator is a Katalog. Maturity, compatibility, deprecation, and deletion protection are fields in that Katalog. There are no new CRDs, no new controllers, no lifecycle stack to operate. The lifecycle is not a process running somewhere else. It is a record traveling with the artifact — from first push to final EOL. diff --git a/documentation/concepts/operator-autoscaler/01-autoscaler-condition-engine.md b/documentation/concepts/operator-autoscaler/01-autoscaler-condition-engine.md index de745e883..81b90b9cc 100644 --- a/documentation/concepts/operator-autoscaler/01-autoscaler-condition-engine.md +++ b/documentation/concepts/operator-autoscaler/01-autoscaler-condition-engine.md @@ -16,14 +16,14 @@ Autoscale conditions are expressed using two blocks: ```yaml conditions: - anyOf: # OR + or: # OR when: # AND ``` The combined logic is: ```text -(anyOf is empty OR anyOf evaluates to true) +(or is empty OR or evaluates to true) AND (when is empty OR when evaluates to true) ``` @@ -93,7 +93,7 @@ If the referenced operator is not found, the metrics block is omitted and the co Clock conditions define a time window using `after:` and/or `before:`. ```yaml -anyOf: +or: - time: after: "08:00" before: "17:00" @@ -113,7 +113,7 @@ Rules: Day‑of‑week conditions activate on specific days. ```yaml -anyOf: +or: - dayOfWeek: in: ["Saturday", "Sunday"] ``` @@ -137,7 +137,7 @@ Cron expressions define **when a time window opens**. The optional `duration:` defines how long the window stays open. ```yaml -anyOf: +or: - cron: "0 8 * * 1-5" duration: 9h ``` @@ -156,7 +156,7 @@ minute hour dayOfMonth month dayOfWeek The autoscaler evaluates conditions in this order: -1. **Evaluate anyOf (OR)** +1. **Evaluate or (OR)** - empty → true - any entry true → pass - all false → fail @@ -167,7 +167,7 @@ The autoscaler evaluates conditions in this order: 3. **Final result** ``` - final = anyOf_passes AND when_passes + final = or_passes AND when_passes ``` If `final == true` → apply `do:` overrides. @@ -192,7 +192,7 @@ conditions: ```yaml conditions: - anyOf: + or: - cron: "0 23 * * *" duration: 3h when: @@ -204,7 +204,7 @@ conditions: ```yaml conditions: - anyOf: + or: - dayOfWeek: in: ["Saturday", "Sunday"] ``` diff --git a/documentation/concepts/operator-autoscaler/03-autoscaler-runtime-behaviour.md b/documentation/concepts/operator-autoscaler/03-autoscaler-runtime-behaviour.md index cb907422d..60ecd6f56 100644 --- a/documentation/concepts/operator-autoscaler/03-autoscaler-runtime-behaviour.md +++ b/documentation/concepts/operator-autoscaler/03-autoscaler-runtime-behaviour.md @@ -20,12 +20,12 @@ On every tick: 1. Read local metrics (`metrics.*`) 2. Read cross‑operator metrics (`cross..metrics.*`) -3. Evaluate `anyOf` (OR) +3. Evaluate `or` (OR) 4. Evaluate `when` (AND) 5. Combine results ```text -final = anyOf_passes AND when_passes +final = or_passes AND when_passes ``` This evaluation is O(1) and entirely in‑memory. diff --git a/documentation/concepts/operator-autoscaler/04-autoscaler-yaml-reference.md b/documentation/concepts/operator-autoscaler/04-autoscaler-yaml-reference.md index fa3a2c745..c2997a283 100644 --- a/documentation/concepts/operator-autoscaler/04-autoscaler-yaml-reference.md +++ b/documentation/concepts/operator-autoscaler/04-autoscaler-yaml-reference.md @@ -15,7 +15,7 @@ operatorBox: interval: cooldown: conditions: - anyOf: [, ...] + or: [, ...] when: [, ...] do: workers: @@ -61,14 +61,14 @@ Defines when the autoscaler should apply overrides. ```yaml conditions: - anyOf: [ ... ] # OR + or: [ ... ] # OR when: [ ... ] # AND ``` The combined logic is: ```text -(anyOf empty OR anyOf passes) +(or empty OR or passes) AND (when empty OR when passes) ``` @@ -318,7 +318,7 @@ autoscale: interval: 60s cooldown: 10m conditions: - anyOf: + or: - time: after: "08:00" before: "17:00" @@ -334,7 +334,7 @@ autoscale: interval: 60s cooldown: 5m conditions: - anyOf: + or: - cron: "0 23 * * *" duration: 3h do: diff --git a/documentation/concepts/operator-autoscaler/05-scenarios.md b/documentation/concepts/operator-autoscaler/05-scenarios.md index eaf55f693..3637f8d68 100644 --- a/documentation/concepts/operator-autoscaler/05-scenarios.md +++ b/documentation/concepts/operator-autoscaler/05-scenarios.md @@ -81,7 +81,7 @@ autoscale: cooldown: 10m conditions: - anyOf: + or: - time: after: "08:00" before: "17:00" @@ -108,7 +108,7 @@ autoscale: cooldown: 30m conditions: - anyOf: + or: - dayOfWeek: in: ["Saturday", "Sunday"] @@ -135,7 +135,7 @@ autoscale: cooldown: 5m conditions: - anyOf: + or: - cron: "0 23 * * *" # 23:00 every day duration: 3h # active until 02:00 @@ -214,7 +214,7 @@ autoscale: cooldown: 5m conditions: - anyOf: + or: - time: after: "08:00" before: "17:00" @@ -272,7 +272,7 @@ autoscale: cooldown: 2m conditions: - anyOf: + or: - cron: "0 8 * * 1-5" # morning duration: 2h - cron: "0 18 * * 1-5" # evening diff --git a/documentation/concepts/operator-autoscaler/06-cross-operator-autoscaling.md b/documentation/concepts/operator-autoscaler/06-cross-operator-autoscaling.md index a8bc896bf..897adba03 100644 --- a/documentation/concepts/operator-autoscaler/06-cross-operator-autoscaling.md +++ b/documentation/concepts/operator-autoscaler/06-cross-operator-autoscaling.md @@ -167,7 +167,7 @@ autoscale: cooldown: 1m conditions: - anyOf: + or: - field: cross.db.metrics.errorRatePercent greaterThan: "5" diff --git a/documentation/concepts/operator-autoscaler/07-cross-operator-autoscaling-scenarios.md b/documentation/concepts/operator-autoscaler/07-cross-operator-autoscaling-scenarios.md index 5f3abcc04..1a27980b0 100644 --- a/documentation/concepts/operator-autoscaler/07-cross-operator-autoscaling-scenarios.md +++ b/documentation/concepts/operator-autoscaler/07-cross-operator-autoscaling-scenarios.md @@ -136,7 +136,7 @@ autoscale: cooldown: 5m conditions: - anyOf: + or: - cron: "0 23 * * *" duration: 3h @@ -225,7 +225,7 @@ autoscale: cooldown: 1m conditions: - anyOf: + or: - field: cross.db.metrics.queueDepth greaterThan: "500" diff --git a/documentation/concepts/operatorbox/01-reconcile-pipeline/index.md b/documentation/concepts/operatorbox/01-reconcile-pipeline/index.md index 06404d0d3..638427953 100644 --- a/documentation/concepts/operatorbox/01-reconcile-pipeline/index.md +++ b/documentation/concepts/operatorbox/01-reconcile-pipeline/index.md @@ -30,7 +30,7 @@ Each step receives the output of the previous step. No step can see the output o **forEach expansion** expands list or map fields into repeated resource declarations. Each expansion adds `.item` and optional `.index` to the template context. -**onCreate / onReconcile resource groups** are the heart of the declarative path. Each resource group evaluates `when:`/`anyOf:` conditions, resolves template expressions, and dispatches creates or updates. See [Drift](01-drift.md) for the exact semantics of what gets corrected and what does not. +**onCreate / onReconcile resource groups** are the heart of the declarative path. Each resource group evaluates `when:`/`or:` conditions, resolves template expressions, and dispatches creates or updates. See [Drift](01-drift.md) for the exact semantics of what gets corrected and what does not. **Provider dispatch** calls registered providers (AWS, MongoDB, etc.) after all built-in resource groups complete. diff --git a/documentation/concepts/operatorbox/05-enrich/03-conditional.md b/documentation/concepts/operatorbox/05-enrich/03-conditional.md index 1759cbffc..400dbe810 100644 --- a/documentation/concepts/operatorbox/05-enrich/03-conditional.md +++ b/documentation/concepts/operatorbox/05-enrich/03-conditional.md @@ -44,14 +44,14 @@ In steady state: no event fetch, no `firstWarning` field written. Under degradat --- -## `anyOf:` — OR logic +## `or:` — OR logic -`anyOf:` fetches the target when any one condition is true: +`or:` fetches the target when any one condition is true: ```yaml enrich: - events: - anyOf: + or: - field: "{{ hasCrashingPod .children.deployment }}" equals: "true" - field: "{{ replicasReady .children.deployment }}" diff --git a/documentation/concepts/operatorbox/05-enrich/index.md b/documentation/concepts/operatorbox/05-enrich/index.md index 65250f245..5467c24f8 100644 --- a/documentation/concepts/operatorbox/05-enrich/index.md +++ b/documentation/concepts/operatorbox/05-enrich/index.md @@ -71,4 +71,4 @@ ork run - [Cost and When to Use](01-cost-and-when.md) — API call budget, enrichAll warning, the gating pattern - [Targets](02-targets.md) — all enrichment targets, what they embed, which notes they unlock -- [Conditional Enrichment](03-conditional.md) — gate patterns, anyOf, combining conditions +- [Conditional Enrichment](03-conditional.md) — gate patterns, or, combining conditions diff --git a/documentation/concepts/operatorbox/07-external/02-reference.md b/documentation/concepts/operatorbox/07-external/02-reference.md index 9c919be32..2d99854c8 100644 --- a/documentation/concepts/operatorbox/07-external/02-reference.md +++ b/documentation/concepts/operatorbox/07-external/02-reference.md @@ -19,7 +19,7 @@ operatorBox: when: - field: status.phase notEquals: "Ready" - anyOf: [] + or: [] sleep: "" ``` @@ -37,7 +37,7 @@ operatorBox: | `expectedStatus` | no | `0` | When set, any response with a different status code is treated as a failure. When `0`: 4xx/5xx are errors, 2xx/3xx succeed. | | `continueOnError` | no | `false` | `false`: failure halts the reconcile and writes `Ready=False` to the CR condition. `true`: failure sets `.error`, the reconcile continues, status fields surface the details. | | `when` | no | `[]` | AND conditions evaluated before the call runs. If any condition fails, the call is skipped and `.called` is `"false"`. Template expressions in `field:` and in comparison values (`equals:`, `notEquals:`, etc.) are both resolved. | -| `anyOf` | no | `[]` | OR conditions. At least one must pass. Combined with `when:` using AND semantics: `(all when:) AND (any one anyOf:)`. | +| `or` | no | `[]` | OR conditions. At least one must pass. Combined with `when:` using AND semantics: `(all when:) AND (any one or:)`. | | `sleep` | no | `""` | Delay injected before this call runs. Go duration format: `"2s"`. Use to pace sequential calls against rate-limited APIs or to wait for an async side-effect from a prior call. Not a substitute for proper `when:` conditions. | ## Result context @@ -49,7 +49,7 @@ After a call completes, the following fields are available under `.external.` | any | When the response body is a valid JSON object, its top-level keys are merged directly into `.external.` and are navigable by dot path. | If the response is `{ "queue": { "pendingJobs": 8 } }` and the call is named `metrics`, then `.external.metrics.queue.pendingJobs` resolves to `8` — in template expressions and in `field:` conditions. diff --git a/documentation/concepts/ordered-deletion/02-condition-based.md b/documentation/concepts/ordered-deletion/02-condition-based.md index 40ae5a002..b9ae5f509 100644 --- a/documentation/concepts/ordered-deletion/02-condition-based.md +++ b/documentation/concepts/ordered-deletion/02-condition-based.md @@ -1,6 +1,6 @@ # Condition-Based Deletion -In addition to hard ordered deletion, Orkestra supports a declarative sequencing model using `when:` and `anyOf:` conditions on `onDelete:` blocks. Each deletion step becomes eligible only when its condition evaluates to true. +In addition to hard ordered deletion, Orkestra supports a declarative sequencing model using `when:` and `or:` conditions on `onDelete:` blocks. Each deletion step becomes eligible only when its condition evaluates to true. This model is non-blocking: the CR's finalizer is never held, the CR never gets stuck. @@ -25,7 +25,7 @@ onDelete: # - the CR has already entered a Failed phase services: - name: "{{ .metadata.name }}-svc" - anyOf: + or: - "{{ not (resourceExists .children.deployment) }}" - "{{ eq .status.phase \"Failed\" }}" @@ -43,7 +43,7 @@ onDelete: 1. Orkestra evaluates deletion blocks in the order they appear 2. A block with no conditions runs immediately 3. A block with `when:` runs only when **all** conditions are true -4. A block with `anyOf:` runs when **any** condition is true +4. A block with `or:` runs when **any** condition is true 5. If conditions never become true, the block is skipped 6. The CR's finalizer is not held — deletion is non-blocking diff --git a/documentation/concepts/ordered-deletion/index.md b/documentation/concepts/ordered-deletion/index.md index c528ebc77..9bb6dc088 100644 --- a/documentation/concepts/ordered-deletion/index.md +++ b/documentation/concepts/ordered-deletion/index.md @@ -22,7 +22,7 @@ Ordered deletion is for the cases where sequence matters: | | Hard ordered | Condition-based | |---|---|---| -| Mechanism | `ordered: true` | `when:` / `anyOf:` conditions | +| Mechanism | `ordered: true` | `when:` / `or:` conditions | | Finalizer | Held until complete | Never held | | CR can get stuck | Yes (on timeout) | Never | | Guarantee | Sequential, enforced | Best-effort | @@ -33,4 +33,4 @@ Ordered deletion is for the cases where sequence matters: ## Where to go next - [Hard Ordered Deletion](hard-ordered/) — `ordered: true`, groups, timeouts -- [Condition-Based Deletion](condition-based/) — `when:` / `anyOf:` sequencing without blocking +- [Condition-Based Deletion](condition-based/) — `when:` / `or:` sequencing without blocking diff --git a/documentation/concepts/reconciler-model/01-create-update.md b/documentation/concepts/reconciler-model/01-create-update.md index 6824bb933..8b6f092d0 100644 --- a/documentation/concepts/reconciler-model/01-create-update.md +++ b/documentation/concepts/reconciler-model/01-create-update.md @@ -54,7 +54,7 @@ If a Go hook is registered for this operatorBox, it runs here — before templat ## 11. Template reconciliation -`onCreate` templates run on the first reconcile for a CR. `onReconcile` templates run on every reconcile. Conditions (`when:`, `anyOf:`) are evaluated, `forEach:` expansions are applied, and the resolved resources are created or updated. +`onCreate` templates run on the first reconcile for a CR. `onReconcile` templates run on every reconcile. Conditions (`when:`, `or:`) are evaluated, `forEach:` expansions are applied, and the resolved resources are created or updated. ## 12. Drift correction diff --git a/documentation/concepts/reconciler-model/03-requeue.md b/documentation/concepts/reconciler-model/03-requeue.md new file mode 100644 index 000000000..efbb0c30f --- /dev/null +++ b/documentation/concepts/reconciler-model/03-requeue.md @@ -0,0 +1,95 @@ +# Requeue + +After a successful reconcile, Orkestra normally waits for the next informer event before running again. `reconciler.requeue:` changes this: declare a duration (static or template-driven) and Orkestra re-enqueues the CR on a timer — no external event required. + +This is the right tool when your reconciler needs to act on time, not just on change. + +--- + +## When to use it + +`watch:` fires when another resource changes. `resync:` fires for all CRs uniformly. `requeue:` fires per-object, on a schedule derived from the object itself. + +Use it when: + +- Different objects need different polling intervals — `{{ .spec.checkInterval | default "60s" }}` +- You want to re-evaluate a condition on a timer — a lease expiry, a TTL, a certificate approaching rotation. +- A hook or external call produces a result that changes over time and you want to react without an external event source. + +Do not use it as a substitute for `watch:`. If a resource your reconciler reads can send events, declare it in `watch:` instead — event-driven is always cheaper than polled. + +--- + +## Basic usage + +```yaml +operatorBox: + reconciler: + requeue: + after: "60s" +``` + +After every successful reconcile, the CR is re-enqueued 60 seconds later. Failed reconciles are handled by `queue.retryBackoff` — `requeue:` only fires on success. + +--- + +## Template expression + +`after:` is evaluated against the live CR at reconcile time, so each CR can carry its own timing: + +```yaml +operatorBox: + reconciler: + requeue: + after: '{{ .spec.checkInterval | default "60s" }}' +``` + +If `.spec.checkInterval` is `"30s"` on one CR and `"5m"` on another, each gets its own schedule. The expression is re-evaluated every reconcile — changing `spec.checkInterval` on the CR takes effect on the next cycle. + +--- + +## Conditional requeue + +`when:` and `or:` gate the requeue. If the conditions fail, no requeue is scheduled — the CR waits for the next informer event instead. + +```yaml +operatorBox: + reconciler: + requeue: + after: "30s" + when: + - field: status.phase + notEquals: "Complete" +``` + +The CR is re-enqueued every 30 seconds while `status.phase` is not `Complete`. Once it reaches `Complete`, requeue stops and the CR is idle until its next informer event. + +Both `when:` (AND) and `or:` (OR) follow the same semantics as gate conditions. When both are present, both must pass. + +--- + +## Relationship to other timing primitives + +| Primitive | Fires when | Scope | +|-----------|-----------|-------| +| `requeue.after:` | After a successful reconcile, per-object timing | Per CR, template-driven | +| `reconciler.resync:` | On a fixed interval, for every CR of this CRD | Per CRD, uniform | +| `watch:` | When a declared secondary resource changes | Event-driven | +| `queue.retryBackoff:` | After a failed reconcile | Error path only | + +`requeue:` and `resync:` are additive — both can be declared. The CR is re-enqueued by whichever fires first. + +--- + +## Typed operators + +Typed reconcilers (`domain.ReconcilerFrom`) set requeue timing by returning a non-zero `domain.Result.RequeueAfter`: + +```go +func (r *MyReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + // ... + return ctrl.Result{RequeueAfter: 30 * time.Second}, nil +} +``` + +`domain.ReconcilerFrom` forwards the value directly. The declarative `requeue:` block is for operatorBox reconcilers — both mechanisms use the same workqueue path. diff --git a/documentation/concepts/reconciler-model/04-resync-vs-requeue.md b/documentation/concepts/reconciler-model/04-resync-vs-requeue.md new file mode 100644 index 000000000..08e7539cb --- /dev/null +++ b/documentation/concepts/reconciler-model/04-resync-vs-requeue.md @@ -0,0 +1,68 @@ +# resync vs requeue + +Both primitives schedule reconciles on a timer, but they operate at different scopes and serve different purposes. + +--- + +## resync — uniform, whole-CRD + +`reconciler.resync:` is the informer resync period. On the interval, Orkestra re-lists every CR of the CRD from the API server and re-queues all of them, regardless of their individual state. Every object gets the same cadence. + +```yaml +operatorBox: + reconciler: + resync: 10m # re-enqueue every CR of this CRD every 10 minutes +``` + +Think of it as a safety net: even if a watch event is missed, the informer catches up on the next resync. It is a blunt instrument — it does not care whether a specific CR needs attention. + +--- + +## requeue — targeted, per-object + +`reconciler.requeue:` schedules a re-enqueue for the specific CR that just reconciled. Each object can carry its own timing, derived from its own fields. Other CRs are not affected. + +```yaml +operatorBox: + reconciler: + requeue: + after: '{{ .spec.checkInterval | default "60s" }}' +``` + +One CR with `spec.checkInterval: 30s` and another with `spec.checkInterval: 5m` each run on their own schedule, independent of each other and of resync. + +--- + +## They compose + +Both can be declared at the same time. The CR is re-enqueued by whichever fires first. + +```yaml +operatorBox: + reconciler: + resync: 10m + requeue: + after: '{{ .spec.checkInterval | default "60s" }}' +``` + +A CR with a 30-second `checkInterval` reconciles roughly every 30 seconds. The 10-minute resync also fires, but the CR was already reconciled recently — the queue deduplicates and the extra enqueue is a no-op. A CR with no `checkInterval` falls back to the 60-second default. Everything still gets the 10-minute resync as a floor. + +--- + +## Decision guide + +| I want to… | Use | +|---|---| +| Re-evaluate all CRs periodically as a safety net | `resync:` | +| React when a secondary resource changes | `watch:` | +| Check a specific CR on its own schedule | `requeue:` | +| Re-check based on a field in the CR (`spec.ttl`, `status.certExpiry`) | `requeue:` with a template `after:` | +| Retry after a failed reconcile | `queue.retryBackoff:` — not requeue | + +`requeue:` only fires on success. Errors are always handled by `queue.retryBackoff`. + +--- + +## Relationship to typed operators + +A typed reconciler signals per-object requeue timing by returning a non-zero `domain.Result.RequeueAfter`. The declarative `requeue:` block and the Go `RequeueAfter` field use the same workqueue path — they are two surfaces for the same mechanism. diff --git a/documentation/concepts/reconciler-model/05-kordinator.md b/documentation/concepts/reconciler-model/05-kordinator.md new file mode 100644 index 000000000..8b27df182 --- /dev/null +++ b/documentation/concepts/reconciler-model/05-kordinator.md @@ -0,0 +1,83 @@ +# Kordinator + +The kordinator is the part of the Orkestra runtime that manages when each CRD starts reconciling, how many workers it runs, and what happens when things change at runtime. + +--- + +## Startup in dependency order + +When your Katalog declares multiple CRDs, the kordinator resolves their `dependsOn:` declarations into a startup sequence. CRDs without dependencies start first. A CRD that depends on another waits until that dependency has reached the declared condition before its workers start. + +```yaml +spec: + crds: + database: + dependsOn: {} # starts first + + api-server: + dependsOn: + database: + condition: healthy # waits until database has reconciled at least once +``` + +Two conditions are available: + +| Condition | Meaning | +|-----------|---------| +| `started` (default) | The dependency's workers are running | +| `healthy` | The dependency has completed at least one successful reconcile | + +If a dependency is not yet ready when the operator starts, the kordinator skips that CRD and a background loop checks periodically, starting it as soon as the condition is met. Nothing blocks the rest of the operator. + +--- + +## Per-CRD worker pools + +Each CRD gets its own independent worker pool. Workers run concurrently — the number is set by `reconciler.workers:` and defaults to 1. + +```yaml +operatorBox: + reconciler: + workers: 3 +``` + +Three workers means three CRs of that type can be reconciling simultaneously. Workers for one CRD do not share a pool with any other CRD. + +Worker count can be adjusted at runtime without restarting the operator. The `autoscale:` block lets the kordinator scale workers up or down based on queue depth, reconcile latency, or custom conditions — see [Operator Autoscaler](../operator-autoscaler/). + +--- + +## Health states + +The kordinator tracks a health state for each CRD as it processes items: + +| State | Meaning | +|-------|---------| +| `pending` | Workers not yet started — dependency conditions not met | +| `started` | Workers running, no successful reconcile yet | +| `healthy` | At least one successful reconcile completed | +| `degraded` | Consecutive failures exceeded the threshold, or the CRD has disappeared from the cluster | + +These states feed the Control Center dashboard and the `/katalog` health endpoints. A dependent CRD waiting for `condition: healthy` unblocks the moment its dependency transitions to healthy — no restart required. + +--- + +## Self-healing + +The kordinator monitors running CRDs throughout the operator's lifetime. If a CRD is deleted from the cluster after the operator started, the kordinator stops that CRD's workers, marks it degraded, and propagates the change to any dependents. When the CRD reappears, workers restart automatically without restarting the operator. + +This means an operator started before its CRDs are installed in the cluster will come up healthy once the CRDs are applied — no ordering requirement on installation. + +--- + +## Per-target reconciliation + +When a Katalog declares `serve.target:` entries, each CR carries a target annotation set by the gateway at delivery time. The kordinator routes each reconcile to the correct operatorBox for that target — different workers, different hooks, different args — all within the same CRD's worker pool. + +See [serve targets](../self-service/02-target-mode.md) for how targets are declared. + +--- + +## Relationship to the reconciler + +The kordinator and the reconciler are separate concerns. The kordinator decides when to call `Reconcile` and how many concurrent calls to allow. The reconciler decides what to do with a specific CR. Neither depends on the other's implementation — the kordinator calls any `domain.Reconciler`, whether it is the GenericReconciler or your own constructor. diff --git a/documentation/concepts/reconciler-model/index.md b/documentation/concepts/reconciler-model/index.md index fbd1651bf..2423b150c 100644 --- a/documentation/concepts/reconciler-model/index.md +++ b/documentation/concepts/reconciler-model/index.md @@ -1,24 +1,96 @@ # Reconciler Model -When you apply a CR to your cluster, Orkestra reconciles it. It reads your Katalog, follows your instructions, and makes sure the Kubernetes resources you declared exist and stay correct — automatically, without any code. +When a CR is applied to your cluster, Orkestra reconciles it — reads your Katalog, acts on it, and keeps the declared state correct over time. How it does that depends on which reconciler model you are using. -Orkestra creates one reconciler, one informer, and one worker pool **per CRD**. Each CRD in your Katalog gets its own independent reconcile loop — its own watch stream from the API server, its own workqueue, and its own configurable concurrency. Adding a second CRD does not share or contend with the first. +--- + +## Two reconciler models + +### Generic Reconciler + +The default. You write YAML — `onCreate`, `onReconcile`, `onDelete`, `hooks`, conditions, status fields — and Orkestra's built-in reconciler carries out those instructions. You own the declaration; Orkestra owns the loop. + +This is the right model for most operators. It handles drift correction, templating, status patching, finalizers, ordered deletion, external calls, and more — without any Go code. + +```yaml +operatorBox: + onReconcile: + deployments: + - name: "{{ .Name }}-server" + image: "{{ .Spec.Image }}" +``` + +Or with minimal Go hooks for logic that belongs in code: -Think of it like a recipe: you write the recipe (Katalog), you provide the ingredients (CR spec), Orkestra follows the recipe (creates resources), and Orkestra keeps checking the result (drift correction). +```yaml +operatorBox: + reconciler: + hooks: + location: github.com/myorg/operator/hooks + function: AppHooks +``` + +### Your Reconcile() + +When you need full control of the reconcile loop — or you are migrating an existing operator — you provide your own implementation by returning a `domain.Reconciler` from a constructor function declared in the Katalog. + +```go +func NewAppReconciler(kube kubeclient.Interface) domain.Reconciler { + return &AppReconciler{client: kubeclient.ToClient(kube)} +} +``` + +```yaml +operatorBox: + reconciler: + default: false + constructor: + location: github.com/myorg/operator/controller + function: NewAppReconciler +``` + +Orkestra provides the informer, workqueue, worker pool, leader election, and metrics. Your `Reconcile` method handles the business logic. If you are migrating from controller-runtime, `ork migrate` automates the initial scaffolding — see [from-controller-runtime](../typed-operators/05-migration.md). --- -## Three flows +## What the runtime provides to both models + +Regardless of which model you use, Orkestra manages: -**Create / Update** — the main path. A new CR appears or an existing one changes. Orkestra normalizes, mutates, validates, runs hooks, resolves templates, creates or updates resources, patches status, and records health. +- One informer per CRD — a watch stream from the API server, kept in a local store +- One workqueue per CRD — items are deduplicated, rate-limited on error, and re-enqueued on a timer when `requeue:` is declared +- A configurable worker pool — concurrency is set via `reconciler.workers:` and can be adjusted at runtime with `autoscale:` +- Health tracking — each CRD moves through `pending → started → healthy → degraded` as it processes items +- Startup sequencing — CRDs with `dependsOn:` declarations start in dependency order, not all at once + +--- + +## The kordinator + +The kordinator is the part of the runtime that owns startup, worker management, and health. It starts each CRD's workers when its declared dependencies are met, monitors CRDs for disappearance, restarts workers when a CRD reappears, and aggregates health across all CRDs. See [Kordinator](05-kordinator.md). + +--- + +## Three reconcile flows + +**Create / Update** — a CR appears or changes. Orkestra reads the object from the informer store, evaluates any gate conditions, runs the reconcile pipeline, patches status, and records health. + +**Delete** — a CR receives a deletion timestamp. The finalizer holds deletion until `onDelete:` templates and hooks have run. Then the finalizer is removed and Kubernetes garbage-collects child resources via owner references. + +**Drift correction** — a child resource is changed manually. Because the child emits a watch event, the parent CR is re-enqueued and the `onReconcile:` cycle runs again, restoring declared state. + +--- -**Delete** — a CR receives a deletion timestamp. Orkestra runs `onDelete:` hooks and templates, removes finalizers, and lets Kubernetes garbage-collect child resources via owner references. +## Per-target reconciliation -**Drift correction** — a child resource is manually changed. Because the child emits a watch event that Orkestra's informer picks up, the parent CR is requeued and its `onReconcile:` templates run again, restoring the declared state. +When your Katalog declares `serve.target:` entries, CRs routed through the gateway carry a target annotation. Each reconcile cycle reads that annotation and uses the operatorBox declared for that target — different hooks, different args, different conditions, potentially a different reconciler binary — all from the same CRD type. See [Serve targets](../self-service/02-target-mode.md). --- ## Where to go next -- [Create and Update](create-update/) — the 15-step reconcile pipeline -- [Delete](delete/) — finalizer lifecycle and cleanup sequencing +- [Create and Update](01-create-update.md) — the reconcile pipeline step by step +- [Delete](02-delete.md) — finalizer lifecycle and cleanup sequencing +- [Requeue](03-requeue.md) — per-object scheduled requeue after a successful reconcile +- [resync vs requeue](04-resync-vs-requeue.md) — when to use each and how they compose +- [Kordinator](05-kordinator.md) — startup sequencing, worker management, health diff --git a/documentation/concepts/status-management/01-declarative-fields.md b/documentation/concepts/status-management/01-declarative-fields.md index 5be6a108a..8f699c529 100644 --- a/documentation/concepts/status-management/01-declarative-fields.md +++ b/documentation/concepts/status-management/01-declarative-fields.md @@ -45,7 +45,7 @@ status: --- -## `when:` and `anyOf:` +## `when:` and `or:` Gate a field on conditions — the same condition engine used in resource templates and validation rules. @@ -68,14 +68,14 @@ status: - path: phase value: "Pending" - anyOf: + or: - field: external.healthCheck.called equals: "false" - field: "{{ allReplicasReady .children.deployment }}" equals: "false" ``` -`when:` requires all conditions to pass (AND). `anyOf:` requires at least one to pass (OR). When both are declared, both blocks must pass. +`when:` requires all conditions to pass (AND). `or:` requires at least one to pass (OR). When both are declared, both blocks must pass. A path can appear multiple times with different conditions — the first matching entry wins. Use this to build declarative state machines. @@ -118,9 +118,9 @@ Included fields come first. Inline `fields:` append after. The path is resolved **Paths are relative to `status`.** `phase` writes to `status.phase`. `database.host` writes to `status.database.host`. Dot-notation works at any depth. -**Unconditional fields are only written on successful reconcile.** A field with no `when:` or `anyOf:` is skipped when reconcile fails — writing it on error would produce misleading status (e.g. `phase: Active` while the CR is denied). +**Unconditional fields are only written on successful reconcile.** A field with no `when:` or `or:` is skipped when reconcile fails — writing it on error would produce misleading status (e.g. `phase: Active` while the CR is denied). -**Conditional fields always evaluate.** A field with `when:` or `anyOf:` is evaluated on both success and failure. This is what allows status to reflect *why* reconcile failed — for example, surfacing an external health check result or the denial reason as `phase: Degraded`. +**Conditional fields always evaluate.** A field with `when:` or `or:` is evaluated on both success and failure. This is what allows status to reflect *why* reconcile failed — for example, surfacing an external health check result or the denial reason as `phase: Degraded`. --- diff --git a/documentation/concepts/status-management/04-transient-fields.md b/documentation/concepts/status-management/04-transient-fields.md index 2bc63ec4d..97072c7fb 100644 --- a/documentation/concepts/status-management/04-transient-fields.md +++ b/documentation/concepts/status-management/04-transient-fields.md @@ -45,7 +45,7 @@ Do **not** use it on `phase` or other fields that describe the last stable state ## Effect on always-written fields -`clearOnFalse` has no effect when no `when:` or `anyOf:` conditions are declared. Fields written unconditionally are not affected. +`clearOnFalse` has no effect when no `when:` or `or:` conditions are declared. Fields written unconditionally are not affected. --- diff --git a/documentation/concepts/temporal/index.md b/documentation/concepts/temporal/index.md index 2e204809e..85f1a3353 100644 --- a/documentation/concepts/temporal/index.md +++ b/documentation/concepts/temporal/index.md @@ -132,6 +132,6 @@ ork run ## Where to go next - [Built-in notes reference](../../reference/orkestra-notes/index.md) — full time domain notes with examples -- [Conditionals](../conditional/) — `when:` and `anyOf:` semantics +- [Conditionals](../conditional/) — `when:` and `or:` semantics - [User-defined notes](../notes/) — composing built-ins into domain vocabulary - [Operator Autoscaler](../operator-autoscaler/) — time-driven worker and resync tuning diff --git a/documentation/concepts/testing/01-gate.md b/documentation/concepts/testing/01-gate.md index b0a435163..988392bbf 100644 --- a/documentation/concepts/testing/01-gate.md +++ b/documentation/concepts/testing/01-gate.md @@ -53,7 +53,7 @@ admission denied Denials exit non-zero. Warnings exit zero — they are advisory only. -`when:` conditions, `anyOf:` groups, and field path expressions all run identically to the real webhook. A rule guarded by `when: workloadType=cert` does not fire for an `app` CR, just as it wouldn't in the cluster. +`when:` conditions, `or:` groups, and field path expressions all run identically to the real webhook. A rule guarded by `when: workloadType=cert` does not fire for an `app` CR, just as it wouldn't in the cluster. --- diff --git a/documentation/concepts/typed-operators/01-hooks.md b/documentation/concepts/typed-operators/01-hooks.md index 1dc91e421..bf6304fca 100644 --- a/documentation/concepts/typed-operators/01-hooks.md +++ b/documentation/concepts/typed-operators/01-hooks.md @@ -113,7 +113,10 @@ spec: For private modules with `fetch: true`, set `GOPRIVATE` and ensure credentials are available before running `ork generate registry`. -`resources` declares what Kubernetes resources the hook manages — required for RBAC generation. +`resources` declares what Kubernetes resources the hook manages. It serves two purposes: + +- **RBAC generation** — Orkestra generates `get/list/watch/create/update/patch/delete` permissions for each declared type. +- **Implicit watch informer** — Orkestra automatically starts a watch informer for each declared resource, giving cache-backed reads and automatic re-enqueue of the primary CR when an owned resource changes via ownerReference. Explicit `watch:` entries take priority if the same type is declared in both. `args` passes configuration from the Katalog into the hook at reconcile time. **String values support Go template expressions** — the GenericReconciler evaluates them against the current CR before the hook runs, so the hook sees fully-resolved values: @@ -174,7 +177,7 @@ func onReconcile(ctx context.Context, obj *apiv1.App) error { } ``` -`external:` under `hooks:` uses the same field schema as the top-level `external:` block — including `when:` / `anyOf:` gating, `continueOnError`, and response accessors (`.body`, `.status`, `.headers`). Any infrastructure available to declarative external calls is automatically available to hooks for free as the feature evolves. +`external:` under `hooks:` uses the same field schema as the top-level `external:` block — including `when:` / `or:` gating, `continueOnError`, and response accessors (`.body`, `.status`, `.headers`). Any infrastructure available to declarative external calls is automatically available to hooks for free as the feature evolves. --- diff --git a/documentation/concepts/typed-operators/02-constructor.md b/documentation/concepts/typed-operators/02-constructor.md index a235bcd2c..f2ed6a675 100644 --- a/documentation/concepts/typed-operators/02-constructor.md +++ b/documentation/concepts/typed-operators/02-constructor.md @@ -4,7 +4,7 @@ A constructor replaces the GenericReconciler entirely. Your Go code owns the ful Use a constructor when: -- **Migrating an existing controller-runtime operator** — change the `Reconcile` signature from `(ctx, req) (Result, error)` to `(ctx, key string) error`, remove the manager setup, and register the constructor in the Katalog. The informer, workqueue, worker pool, leader election, metrics, and panic recovery are all provided by Orkestra. Your reconcile logic is unchanged. +- **Migrating an existing controller-runtime operator** — change the `Reconcile` signature from `(ctx, req ctrl.Request) (ctrl.Result, error)` to `(ctx context.Context, req domain.Request) (domain.Result, error)`, remove the manager setup, and register the constructor in the Katalog. The informer, workqueue, worker pool, leader election, metrics, and panic recovery are all provided by Orkestra. Your reconcile logic is unchanged. - **Running a custom state machine** — when the reconcile loop itself is stateful and not easily expressed as declarative templates with `when:` conditions. For new operators, prefer [hooks in hybrid mode](./01-hooks.md#hybrid). Only reach for a constructor when you need to own the full loop. @@ -59,7 +59,14 @@ The `@version` suffix in `location` is shorthand for the `version:` field — `l Use `fetch: true` when pulling the constructor from a remote module you have not yet added to the project. Use `fetch: false` (or omit it) when the module is already a local dependency. -`resources` declares what Kubernetes resources the constructor manages — required for RBAC generation. +`resources` declares what Kubernetes resources the constructor manages. It serves two purposes: + +- **RBAC generation** — Orkestra generates `get/list/watch/create/update/patch/delete` permissions for each declared type. +- **Implicit watch informer** — Orkestra automatically starts a watch informer for each declared resource, the same as declaring an explicit `watch:` entry with all events and owner-reference key resolution. This means: + - `r.client.Get` and `r.client.List` for that type are served from cache (no live API call after the informer syncs) + - When an owned resource changes and has an ownerReference pointing to the primary CR, Orkestra re-enqueues that CR automatically + +No extra YAML is needed. If you need finer control — custom event filters, field indexes, or a different key resolution strategy — declare an explicit `watch:` entry for that type. It takes priority over the implicit informer from `resources:`. `args` passes configuration from the Katalog into the constructor. Orkestra attaches the args to the `kube` client before calling the constructor function — no extra wiring needed. @@ -141,16 +148,16 @@ func (r *WebAppReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctr **After (Orkestra constructor)**: ```go -func (r *WebAppReconciler) Reconcile(ctx context.Context, key string) error { - // key == req.String() — same content, no other change to this method +func (r *WebAppReconciler) Reconcile(ctx context.Context, req domain.Request) (domain.Result, error) { + key := req.Key // namespace/name — same content as the old req.String() existing := &appsv1.Deployment{} err := r.kube.Get(ctx, namespace, name, existing) if errors.IsNotFound(err) { - return r.kube.Create(ctx, desired) + return domain.Result{}, r.kube.Create(ctx, desired) } patch := client.MergeFrom(existing.DeepCopy()) existing.Spec = desired.Spec - return r.kube.Patch(ctx, existing, patch) + return domain.Result{}, r.kube.Patch(ctx, existing, patch) } ``` diff --git a/documentation/concepts/typed-operators/05-migration.md b/documentation/concepts/typed-operators/05-migration.md index bdd1a0704..8abf4b3a9 100644 --- a/documentation/concepts/typed-operators/05-migration.md +++ b/documentation/concepts/typed-operators/05-migration.md @@ -55,16 +55,17 @@ my-operator/ 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, req domain.Request) (domain.Result, error) ``` -`key` is `namespace/name` — the same as `req.String()`. Orkestra calls this from its worker pool. +`req.String()` returns `namespace/name` — same as the controller-runtime `req.String()`. `req.Key` holds the same value as a plain field. `req.NamespacedName` is also available directly. Orkestra calls this from its worker pool. -**Return values collapsed:** +**Return values rewritten:** ```go -return ctrl.Result{}, err → return err -return ctrl.Result{}, nil → return nil +return ctrl.Result{}, err → return domain.Result{}, err +return ctrl.Result{}, nil → return domain.Result{}, nil +return ctrl.Result{RequeueAfter: X}, nil → return domain.Result{RequeueAfter: X}, nil ``` **Struct and constructor rewritten:** diff --git a/documentation/concepts/typed-operators/index.md b/documentation/concepts/typed-operators/index.md index a54beb408..63e002f72 100644 --- a/documentation/concepts/typed-operators/index.md +++ b/documentation/concepts/typed-operators/index.md @@ -6,7 +6,7 @@ Every controller-runtime operator has two layers. **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. +Orkestra separates them. You write `Reconcile(ctx context.Context, req domain.Request) (domain.Result, 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. diff --git a/documentation/concepts/workload-autoscaler/01-scaling-signals.md b/documentation/concepts/workload-autoscaler/01-scaling-signals.md index a86603562..3435b13d4 100644 --- a/documentation/concepts/workload-autoscaler/01-scaling-signals.md +++ b/documentation/concepts/workload-autoscaler/01-scaling-signals.md @@ -2,7 +2,7 @@ Orkestra can scale any Deployment — not just ones it created. Add a Deployment by name to `onReconcile:` and Orkestra manages its replica count. Use `apiTypes.kind: Deployment` to watch every Deployment in the cluster and scope with label selectors. -`autoscale:` conditions evaluate against the same Resolver data map used everywhere else in a Katalog. Any source resolved before deployment evaluation — `external:`, `cross:`, time notes, user-defined notes — is available as a field reference in `when:` and `anyOf:` blocks. +`autoscale:` conditions evaluate against the same Resolver data map used everywhere else in a Katalog. Any source resolved before deployment evaluation — `external:`, `cross:`, time notes, user-defined notes — is available as a field reference in `when:` and `or:` blocks. --- @@ -240,7 +240,7 @@ Notes can guard scale-up — here, the operator only scales when there are no cr ```yaml scaleUp: conditions: - anyOf: + or: - field: external.kafka.consumerLag greaterThan: "1000" - field: cross.worker.metrics.queueDepth @@ -252,4 +252,4 @@ scaleUp: weekday: true ``` -`anyOf:` evaluates as OR — scale up if any condition passes. `when:` evaluates as AND — all conditions must pass. Both can appear in the same `conditions:` block; `when:` and `anyOf:` are evaluated independently and combined with AND. +`or:` evaluates as OR — scale up if any condition passes. `when:` evaluates as AND — all conditions must pass. Both can appear in the same `conditions:` block; `when:` and `or:` are evaluated independently and combined with AND. diff --git a/documentation/concepts/workload-autoscaler/02-schema.md b/documentation/concepts/workload-autoscaler/02-schema.md index 2556b94b1..d920c993d 100644 --- a/documentation/concepts/workload-autoscaler/02-schema.md +++ b/documentation/concepts/workload-autoscaler/02-schema.md @@ -19,7 +19,7 @@ Two modes, mutually exclusive per direction: | `max` | yes | Ceiling — autoscaler never exceeds this value | | `min` | no | Floor — defaults to the resolved `replicas` value | | `cooldown` | no | Wait between scale events (e.g. `3m`, `30s`). Default `1m` | -| `scaleUp.conditions` | no | `when:` / `anyOf:` conditions that trigger scale-up | +| `scaleUp.conditions` | no | `when:` / `or:` conditions that trigger scale-up | | `scaleUp.target` | no | Jump to this exact replica count | | `scaleUp.increment` | no | Add this many replicas per evaluation tick | | `scaleDown.conditions` | no | Conditions that trigger scale-down | diff --git a/documentation/faqs/01-concepts.md b/documentation/faqs/01-concepts.md index b0ea5fa5d..e7a722488 100644 --- a/documentation/faqs/01-concepts.md +++ b/documentation/faqs/01-concepts.md @@ -63,7 +63,7 @@ Orkestra provides these capabilities declaratively, with no Go: - Admission-time validation and mutation Go hooks are available when you need them — complex external API calls not covered by the `external:` block, complex -conditional logic not covered by the `when:` and `anyOf:` blocks. But hooks are additive. The +conditional logic not covered by the `when:` and `or:` blocks. But hooks are additive. The declarative layer handles everything else. !!! note "When Go becomes necessary" diff --git a/documentation/faqs/03-usage.md b/documentation/faqs/03-usage.md index fff9bb3b4..98a4de23c 100644 --- a/documentation/faqs/03-usage.md +++ b/documentation/faqs/03-usage.md @@ -212,7 +212,7 @@ The rule: if you control the CRD and want to tolerate different input shapes — ## When do I need Go hooks? -The `external:` block handles HTTP already — GET, POST, bearer tokens, chained calls where each result flows into the next via `.external.{name}.body`. `when:` and `anyOf:` handle conditional logic. For most operators, those are enough. +The `external:` block handles HTTP already — GET, POST, bearer tokens, chained calls where each result flows into the next via `.external.{name}.body`. `when:` and `or:` handle conditional logic. For most operators, those are enough. Hooks become necessary when the work is genuinely outside HTTP: diff --git a/documentation/getting-started/01-learning-to-orkestrate/04-use-cases.md b/documentation/getting-started/01-learning-to-orkestrate/04-use-cases.md index 7b5c15b11..81fb76b6f 100644 --- a/documentation/getting-started/01-learning-to-orkestrate/04-use-cases.md +++ b/documentation/getting-started/01-learning-to-orkestrate/04-use-cases.md @@ -39,7 +39,7 @@ ork run |---|---| | `01-pod-health` | Always-on pod enrichment. `_podHealth` embedded in status on every reconcile: ready count, restart count, crash detection. | | `02-warning-events` | Conditional event enrichment. Kubernetes warning events only fetched when the operator detects degraded state — `when:` keeps the cost near-zero in steady state. | -| `03-rollout-observer` | Conditional ReplicaSet enrichment. Rollout history only fetched during active rollouts — `anyOf:` limits calls to when they actually matter. | +| `03-rollout-observer` | Conditional ReplicaSet enrichment. Rollout history only fetched during active rollouts — `or:` limits calls to when they actually matter. | --- @@ -77,8 +77,8 @@ ork run # runs the komposer — all six operators together | `02-external-gate` | `external:` with two calls: a blocking health check and a non-blocking feature flag fetch. `continueOnError: false` vs `true` side by side. | | `03-cross-crd` | `cross:` between two CRDs. `DatabaseBackedApp` waits for `ManagedDatabase` to reach `Ready`. The database endpoint flows into a ConfigMap automatically. | | `04-once-secret` | `once: true` on a Secret. Generated exactly once. Stable credentials across the lifetime of the CR. | -| `05-anyof` | `anyOf:` for OR conditions. Combines with `when:` (AND) for `(all of these) AND (any of those)` logic without Go code. | -| `06-full-stack` | All five patterns together: forEach, external, cross-CRD, once-secret, anyOf. | +| `05-or` | `or:` for OR conditions. Combines with `when:` (AND) for `(all of these) AND (any of those)` logic without Go code. | +| `06-full-stack` | All five patterns together: forEach, external, cross-CRD, once-secret, or. | --- diff --git a/documentation/guides/migration/07-ork-migrate.md b/documentation/guides/migration/07-ork-migrate.md index a429bc343..de6a27bb3 100644 --- a/documentation/guides/migration/07-ork-migrate.md +++ b/documentation/guides/migration/07-ork-migrate.md @@ -22,7 +22,7 @@ The TODOs cover what the tool cannot infer from syntax alone: - Add Orkestra imports (`domain`, `kubeclient`, `event`, `cache`) - Replace sub-method client calls (`r.Get`, `r.Create`, `r.Patch`) with `r.kube.*` - Replace `r.Status().Update()` with `r.kube.PatchStatus()` -- Fill in `group`, `kind`, `plural`, `location` in `katalog.yaml` +- Set `group` and `plural` in `katalog.yaml` — `kind`, `version`, `location`, `alias`, `object`, `objectList`, owned `resources:`, and `watch:` entries are auto-detected from `SetupWithManager` - Fill in simulate assertions and e2e setup Once resolved, build and simulate: diff --git a/documentation/reference/cli/01-init.md b/documentation/reference/cli/01-init.md index 3b7083b12..a87eaa0df 100644 --- a/documentation/reference/cli/01-init.md +++ b/documentation/reference/cli/01-init.md @@ -36,7 +36,7 @@ ork init --list Available packs: - `beginner` — Simple CRDs, Deployments, Services -- `intermediate` — Multi-resource patterns, when/anyOf, Komposer basics +- `intermediate` — Multi-resource patterns, when/or, Komposer basics - `advanced` — Hooks, constructors, validation/mutation, registries - `use-cases` — Full-stack, cross-CRD flows, external gates, once-secrets diff --git a/documentation/reference/cli/14-gate.md b/documentation/reference/cli/14-gate.md index 77f5f40fa..3fff5b90a 100644 --- a/documentation/reference/cli/14-gate.md +++ b/documentation/reference/cli/14-gate.md @@ -96,7 +96,7 @@ Both are clearly noted in the output so you know they were not checked. ## How it works -`ork gate` runs the same admission evaluation as the real webhook — the same rules, the same `when:` conditions, the same `anyOf:` logic. A rule guarded by `when: workloadType=cert` does not fire for an `app` CR, just as it wouldn't in the cluster. +`ork gate` runs the same admission evaluation as the real webhook — the same rules, the same `when:` conditions, the same `or:` logic. A rule guarded by `when: workloadType=cert` does not fire for an `app` CR, just as it wouldn't in the cluster. `unique:` and `external:` rules are the only exceptions (see Limitations above). diff --git a/documentation/reference/cli/migrate.md b/documentation/reference/cli/migrate.md index cbf7cb156..74638736c 100644 --- a/documentation/reference/cli/migrate.md +++ b/documentation/reference/cli/migrate.md @@ -37,13 +37,13 @@ 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` | +| `Reconcile(ctx, req ctrl.Request) (ctrl.Result, error)` | `Reconcile(ctx context.Context, req domain.Request) (domain.Result, error)` | +| `return ctrl.Result{}, err` | `return domain.Result{}, err` | +| `return ctrl.Result{}, nil` | `return domain.Result{}, nil` | +| `return ctrl.Result{RequeueAfter: X}, nil` | `return domain.Result{RequeueAfter: X}, nil` | +| `req.NamespacedName` | `req.NamespacedName` (available on `domain.Request` directly) | +| `req.String()` | `req.String()` (preserved — `domain.Request` implements `Stringer`) | | `r.Status().Update(...)` | flagged `// TODO(ork migrate):` | -| `ctrl.Result{RequeueAfter: X}` | flagged `// TODO(ork migrate):` | | `SetupWithManager` method | removed with explanation comment | | `ctrl` import | removed | @@ -89,7 +89,7 @@ grep -rn "TODO(ork migrate)" ./my-operator/ ``` **toclient mode:** -1. Set `group`, `kind`, `plural`, `location` in `katalog.yaml` +1. Set `group` and `plural` in `katalog.yaml` — `kind`, `version`, `location`, `alias`, `object`, `objectList`, `resources:`, and `watch:` are auto-detected from `SetupWithManager` 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` diff --git a/documentation/reference/orkestra-notes/time.md b/documentation/reference/orkestra-notes/time.md index 14a4ca84c..e13cdde77 100644 --- a/documentation/reference/orkestra-notes/time.md +++ b/documentation/reference/orkestra-notes/time.md @@ -10,6 +10,7 @@ All timestamp notes accept RFC3339, RFC3339Nano, `2006-01-02T15:04:05Z`, and `YY |------|-------------| | `timeAgo` | Return a human-readable elapsed-time string from a timestamp. | | `timeSince` | Return the number of seconds elapsed since a timestamp as an integer. | +| `timeUntil` | Return a Go duration string representing the time remaining until a timestamp. | | `isExpired` | Return `true` when a timestamp plus a duration is in the past. | | `timeFormat` | Reformat a timestamp string using Go's time format layout. | | `durationSeconds` | Parse an extended duration string (Go units plus `d`/`w`/`mo`/`y`) and return the total number of seconds as an integer. | @@ -41,6 +42,22 @@ when: operator: gte value: "2592000" +# timeUntil +operatorBox: + reconciler: + requeue: + after: "{{ timeUntil .status.certExpiry }}" + when: + - field: status.certExpiry + operator: exists + +status: + fields: + - path: timeUntilExpiry + value: "{{ timeUntil .status.certExpiry }}" + # → "719h43m12s" (about 30 days) + # → "0s" (already expired) + # isExpired # Recreate the secret when the rotation annotation says it is due onCreate: diff --git a/documentation/reference/schema/02-katalog/02-crd-entry.md b/documentation/reference/schema/02-katalog/02-crd-entry.md index 183af0d84..9b55388ec 100644 --- a/documentation/reference/schema/02-katalog/02-crd-entry.md +++ b/documentation/reference/schema/02-katalog/02-crd-entry.md @@ -274,7 +274,7 @@ spec: label: "Certificate Issuer" category: "TLS" order: 20 - anyOf: + or: - field: workloadType equals: cert maintenanceMode: diff --git a/documentation/reference/schema/02-katalog/04-operatorbox.md b/documentation/reference/schema/02-katalog/04-operatorbox.md index 24713079f..8fb9658d0 100644 --- a/documentation/reference/schema/02-katalog/04-operatorbox.md +++ b/documentation/reference/schema/02-katalog/04-operatorbox.md @@ -63,7 +63,7 @@ operatorBox: - ... when: - ... - anyOf: + or: - ... rollBackOnError: false @@ -75,6 +75,48 @@ operatorBox: Groups the reconciler identity fields. Omit for declarative-only CRDs — GenericReconciler is the default. +### `reconciler.include` + +Loads a shared reconciler config from a file. The file's `reconciler:` block is merged under the inline config — inline fields take precedence over included ones. The path is resolved relative to the Katalog file. Cleared after expansion. + +Use this to share hooks location, function, resources, and tuning across targets that only differ in `args` or `preReconcile`: + +```yaml +# katalog.yaml +serve: + target: + v2-enabled: + operatorBox: + reconciler: + include: ./shared-reconciler.yaml + hooks: + args: + featureEnabled: "true" + + v2-disabled: + operatorBox: + reconciler: + include: ./shared-reconciler.yaml + hooks: + args: + featureEnabled: "false" +``` + +```yaml +# shared-reconciler.yaml +reconciler: + hooks: + location: github.com/myorg/operator/hooks + function: AppHooks + resources: + - kind: Deployment + - kind: Service + workers: 3 + resync: 30s +``` + +Inline `hooks.args` overrides anything declared in the file's `hooks.args`. The location, function, resources, workers, and resync are inherited from the file. + ### `reconciler.default` | Value | Behaviour | @@ -94,7 +136,7 @@ operatorBox: function: DatabaseHooks # exported function name alias: dbhooks # import alias (auto-derived if omitted) runHooksFirst: false # see below - resources: # RBAC verbs claimed for this hook + managedResources: # RBAC + implicit watch informer per type - kind: StatefulSet - kind: Service - kind: CronJob @@ -204,7 +246,7 @@ func onReconcile(ctx context.Context, obj *apiv1.App) error { | `timeout` | no | Per-call timeout. Default `5s`. | | `continueOnError` | no | When `true`, a failed call leaves `.external..body` empty rather than aborting reconciliation. Default `false`. | | `when` | no | AND-gate conditions. The call is skipped when any condition is false. Same `[]Condition` type as Katalog `when:` blocks — see [conditions reference](06-when-conditions.md). | -| `anyOf` | no | OR-gate conditions. The call is skipped when no condition is true. | +| `or` | no | OR-gate conditions. The call is skipped when no condition is true. | The full `external:` field reference (shared with the top-level `external:` block) is in [13-external.md](13-external.md). @@ -236,7 +278,7 @@ operatorBox: location: github.com/example/operator function: NewDatabaseReconciler alias: dbreconciler - resources: + managedResources: # RBAC + implicit watch informer per type - kind: StatefulSet - kind: Service args: @@ -245,6 +287,15 @@ operatorBox: notifyOnSuccess: true ``` +#### `reconciler.constructor.managedResources` + +Declares the Kubernetes resource types this constructor creates, updates, or deletes. Two things happen for each entry: + +- **RBAC** — `get/list/watch/create/update/patch/delete` permissions are generated for the operator ServiceAccount. +- **Implicit watch** — Orkestra starts a watch informer for the type. After the informer syncs, `r.client.Get` and `r.client.List` for that type are served from cache. When an owned resource changes and has an ownerReference pointing to the primary CR, Orkestra re-enqueues it automatically — no explicit `watch:` entry needed. + +If you need a field index, event filtering, or a different key resolution strategy, declare an explicit `watch:` entry for that type. It takes priority over the implicit informer from `managedResources:`. + #### `reconciler.constructor.args` Key/value pairs delivered to the constructor function via `kube.Args()`. The constructor receives `kube` with args already attached — no additional wiring required. @@ -311,7 +362,7 @@ Pre-reconcile gate conditions. Two sub-blocks control where in the pipeline the - **`enqueueGate`** — evaluated by the informer before the item enters the work queue. - **`reconcileGate`** — evaluated by the kordinator after the item is dequeued, before the reconciler is called. -`external:` calls can be declared at the `preReconcile:` level (shared, available to both gates) or inside either gate (gate-specific). Calls run in order — shared first, then gate-level. Results accumulate in the resolver under `.external..*` and are available to subsequent calls and `when:`/`anyOf:` conditions. +`external:` calls can be declared at the `preReconcile:` level (shared, available to both gates) or inside either gate (gate-specific). Calls run in order — shared first, then gate-level. Results accumulate in the resolver under `.external..*` and are available to subsequent calls and `when:`/`or:` conditions. ```yaml operatorBox: @@ -332,7 +383,7 @@ operatorBox: equals: "true" - field: "{{ .external.quota.body }}" equals: "available" - anyOf: + or: - field: "{{ .spec.environment }}" equals: "production" - field: "{{ .spec.environment }}" @@ -377,7 +428,7 @@ HTTP or gRPC calls declared here run before either gate. Results are available t | Health on gate | No effect | `gated` (idle) | No effect | | Supports `external:` | Yes | Yes | Yes | -All [condition operators](06-when-conditions.md#operators) are supported. `when:` requires ALL conditions to pass (AND). `anyOf:` requires at least one (OR). Both may be specified simultaneously — both must pass. +All [condition operators](06-when-conditions.md#operators) are supported. `when:` requires ALL conditions to pass (AND). `or:` requires at least one (OR). Both may be specified simultaneously — both must pass. See [Conditional Reconciliation](../../../concepts/conditional/04-conditional-reconciliation.md) for the full concept guide. @@ -416,7 +467,7 @@ autoscale: | `interval` | Evaluation frequency (default: `15s`) | | `cooldown` | Min time conditions must be false before restoring baseline (default: `2m`) | | `conditions.when` | AND conditions — all must be true | -| `conditions.anyOf` | OR conditions — at least one must be true | +| `conditions.or` | OR conditions — at least one must be true | | `do.workers` | Override concurrent goroutines when conditions are met | | `do.queueDepth` | Override max queue depth | | `do.resync` | Override resync interval | diff --git a/documentation/reference/schema/02-katalog/05-status.md b/documentation/reference/schema/02-katalog/05-status.md index 1b40fa1ed..17ec41503 100644 --- a/documentation/reference/schema/02-katalog/05-status.md +++ b/documentation/reference/schema/02-katalog/05-status.md @@ -8,7 +8,7 @@ Orkestra writes status in two layers — one automatic, one declarative. No declaration required. Every managed CR gets this. -**Layer 2 — declarative fields.** Declared under `operatorBox.status`. Fields with `when:`/`anyOf:` conditions are always evaluated — including when reconcile fails — so status can reflect why. Fields without conditions are only written on successful reconcile. +**Layer 2 — declarative fields.** Declared under `operatorBox.status`. Fields with `when:`/`or:` conditions are always evaluated — including when reconcile fails — so status can reflect why. Fields without conditions are only written on successful reconcile. ## Wire format @@ -57,7 +57,7 @@ fields: clearOnFalse: false # optional when: # optional — AND conditions - ... - anyOf: # optional — OR conditions + or: # optional — OR conditions - ... ``` @@ -69,10 +69,10 @@ fields: | `value` | yes | Value to write. Supports Go template expressions. Static strings skip parsing. | | `type` | no | Cast the resolved value before writing. Defaults to `string`. | | `when` | no | List of conditions — **all must pass** (AND). Field is skipped if any fails. | -| `anyOf` | no | List of conditions — **at least one must pass** (OR). | -| `clearOnFalse` | no | When `true` and the `when:`/`anyOf:` condition evaluates to false, write `""` to the field instead of leaving the previous value. Use for transient fields (crash reasons, warning messages) that should disappear when the triggering condition clears. No effect when no conditions are declared. | +| `or` | no | List of conditions — **at least one must pass** (OR). | +| `clearOnFalse` | no | When `true` and the `when:`/`or:` condition evaluates to false, write `""` to the field instead of leaving the previous value. Use for transient fields (crash reasons, warning messages) that should disappear when the triggering condition clears. No effect when no conditions are declared. | -When both `when` and `anyOf` are declared, both blocks must pass. +When both `when` and `or` are declared, both blocks must pass. ### `type` values @@ -141,7 +141,7 @@ Functions from the Orkestra note library useful in status values: | `toBool .spec.enabled` | Cast to bool | | `toString .spec.count` | Cast to string | -### `when` and `anyOf` conditions +### `when` and `or` conditions Each condition targets a dot-notation field path and applies an operator. @@ -155,7 +155,7 @@ when: value: "0" ``` -`status.fields[].when`/`anyOf` use the exact same `Condition` type, operators, and shorthand fields as resource-template `when:`/`anyOf:` — see [when/anyOf conditions § Operators](06-when-conditions.md#operators) for the full list (`equals`, `contains`, `prefix`/`suffix`, `regex`, `exists`/`notExists`, `gt`/`lt`/`gte`/`lte`/`between`, `in`/`notIn`, the `typeOf` family, and their shorthand names). Absent field is treated as `0` for numeric comparisons. +`status.fields[].when`/`or` use the exact same `Condition` type, operators, and shorthand fields as resource-template `when:`/`or:` — see [when/or conditions § Operators](06-when-conditions.md#operators) for the full list (`equals`, `contains`, `prefix`/`suffix`, `regex`, `exists`/`notExists`, `gt`/`lt`/`gte`/`lte`/`between`, `in`/`notIn`, the `typeOf` family, and their shorthand names). Absent field is treated as `0` for numeric comparisons. ## Example: declarative state machine diff --git a/documentation/reference/schema/02-katalog/06-when-conditions.md b/documentation/reference/schema/02-katalog/06-when-conditions.md index 69d161117..c7ea16b69 100644 --- a/documentation/reference/schema/02-katalog/06-when-conditions.md +++ b/documentation/reference/schema/02-katalog/06-when-conditions.md @@ -1,4 +1,4 @@ -# when / anyOf conditions +# when / or conditions Conditions control whether a resource template field is written during reconciliation. Used inside `operatorBox` and `autoscale`. @@ -11,7 +11,7 @@ operatorBox: value: "1" valueType: int - anyOf: + or: - field: spec.mode equals: production - field: spec.mode @@ -23,9 +23,9 @@ operatorBox: | Block | Behaviour | |-------|-----------| | `when` | AND — all conditions must be true | -| `anyOf` | OR — at least one condition must be true | +| `or` | OR — at least one condition must be true | -Both can be combined. The overall result is: `when` AND `anyOf`. +Both can be combined. The overall result is: `when` AND `or`. --- @@ -67,7 +67,7 @@ Compare a dot-notation path into the CR against a value. | `notBetween` | `notBetween` | Field is numerically outside an inclusive range. Value is `"min,max"` | | `in` | `in` | Field is one of a comma-separated list | | `notIn` | `notIn` | Field is none of a comma-separated list | -| `unique` | — | Field value must be unique across all existing instances of this CRD. Works in both `validation.rules` and `when:`/`anyOf:`, enforced at both reconcile time (a live, authoritative check) and admission time (a fast best-effort check against the runtime's cache) — see [unique](07-validation.md#validationrules) for the difference between the two | +| `unique` | — | Field value must be unique across all existing instances of this CRD. Works in both `validation.rules` and `when:`/`or:`, enforced at both reconcile time (a live, authoritative check) and admission time (a fast best-effort check against the runtime's cache) — see [unique](07-validation.md#validationrules) for the difference between the two | | `typeOf` / `typeMap` / `typeList` / `typeString` / `typeNumber` / `typeBool` / `typeNull` | — | Check the field's YAML type rather than its value. No shorthand — use `operator:` explicitly. | `gt`/`lt` are strict (exclusive); use `gte`/`lte` (or the `min`/`max` shorthand) for an inclusive bound. `min`/`max` and `greaterThanOrEqual`/`lessThanOrEqual` resolve to the same `gte`/`lte` operators — `min`/`max` read better for a bound on a quantity (`min: "1"`), `greaterThanOrEqual`/`lessThanOrEqual` for a direct comparison. Same operators and shorthand as [validation.rules](07-validation.md#operators) — the `Condition` type is shared by both. diff --git a/documentation/reference/schema/02-katalog/07-validation.md b/documentation/reference/schema/02-katalog/07-validation.md index 54dec5f9e..9b1552438 100644 --- a/documentation/reference/schema/02-katalog/07-validation.md +++ b/documentation/reference/schema/02-katalog/07-validation.md @@ -37,7 +37,7 @@ validation: when: - field: spec.workloadType equals: cert - anyOf: + or: - field: spec.workloadType equals: cert - field: spec.workloadType @@ -87,13 +87,13 @@ Each rule describes one check. Rules are evaluated in order. | `operator` + `value` | yes* | Explicit comparison (see operators). `value` supports Go templates. | | `valueType` | no | `string` (default), `int`, `float`, `bool` | | `when` | no | All conditions must pass for this rule to be evaluated (AND). Empty means unconditional. Conditions support Go template expressions via `EvaluateConditions`. | -| `anyOf` | no | At least one condition must pass for this rule to be evaluated (OR). When both `when` and `anyOf` are declared, both blocks must pass. | +| `or` | no | At least one condition must pass for this rule to be evaluated (OR). When both `when` and `or` are declared, both blocks must pass. | | `link` | no | The `serve.fields`/`serve labels/annotations` key this rule concerns, when `field:` isn't already a plain, self-describing path — see [Linking a rule to its form field](#linking-a-rule-to-its-form-field-link) below. | | `fires.reconcile` | no | `true` (default). Set `false` to make this rule admission-only — the reconciler skips it. Use for rules that read `.request.*` (raw intent) which is only present at the serve-layer admission boundary, not during reconcile. | *Use either an operator+value pair or a shorthand field. -`when` and `anyOf` use the same `Condition` type as resource templates — see [06-when-conditions.md](06-when-conditions.md) for the full operator reference. +`when` and `or` use the same `Condition` type as resource templates — see [06-when-conditions.md](06-when-conditions.md) for the full operator reference. ### Required fields are enforced automatically @@ -260,9 +260,9 @@ See [13-external.md](13-external.md) for the full field reference. ## Operators -`validation.rules` uses the exact same operator set and shorthand fields as `when:`/`anyOf:` — both are backed by the same `Condition`/`ConditionOperator` evaluation code (`pkg/types/validation_eval.go` and `pkg/types/when.go` share one operator table so the two can't drift apart). See [when/anyOf conditions § Operators](06-when-conditions.md#operators) for the full list. +`validation.rules` uses the exact same operator set and shorthand fields as `when:`/`or:` — both are backed by the same `Condition`/`ConditionOperator` evaluation code (`pkg/types/validation_eval.go` and `pkg/types/when.go` share one operator table so the two can't drift apart). See [when/or conditions § Operators](06-when-conditions.md#operators) for the full list. -One operator worth calling out: `unique` — field value must be unique across all existing instances of this CRD. It works the same way in `validation.rules` and in `when:`/`anyOf:` (e.g. gating a template source or mutation rule on whether a field is still available), and it's enforced at both reconcile and admission time — just via two different checks with different guarantees: +One operator worth calling out: `unique` — field value must be unique across all existing instances of this CRD. It works the same way in `validation.rules` and in `when:`/`or:` (e.g. gating a template source or mutation rule on whether a field is still available), and it's enforced at both reconcile and admission time — just via two different checks with different guarantees: - **Reconcile time** — the reconciler lists other instances via a live call against the API server. This is the authoritative check: immune to cache staleness, always correct. - **Admission time** — the gateway asks the runtime's own `/katalog/{crd}/cr?field=` endpoint (served from its informer cache, not a live API call) whether any other instance already has this value. This is a fast, best-effort early rejection, not a second source of truth — if the runtime's cache is a moment stale, a duplicate can still slip through admission, but it's always caught on the very next reconcile regardless. Nothing about the reconcile-time guarantee depends on admission catching it first. diff --git a/documentation/reference/schema/02-katalog/08-mutation.md b/documentation/reference/schema/02-katalog/08-mutation.md index 469f43234..83f603764 100644 --- a/documentation/reference/schema/02-katalog/08-mutation.md +++ b/documentation/reference/schema/02-katalog/08-mutation.md @@ -54,12 +54,12 @@ Each rule sets one field. Rules are applied in order. | `override` | one of | **Always** set, regardless of current value. Supports Go templates. | | `valueType` | no | `string` (default), `int`, `float`, `bool` | | `when` | no | All conditions must pass for this rule to be applied (AND). Empty means unconditional. Conditions support Go template expressions via `EvaluateConditions`. | -| `anyOf` | no | At least one condition must pass for this rule to be applied (OR). When both `when` and `anyOf` are declared, both blocks must pass. | +| `or` | no | At least one condition must pass for this rule to be applied (OR). When both `when` and `or` are declared, both blocks must pass. | | `fires.reconcile` | no | `true` (default). Set `false` to make this rule admission-only — the reconciler skips it. | Declare either `default` or `override` on each rule, not both. -`when` and `anyOf` use the same `Condition` type as resource templates — see [06-when-conditions.md](06-when-conditions.md) for the full operator reference. +`when` and `or` use the same `Condition` type as resource templates — see [06-when-conditions.md](06-when-conditions.md) for the full operator reference. ```yaml mutation: diff --git a/documentation/reference/schema/02-katalog/13-external.md b/documentation/reference/schema/02-katalog/13-external.md index 238070f6e..9effa87d2 100644 --- a/documentation/reference/schema/02-katalog/13-external.md +++ b/documentation/reference/schema/02-katalog/13-external.md @@ -40,7 +40,7 @@ operatorBox: | `expectedStatus` | no | `0` | When set: any other status code is a failure. When `0`: `4xx`/`5xx` is a failure, `2xx` succeeds. | | `continueOnError` | no | `false` | `false`: failure halts reconcile, writes `Ready=False`. `true`: failure logged, reconcile continues. | | `when` | no | `[]` | AND gate conditions. If any fail, the call is skipped and `.called = "false"`. | -| `anyOf` | no | `[]` | OR gate conditions. At least one must pass. Combined with `when:` using AND semantics. | +| `or` | no | `[]` | OR gate conditions. At least one must pass. Combined with `when:` using AND semantics. | | `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. | @@ -53,7 +53,7 @@ operatorBox: | `.external..status` | HTTP status code string (`"200"`, `"503"`). Empty on pre-response failure. | | `.external..body` | First 4096 bytes of response body. | | `.external..error` | Error message on failure; `""` on success. | -| `.external..called` | `"true"` when the call ran; `"false"` when skipped by `when:`/`anyOf:`. | +| `.external..called` | `"true"` when the call ran; `"false"` when skipped by `when:`/`or:`. | | `.external..` | When the response body is a valid JSON object, its top-level keys are merged in and navigable by dot path. | If the response is `{ "queue": { "pendingJobs": 8 } }` and the call is named `metrics`, then `external.metrics.queue.pendingJobs` is directly usable in `field:` conditions and `{{ .external.metrics.queue.pendingJobs }}` in template expressions. `.body` is always present alongside parsed fields. diff --git a/documentation/reference/schema/02-katalog/15-enrich.md b/documentation/reference/schema/02-katalog/15-enrich.md index 373fb12c1..36a3446d8 100644 --- a/documentation/reference/schema/02-katalog/15-enrich.md +++ b/documentation/reference/schema/02-katalog/15-enrich.md @@ -65,12 +65,12 @@ enrich: equals: "true" ``` -`when:` accepts the same condition operators as any other `when:` block — including template expressions that call note functions. `anyOf:` is also supported for OR semantics. +`when:` accepts the same condition operators as any other `when:` block — including template expressions that call note functions. `or:` is also supported for OR semantics. ```yaml enrich: - events: - anyOf: + or: - field: "{{ hasCrashingPod .children.deployment }}" equals: "true" - field: "{{ replicasReady .children.deployment }}" diff --git a/documentation/reference/schema/02-katalog/20-serve.md b/documentation/reference/schema/02-katalog/20-serve.md index 23d9427f8..2ab3e0e36 100644 --- a/documentation/reference/schema/02-katalog/20-serve.md +++ b/documentation/reference/schema/02-katalog/20-serve.md @@ -36,7 +36,7 @@ spec: label: "Certificate Issuer" category: "TLS" order: 20 - anyOf: + or: - field: workloadType equals: cert maintenanceMode: @@ -85,8 +85,8 @@ fields: | `category` | Section heading in the form. Fields with the same category are grouped together. Empty defaults to `"Spec"`. | | `order` | Sort order within the form *and* validation-rule priority — see note below. `0`/unset follows every field that declares one. | | `when` | All conditions must pass for this field to be shown (AND). Uses the same `Condition` type as resource templates — see [06-when-conditions.md](06-when-conditions.md). Evaluated client-side in the Control Center; gateway/admission is the backstop. | -| `anyOf` | At least one condition must pass for this field to be shown (OR). When both `when` and `anyOf` are declared, both blocks must pass. | -| `required` | When `true`, marks the field as mandatory — enforced both client-side (the browser shows an asterisk and blocks submission while empty) and server-side: an implicit `exists` rule with `action: deny` is synthesized automatically at load time, so every caller of the Gateway API is covered, not just the Control Center form. No matching `validation.rules` entry needs to be hand-written. Has no effect on fields currently hidden by a `when:` or `anyOf:` condition. | +| `or` | At least one condition must pass for this field to be shown (OR). When both `when` and `or` are declared, both blocks must pass. | +| `required` | When `true`, marks the field as mandatory — enforced both client-side (the browser shows an asterisk and blocks submission while empty) and server-side: an implicit `exists` rule with `action: deny` is synthesized automatically at load time, so every caller of the Gateway API is covered, not just the Control Center form. No matching `validation.rules` entry needs to be hand-written. Has no effect on fields currently hidden by a `when:` or `or:` condition. | | `disabled` | Non-empty string — field is rendered greyed-out with this message. Useful for platform-managed fields that should be visible but not editable. | | `path` | — | Dot-notation path mapping the field to a nested location in the CRD `spec`. When set, the field value is written to `spec.` instead of `spec.`. See [`path` — nested spec paths](#servefieldspath) below. | | `value` | — | Template expression that transforms the submitted value before writing to `spec.` (or `spec.`). Use `.value` for the raw submitted value. Mutually exclusive with `values`. → [Field translation](22-serve-field-translation.md) | diff --git a/documentation/reference/schema/02-katalog/26-serve-target-operatorbox.md b/documentation/reference/schema/02-katalog/26-serve-target-operatorbox.md index 50a7a340e..53c68a265 100644 --- a/documentation/reference/schema/02-katalog/26-serve-target-operatorbox.md +++ b/documentation/reference/schema/02-katalog/26-serve-target-operatorbox.md @@ -91,7 +91,7 @@ operatorBox: when: - field: "{{ len .spec.regions }}" notEquals: "0" - anyOf: + or: - field: '{{ .spec.tier }}' equals: premium ``` diff --git a/documentation/reference/schema/02-katalog/27-watch.md b/documentation/reference/schema/02-katalog/27-watch.md index 4e4c7eaa6..7dbb0118f 100644 --- a/documentation/reference/schema/02-katalog/27-watch.md +++ b/documentation/reference/schema/02-katalog/27-watch.md @@ -44,11 +44,56 @@ spec: | `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. | +| `include` | string | no | Path to a YAML file whose `watch:` list replaces this entry. See [`include`](#include). | Each `(apiVersion, kind, namespace)` combination must be unique across the `watch` list. --- +## `include` + +An entry with only `include:` set is replaced in-place by the `watch:` list from the referenced file. All other fields on that entry are ignored. The path is resolved relative to the Katalog file. + +```yaml +# katalog.yaml +operatorBox: + watch: + - include: ./shared-watches.yaml + - apiVersion: v1 + kind: Secret + name: api-credentials + namespace: default +``` + +```yaml +# shared-watches.yaml +watch: + - apiVersion: apps/v1 + kind: Deployment + - apiVersion: v1 + kind: ConfigMap + index: + - name: metadata.labels.app + field: metadata.labels.app +``` + +Multiple targets that share the same watched types can point to the same file instead of repeating entries: + +```yaml +serve: + target: + v2-enabled: + operatorBox: + watch: + - include: ./shared-watches.yaml + v2-disabled: + operatorBox: + watch: + - include: ./shared-watches.yaml +``` + +--- + ## Key resolution When an event fires on a watched object, Orkestra resolves which primary CR(s) to enqueue using this order (first match wins): @@ -96,6 +141,39 @@ A watch-triggered enqueue goes through the same `preReconcile.enqueueGate` as a --- +## `preReconcile` gates and `failPolicy` + +`preReconcile.enqueueGate` and `preReconcile.reconcileGate` both accept a `failPolicy:` field that controls what the gate does when it cannot evaluate — for example when an `external:` call fails or times out. + +| Value | Behaviour | +|-------|-----------| +| `open` (default) | Evaluation failure passes the gate — the CR is enqueued / reconciled as if the gate was not declared. | +| `closed` | Evaluation failure holds the gate — the CR is dropped from the queue or held back from the reconciler. | + +```yaml +operatorBox: + preReconcile: + reconcileGate: + failPolicy: closed # unknown state → hold back, do not reconcile + external: + - name: depHealth + url: "{{ .spec.dependencyUrl }}/health" + when: + - field: external.depHealth.status + equals: "200" +``` + +`open` is the safe default for `enqueueGate` — if you cannot evaluate, let the object through. `closed` is the right choice for `reconcileGate` when reconciling against an unknown dependency state is worse than missing a reconcile cycle. + +### Validator warnings + +`ork validate` emits a warning when: + +1. `external:` calls are declared on a gate but `failPolicy` is omitted — the default is `open`, which may not be the intent for `reconcileGate`. +2. `failPolicy: closed` is declared but all `external:` calls have `continueOnError: true` — `continueOnError` suppresses call errors before they reach the gate, so `closed` will never trigger. Use `when: external.*.error` conditions instead. + +--- + ## Validation `ork validate` enforces: @@ -104,3 +182,4 @@ A watch-triggered enqueue goes through the same `preReconcile.enqueueGate` as a - `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`. +- `failPolicy`, when present, is one of `open`, `closed`. diff --git a/documentation/reference/schema/02-katalog/28-requeue.md b/documentation/reference/schema/02-katalog/28-requeue.md new file mode 100644 index 000000000..7b73b50b9 --- /dev/null +++ b/documentation/reference/schema/02-katalog/28-requeue.md @@ -0,0 +1,70 @@ +# reconciler.requeue + +`reconciler.requeue` schedules a re-enqueue of the CR after a successful reconcile. The CR is added back to the workqueue after `after:` elapses — no informer event is needed. + +Failed reconciles use `queue.retryBackoff`, not `requeue:`. + +--- + +## Declaration + +```yaml +spec: + crds: + myapp: + operatorBox: + reconciler: + requeue: + after: '{{ .spec.checkInterval | default "60s" }}' + when: + - field: status.phase + notEquals: "Complete" +``` + +--- + +## Fields + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `after` | duration or template | — | How long to wait before re-enqueuing. A Go duration string (`"30s"`, `"5m"`, `"1h"`) or a template expression evaluated against the live CR. Empty string disables requeue. | +| `when` | `[]Condition` | — | AND conditions. Requeue only fires when all pass. Omit to requeue unconditionally after every successful reconcile. | +| `or` | `[]Condition` | — | OR conditions. When both `when:` and `or:` are present, both must pass. | + +### `after:` as a template + +The expression is evaluated against the live CR at reconcile time using the full template context (fields, notes, profiles). Each CR can carry its own timing: + +```yaml +after: '{{ .spec.syncInterval | default "2m" }}' +``` + +If the expression renders to an empty string, `"0s"`, or fails to parse as a duration, no requeue is scheduled. + +### Validation + +`ork validate` rejects a non-template `after:` value that is not a valid Go duration: + +```text +✗ crd "myapp": requeue.after "every-minute" is not a valid duration or template expression. + Use a Go duration string (e.g. "30s", "5m") or a template (e.g. '{{ .spec.interval | default "60s" }}') +``` + +--- + +## Relationship to other timing fields + +| Field | Error path | Success path | Scope | +|-------|-----------|--------------|-------| +| `queue.retryBackoff` | ✓ | — | Per CRD | +| `reconciler.resync` | — | ✓ (uniform) | Per CRD, all CRs | +| `reconciler.requeue` | — | ✓ (conditional) | Per CR, template-driven | + +`requeue:` and `resync:` are additive — whichever fires first re-enqueues the CR. + +--- + +## See also + +- [Requeue concept](../../../concepts/reconciler-model/03-requeue.md) +- [queue](14-queue.md) — `retryBackoff` and queue depth diff --git a/documentation/reference/schema/02-katalog/index.md b/documentation/reference/schema/02-katalog/index.md index d4ff64383..e861b798e 100644 --- a/documentation/reference/schema/02-katalog/index.md +++ b/documentation/reference/schema/02-katalog/index.md @@ -81,7 +81,7 @@ This scaffolds the simplest Katalog — a single CRD that creates a Deployment a | [03-apitypes.md](03-apitypes.md) | `apiTypes` — group, kind, version, plural, typed mode | | [04-operatorbox.md](04-operatorbox.md) | `operatorBox` — resource templates, reconciliation strategy, pre-reconcile gates | | [05-status.md](05-status.md) | `status` — fields written to CR status after reconcile | -| [06-when-conditions.md](06-when-conditions.md) | `when` / `anyOf` — conditional resource creation | +| [06-when-conditions.md](06-when-conditions.md) | `when` / `or` — conditional resource creation | | [07-validation.md](07-validation.md) | `validation` — admission rules | | [08-mutation.md](08-mutation.md) | `mutation` — admission defaults and overrides | | [09-conversion.md](09-conversion.md) | `conversion` — multi-version CRD support | diff --git a/documentation/reference/schema/04-e2e/01-spec.md b/documentation/reference/schema/04-e2e/01-spec.md index c6b41c4ac..87f9d3408 100644 --- a/documentation/reference/schema/04-e2e/01-spec.md +++ b/documentation/reference/schema/04-e2e/01-spec.md @@ -115,7 +115,7 @@ See [05-custom-target.md](05-custom-target.md) for full documentation and use ca ## `spec.notes` -Declares user-defined note functions available as template expressions in `when:` and `anyOf:` conditions on `expect` entries. Uses the same syntax as `notes:` in a Katalog — a list of named Go template expressions evaluated against the current context. +Declares user-defined note functions available as template expressions in `when:` and `or:` conditions on `expect` entries. Uses the same syntax as `notes:` in a Katalog — a list of named Go template expressions evaluated against the current context. ```yaml spec: @@ -125,7 +125,7 @@ spec: expression: '{{ and weekday (timeInWindow "09:00" "18:00") }}' ``` -Notes defined here are available by name in any `when:` or `anyOf:` condition on an `expect` entry: +Notes defined here are available by name in any `when:` or `or:` condition on an `expect` entry: ```yaml expect: diff --git a/documentation/reference/schema/04-e2e/03-expect.md b/documentation/reference/schema/04-e2e/03-expect.md index 5ece8894c..01814134a 100644 --- a/documentation/reference/schema/04-e2e/03-expect.md +++ b/documentation/reference/schema/04-e2e/03-expect.md @@ -29,8 +29,8 @@ expect: | `resources` | no | Resource state assertions, polled until passing. | | `commands` | no | Shell command assertions, run in the same polling loop. | | `kubectl` | no | Structured kubectl subcommand assertions. See [kubectl block](07-kubectl.md). | -| `when` | no | AND-gate: all conditions must be true or the checkpoint is skipped. See [Conditional checkpoints](#conditional-checkpoints-when-and-anyof). | -| `anyOf` | no | OR-gate: at least one condition must be true or the checkpoint is skipped. See [Conditional checkpoints](#conditional-checkpoints-when-and-anyof). | +| `when` | no | AND-gate: all conditions must be true or the checkpoint is skipped. See [Conditional checkpoints](#conditional-checkpoints-when-and-or). | +| `or` | no | OR-gate: at least one condition must be true or the checkpoint is skipped. See [Conditional checkpoints](#conditional-checkpoints-when-and-or). | | `onFailure` | no | Diagnostic kubectl and shell commands to run and print when this specific checkpoint fails. See [Per-expectation onFailure](#per-expectation-onfailure). | | `include` | no | Path to a YAML file containing a bare list of checkpoints to expand in place. See [Composing expectations](#composing-expectations-with-include). | @@ -111,11 +111,11 @@ commands: | `exists` | no | Output (trimmed) must be non-empty — field is present and has a value. | | `notExists` | no | Output (trimmed) must be empty — field is absent or unset. | -Multiple assertion fields on the same entry all apply — every one set must pass. These are evaluated with the same `Condition` operators as `when:`/`anyOf:` (see [when/anyOf conditions § Operators](../02-katalog/06-when-conditions.md#operators)), against a single synthetic `output` field holding the trimmed command output. +Multiple assertion fields on the same entry all apply — every one set must pass. These are evaluated with the same `Condition` operators as `when:`/`or:` (see [when/or conditions § Operators](../02-katalog/06-when-conditions.md#operators)), against a single synthetic `output` field holding the trimmed command output. --- -## Conditional checkpoints: `when` and `anyOf` +## Conditional checkpoints: `when` and `or` A checkpoint can be gated by runtime conditions. When the gate does not pass, the checkpoint is **skipped** — not failed. Skipped checkpoints appear in results as `~ name (skipped)` and are counted separately from passed and failed. @@ -146,7 +146,7 @@ expect: - name: Feature disabled outside business hours after: cr-applied timeout: 30s - anyOf: + or: - field: '{{ inBusinessHours }}' equals: "false" kubectl: @@ -170,21 +170,21 @@ when: equals: "true" ``` -### `anyOf` — OR gate +### `or` — OR gate -At least one condition in `anyOf` must be true. If no condition is true, the checkpoint is skipped. +At least one condition in `or` must be true. If no condition is true, the checkpoint is skipped. ```yaml -anyOf: +or: - field: '{{ inBusinessHours }}' equals: "false" ``` -Each entry in `when` or `anyOf` is a `Condition` — the same type used in Katalog `when:` blocks. See [Conditions reference](../02-katalog/06-when-conditions.md) for the full field list. +Each entry in `when` or `or` is a `Condition` — the same type used in Katalog `when:` blocks. See [Conditions reference](../02-katalog/06-when-conditions.md) for the full field list. Template expressions in `field` are evaluated using note functions declared in `spec.notes`. Built-in notes (`weekday`, `timeInWindow`, etc.) are always available. -Both `when` and `anyOf` can appear together on the same checkpoint — both gates must pass. +Both `when` and `or` can appear together on the same checkpoint — both gates must pass. --- diff --git a/documentation/reference/schema/04-e2e/07-kubectl.md b/documentation/reference/schema/04-e2e/07-kubectl.md index 0289435dd..99581992b 100644 --- a/documentation/reference/schema/04-e2e/07-kubectl.md +++ b/documentation/reference/schema/04-e2e/07-kubectl.md @@ -66,7 +66,7 @@ Every subcommand supports the same assertion fields: | `exists` | Output (trimmed) must be non-empty — field is present and has a value | | `notExists` | Output (trimmed) must be empty — field is absent or unset | -Multiple assertions on the same entry all apply. Empty fields are ignored. These are evaluated with the same `Condition` operators as `when:`/`anyOf:` (see [when/anyOf conditions & Operators](../02-katalog/06-when-conditions.md#operators)), against a single synthetic `output` field holding the trimmed command output — the numeric comparisons fail if the output is not parseable as a number. +Multiple assertions on the same entry all apply. Empty fields are ignored. These are evaluated with the same `Condition` operators as `when:`/`or:` (see [when/or conditions & Operators](../02-katalog/06-when-conditions.md#operators)), against a single synthetic `output` field holding the trimmed command output — the numeric comparisons fail if the output is not parseable as a number. `oneOf` is useful when the expected value is one of several valid strings — for example, a status field that reflects current runtime state: diff --git a/documentation/reference/schema/06-resources/clusterrolebindings.md b/documentation/reference/schema/06-resources/clusterrolebindings.md index 892fdfc15..ba1fb7feb 100644 --- a/documentation/reference/schema/06-resources/clusterrolebindings.md +++ b/documentation/reference/schema/06-resources/clusterrolebindings.md @@ -105,14 +105,14 @@ Reconcile: true — also apply this declaration as drift correction on every rec --- -### `anyOf` +### `or` Type: list -AnyOf holds OR conditions — at least one must pass for this resource to be created. Works alongside the existing Conditions (when:) field which uses AND semantics. +Or holds OR conditions — at least one must pass for this resource to be created. Works alongside the existing Conditions (when:) field which uses AND semantics. ```yaml -anyOf: +or: - field: spec.tier equals: pro - field: spec.tier @@ -153,6 +153,6 @@ Sleep injects an artificial delay into the reconcile of this resource. Useful fo | `subjects` | list | | `when` | list | | `reconcile` | boolean | -| `anyOf` | list | +| `or` | list | | `forEach` | object | | `sleep` | string | diff --git a/documentation/reference/schema/06-resources/clusterroles.md b/documentation/reference/schema/06-resources/clusterroles.md index 03d820bde..847160a7f 100644 --- a/documentation/reference/schema/06-resources/clusterroles.md +++ b/documentation/reference/schema/06-resources/clusterroles.md @@ -89,14 +89,14 @@ Reconcile: true — also apply this declaration as drift correction on every rec --- -### `anyOf` +### `or` Type: list -AnyOf holds OR conditions — at least one must pass for this resource to be created. Works alongside the existing Conditions (when:) field which uses AND semantics. +Or holds OR conditions — at least one must pass for this resource to be created. Works alongside the existing Conditions (when:) field which uses AND semantics. ```yaml -anyOf: +or: - field: spec.tier equals: pro - field: spec.tier @@ -136,6 +136,6 @@ Sleep injects an artificial delay into the reconcile of this resource. Useful fo | `rules` | list | | `when` | list | | `reconcile` | boolean | -| `anyOf` | list | +| `or` | list | | `forEach` | object | | `sleep` | string | diff --git a/documentation/reference/schema/06-resources/configmaps.md b/documentation/reference/schema/06-resources/configmaps.md index 5bed45403..640bde21b 100644 --- a/documentation/reference/schema/06-resources/configmaps.md +++ b/documentation/reference/schema/06-resources/configmaps.md @@ -121,11 +121,11 @@ ForEach declares dynamic expansion over a list field. When set, one source decla --- -### `anyOf` +### `or` Type: list -AnyOf holds OR conditions — at least one must pass for this resource to be created. Works alongside the existing Conditions (when:) field which uses AND semantics. +Or holds OR conditions — at least one must pass for this resource to be created. Works alongside the existing Conditions (when:) field which uses AND semantics. --- @@ -151,5 +151,5 @@ Sleep injects an artificial delay into the reconcile of this resource. Useful fo | `reconcile` | boolean | | `when` | list | | `forEach` | object | -| `anyOf` | list | +| `or` | list | | `sleep` | string | diff --git a/documentation/reference/schema/06-resources/cronjobs.md b/documentation/reference/schema/06-resources/cronjobs.md index e3dbdb144..c9acf6c2e 100644 --- a/documentation/reference/schema/06-resources/cronjobs.md +++ b/documentation/reference/schema/06-resources/cronjobs.md @@ -202,14 +202,14 @@ forEach: --- -### `anyOf` +### `or` Type: list -AnyOf holds OR conditions — at least one must pass for this resource to be created. Works alongside the existing Conditions (when:) field which uses AND semantics. +Or holds OR conditions — at least one must pass for this resource to be created. Works alongside the existing Conditions (when:) field which uses AND semantics. ```yaml -anyOf: +or: - field: spec.tier equals: pro - field: spec.tier @@ -283,7 +283,7 @@ Sleep injects an artificial delay into the reconcile of this resource. Useful fo | `startingDeadlineSeconds` | string | | `resources` | object | | `forEach` | object | -| `anyOf` | list | +| `or` | list | | `workingDirectory` | string | | `securityContext` | object | | `podSecurity` | object | diff --git a/documentation/reference/schema/06-resources/custom.md b/documentation/reference/schema/06-resources/custom.md index 43e56f50d..e04e16b15 100644 --- a/documentation/reference/schema/06-resources/custom.md +++ b/documentation/reference/schema/06-resources/custom.md @@ -125,14 +125,14 @@ Conditions allow templates to be selectively activated based on the CR's state, --- -### `anyOf` +### `or` Type: list -AnyOf holds OR conditions — at least one must pass for this resource to be created. Works alongside the existing Conditions (when:) field which uses AND semantics. +Or holds OR conditions — at least one must pass for this resource to be created. Works alongside the existing Conditions (when:) field which uses AND semantics. ```yaml -anyOf: +or: - field: spec.tier equals: pro - field: spec.tier @@ -175,6 +175,6 @@ Sleep injects an artificial delay into the reconcile of this resource. Useful fo | `hasStatus` | boolean | | `reconcile` | boolean | | `when` | list | -| `anyOf` | list | +| `or` | list | | `forEach` | object | | `sleep` | string | diff --git a/documentation/reference/schema/06-resources/deployments.md b/documentation/reference/schema/06-resources/deployments.md index 9a4dbe1d2..3ce54f04f 100644 --- a/documentation/reference/schema/06-resources/deployments.md +++ b/documentation/reference/schema/06-resources/deployments.md @@ -240,14 +240,14 @@ forEach: --- -### `anyOf` +### `or` Type: list -AnyOf holds OR conditions — at least one must pass for this resource to be created. Works alongside the existing Conditions (when:) field which uses AND semantics. +Or holds OR conditions — at least one must pass for this resource to be created. Works alongside the existing Conditions (when:) field which uses AND semantics. ```yaml -anyOf: +or: - field: spec.tier equals: pro - field: spec.tier @@ -429,7 +429,7 @@ autoscale: | `reconcile` | boolean | | `when` | list | | `forEach` | object | -| `anyOf` | list | +| `or` | list | | `workingDirectory` | string | | `probes` | object | | `securityContext` | object | diff --git a/documentation/reference/schema/06-resources/horizontalpodautoscalers.md b/documentation/reference/schema/06-resources/horizontalpodautoscalers.md index d76a1b2ec..efa5c1b7f 100644 --- a/documentation/reference/schema/06-resources/horizontalpodautoscalers.md +++ b/documentation/reference/schema/06-resources/horizontalpodautoscalers.md @@ -143,14 +143,14 @@ Conditions allow templates to be selectively activated based on the CR's state, --- -### `anyOf` +### `or` Type: list -AnyOf holds OR conditions — at least one must pass for this resource to be created. Works alongside the existing Conditions (when:) field which uses AND semantics. +Or holds OR conditions — at least one must pass for this resource to be created. Works alongside the existing Conditions (when:) field which uses AND semantics. ```yaml -anyOf: +or: - field: spec.tier equals: pro - field: spec.tier @@ -195,6 +195,6 @@ Sleep injects an artificial delay into the reconcile of this resource. Useful fo | `labels` | map | | `reconcile` | boolean | | `when` | list | -| `anyOf` | list | +| `or` | list | | `forEach` | object | | `sleep` | string | diff --git a/documentation/reference/schema/06-resources/ingresses.md b/documentation/reference/schema/06-resources/ingresses.md index 309c19985..03499205b 100644 --- a/documentation/reference/schema/06-resources/ingresses.md +++ b/documentation/reference/schema/06-resources/ingresses.md @@ -159,14 +159,14 @@ Conditions allow templates to be selectively activated based on the CR's state, --- -### `anyOf` +### `or` Type: list -AnyOf holds OR conditions — at least one must pass for this resource to be created. Works alongside the existing Conditions (when:) field which uses AND semantics. +Or holds OR conditions — at least one must pass for this resource to be created. Works alongside the existing Conditions (when:) field which uses AND semantics. ```yaml -anyOf: +or: - field: spec.tier equals: pro - field: spec.tier @@ -214,6 +214,6 @@ Sleep injects an artificial delay into the reconcile of this resource. Useful fo | `tls` | object | | `reconcile` | boolean | | `when` | list | -| `anyOf` | list | +| `or` | list | | `forEach` | object | | `sleep` | string | diff --git a/documentation/reference/schema/06-resources/jobs.md b/documentation/reference/schema/06-resources/jobs.md index 34a6da438..ea3c48848 100644 --- a/documentation/reference/schema/06-resources/jobs.md +++ b/documentation/reference/schema/06-resources/jobs.md @@ -156,14 +156,14 @@ forEach: --- -### `anyOf` +### `or` Type: list -AnyOf holds OR conditions — at least one must pass for this resource to be created. Works alongside the existing Conditions (when:) field which uses AND semantics. +Or holds OR conditions — at least one must pass for this resource to be created. Works alongside the existing Conditions (when:) field which uses AND semantics. ```yaml -anyOf: +or: - field: spec.tier equals: pro - field: spec.tier @@ -273,7 +273,7 @@ Sleep injects an artificial delay into the reconcile of this resource. Useful fo | `when` | list | | `reconcile` | boolean | | `forEach` | object | -| `anyOf` | list | +| `or` | list | | `workingDirectory` | string | | `resources` | object | | `securityContext` | object | diff --git a/documentation/reference/schema/06-resources/limitranges.md b/documentation/reference/schema/06-resources/limitranges.md index 975759ab1..3714d4bb4 100644 --- a/documentation/reference/schema/06-resources/limitranges.md +++ b/documentation/reference/schema/06-resources/limitranges.md @@ -141,11 +141,11 @@ Conditions (when:) — all must pass for this resource to be applied. --- -### `anyOf` +### `or` Type: list -AnyOf — at least one must pass. +Or — at least one must pass. --- @@ -186,7 +186,7 @@ Sleep injects an artificial delay. Accepts extended duration units (s, m, h, d, | `limits` | list | | `labels` | map | | `when` | list | -| `anyOf` | list | +| `or` | list | | `reconcile` | boolean | | `forEach` | object | | `sleep` | string | diff --git a/documentation/reference/schema/06-resources/namespaces.md b/documentation/reference/schema/06-resources/namespaces.md index ce12374c0..6780a0a27 100644 --- a/documentation/reference/schema/06-resources/namespaces.md +++ b/documentation/reference/schema/06-resources/namespaces.md @@ -97,14 +97,14 @@ forEach: --- -### `anyOf` +### `or` Type: list -AnyOf holds OR conditions — at least one must pass for this resource to be created. Works alongside the existing Conditions (when:) field which uses AND semantics. +Or holds OR conditions — at least one must pass for this resource to be created. Works alongside the existing Conditions (when:) field which uses AND semantics. ```yaml -anyOf: +or: - field: spec.tier equals: pro - field: spec.tier @@ -131,5 +131,5 @@ Sleep injects an artificial delay into the reconcile of this resource. Useful fo | `when` | list | | `reconcile` | boolean | | `forEach` | object | -| `anyOf` | list | +| `or` | list | | `sleep` | string | diff --git a/documentation/reference/schema/06-resources/networkpolicies.md b/documentation/reference/schema/06-resources/networkpolicies.md index bf5fbff56..23d4a1c58 100644 --- a/documentation/reference/schema/06-resources/networkpolicies.md +++ b/documentation/reference/schema/06-resources/networkpolicies.md @@ -150,11 +150,11 @@ Conditions (when:) — all must pass for this resource to be applied. --- -### `anyOf` +### `or` Type: list -AnyOf — at least one must pass. +Or — at least one must pass. --- @@ -205,7 +205,7 @@ Sleep injects an artificial delay. Accepts extended duration units (s, m, h, d, | `policyTypes` | list | | `labels` | map | | `when` | list | -| `anyOf` | list | +| `or` | list | | `profile` | string | | `reconcile` | boolean | | `forEach` | object | diff --git a/documentation/reference/schema/06-resources/persistentvolumeclaims.md b/documentation/reference/schema/06-resources/persistentvolumeclaims.md index 1b12284db..fae0847e0 100644 --- a/documentation/reference/schema/06-resources/persistentvolumeclaims.md +++ b/documentation/reference/schema/06-resources/persistentvolumeclaims.md @@ -118,14 +118,14 @@ Conditions allow templates to be selectively activated based on the CR's state, --- -### `anyOf` +### `or` Type: list -AnyOf holds OR conditions — at least one must pass for this resource to be created. Works alongside the existing Conditions (when:) field which uses AND semantics. +Or holds OR conditions — at least one must pass for this resource to be created. Works alongside the existing Conditions (when:) field which uses AND semantics. ```yaml -anyOf: +or: - field: spec.tier equals: pro - field: spec.tier @@ -170,6 +170,6 @@ Sleep injects an artificial delay into the reconcile of this resource. Useful fo | `labels` | map | | `reconcile` | boolean | | `when` | list | -| `anyOf` | list | +| `or` | list | | `forEach` | object | | `sleep` | string | diff --git a/documentation/reference/schema/06-resources/persistentvolumes.md b/documentation/reference/schema/06-resources/persistentvolumes.md index 04a61e972..f27294216 100644 --- a/documentation/reference/schema/06-resources/persistentvolumes.md +++ b/documentation/reference/schema/06-resources/persistentvolumes.md @@ -137,14 +137,14 @@ Conditions allow templates to be selectively activated based on the CR's state, --- -### `anyOf` +### `or` Type: list -AnyOf holds OR conditions — at least one must pass for this resource to be created. Works alongside the existing Conditions (when:) field which uses AND semantics. +Or holds OR conditions — at least one must pass for this resource to be created. Works alongside the existing Conditions (when:) field which uses AND semantics. ```yaml -anyOf: +or: - field: spec.tier equals: pro - field: spec.tier @@ -190,6 +190,6 @@ Sleep injects an artificial delay into the reconcile of this resource. Useful fo | `labels` | map | | `reconcile` | boolean | | `when` | list | -| `anyOf` | list | +| `or` | list | | `forEach` | object | | `sleep` | string | diff --git a/documentation/reference/schema/06-resources/poddisruptionbudgets.md b/documentation/reference/schema/06-resources/poddisruptionbudgets.md index e63eeb11d..e4bdc2a97 100644 --- a/documentation/reference/schema/06-resources/poddisruptionbudgets.md +++ b/documentation/reference/schema/06-resources/poddisruptionbudgets.md @@ -119,14 +119,14 @@ Conditions allow templates to be selectively activated based on the CR's state, --- -### `anyOf` +### `or` Type: list -AnyOf holds OR conditions — at least one must pass for this resource to be created. Works alongside the existing Conditions (when:) field which uses AND semantics. +Or holds OR conditions — at least one must pass for this resource to be created. Works alongside the existing Conditions (when:) field which uses AND semantics. ```yaml -anyOf: +or: - field: spec.tier equals: pro - field: spec.tier @@ -170,6 +170,6 @@ Sleep injects an artificial delay into the reconcile of this resource. Useful fo | `behavior` | object | | `reconcile` | boolean | | `when` | list | -| `anyOf` | list | +| `or` | list | | `forEach` | object | | `sleep` | string | diff --git a/documentation/reference/schema/06-resources/pods.md b/documentation/reference/schema/06-resources/pods.md index 0977e8e47..62e3a97c2 100644 --- a/documentation/reference/schema/06-resources/pods.md +++ b/documentation/reference/schema/06-resources/pods.md @@ -165,14 +165,14 @@ forEach: --- -### `anyOf` +### `or` Type: list -AnyOf holds OR conditions — at least one must pass for this resource to be created. Works alongside the existing Conditions (when:) field which uses AND semantics. +Or holds OR conditions — at least one must pass for this resource to be created. Works alongside the existing Conditions (when:) field which uses AND semantics. ```yaml -anyOf: +or: - field: spec.tier equals: pro - field: spec.tier @@ -285,7 +285,7 @@ Sleep injects an artificial delay into the reconcile of this resource. Useful fo | `serviceAccountName` | string | | `when` | list | | `forEach` | object | -| `anyOf` | list | +| `or` | list | | `probes` | object | | `reconcile` | boolean | | `securityContext` | object | diff --git a/documentation/reference/schema/06-resources/replicasets.md b/documentation/reference/schema/06-resources/replicasets.md index 6440c6de9..caef8ac88 100644 --- a/documentation/reference/schema/06-resources/replicasets.md +++ b/documentation/reference/schema/06-resources/replicasets.md @@ -216,11 +216,11 @@ Autoscale declares workload autoscaling behaviour for this ReplicaSet. --- -### `anyOf` +### `or` Type: list -AnyOf holds OR conditions — at least one must pass for this resource. +Or holds OR conditions — at least one must pass for this resource. --- @@ -310,7 +310,7 @@ Sleep injects an artificial delay into the reconcile of this resource. Useful fo | `when` | list | | `forEach` | object | | `autoscale` | object | -| `anyOf` | list | +| `or` | list | | `workingDirectory` | string | | `probes` | object | | `securityContext` | object | diff --git a/documentation/reference/schema/06-resources/resourcequotas.md b/documentation/reference/schema/06-resources/resourcequotas.md index 3bdb1054e..f75c71437 100644 --- a/documentation/reference/schema/06-resources/resourcequotas.md +++ b/documentation/reference/schema/06-resources/resourcequotas.md @@ -127,11 +127,11 @@ Conditions (when:) — all must pass for this resource to be applied. --- -### `anyOf` +### `or` Type: list -AnyOf — at least one must pass. +Or — at least one must pass. --- @@ -179,7 +179,7 @@ Sleep injects an artificial delay. Accepts extended duration units (s, m, h, d, | `hard` | map | | `labels` | map | | `when` | list | -| `anyOf` | list | +| `or` | list | | `reconcile` | boolean | | `forEach` | object | | `profile` | string | diff --git a/documentation/reference/schema/06-resources/rolebindings.md b/documentation/reference/schema/06-resources/rolebindings.md index 3e13cf1b0..5d6b7528e 100644 --- a/documentation/reference/schema/06-resources/rolebindings.md +++ b/documentation/reference/schema/06-resources/rolebindings.md @@ -126,14 +126,14 @@ forEach: --- -### `anyOf` +### `or` Type: list -AnyOf holds OR conditions — at least one must pass for this resource to be created. Works alongside the existing Conditions (when:) field which uses AND semantics. +Or holds OR conditions — at least one must pass for this resource to be created. Works alongside the existing Conditions (when:) field which uses AND semantics. ```yaml -anyOf: +or: - field: spec.tier equals: pro - field: spec.tier @@ -162,5 +162,5 @@ Sleep injects an artificial delay into the reconcile of this resource. Useful fo | `when` | list | | `reconcile` | boolean | | `forEach` | object | -| `anyOf` | list | +| `or` | list | | `sleep` | string | diff --git a/documentation/reference/schema/06-resources/roles.md b/documentation/reference/schema/06-resources/roles.md index edc04417e..ca28c23be 100644 --- a/documentation/reference/schema/06-resources/roles.md +++ b/documentation/reference/schema/06-resources/roles.md @@ -112,14 +112,14 @@ forEach: --- -### `anyOf` +### `or` Type: list -AnyOf holds OR conditions — at least one must pass for this resource to be created. Works alongside the existing Conditions (when:) field which uses AND semantics. +Or holds OR conditions — at least one must pass for this resource to be created. Works alongside the existing Conditions (when:) field which uses AND semantics. ```yaml -anyOf: +or: - field: spec.tier equals: pro - field: spec.tier @@ -147,5 +147,5 @@ Sleep injects an artificial delay into the reconcile of this resource. Useful fo | `when` | list | | `reconcile` | boolean | | `forEach` | object | -| `anyOf` | list | +| `or` | list | | `sleep` | string | diff --git a/documentation/reference/schema/06-resources/secrets.md b/documentation/reference/schema/06-resources/secrets.md index 2b5bac6ed..d8eb3d304 100644 --- a/documentation/reference/schema/06-resources/secrets.md +++ b/documentation/reference/schema/06-resources/secrets.md @@ -140,11 +140,11 @@ ForEach declares dynamic expansion (same as other resource types) --- -### `anyOf` +### `or` Type: list -AnyOf holds OR conditions (same as other resource types) +Or holds OR conditions (same as other resource types) --- @@ -240,7 +240,7 @@ Sleep injects an artificial delay into the reconcile of this resource. Useful fo | `reconcile` | boolean | | `when` | list | | `forEach` | object | -| `anyOf` | list | +| `or` | list | | `once` | boolean | | `rotateAfter` | string | | `tls` | object | diff --git a/documentation/reference/schema/06-resources/serviceaccounts.md b/documentation/reference/schema/06-resources/serviceaccounts.md index 8221a0438..d4d549610 100644 --- a/documentation/reference/schema/06-resources/serviceaccounts.md +++ b/documentation/reference/schema/06-resources/serviceaccounts.md @@ -93,14 +93,14 @@ forEach: --- -### `anyOf` +### `or` Type: list -AnyOf holds OR conditions — at least one must pass for this resource to be created. Works alongside the existing Conditions (when:) field which uses AND semantics. +Or holds OR conditions — at least one must pass for this resource to be created. Works alongside the existing Conditions (when:) field which uses AND semantics. ```yaml -anyOf: +or: - field: spec.tier equals: pro - field: spec.tier @@ -127,5 +127,5 @@ Sleep injects an artificial delay into the reconcile of this resource. Useful fo | `when` | list | | `reconcile` | boolean | | `forEach` | object | -| `anyOf` | list | +| `or` | list | | `sleep` | string | diff --git a/documentation/reference/schema/06-resources/services.md b/documentation/reference/schema/06-resources/services.md index 6f02f4e24..d26c2cb6d 100644 --- a/documentation/reference/schema/06-resources/services.md +++ b/documentation/reference/schema/06-resources/services.md @@ -157,14 +157,14 @@ forEach: --- -### `anyOf` +### `or` Type: list -AnyOf holds OR conditions — at least one must pass for this resource to be created. Works alongside the existing Conditions (when:) field which uses AND semantics. +Or holds OR conditions — at least one must pass for this resource to be created. Works alongside the existing Conditions (when:) field which uses AND semantics. ```yaml -anyOf: +or: - field: spec.tier equals: pro - field: spec.tier @@ -197,5 +197,5 @@ Sleep injects an artificial delay into the reconcile of this resource. Useful fo | `reconcile` | boolean | | `when` | list | | `forEach` | object | -| `anyOf` | list | +| `or` | list | | `sleep` | string | diff --git a/documentation/reference/schema/06-resources/statefulsets.md b/documentation/reference/schema/06-resources/statefulsets.md index 10ec5e56c..801181fd7 100644 --- a/documentation/reference/schema/06-resources/statefulsets.md +++ b/documentation/reference/schema/06-resources/statefulsets.md @@ -226,14 +226,14 @@ Conditions allow templates to be selectively activated based on the CR's state, --- -### `anyOf` +### `or` Type: list -AnyOf holds OR conditions — at least one must pass for this resource to be created. Works alongside the existing Conditions (when:) field which uses AND semantics. +Or holds OR conditions — at least one must pass for this resource to be created. Works alongside the existing Conditions (when:) field which uses AND semantics. ```yaml -anyOf: +or: - field: spec.tier equals: pro - field: spec.tier @@ -397,7 +397,7 @@ Sleep injects an artificial delay into the reconcile of this resource. Useful fo | `resources` | object | | `reconcile` | boolean | | `when` | list | -| `anyOf` | list | +| `or` | list | | `forEach` | object | | `autoscale` | object | | `probes` | object | diff --git a/documentation/reference/schema/index.md b/documentation/reference/schema/index.md index 874ff1748..9c6f779cf 100644 --- a/documentation/reference/schema/index.md +++ b/documentation/reference/schema/index.md @@ -25,7 +25,7 @@ All fields that live inside a Katalog `spec.crds.` entry: | [apitypes](02-katalog/03-apitypes.md) | `apiTypes` — group, kind, version, typed mode | | [operatorbox](02-katalog/04-operatorbox.md) | `operatorBox` — reconciliation strategy | | [status](02-katalog/05-status.md) | `status` — fields written after reconcile | -| [when-conditions](02-katalog/06-when-conditions.md) | `when` / `anyOf` conditions | +| [when-conditions](02-katalog/06-when-conditions.md) | `when` / `or` conditions | | [validation](02-katalog/07-validation.md) | `validation` — admission rules | | [mutation](02-katalog/08-mutation.md) | `mutation` — admission defaults and overrides | | [conversion](02-katalog/09-conversion.md) | `conversion` — multi-version CRD support | diff --git a/domain/domain.go b/domain/domain.go index 1abb08dd1..0ef36e5a9 100644 --- a/domain/domain.go +++ b/domain/domain.go @@ -18,6 +18,8 @@ type Komponent interface { } type Reconciler interface { - // Reconcile handles the actual business logic for a resource - Reconcile(ctx context.Context, key string) error + // Reconcile handles the actual business logic for a resource. + // Return a non-zero Result.RequeueAfter to schedule a precise re-enqueue + // after a successful reconcile. Return an error to trigger retryBackoff. + Reconcile(ctx context.Context, req Request) (Result, error) } diff --git a/domain/reconciler_adapter.go b/domain/reconciler_adapter.go index 8e2a0e796..c6d459195 100644 --- a/domain/reconciler_adapter.go +++ b/domain/reconciler_adapter.go @@ -12,10 +12,8 @@ import ( // 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. +// ctrl.Result.RequeueAfter is forwarded to domain.Result so migrated operators +// that return precise per-object requeue timing have that honored by Orkestra's queue. // // Usage: // @@ -34,14 +32,13 @@ type ctrlReconcilerAdapter struct { var _ Reconciler = (*ctrlReconcilerAdapter)(nil) -func (a *ctrlReconcilerAdapter) Reconcile(ctx context.Context, key string) error { - ns, name, err := cache.SplitMetaNamespaceKey(key) +func (a *ctrlReconcilerAdapter) Reconcile(ctx context.Context, req Request) (Result, error) { + ns, name, err := cache.SplitMetaNamespaceKey(req.Key) if err != nil { - return err + return Result{}, err } - // ctrl.Result is intentionally discarded — Orkestra manages requeue. - _, err = a.r.Reconcile(ctx, reconcile.Request{ + result, err := a.r.Reconcile(ctx, reconcile.Request{ NamespacedName: types.NamespacedName{Namespace: ns, Name: name}, }) - return err + return Result{RequeueAfter: result.RequeueAfter}, err } diff --git a/domain/request.go b/domain/request.go new file mode 100644 index 000000000..04c977c92 --- /dev/null +++ b/domain/request.go @@ -0,0 +1,23 @@ +package domain + +import "k8s.io/apimachinery/pkg/types" + +// Request carries the identity of the object being reconciled. +// Mirrors reconcile.Request from controller-runtime so fields can be added +// without breaking the Reconciler interface. +type Request struct { + // Key is "namespace/name" — the canonical queue identifier. + Key string + // NamespacedName is parsed from Key for convenience. + NamespacedName types.NamespacedName +} + +// String returns the Key — satisfies fmt.Stringer and matches +// the ctrl.Request.String() behaviour that migrated code may reference. +func (r Request) String() string { return r.Key } + +// Namespace returns the namespace component of the key. +func (r Request) Namespace() string { return r.NamespacedName.Namespace } + +// Name returns the name component of the key. +func (r Request) Name() string { return r.NamespacedName.Name } diff --git a/domain/result.go b/domain/result.go new file mode 100644 index 000000000..1f46d38c5 --- /dev/null +++ b/domain/result.go @@ -0,0 +1,11 @@ +package domain + +import "time" + +// Result is returned by Reconcile to signal post-reconcile scheduling intent. +// Zero value means: no special scheduling — the runtime's resync and queue handle it. +type Result struct { + // RequeueAfter schedules an exact re-enqueue of this object after the given duration. + // Ignored if zero. Honored only on successful reconcile; error path uses retryBackoff. + RequeueAfter time.Duration +} diff --git a/examples/README.md b/examples/README.md index e77f66e87..5937d18d0 100644 --- a/examples/README.md +++ b/examples/README.md @@ -123,7 +123,7 @@ Real-world patterns combining multiple Orkestra features. | Example | What you learn | |---------|----------------| -| [Full-Stack App](./use-cases/full-stack-app/) | forEach + external + cross + once + anyOf in one CR. | +| [Full-Stack App](./use-cases/full-stack-app/) | forEach + external + cross + once + or in one CR. | | [Multi-Region Map](./use-cases/multi-region-map/) | Deploy across regions using `forEach` over a map. | | [CRD Conversion](./use-cases/crd-conversion/) | Multi-version CRDs with or without a conversion webhook. | | [Custom Target](./use-cases/custom-operator/) | `spec.custom.target: kubernetes` — use `ork e2e` as a test harness for any operator. | diff --git a/examples/advanced/09-hooks/katalog.yaml b/examples/advanced/09-hooks/katalog.yaml index 625ab6d6a..54a6a0ce9 100644 --- a/examples/advanced/09-hooks/katalog.yaml +++ b/examples/advanced/09-hooks/katalog.yaml @@ -40,7 +40,7 @@ spec: location: github.com/orkspace/orkestra-hooks-demo/hooks function: DatabaseHooks alias: dbhooks - resources: # A list of resources managed by this hook (required for RBAC) + managedResources: # A list of resources managed by this hook (required for RBAC) - kind: StatefulSet - kind: Service - kind: CronJob diff --git a/examples/advanced/10-constructor/katalog.yaml b/examples/advanced/10-constructor/katalog.yaml index 2fdcb52cd..1206aa1a9 100644 --- a/examples/advanced/10-constructor/katalog.yaml +++ b/examples/advanced/10-constructor/katalog.yaml @@ -41,7 +41,7 @@ spec: # # (Required for RBAC) # Define the resources managed by this reconciler - resources: + managedResources: - kind: Job # For built-ins, only kind is required group: batch version: v1 diff --git a/examples/advanced/10-constructor/reconciler/pipeline_reconciler.go b/examples/advanced/10-constructor/reconciler/pipeline_reconciler.go index 8f5e1fd75..1299baaf4 100644 --- a/examples/advanced/10-constructor/reconciler/pipeline_reconciler.go +++ b/examples/advanced/10-constructor/reconciler/pipeline_reconciler.go @@ -62,38 +62,39 @@ func NewPipelineReconciler(kube kubeclient.Interface) domain.Reconciler { // Reconcile is called by Orkestra's worker pool for every queued Pipeline key. // It is wrapped in safeReconcile — panics are caught and returned as errors. -func (r *PipelineReconciler) Reconcile(ctx context.Context, key string) error { +func (r *PipelineReconciler) Reconcile(ctx context.Context, req domain.Request) (domain.Result, error) { + key := req.Key namespace, _, err := cache.SplitMetaNamespaceKey(key) if err != nil { - return fmt.Errorf("invalid key %q: %w", key, err) + return domain.Result{}, fmt.Errorf("invalid key %q: %w", key, err) } // Read from the informer cache — no API call raw, exists, err := r.kube.GetInformer().GetIndexer().GetByKey(key) if err != nil { - return fmt.Errorf("cache lookup %q: %w", key, err) + return domain.Result{}, fmt.Errorf("cache lookup %q: %w", key, err) } if !exists { // CR deleted and finalizers already removed — nothing to do - return nil + return domain.Result{}, nil } pipeline, ok := raw.(*apiv1.Pipeline) if !ok { - return fmt.Errorf("unexpected type %T for key %q", raw, key) + return domain.Result{}, fmt.Errorf("unexpected type %T for key %q", raw, key) } pipeline = pipeline.DeepCopyObject().(*apiv1.Pipeline) // ── Deletion handling ────────────────────────────────────────────────── if pipeline.DeletionTimestamp != nil { - return r.handleDeletion(ctx, pipeline) + return domain.Result{}, r.handleDeletion(ctx, pipeline) } // ── Finalizer ───────────────────────────────────────────────────────── if !containsFinalizer(pipeline, finalizerName) { pipeline.Finalizers = append(pipeline.Finalizers, finalizerName) if err := r.kube.PatchFinalizers(ctx, pipeline, pipeline.Finalizers); err != nil { - return fmt.Errorf("adding finalizer: %w", err) + return domain.Result{}, fmt.Errorf("adding finalizer: %w", err) } } @@ -102,14 +103,14 @@ func (r *PipelineReconciler) Reconcile(ctx context.Context, key string) error { // ── State machine ───────────────────────────────────────────────────── switch pipeline.Status.Phase { case "", apiv1.PipelinePhasePending: - return r.handlePending(ctx, pipeline) + return domain.Result{}, r.handlePending(ctx, pipeline) case apiv1.PipelinePhaseRunning: - return r.handleRunning(ctx, pipeline) + return domain.Result{}, r.handleRunning(ctx, pipeline) case apiv1.PipelinePhaseSucceeded, apiv1.PipelinePhaseFailed: // Terminal state — nothing to reconcile - return nil + return domain.Result{}, nil default: - return fmt.Errorf("unknown phase %q for pipeline %s/%s", + return domain.Result{}, fmt.Errorf("unknown phase %q for pipeline %s/%s", pipeline.Status.Phase, pipeline.Namespace, pipeline.Name) } } diff --git a/examples/advanced/11-mixed-operator-pattern/09-hooks/katalog.yaml b/examples/advanced/11-mixed-operator-pattern/09-hooks/katalog.yaml index b3638c309..3996e368d 100644 --- a/examples/advanced/11-mixed-operator-pattern/09-hooks/katalog.yaml +++ b/examples/advanced/11-mixed-operator-pattern/09-hooks/katalog.yaml @@ -39,7 +39,7 @@ spec: location: github.com/orkspace/orkestra-mixed-operator-pattern/09-hooks/hooks function: DatabaseHooks alias: dbhooks - resources: # A list of resources managed by this hook (required for RBAC) + managedResources: # A list of resources managed by this hook (required for RBAC) - kind: StatefulSet - kind: Service - kind: CronJob diff --git a/examples/advanced/11-mixed-operator-pattern/10-constructor/katalog.yaml b/examples/advanced/11-mixed-operator-pattern/10-constructor/katalog.yaml index a87fdaac6..de40ca577 100644 --- a/examples/advanced/11-mixed-operator-pattern/10-constructor/katalog.yaml +++ b/examples/advanced/11-mixed-operator-pattern/10-constructor/katalog.yaml @@ -42,7 +42,7 @@ spec: # # (Required for RBAC) # Define the resources managed by this reconciler - resources: + managedResources: - kind: Job # For built-ins, only kind is required group: batch version: v1 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 d8ec5ecc5..e2561325d 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 @@ -62,38 +62,39 @@ func NewPipelineReconciler(kube kubeclient.Interface) domain.Reconciler { // Reconcile is called by Orkestra's worker pool for every queued Pipeline key. // It is wrapped in safeReconcile — panics are caught and returned as errors. -func (r *PipelineReconciler) Reconcile(ctx context.Context, key string) error { +func (r *PipelineReconciler) Reconcile(ctx context.Context, req domain.Request) (domain.Result, error) { + key := req.Key namespace, _, err := cache.SplitMetaNamespaceKey(key) if err != nil { - return fmt.Errorf("invalid key %q: %w", key, err) + return domain.Result{}, fmt.Errorf("invalid key %q: %w", key, err) } // Read from the informer cache — no API call raw, exists, err := r.kube.GetInformer().GetIndexer().GetByKey(key) if err != nil { - return fmt.Errorf("cache lookup %q: %w", key, err) + return domain.Result{}, fmt.Errorf("cache lookup %q: %w", key, err) } if !exists { // CR deleted and finalizers already removed — nothing to do - return nil + return domain.Result{}, nil } pipeline, ok := raw.(*apiv1.Pipeline) if !ok { - return fmt.Errorf("unexpected type %T for key %q", raw, key) + return domain.Result{}, fmt.Errorf("unexpected type %T for key %q", raw, key) } pipeline = pipeline.DeepCopyObject().(*apiv1.Pipeline) // ── Deletion handling ────────────────────────────────────────────────── if pipeline.DeletionTimestamp != nil { - return r.handleDeletion(ctx, pipeline) + return domain.Result{}, r.handleDeletion(ctx, pipeline) } // ── Finalizer ───────────────────────────────────────────────────────── if !containsFinalizer(pipeline, finalizerName) { pipeline.Finalizers = append(pipeline.Finalizers, finalizerName) if err := r.kube.PatchFinalizers(ctx, pipeline, pipeline.Finalizers); err != nil { - return fmt.Errorf("adding finalizer: %w", err) + return domain.Result{}, fmt.Errorf("adding finalizer: %w", err) } } @@ -102,14 +103,14 @@ func (r *PipelineReconciler) Reconcile(ctx context.Context, key string) error { // ── State machine ───────────────────────────────────────────────────── switch pipeline.Status.Phase { case "", apiv1.PipelinePhasePending: - return r.handlePending(ctx, pipeline) + return domain.Result{}, r.handlePending(ctx, pipeline) case apiv1.PipelinePhaseRunning: - return r.handleRunning(ctx, pipeline) + return domain.Result{}, r.handleRunning(ctx, pipeline) case apiv1.PipelinePhaseSucceeded, apiv1.PipelinePhaseFailed: // Terminal state — nothing to reconcile - return nil + return domain.Result{}, nil default: - return fmt.Errorf("unknown phase %q for pipeline %s/%s", + return domain.Result{}, fmt.Errorf("unknown phase %q for pipeline %s/%s", pipeline.Status.Phase, pipeline.Namespace, pipeline.Name) } } diff --git a/examples/from-controller-runtime/02-hybrid/katalog.yaml b/examples/from-controller-runtime/02-hybrid/katalog.yaml index 23c1d6629..fb75b2892 100644 --- a/examples/from-controller-runtime/02-hybrid/katalog.yaml +++ b/examples/from-controller-runtime/02-hybrid/katalog.yaml @@ -48,7 +48,7 @@ spec: function: WebAppHooks alias: webhooks runHooksFirst: true - resources: + managedResources: - kind: Service status: diff --git a/examples/from-controller-runtime/03-hooks-only/katalog.yaml b/examples/from-controller-runtime/03-hooks-only/katalog.yaml index 3c5543b66..6e8f9abfb 100644 --- a/examples/from-controller-runtime/03-hooks-only/katalog.yaml +++ b/examples/from-controller-runtime/03-hooks-only/katalog.yaml @@ -40,7 +40,7 @@ spec: location: github.com/orkspace/from-controller-runtime-demo/hooks function: WebAppHooks alias: webhooks - resources: + managedResources: - kind: Deployment - kind: Service diff --git a/examples/from-controller-runtime/04-constructor-migration/katalog.yaml b/examples/from-controller-runtime/04-constructor-migration/katalog.yaml index 0796d8d1f..083de9428 100644 --- a/examples/from-controller-runtime/04-constructor-migration/katalog.yaml +++ b/examples/from-controller-runtime/04-constructor-migration/katalog.yaml @@ -38,6 +38,6 @@ spec: constructor: location: github.com/orkspace/from-controller-runtime-demo/reconciler function: NewWebAppReconciler - resources: + managedResources: - kind: Deployment - kind: Service diff --git a/examples/from-controller-runtime/05-constructor-orkestra-resources/katalog.yaml b/examples/from-controller-runtime/05-constructor-orkestra-resources/katalog.yaml index fc071dcb0..5f3876c4e 100644 --- a/examples/from-controller-runtime/05-constructor-orkestra-resources/katalog.yaml +++ b/examples/from-controller-runtime/05-constructor-orkestra-resources/katalog.yaml @@ -39,6 +39,6 @@ spec: constructor: location: github.com/orkspace/from-controller-runtime-demo/reconciler function: NewWebAppReconciler - resources: + managedResources: - kind: Deployment - kind: Service 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 3a2d98dbe..28448c3fe 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 @@ -51,36 +51,37 @@ func NewWebAppReconciler(kube kubeclient.Interface) domain.Reconciler { } // Reconcile is called by Orkestra's worker pool for every queued WebApp key. -func (r *WebAppReconciler) Reconcile(ctx context.Context, key string) error { +func (r *WebAppReconciler) Reconcile(ctx context.Context, req domain.Request) (domain.Result, error) { + key := req.Key raw, exists, err := r.kube.GetInformer().GetIndexer().GetByKey(key) if err != nil { - return fmt.Errorf("cache lookup %q: %w", key, err) + return domain.Result{}, fmt.Errorf("cache lookup %q: %w", key, err) } if !exists { - return nil + return domain.Result{}, nil } webapp, ok := raw.(*apiv1.WebApp) if !ok { - return fmt.Errorf("unexpected type %T", raw) + return domain.Result{}, fmt.Errorf("unexpected type %T", raw) } webapp = webapp.DeepCopyObject().(*apiv1.WebApp) if webapp.DeletionTimestamp != nil { - return nil + return domain.Result{}, nil } if err := r.reconcileDeployment(ctx, webapp); err != nil { - return err + return domain.Result{}, err } if err := r.reconcileService(ctx, webapp); err != nil { - return err + return domain.Result{}, err } 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{}{ + return domain.Result{}, 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, diff --git a/examples/from-controller-runtime/07-all-options/options/constructor/katalog.yaml b/examples/from-controller-runtime/07-all-options/options/constructor/katalog.yaml index e8a8fed0b..1c88f7784 100644 --- a/examples/from-controller-runtime/07-all-options/options/constructor/katalog.yaml +++ b/examples/from-controller-runtime/07-all-options/options/constructor/katalog.yaml @@ -38,6 +38,6 @@ spec: constructor: location: github.com/orkspace/from-controller-runtime-all-options/options/constructor/reconciler function: NewWebAppReconciler - resources: + managedResources: - kind: Deployment - kind: Service 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 81b887618..235648d64 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 @@ -64,38 +64,38 @@ func NewWebAppReconciler(kube kubeclient.Interface) domain.Reconciler { } // 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 { +func (r *WebAppReconciler) Reconcile(ctx context.Context, req domain.Request) (domain.Result, error) { + key := req.Key raw, exists, err := r.kube.GetInformer().GetIndexer().GetByKey(key) if err != nil { - return fmt.Errorf("cache lookup %q: %w", key, err) + return domain.Result{}, fmt.Errorf("cache lookup %q: %w", key, err) } if !exists { - return nil + return domain.Result{}, nil } webapp, ok := raw.(*apiv1.ConstructorApp) if !ok { - return fmt.Errorf("unexpected type %T", raw) + return domain.Result{}, fmt.Errorf("unexpected type %T", raw) } webapp = webapp.DeepCopyObject().(*apiv1.ConstructorApp) if webapp.DeletionTimestamp != nil { // Owner references clean up Deployment and Service automatically. - return nil + return domain.Result{}, nil } if err := r.reconcileDeployment(ctx, webapp); err != nil { - return err + return domain.Result{}, err } if err := r.reconcileService(ctx, webapp); err != nil { - return err + return domain.Result{}, err } 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{}{ + return domain.Result{}, 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, diff --git a/examples/from-controller-runtime/07-all-options/options/hooks/katalog.yaml b/examples/from-controller-runtime/07-all-options/options/hooks/katalog.yaml index d74ab0fd0..d00a90dae 100644 --- a/examples/from-controller-runtime/07-all-options/options/hooks/katalog.yaml +++ b/examples/from-controller-runtime/07-all-options/options/hooks/katalog.yaml @@ -40,7 +40,7 @@ spec: location: github.com/orkspace/from-controller-runtime-all-options/options/hooks/hooks function: WebAppHooks alias: hookswh - resources: + managedResources: - kind: Deployment - kind: Service diff --git a/examples/from-controller-runtime/07-all-options/options/hybrid/katalog.yaml b/examples/from-controller-runtime/07-all-options/options/hybrid/katalog.yaml index 44e7abc15..4ad00e6f3 100644 --- a/examples/from-controller-runtime/07-all-options/options/hybrid/katalog.yaml +++ b/examples/from-controller-runtime/07-all-options/options/hybrid/katalog.yaml @@ -48,7 +48,7 @@ spec: function: WebAppHooks alias: webhooks runHooksFirst: true - resources: + managedResources: - kind: Service status: diff --git a/examples/from-controller-runtime/07-all-options/options/ork-resources/katalog.yaml b/examples/from-controller-runtime/07-all-options/options/ork-resources/katalog.yaml index 25982a95e..0c45bd49f 100644 --- a/examples/from-controller-runtime/07-all-options/options/ork-resources/katalog.yaml +++ b/examples/from-controller-runtime/07-all-options/options/ork-resources/katalog.yaml @@ -39,6 +39,6 @@ spec: constructor: location: github.com/orkspace/from-controller-runtime-all-options/options/ork-resources/reconciler function: NewWebAppReconciler - resources: + managedResources: - kind: Deployment - kind: Service 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 bb5ead777..d8ff2920d 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 @@ -51,36 +51,37 @@ func NewWebAppReconciler(kube kubeclient.Interface) domain.Reconciler { } // Reconcile is called by Orkestra's worker pool for every queued OrkApp key. -func (r *WebAppReconciler) Reconcile(ctx context.Context, key string) error { +func (r *WebAppReconciler) Reconcile(ctx context.Context, req domain.Request) (domain.Result, error) { + key := req.Key raw, exists, err := r.kube.GetInformer().GetIndexer().GetByKey(key) if err != nil { - return fmt.Errorf("cache lookup %q: %w", key, err) + return domain.Result{}, fmt.Errorf("cache lookup %q: %w", key, err) } if !exists { - return nil + return domain.Result{}, nil } webapp, ok := raw.(*apiv1.OrkApp) if !ok { - return fmt.Errorf("unexpected type %T", raw) + return domain.Result{}, fmt.Errorf("unexpected type %T", raw) } webapp = webapp.DeepCopyObject().(*apiv1.OrkApp) if webapp.DeletionTimestamp != nil { - return nil + return domain.Result{}, nil } if err := r.reconcileDeployment(ctx, webapp); err != nil { - return err + return domain.Result{}, err } if err := r.reconcileService(ctx, webapp); err != nil { - return err + return domain.Result{}, err } 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{}{ + return domain.Result{}, 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, diff --git a/examples/registry-guide/09-hooks-katalog/README.md b/examples/registry-guide/09-hooks-katalog/README.md index ffd715f51..69b44d068 100644 --- a/examples/registry-guide/09-hooks-katalog/README.md +++ b/examples/registry-guide/09-hooks-katalog/README.md @@ -184,7 +184,7 @@ operatorBox: location: github.com/orkspace/orkestra-registry-guide/hooks function: DatabaseHooks alias: dbhooks - resources: + managedResources: - kind: StatefulSet - kind: Service - kind: CronJob diff --git a/examples/registry-guide/09-hooks-katalog/katalog.yaml b/examples/registry-guide/09-hooks-katalog/katalog.yaml index 58b0a5492..13a09a523 100644 --- a/examples/registry-guide/09-hooks-katalog/katalog.yaml +++ b/examples/registry-guide/09-hooks-katalog/katalog.yaml @@ -41,7 +41,7 @@ spec: location: github.com/orkspace/orkestra-registry-guide/hooks function: DatabaseHooks alias: dbhooks - resources: # Resources managed by this hook — required for RBAC generation + managedResources: # Resources managed by this hook — required for RBAC generation - kind: StatefulSet - kind: Service - kind: CronJob diff --git a/examples/registry-guide/11-ork-action/database-operator/README.md b/examples/registry-guide/11-ork-action/database-operator/README.md index 154785bdc..ad2df695b 100644 --- a/examples/registry-guide/11-ork-action/database-operator/README.md +++ b/examples/registry-guide/11-ork-action/database-operator/README.md @@ -158,7 +158,7 @@ operatorBox: location: github.com/orkspace/orkestra-registry-guide/hooks function: DatabaseHooks alias: dbhooks - resources: + managedResources: - kind: StatefulSet - kind: Service - kind: CronJob diff --git a/examples/registry-guide/11-ork-action/database-operator/katalog.yaml b/examples/registry-guide/11-ork-action/database-operator/katalog.yaml index 17b999df0..dab7f1df0 100644 --- a/examples/registry-guide/11-ork-action/database-operator/katalog.yaml +++ b/examples/registry-guide/11-ork-action/database-operator/katalog.yaml @@ -37,7 +37,7 @@ spec: location: github.com/orkspace/orkestra-registry-guide/hooks function: DatabaseHooks alias: dbhooks - resources: # Resources managed by this hook — required for RBAC generation + managedResources: # Resources managed by this hook — required for RBAC generation - kind: StatefulSet - kind: Service - kind: CronJob diff --git a/examples/resilience/safe-reconcile/katalog.yaml b/examples/resilience/safe-reconcile/katalog.yaml index cd5b188b7..116d68fbb 100644 --- a/examples/resilience/safe-reconcile/katalog.yaml +++ b/examples/resilience/safe-reconcile/katalog.yaml @@ -85,6 +85,6 @@ spec: location: github.com/orkspace/safe-reconcile-demo/hooks function: AppHooks alias: safehooks - resources: + managedResources: - kind: Deployment resync: 30s diff --git a/examples/use-cases/README.md b/examples/use-cases/README.md index c113c4cc8..14757c551 100644 --- a/examples/use-cases/README.md +++ b/examples/use-cases/README.md @@ -12,7 +12,7 @@ ork init my-operator --pack use-cases | Example | Pattern | What you learn | |---------|---------|----------------| -| [full-stack-app](./full-stack-app/) | Multi-feature composition | `forEach`, `external`, `cross`, `once`, `anyOf` in one CR | +| [full-stack-app](./full-stack-app/) | Multi-feature composition | `forEach`, `external`, `cross`, `once`, `or` in one CR | | [multi-region-map](./multi-region-map/) | Multi-target deployment | `forEach` over a map — deploy to N regions from one CR | | [crd-conversion](./crd-conversion/) | Schema evolution | Multi-version CRDs with and without a conversion webhook | | [custom-operator](./custom-operator/) | Third-party test harness | `spec.custom.target: kubernetes` — use `ork e2e` to test any operator | diff --git a/examples/use-cases/enrich/03-rollout-observer/README.md b/examples/use-cases/enrich/03-rollout-observer/README.md index 0653c668b..b2c9f862d 100644 --- a/examples/use-cases/enrich/03-rollout-observer/README.md +++ b/examples/use-cases/enrich/03-rollout-observer/README.md @@ -1,10 +1,10 @@ # Enrich 03 — Rollout Observer -`enrich: [replicasets]` with `anyOf:` — replicaset data is fetched when the deployment is not fully ready (rolling update in progress) OR when `spec.debug` is `"true"`. In steady state both conditions are false: the replicaset-list call never fires. During a rollout you can watch the old and new ReplicaSet counts change in real time. +`enrich: [replicasets]` with `or:` — replicaset data is fetched when the deployment is not fully ready (rolling update in progress) OR when `spec.debug` is `"true"`. In steady state both conditions are false: the replicaset-list call never fires. During a rollout you can watch the old and new ReplicaSet counts change in real time. -**Cost:** zero API calls for the replicaset enrichment in steady state — `anyOf:` acts as a circuit breaker. The pod-list from `enrich: [pods]` still runs unconditionally. Setting `spec.debug: "true"` on a single CR enables the expensive enrichment for that CR only; other CRs in the same operator are unaffected. +**Cost:** zero API calls for the replicaset enrichment in steady state — `or:` acts as a circuit breaker. The pod-list from `enrich: [pods]` still runs unconditionally. Setting `spec.debug: "true"` on a single CR enables the expensive enrichment for that CR only; other CRs in the same operator are unaffected. -**What you learn:** `anyOf:` in enrichment conditions, combining always-on and conditional targets, debug-mode enrichment without affecting other CRs. +**What you learn:** `or:` in enrichment conditions, combining always-on and conditional targets, debug-mode enrichment without affecting other CRs. --- @@ -97,7 +97,7 @@ status: # replicaSetCount and oldReplicaSets absent again ``` -During the rollout: **3 API calls** (pod-list + replicaset-list × the anyOf gate). After: **1 API call**. +During the rollout: **3 API calls** (pod-list + replicaset-list × the or gate). After: **1 API call**. --- @@ -142,7 +142,7 @@ This runs everything defined in [e2e.yaml](./e2e.yaml): ```yaml expect: - - name: No replicaset data in steady state (anyOf gate held) + - name: No replicaset data in steady state (or gate held) after: cr-applied timeout: 60s commands: diff --git a/examples/use-cases/enrich/03-rollout-observer/e2e.yaml b/examples/use-cases/enrich/03-rollout-observer/e2e.yaml index 63fe1825f..34e625370 100644 --- a/examples/use-cases/enrich/03-rollout-observer/e2e.yaml +++ b/examples/use-cases/enrich/03-rollout-observer/e2e.yaml @@ -4,7 +4,7 @@ kind: E2E metadata: name: enrich-rollout-observer-e2e description: > - anyOf: enrich condition — replicaset data is fetched only during rollouts or + or: enrich condition — replicaset data is fetched only during rollouts or when spec.debug is true. Verifies no replicaset data in status during steady state, and that setting debug: true surfaces the replicaset data immediately. @@ -31,7 +31,7 @@ spec: namespace: default ready: true - - name: No replicaset data in steady state (anyOf gate held) + - name: No replicaset data in steady state (or gate held) after: cr-applied timeout: 60s commands: diff --git a/examples/use-cases/enrich/03-rollout-observer/katalog.yaml b/examples/use-cases/enrich/03-rollout-observer/katalog.yaml index 8b84dbac1..93c0827c6 100644 --- a/examples/use-cases/enrich/03-rollout-observer/katalog.yaml +++ b/examples/use-cases/enrich/03-rollout-observer/katalog.yaml @@ -23,9 +23,9 @@ spec: - pods # replicasets: only during rollouts (replicas not ready) or debug mode. - # anyOf: fetches when EITHER condition is true. + # or: fetches when EITHER condition is true. - replicasets: - anyOf: + or: - field: "{{ replicasReady .children.deployment }}" equals: "false" - field: spec.debug diff --git a/examples/use-cases/enrich/README.md b/examples/use-cases/enrich/README.md index a19045025..bf6ab47db 100644 --- a/examples/use-cases/enrich/README.md +++ b/examples/use-cases/enrich/README.md @@ -6,7 +6,7 @@ Three focused examples showing what `enrich` does, why it costs what it costs, a |---|---| | [01 — Pod Health](01-pod-health/README.md) | `enrich: [pods]` — always-on pod count, readiness, crash detection in status | | [02 — Warning Events](02-warning-events/README.md) | `enrich: [events]` with a conditional gate — zero API calls in steady state, warning details when degraded | -| [03 — Rollout Observer](03-rollout-observer/README.md) | `enrich: [replicasets]` with `anyOf:` — replicaset data only during rollouts or debug mode | +| [03 — Rollout Observer](03-rollout-observer/README.md) | `enrich: [replicasets]` with `or:` — replicaset data only during rollouts or debug mode | All three share one CRD (`crd.yaml` at the root of this directory). diff --git a/examples/use-cases/full-stack-app/05-anyof/README.md b/examples/use-cases/full-stack-app/05-or/README.md similarity index 77% rename from examples/use-cases/full-stack-app/05-anyof/README.md rename to examples/use-cases/full-stack-app/05-or/README.md index 9f196cecc..2b3b7bf3e 100644 --- a/examples/use-cases/full-stack-app/05-anyof/README.md +++ b/examples/use-cases/full-stack-app/05-or/README.md @@ -1,8 +1,8 @@ -# 05 — OR Conditions (anyOf:) +# 05 — OR Conditions (or:) -`anyOf:` fires a resource when any one of several conditions is true. Combined with `when:` (AND), the full logic is: `(when conditions) AND (any one of anyOf conditions)`. This replaces multi-branch `if a || b` hooks in Go with a declarative block on the resource itself. +`or:` fires a resource when any one of several conditions is true. Combined with `when:` (AND), the full logic is: `(when conditions) AND (any one of or conditions)`. This replaces multi-branch `if a || b` hooks in Go with a declarative block on the resource itself. -**What you learn:** `anyOf:` semantics, combining `when:` AND `anyOf:` OR on the same resource, and how phase transitions cascade through a Job sequence without custom state machine code. +**What you learn:** `or:` semantics, combining `when:` AND `or:` OR on the same resource, and how phase transitions cascade through a Job sequence without custom state machine code. --- @@ -76,7 +76,7 @@ kubectl patch flexapp my-flex-app --type=merge -p '{"spec":{"notify":"true"}}' The notify Job's combined condition now passes: - `when: spec.notify == "true"` ✓ -- `anyOf: phase == Running` ✓ +- `or: phase == Running` ✓ ```bash kubectl get jobs @@ -96,7 +96,7 @@ kubectl get flexapp my-flex-app ## Step 6 — Cascade to cleanup -Phase is now `Succeeded` — the cleanup Job's `anyOf:` fires: +Phase is now `Succeeded` — the cleanup Job's `or:` fires: ```bash kubectl get jobs @@ -111,7 +111,7 @@ Both Jobs appeared in sequence without writing any state machine logic. The phas ## E2E -Run the full lifecycle in one command — spins up a kind cluster, applies the CRD, starts the operator, applies the CR, asserts the Deployment is created and the notify Job fires when the `anyOf:` condition is met, then tears down: +Run the full lifecycle in one command — spins up a kind cluster, applies the CRD, starts the operator, applies the CR, asserts the Deployment is created and the notify Job fires when the `or:` condition is met, then tears down: ```bash ork e2e @@ -130,7 +130,7 @@ expect: namespace: default ready: true - - name: Notify Job created when anyOf condition is met + - name: Notify Job created when or condition is met after: cr-applied timeout: 60s commands: diff --git a/examples/use-cases/full-stack-app/05-anyof/cr.yaml b/examples/use-cases/full-stack-app/05-or/cr.yaml similarity index 83% rename from examples/use-cases/full-stack-app/05-anyof/cr.yaml rename to examples/use-cases/full-stack-app/05-or/cr.yaml index b668a64b8..9007086a0 100644 --- a/examples/use-cases/full-stack-app/05-anyof/cr.yaml +++ b/examples/use-cases/full-stack-app/05-or/cr.yaml @@ -1,4 +1,4 @@ -# 05 — anyOf: OR conditions +# 05 — or: OR conditions apiVersion: advanced.orkestra.io/v1alpha1 kind: FlexApp metadata: diff --git a/examples/use-cases/full-stack-app/05-anyof/crd.yaml b/examples/use-cases/full-stack-app/05-or/crd.yaml similarity index 95% rename from examples/use-cases/full-stack-app/05-anyof/crd.yaml rename to examples/use-cases/full-stack-app/05-or/crd.yaml index b74cfdd59..6476b16c7 100644 --- a/examples/use-cases/full-stack-app/05-anyof/crd.yaml +++ b/examples/use-cases/full-stack-app/05-or/crd.yaml @@ -1,4 +1,4 @@ -# FlexApp — demonstrates anyOf: OR condition logic +# FlexApp — demonstrates or: OR condition logic apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: diff --git a/examples/use-cases/full-stack-app/05-anyof/e2e.yaml b/examples/use-cases/full-stack-app/05-or/e2e.yaml similarity index 91% rename from examples/use-cases/full-stack-app/05-anyof/e2e.yaml rename to examples/use-cases/full-stack-app/05-or/e2e.yaml index 71f2fbd2d..3af5c712f 100644 --- a/examples/use-cases/full-stack-app/05-anyof/e2e.yaml +++ b/examples/use-cases/full-stack-app/05-or/e2e.yaml @@ -2,9 +2,9 @@ apiVersion: orkestra.orkspace.io/v1 kind: E2E metadata: - name: anyof-e2e + name: or-e2e description: > - anyOf: OR conditions — a cleanup Job fires when the FlexApp phase is Failed + or: OR conditions — a cleanup Job fires when the FlexApp phase is Failed OR the CR has notify: "true". Verifies the Deployment is created in normal operation, and that setting notify: "true" triggers the notification Job. @@ -52,7 +52,7 @@ spec: - run: kubectl patch flexapp my-flex-app --type=merge -p '{"spec":{"notify":"true"}}' exitCode: 0 - - name: Notify Job created after anyOf condition met + - name: Notify Job created after or condition met after: cr-applied timeout: 60s resources: diff --git a/examples/use-cases/full-stack-app/05-anyof/katalog.yaml b/examples/use-cases/full-stack-app/05-or/katalog.yaml similarity index 91% rename from examples/use-cases/full-stack-app/05-anyof/katalog.yaml rename to examples/use-cases/full-stack-app/05-or/katalog.yaml index 2047866cc..6edfda536 100644 --- a/examples/use-cases/full-stack-app/05-anyof/katalog.yaml +++ b/examples/use-cases/full-stack-app/05-or/katalog.yaml @@ -3,9 +3,9 @@ kind: Katalog metadata: name: flex-app description: > - Pattern 05 — anyOf:. + Pattern 05 — or:. Creates Jobs when any one of several phase conditions is true (OR logic). - when: and anyOf: can be combined: when: is AND, anyOf: is OR, both must pass. + when: and or: can be combined: when: is AND, or: is OR, both must pass. Replaces multi-branch if-conditions in OnReconcile hooks. spec: @@ -28,7 +28,7 @@ spec: - name: "{{ .metadata.name }}-cleanup" image: alpine:3.19 command: ["/bin/sh", "-c", "echo 'cleanup complete'"] - anyOf: + or: - field: status.phase equals: "Failed" - field: status.phase @@ -40,7 +40,7 @@ spec: when: - field: spec.notify equals: "true" - anyOf: + or: - field: status.phase equals: "Running" - field: status.phase diff --git a/examples/use-cases/full-stack-app/06-full-stack/README.md b/examples/use-cases/full-stack-app/06-full-stack/README.md index 6001873ff..4a47e0976 100644 --- a/examples/use-cases/full-stack-app/06-full-stack/README.md +++ b/examples/use-cases/full-stack-app/06-full-stack/README.md @@ -1,6 +1,6 @@ # 06 — Full Stack (all patterns combined) -One CR, all five patterns at once. A `FullStackApp` creates 3 regional Deployments (`forEach`), a generated Secret (`once:`), a ConfigMap sourced from a database CR (`cross:`), all gated on a health check (`external:`), with a cleanup Job on terminal phases (`anyOf:`). This is the showcase — everything Orkestra can do in a single declaration. +One CR, all five patterns at once. A `FullStackApp` creates 3 regional Deployments (`forEach`), a generated Secret (`once:`), a ConfigMap sourced from a database CR (`cross:`), all gated on a health check (`external:`), with a cleanup Job on terminal phases (`or:`). This is the showcase — everything Orkestra can do in a single declaration. --- @@ -80,7 +80,7 @@ spec: regions: [us-east-1, eu-west-1, ap-southeast-1] # forEach serviceUrl: http://localhost:9999 # external: environment: production # ConfigMap value - notify: "true" # anyOf: notify trigger + notify: "true" # or: notify trigger ``` --- diff --git a/examples/use-cases/full-stack-app/06-full-stack/crd.yaml b/examples/use-cases/full-stack-app/06-full-stack/crd.yaml index aab7a61c2..8a662ef26 100644 --- a/examples/use-cases/full-stack-app/06-full-stack/crd.yaml +++ b/examples/use-cases/full-stack-app/06-full-stack/crd.yaml @@ -1,4 +1,4 @@ -# FullStackApp — combines forEach + external + cross + once + anyOf +# FullStackApp — combines forEach + external + cross + once + or apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: diff --git a/examples/use-cases/full-stack-app/06-full-stack/katalog.yaml b/examples/use-cases/full-stack-app/06-full-stack/katalog.yaml index 59c80a94d..b797a0fc3 100644 --- a/examples/use-cases/full-stack-app/06-full-stack/katalog.yaml +++ b/examples/use-cases/full-stack-app/06-full-stack/katalog.yaml @@ -6,7 +6,7 @@ metadata: Pattern 06 — all patterns combined. One CR creates 3 regional Deployments (forEach), a generated Secret (once:), a ConfigMap from a database CR (cross:), all gated on a health check (external:), - with a cleanup Job on terminal phases (anyOf:). + with a cleanup Job on terminal phases (or:). spec: crds: @@ -137,7 +137,7 @@ spec: - name: "{{ .metadata.name }}-cleanup" image: alpine:3.19 command: ["/bin/sh", "-c", "echo cleanup"] - anyOf: + or: - field: status.phase equals: "Failed" - field: status.phase diff --git a/examples/use-cases/full-stack-app/README.md b/examples/use-cases/full-stack-app/README.md index c32e5669e..6914d7ab1 100644 --- a/examples/use-cases/full-stack-app/README.md +++ b/examples/use-cases/full-stack-app/README.md @@ -8,7 +8,7 @@ Six examples showing what Orkestra can express declaratively that previously req | [02 — External Gate](02-external-gate/README.md) | `external:` health check | `http.Get + if status != 200 { return err }` | | [03 — Cross-CRD](03-cross-crd/README.md) | `cross:` observation | `client.Get(otherCRD) + if notFound { requeue }` | | [04 — Once Secret](04-once-secret/README.md) | `once:` + random generation | `crypto/rand + secretExists check` | -| [05 — anyOf](05-anyof/README.md) | OR conditions | `if phase == Failed \|\| phase == Succeeded` | +| [05 — or](05-or/README.md) | OR conditions | `if phase == Failed \|\| phase == Succeeded` | | [06 — Full Stack](06-full-stack/README.md) | All five combined | Everything above in one CR | Each subfolder has its own `katalog.yaml` and `README.md` — run any example in isolation from inside the subfolder. To run all six at once as one operator: diff --git a/examples/use-cases/full-stack-app/cleanup.sh b/examples/use-cases/full-stack-app/cleanup.sh index 3526a66b6..636ee5707 100755 --- a/examples/use-cases/full-stack-app/cleanup.sh +++ b/examples/use-cases/full-stack-app/cleanup.sh @@ -7,7 +7,7 @@ kubectl delete -f 01-multi-region/crd.yaml --ignore-not-found kubectl delete -f 02-external-gate/crd.yaml --ignore-not-found kubectl delete -f 03-cross-crd/crd.yaml --ignore-not-found kubectl delete -f 04-once-secret/crd.yaml --ignore-not-found -kubectl delete -f 05-anyof/crd.yaml --ignore-not-found +kubectl delete -f 05-or/crd.yaml --ignore-not-found kubectl delete -f 06-full-stack/crd.yaml --ignore-not-found echo "✓ Done. Stop 'ork run' with Ctrl+C if still running." diff --git a/examples/use-cases/full-stack-app/e2e.yaml b/examples/use-cases/full-stack-app/e2e.yaml index f415fb439..ee5195f1c 100644 --- a/examples/use-cases/full-stack-app/e2e.yaml +++ b/examples/use-cases/full-stack-app/e2e.yaml @@ -5,7 +5,7 @@ metadata: name: full-stack-app-suite description: > Suite for the full-stack-app use-case track. Runs each sub-example - in the same cluster — multi-region, cross-CRD, once-secret, and anyOf. + in the same cluster — multi-region, cross-CRD, once-secret, and or. 02-external-gate and 06-full-stack require a running dev server and are excluded from the automated suite. @@ -13,4 +13,4 @@ imports: - ./01-multi-region/e2e.yaml - ./03-cross-crd/e2e.yaml - ./04-once-secret/e2e.yaml - - ./05-anyof/e2e.yaml + - ./05-or/e2e.yaml diff --git a/examples/use-cases/full-stack-app/komposer.yaml b/examples/use-cases/full-stack-app/komposer.yaml index 186091575..7a8edbbb6 100644 --- a/examples/use-cases/full-stack-app/komposer.yaml +++ b/examples/use-cases/full-stack-app/komposer.yaml @@ -5,7 +5,7 @@ metadata: author: orkspace version: 0.2.0 description: > - Advanced Orkestra patterns — forEach, external:, cross:, once:, anyOf:. + Advanced Orkestra patterns — forEach, external:, cross:, once:, or:. Each katalog demonstrates one pattern that previously required custom Go code. Import all six to run the full example pack, or import individual katalogs to focus on a single pattern. @@ -16,5 +16,5 @@ imports: - ./02-external-gate/katalog.yaml - ./03-cross-crd/katalog.yaml - ./04-once-secret/katalog.yaml - - ./05-anyof/katalog.yaml + - ./05-or/katalog.yaml - ./06-full-stack/katalog.yaml diff --git a/examples/use-cases/temporal/04-autoscale/README.md b/examples/use-cases/temporal/04-autoscale/README.md index 483c266cd..fa1601bee 100644 --- a/examples/use-cases/temporal/04-autoscale/README.md +++ b/examples/use-cases/temporal/04-autoscale/README.md @@ -39,7 +39,7 @@ notes: It composes two built-in time notes — `weekday` and `timeInWindow` — into a single named boolean. Both `scaleUp` and `scaleDown` reference it, so the business hours rule lives in one place. -The `autoscale:` block sits alongside the Deployment declaration. Conditions use the same `when:` / `anyOf:` engine as everything else in Orkestra: +The `autoscale:` block sits alongside the Deployment declaration. Conditions use the same `when:` / `or:` engine as everything else in Orkestra: ```yaml deployments: diff --git a/examples/use-cases/workload-autoscaler/01-time-based/README.md b/examples/use-cases/workload-autoscaler/01-time-based/README.md index 4b2b8fa69..451f826ea 100644 --- a/examples/use-cases/workload-autoscaler/01-time-based/README.md +++ b/examples/use-cases/workload-autoscaler/01-time-based/README.md @@ -30,7 +30,7 @@ The replica count changes automatically on the next resync after the window open ## How it works -The `autoscale:` block sits alongside the Deployment declaration. Conditions use the same `when:` / `anyOf:` engine as everything else in Orkestra: +The `autoscale:` block sits alongside the Deployment declaration. Conditions use the same `when:` / `or:` engine as everything else in Orkestra: ```yaml autoscale: diff --git a/pkg/children/docs/04-enrichment.md b/pkg/children/docs/04-enrichment.md index 9c7dbc386..1aa1f47ff 100644 --- a/pkg/children/docs/04-enrichment.md +++ b/pkg/children/docs/04-enrichment.md @@ -15,7 +15,7 @@ Each layer is a no-op when its key is absent from `enrich`. ## Conditional enrichment targets -Enrichment targets can be gated with `when:` or `anyOf:` conditions so that the API calls only happen when needed: +Enrichment targets can be gated with `when:` or `or:` conditions so that the API calls only happen when needed: ```yaml enrich: diff --git a/pkg/children/foreach.go b/pkg/children/foreach.go index f7bc12cb4..a586d8d80 100644 --- a/pkg/children/foreach.go +++ b/pkg/children/foreach.go @@ -32,7 +32,7 @@ // returned slice contains static (non-template) values ready for the // registry functions. // -// when: and anyOf: on forEach sources are evaluated per-item — each +// when: and or: on forEach sources are evaluated per-item — each // expanded source may pass or fail conditions independently. package children diff --git a/pkg/external/runner.go b/pkg/external/runner.go index 6921bab91..ea54d0890 100644 --- a/pkg/external/runner.go +++ b/pkg/external/runner.go @@ -40,7 +40,7 @@ func Run( results := make(map[string]interface{}, len(calls)) for i, call := range calls { - if !orktypes.EvaluateConditions(resolver.Data(), call.Conditions, call.AnyOf, resolver.TemplateEvaluator()) { + if !orktypes.EvaluateConditions(resolver.Data(), call.Conditions, call.Or, resolver.TemplateEvaluator()) { results[call.Name] = skippedResult(call.Protocol) log.Debug(). Str("call", call.Name). diff --git a/pkg/gateway/api/fixture/README.md b/pkg/gateway/api/fixture/README.md index 7cc37ee73..73254e7a4 100644 --- a/pkg/gateway/api/fixture/README.md +++ b/pkg/gateway/api/fixture/README.md @@ -21,12 +21,12 @@ conditional admission rules, type-specific child resources, and status projectio | Schema catalog endpoint (`GET /api/v1/schema/`) | `katalog.yaml` → `serve.category` / `serve.description` | | Admission webhook — unconditional deny | `admission/platformresource.yaml` rules 1–3 | | Admission webhook — `deny when:` | `admission/platformresource.yaml` rule 4 (domain for cert) | -| Admission webhook — `deny anyOf:` | `admission/platformresource.yaml` rule 5 (repoURL for app/monitoring) | +| Admission webhook — `deny or:` | `admission/platformresource.yaml` rule 5 (repoURL for app/monitoring) | | Admission webhook — `warn when:` | `admission/platformresource.yaml` rule 7 (productionApproval) | | Admission webhook — `warn when: isDirectApply .` | `admission/platformresource.yaml` last rule — fires for kubectl/CI direct applies | | Serve field categories (`serve.fields.category`) | `serve/platformresource.yaml` | | Conditional field visibility (`serve.fields.when`) | `serve/platformresource.yaml` | -| Conditional field visibility (`serve.fields.anyOf`) | `serve/platformresource.yaml` | +| Conditional field visibility (`serve.fields.or`) | `serve/platformresource.yaml` | | Disabled/locked fields (`serve.fields.disabled`) | `serve/platformresource.yaml` | | `ignore` — hide system fields from form | `katalog.yaml` → `serve.ignore` | | CRD schema defaults pre-populating form inputs | `crd.yaml` | @@ -142,7 +142,7 @@ kubectl get secret ork-apply-token -n orkestra-system \ ### Step 6 — conditional admission rules The CRs in [crs/](./crs/) each target one specific admission rule. Use them to -verify conditional `when:`/`anyOf:` behaviour against a running cluster: +verify conditional `when:`/`or:` behaviour against a running cluster: ```bash # No violations — happy path @@ -183,7 +183,7 @@ pkg/gateway/fixture/ simulate.yaml — in-memory simulation spec e2e.yaml — full cluster e2e spec cleanup.sh — manual teardown for --use-current runs - admission/platformresource.yaml — validation rules (deny, deny when, deny anyOf, warn when) + admission/platformresource.yaml — validation rules (deny, deny when, deny or, warn when) serve/platformresource.yaml — field hints, categories, conditions, disabled fields status/platformresource.yaml — status field projections crs/ — targeted CRs for conditional rule testing diff --git a/pkg/gateway/api/fixture/admission/platformresource.yaml b/pkg/gateway/api/fixture/admission/platformresource.yaml index 408856b83..3aac8180c 100644 --- a/pkg/gateway/api/fixture/admission/platformresource.yaml +++ b/pkg/gateway/api/fixture/admission/platformresource.yaml @@ -25,12 +25,12 @@ rules: - field: spec.workloadType equals: cert - # Deny when: anyOf — app or monitoring workload must declare a repoURL. + # Deny when: or — app or monitoring workload must declare a repoURL. - field: spec.repoURL operator: exists message: "spec.repoURL is required for app and monitoring workloads" action: deny - anyOf: + or: - field: spec.workloadType equals: app - field: spec.workloadType diff --git a/pkg/gateway/api/fixture/crs/README.md b/pkg/gateway/api/fixture/crs/README.md index ea8dde72f..c47dbfdfa 100644 --- a/pkg/gateway/api/fixture/crs/README.md +++ b/pkg/gateway/api/fixture/crs/README.md @@ -11,8 +11,8 @@ Each file is a minimal `PlatformResource` CR targeting one specific rule or comb | `app-production-approved.yaml` | Same `when:` guard — condition met but field present | Accepted, warn rule skipped | | `cert-valid.yaml` | `deny when: workloadType=cert` + domain present | Accepted, deny rule skipped | | `cert-missing-domain.yaml` | `deny when: workloadType=cert` + domain absent | Rejected — `spec.domain is required for cert workloads` | -| `monitoring-valid.yaml` | `deny anyOf: workloadType=app\|monitoring` + repoURL present | Accepted, deny rule skipped | -| `monitoring-missing-repo.yaml` | `deny anyOf: workloadType=app\|monitoring` + repoURL absent | Rejected — `spec.repoURL is required for app and monitoring workloads` | +| `monitoring-valid.yaml` | `deny or: workloadType=app\|monitoring` + repoURL present | Accepted, deny rule skipped | +| `monitoring-missing-repo.yaml` | `deny or: workloadType=app\|monitoring` + repoURL absent | Rejected — `spec.repoURL is required for app and monitoring workloads` | | `app-direct-apply.yaml` | `warn when: isDirectApply .` — no provenance annotation | Accepted with `ValidationWarning` — direct apply detected | | `app-via-gateway.yaml` | `warn when: isDirectApply .` — `serve-target` annotation present | Accepted, warn rule skipped | @@ -23,7 +23,7 @@ deny — spec.team exists (unconditional) deny — spec.workloadType in app,cert,monitoring (unconditional) deny — spec.environment in staging,production (unconditional) deny — spec.domain exists WHEN workloadType=cert -deny — spec.repoURL exists ANYOF workloadType=app | workloadType=monitoring +deny — spec.repoURL exists OR workloadType=app | workloadType=monitoring deny — spec.domain unique (unconditional) warn — spec.productionApproval exists WHEN environment=production warn — (isDirectApply detection) WHEN isDirectApply . == true @@ -32,10 +32,10 @@ warn — (isDirectApply detection) WHEN isDirectApply . == true ## Key assertions - `when:` rules are **skipped entirely** when the condition does not match — no violation, no log entry. -- `anyOf:` rules are **skipped entirely** when none of the listed values match. +- `or:` rules are **skipped entirely** when none of the listed values match. - A staging CR never triggers the `productionApproval` warn — even if the field is absent. - A cert CR never triggers the `repoURL` deny — even though repoURL is absent. -- A monitoring CR with repoURL present passes the `anyOf` deny rule cleanly. +- A monitoring CR with repoURL present passes the `or` deny rule cleanly. - A CR without any provenance annotation triggers the `isDirectApply` warn — `serve-target` annotation present skips it. ## Running manually @@ -71,7 +71,7 @@ kubectl apply -f pkg/gateway/fixture/crs/cert-valid.yaml # Rejected — spec.domain is required for cert workloads kubectl apply -f pkg/gateway/fixture/crs/cert-missing-domain.yaml -# Accepted, deny anyOf rule skipped (repoURL present) +# Accepted, deny or rule skipped (repoURL present) kubectl apply -f pkg/gateway/fixture/crs/monitoring-valid.yaml # Rejected — spec.repoURL is required for app and monitoring workloads diff --git a/pkg/gateway/api/fixture/crs/cert-missing-domain.yaml b/pkg/gateway/api/fixture/crs/cert-missing-domain.yaml index e319166a3..78fcd9b26 100644 --- a/pkg/gateway/api/fixture/crs/cert-missing-domain.yaml +++ b/pkg/gateway/api/fixture/crs/cert-missing-domain.yaml @@ -1,6 +1,6 @@ # Exercises: deny when — cert workload missing spec.domain. # Expected: rejected, "spec.domain is required for cert workloads". -# The anyOf rule (repoURL) does NOT fire — workloadType is cert, not app/monitoring. +# The or rule (repoURL) does NOT fire — workloadType is cert, not app/monitoring. apiVersion: gateway.fixture.orkestra.io/v1alpha1 kind: PlatformResource metadata: diff --git a/pkg/gateway/api/fixture/crs/cert-valid.yaml b/pkg/gateway/api/fixture/crs/cert-valid.yaml index a645156af..91bba32a8 100644 --- a/pkg/gateway/api/fixture/crs/cert-valid.yaml +++ b/pkg/gateway/api/fixture/crs/cert-valid.yaml @@ -1,5 +1,5 @@ # Exercises: cert workload with domain — when: deny rule does NOT fire. -# The anyOf deny (repoURL required for app/monitoring) also skips — workloadType is cert. +# The or deny (repoURL required for app/monitoring) also skips — workloadType is cert. apiVersion: gateway.fixture.orkestra.io/v1alpha1 kind: PlatformResource metadata: diff --git a/pkg/gateway/api/fixture/crs/monitoring-missing-repo.yaml b/pkg/gateway/api/fixture/crs/monitoring-missing-repo.yaml index d1ca8d96f..f5563ed13 100644 --- a/pkg/gateway/api/fixture/crs/monitoring-missing-repo.yaml +++ b/pkg/gateway/api/fixture/crs/monitoring-missing-repo.yaml @@ -1,4 +1,4 @@ -# Exercises: deny anyOf — monitoring workload missing spec.repoURL. +# Exercises: deny or — monitoring workload missing spec.repoURL. # Expected: rejected, "spec.repoURL is required for app and monitoring workloads". # The when: deny (domain for cert) does NOT fire — workloadType is monitoring. apiVersion: gateway.fixture.orkestra.io/v1alpha1 @@ -11,4 +11,4 @@ spec: team: team-platform environment: staging scrapeInterval: 15s - # repoURL intentionally absent — triggers deny anyOf rule + # repoURL intentionally absent — triggers deny or rule diff --git a/pkg/gateway/api/fixture/crs/monitoring-valid.yaml b/pkg/gateway/api/fixture/crs/monitoring-valid.yaml index 58ca982f8..61f6702ad 100644 --- a/pkg/gateway/api/fixture/crs/monitoring-valid.yaml +++ b/pkg/gateway/api/fixture/crs/monitoring-valid.yaml @@ -1,5 +1,5 @@ # Happy path monitoring workload — all required fields present, no rules fire. -# The deny anyOf (repoURL) is satisfied; no production warn since environment=staging. +# The deny or (repoURL) is satisfied; no production warn since environment=staging. apiVersion: gateway.fixture.orkestra.io/v1alpha1 kind: PlatformResource metadata: diff --git a/pkg/gateway/api/fixture/e2e.yaml b/pkg/gateway/api/fixture/e2e.yaml index 6df10a8d2..dc0840e90 100644 --- a/pkg/gateway/api/fixture/e2e.yaml +++ b/pkg/gateway/api/fixture/e2e.yaml @@ -6,7 +6,7 @@ metadata: description: > End-to-end test for the gateway serve fixture. Exercises: Gateway API (app workload), conditional admission rules - (deny when, deny anyOf, warn when), dryRun violation response, + (deny when, deny or, warn when), dryRun violation response, schema catalog endpoint. spec: diff --git a/pkg/gateway/api/fixture/e2e/admission.yaml b/pkg/gateway/api/fixture/e2e/admission.yaml index 9beb52752..d5a8c9fe9 100644 --- a/pkg/gateway/api/fixture/e2e/admission.yaml +++ b/pkg/gateway/api/fixture/e2e/admission.yaml @@ -2,5 +2,5 @@ expect: - include: ./admission/unconditional-deny.yaml - include: ./admission/warn-when.yaml - include: ./admission/deny-when.yaml - - include: ./admission/deny-anyof.yaml + - include: ./admission/deny-or.yaml - include: ./admission/direct-apply.yaml diff --git a/pkg/gateway/api/fixture/e2e/admission/deny-anyof.yaml b/pkg/gateway/api/fixture/e2e/admission/deny-anyof.yaml index 0ea160ed8..e89e6e07b 100644 --- a/pkg/gateway/api/fixture/e2e/admission/deny-anyof.yaml +++ b/pkg/gateway/api/fixture/e2e/admission/deny-anyof.yaml @@ -1,5 +1,5 @@ expect: - - name: Admission blocks app workload missing repoURL (deny anyOf) + - name: Admission blocks app workload missing repoURL (deny or) after: cr-applied timeout: 30s commands: @@ -18,7 +18,7 @@ expect: exitCode: 1 outputContains: "spec.repoURL is required for app and monitoring workloads" - - name: Admission blocks monitoring workload missing repoURL (deny anyOf) + - name: Admission blocks monitoring workload missing repoURL (deny or) after: cr-applied timeout: 30s commands: @@ -38,7 +38,7 @@ expect: exitCode: 1 outputContains: "spec.repoURL is required for app and monitoring workloads" - - name: Deny anyOf skipped for monitoring with repoURL present + - name: Deny or skipped for monitoring with repoURL present after: cr-applied timeout: 30s kubectl: @@ -68,7 +68,7 @@ expect: name: platform-monitor namespace: team-platform - - name: Deny anyOf skipped for cert workload (not in anyOf list) + - name: Deny or skipped for cert workload (not in or list) after: cr-applied timeout: 30s kubectl: diff --git a/pkg/gateway/api/fixture/katalog.yaml b/pkg/gateway/api/fixture/katalog.yaml index 88c61fa0b..8d5dbc4e4 100644 --- a/pkg/gateway/api/fixture/katalog.yaml +++ b/pkg/gateway/api/fixture/katalog.yaml @@ -1,7 +1,7 @@ # Gateway Serve fixture — exercises all serve features introduced in v0.7.12+. # # Sub-files: -# serve/platformresource.yaml — field hints (when/anyOf/category/disabled/defaults) +# serve/platformresource.yaml — field hints (when/or/category/disabled/defaults) # serve/aliases/preview.yaml — alias config for the "preview" entry point # serve/aliases/internal.yaml — alias config for the "internal" entry point # admission/platformresource.yaml — validation rules (required, unique, warn) @@ -17,7 +17,7 @@ # serve.ignore — hides system-managed fields from the form # serve.fields.category — section headings in the form # serve.fields.when — AND conditions (field-based + time-based) -# serve.fields.anyOf — OR conditions +# serve.fields.or — OR conditions # serve.fields.disabled — greyed-out locked fields with a reason message # serve.target (map form) — primary + alias entries with per-entry token + config (include:) # getServeAlias / getServeTarget — provenance notes consumed by the operatorBox diff --git a/pkg/gateway/api/fixture/serve/platformresource.yaml b/pkg/gateway/api/fixture/serve/platformresource.yaml index 818170a7a..5307caffa 100644 --- a/pkg/gateway/api/fixture/serve/platformresource.yaml +++ b/pkg/gateway/api/fixture/serve/platformresource.yaml @@ -1,6 +1,6 @@ # Serve field config for the gateway fixture. # Included from katalog.yaml via serve.include. -# Tests: category, when (field + time), anyOf, disabled, required indicators, placeholders, defaults. +# Tests: category, when (field + time), or, disabled, required indicators, placeholders, defaults. # ── Basic ───────────────────────────────────────────────────────────────────── fields: @@ -85,7 +85,7 @@ fields: # ── Production guard ────────────────────────────────────────────────────────── # Visible when: environment is staging (always visible to devs), OR it is a - # weekday between 09:00–17:00 (production release window). anyOf = OR logic. + # weekday between 09:00–17:00 (production release window). or = OR logic. productionApproval: label: "Production Approval Ticket" @@ -94,7 +94,7 @@ fields: required: true order: 40 category: "Production" - anyOf: + or: - field: environment equals: staging - time: diff --git a/pkg/gateway/webhook/admission_evaluation.go b/pkg/gateway/webhook/admission_evaluation.go index e444f9806..6d4db9574 100644 --- a/pkg/gateway/webhook/admission_evaluation.go +++ b/pkg/gateway/webhook/admission_evaluation.go @@ -59,7 +59,7 @@ func (ws *WebhookServer) evaluateValidationRules( } data := resolver.Data() for _, rule := range cfg.Rules { - if !orktypes.EvaluateConditions(data, rule.When, rule.AnyOf, resolver.TemplateEvaluator()) { + if !orktypes.EvaluateConditions(data, rule.When, rule.Or, resolver.TemplateEvaluator()) { continue } rv := orktypes.EvaluateValidationRule(data, resolver, rule) @@ -110,7 +110,7 @@ func (ws *WebhookServer) applyMutationRules( mdata := resolver.Data() for _, rule := range cfg.Rules { - if !orktypes.EvaluateConditions(mdata, rule.When, rule.AnyOf, resolver.TemplateEvaluator()) { + if !orktypes.EvaluateConditions(mdata, rule.When, rule.Or, resolver.TemplateEvaluator()) { continue } diff --git a/pkg/intent/target/mux.go b/pkg/intent/target/mux.go index 69c5f1872..69adf59ea 100644 --- a/pkg/intent/target/mux.go +++ b/pkg/intent/target/mux.go @@ -8,6 +8,7 @@ import ( "github.com/orkspace/orkestra/domain" "github.com/orkspace/orkestra/pkg/runtime/autoscaler" orkqueue "github.com/orkspace/orkestra/pkg/runtime/queue" + apitypes "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/tools/cache" ) @@ -48,10 +49,11 @@ var _ domain.Reconciler = (*MuxReconciler)(nil) // Reconcile looks up the CR by key, resolves its target, and delegates to the // matching per-target reconciler (or the fallback when no match is found). -func (m *MuxReconciler) Reconcile(ctx context.Context, key string) error { +func (m *MuxReconciler) Reconcile(ctx context.Context, req domain.Request) (domain.Result, error) { + key := req.Key raw, exists, err := m.informer.GetIndexer().GetByKey(key) if err != nil { - return fmt.Errorf("mux: getting %q from store: %w", key, err) + return domain.Result{}, fmt.Errorf("mux: getting %q from store: %w", key, err) } if !exists { return m.reconcileNotFound(ctx, key) @@ -59,12 +61,12 @@ func (m *MuxReconciler) Reconcile(ctx context.Context, key string) error { obj, ok := raw.(domain.Object) if !ok { - return fmt.Errorf("mux: type assertion failed for %q (got %T)", key, raw) + return domain.Result{}, fmt.Errorf("mux: type assertion failed for %q (got %T)", key, raw) } target := ResolveTargetFromAnnotations(obj.GetAnnotations()) m.targetCache.Store(key, target) - return m.reconcilerFor(target).Reconcile(ctx, key) + return m.reconcilerFor(target).Reconcile(ctx, req) } // reconcilerFor returns the reconciler registered for target, or the fallback. @@ -80,13 +82,17 @@ func (m *MuxReconciler) reconcilerFor(target string) domain.Reconciler { // reconcileNotFound routes deletion cycles to the reconciler that last handled // this key. The cache entry is removed after routing so stale entries don't // accumulate for long-lived operators. -func (m *MuxReconciler) reconcileNotFound(ctx context.Context, key string) error { +func (m *MuxReconciler) reconcileNotFound(ctx context.Context, key string) (domain.Result, error) { target := "" if v, ok := m.targetCache.Load(key); ok { target, _ = v.(string) } defer m.targetCache.Delete(key) - return m.reconcilerFor(target).Reconcile(ctx, key) + ns, name, _ := cache.SplitMetaNamespaceKey(key) + return m.reconcilerFor(target).Reconcile(ctx, domain.Request{ + Key: key, + NamespacedName: apitypes.NamespacedName{Namespace: ns, Name: name}, + }) } // ── CRD-level infrastructure forwarding ────────────────────────────────────── diff --git a/pkg/katalog/include.go b/pkg/katalog/include.go index 2774a0df6..fcbf24b1e 100644 --- a/pkg/katalog/include.go +++ b/pkg/katalog/include.go @@ -47,6 +47,45 @@ func populateConversionPathsFromInclude(entry *orktypes.CRDEntry, katalogDir str return nil } +func populateWatchEntriesFromInclude(entry *orktypes.CRDEntry, katalogDir string) error { + var err error + entry.OperatorBox.Watch, err = orktypes.ExpandWatchEntries(entry.OperatorBox.Watch, katalogDir) + if err != nil { + return fmt.Errorf("operatorBox.watch: %w", err) + } + if entry.Serve == nil { + return nil + } + for name, cfg := range entry.Serve.Target.Entries { + if cfg == nil || cfg.OperatorBox == nil { + continue + } + cfg.OperatorBox.Watch, err = orktypes.ExpandWatchEntries(cfg.OperatorBox.Watch, katalogDir) + if err != nil { + return fmt.Errorf("serve.target[%q].operatorBox.watch: %w", name, err) + } + } + return nil +} + +func populateReconcilerFromInclude(entry *orktypes.CRDEntry, katalogDir string) error { + if err := orktypes.ExpandReconcilerInclude(entry.OperatorBox.Reconciler, katalogDir); err != nil { + return fmt.Errorf("operatorBox.reconciler: %w", err) + } + if entry.Serve == nil { + return nil + } + for name, cfg := range entry.Serve.Target.Entries { + if cfg == nil || cfg.OperatorBox == nil { + continue + } + if err := orktypes.ExpandReconcilerInclude(cfg.OperatorBox.Reconciler, katalogDir); err != nil { + return fmt.Errorf("serve.target[%q].operatorBox.reconciler: %w", name, err) + } + } + return nil +} + func populateExternalCallsFromInclude(entry *orktypes.CRDEntry, katalogDir string) error { var err error if entry.OperatorBox.OnReconcile != nil { diff --git a/pkg/katalog/parser.go b/pkg/katalog/parser.go index 20212ac4f..170a948a1 100644 --- a/pkg/katalog/parser.go +++ b/pkg/katalog/parser.go @@ -156,6 +156,12 @@ func (k *Katalog) KomposeRuntimeKatalog( if err := populateExternalCallsFromInclude(&entry, k.katalogDir); err != nil { return nil, fmt.Errorf("CRD %q: %w", name, err) } + if err := populateWatchEntriesFromInclude(&entry, k.katalogDir); err != nil { + return nil, fmt.Errorf("CRD %q: %w", name, err) + } + if err := populateReconcilerFromInclude(&entry, k.katalogDir); err != nil { + return nil, fmt.Errorf("CRD %q: %w", name, err) + } // Enrich enabled CRDs outcome, err := EnrichCRDEntry(&entry) diff --git a/pkg/katalog/pre_reconcile.go b/pkg/katalog/pre_reconcile.go index cc8a63b79..7d1c55d57 100644 --- a/pkg/katalog/pre_reconcile.go +++ b/pkg/katalog/pre_reconcile.go @@ -55,17 +55,20 @@ func (k *Katalog) EvaluatePreReconcile(ctx context.Context, crdName string, obj if rc.HasPreReconcileExternal() { if resolver, err = external.Run(ctx, gvk, resolver, rc.External, cs); err != nil { - return true, "" + return true, "" // shared pre-reconcile external: always fail-open } } if rc.HasReconcileGateExternal() { if resolver, err = external.Run(ctx, gvk, resolver, rc.ReconcileGate.External, cs); err != nil { + if rc.ReconcileGate.FailPolicy == orktypes.FailPolicyClosed { + return false, "reconcileGate: external evaluation failed (failPolicy: closed)" + } return true, "" } } eval := resolver.TemplateEvaluator() - if !orktypes.EvaluateConditions(resolver.Data(), rc.WhenConditions(), rc.AnyOfConditions(), eval) { + if !orktypes.EvaluateConditions(resolver.Data(), rc.WhenConditions(), rc.OrConditions(), eval) { return false, preReconcileGateReason(rc, resolver) } return true, "" @@ -119,12 +122,15 @@ func (k *Katalog) EvaluateEnqueueFilter(ctx context.Context, crdName string, obj g := rc.EnqueueGate if rc.HasEnqueueGateExternal() { if resolver, err = external.Run(ctx, gvk, resolver, g.External, cs); err != nil { + if g.FailPolicy == orktypes.FailPolicyClosed { + return false + } return true } } eval := resolver.TemplateEvaluator() - return orktypes.EvaluateConditions(resolver.Data(), g.WhenConditions(), g.AnyOfConditions(), eval) + return orktypes.EvaluateConditions(resolver.Data(), g.WhenConditions(), g.OrConditions(), eval) } // preReconcileGateReason returns a human-readable description of why the gate fired. @@ -136,5 +142,5 @@ func preReconcileGateReason(rc *orktypes.PreReconcileConfig, resolver *orktmpl.R return fmt.Sprintf("when: %q = %q, want %q", cond.Field, val, cond.Equals) } } - return "anyOf: no condition satisfied" + return "or: no condition satisfied" } diff --git a/pkg/katalog/requeue.go b/pkg/katalog/requeue.go new file mode 100644 index 000000000..ec44f8a57 --- /dev/null +++ b/pkg/katalog/requeue.go @@ -0,0 +1,65 @@ +package katalog + +import ( + "context" + "time" + + orktarget "github.com/orkspace/orkestra/pkg/intent/target" + orktmpl "github.com/orkspace/orkestra/pkg/resources/template" + orktypes "github.com/orkspace/orkestra/pkg/types" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +// EvaluateRequeue computes the requeue duration for a declarative operatorBox +// after a successful reconcile. Returns 0 when no requeue is needed. +// +// Evaluation order: +// 1. If requeue: is absent or effectively empty, return 0. +// 2. Evaluate when/or conditions — if conditions are declared and fail, return 0. +// 3. Render the after: template against the live CR; parse as a Go duration. +// 4. Return the parsed duration (0 on parse failure — fail-open for requeue). +func (k *Katalog) EvaluateRequeue(ctx context.Context, crdName string, obj *unstructured.Unstructured) time.Duration { + if obj == nil { + return 0 + } + entry, ok := k.CRDEntry(crdName) + if !ok { + return 0 + } + target := orktarget.ResolveTargetFromAnnotations(obj.GetAnnotations()) + box := entry.EffectiveOperatorBox(target) + rc := box.Reconciler + if rc.IsRequeueEmpty() { + return 0 + } + rq := rc.Requeue + + resolver, err := orktmpl.NewResolver(ctx, obj) + if err != nil { + return 0 + } + if !k.Profiles.IsEmpty() { + resolver = resolver.WithProfiles(k.Profiles) + } + if !k.Notes.IsEmpty() { + resolver = resolver.WithUserNotes(k.Notes) + } + + eval := resolver.TemplateEvaluator() + if !orktypes.EvaluateConditions(resolver.Data(), rq.When, rq.Or, eval) { + return 0 + } + + if rq.After == "" { + return 0 + } + rendered, ok := resolver.RenderString(rq.After) + if !ok || rendered == "" || rendered == "0s" { + return 0 + } + d, err := parseTimeDuration(rendered) + if err != nil { + return 0 + } + return d +} diff --git a/pkg/katalog/testdata/validate/invalid/bad-requeue-after.yaml b/pkg/katalog/testdata/validate/invalid/bad-requeue-after.yaml new file mode 100644 index 000000000..8ae79f925 --- /dev/null +++ b/pkg/katalog/testdata/validate/invalid/bad-requeue-after.yaml @@ -0,0 +1,14 @@ +apiVersion: orkestra.orkspace.io/v1 +kind: Katalog +metadata: + name: bad-requeue-after + version: 0.1.0 + description: Invalid requeue.after — not a duration or template. +spec: + crds: + widget: + crdFile: ../crd.yaml + operatorBox: + reconciler: + requeue: + after: "not-a-duration" diff --git a/pkg/katalog/testdata/validate/valid/requeue-after-duration.yaml b/pkg/katalog/testdata/validate/valid/requeue-after-duration.yaml new file mode 100644 index 000000000..f48c729a7 --- /dev/null +++ b/pkg/katalog/testdata/validate/valid/requeue-after-duration.yaml @@ -0,0 +1,14 @@ +apiVersion: orkestra.orkspace.io/v1 +kind: Katalog +metadata: + name: requeue-after-duration + version: 0.1.0 + description: Valid requeue.after with a plain duration string. +spec: + crds: + widget: + crdFile: ../crd.yaml + operatorBox: + reconciler: + requeue: + after: "30s" diff --git a/pkg/katalog/testdata/validate/valid/requeue-after-template.yaml b/pkg/katalog/testdata/validate/valid/requeue-after-template.yaml new file mode 100644 index 000000000..415f908c9 --- /dev/null +++ b/pkg/katalog/testdata/validate/valid/requeue-after-template.yaml @@ -0,0 +1,17 @@ +apiVersion: orkestra.orkspace.io/v1 +kind: Katalog +metadata: + name: requeue-after-template + version: 0.1.0 + description: Valid requeue.after with a template expression. +spec: + crds: + widget: + crdFile: ../crd.yaml + operatorBox: + reconciler: + requeue: + after: '{{ .spec.checkInterval | default "60s" }}' + when: + - field: spec.enabled + equals: "true" diff --git a/pkg/katalog/validate.go b/pkg/katalog/validate.go index 39d58755d..cdb70356a 100644 --- a/pkg/katalog/validate.go +++ b/pkg/katalog/validate.go @@ -364,5 +364,13 @@ func (k *Katalog) ValidateConfig(kfg *konfig.Konfig) (*Katalog, error) { return nil, err } + // ------------------------------------------------------------------------- + // 47. Validate requeue.after — must be a template expression or a valid + // Go duration string. + // ------------------------------------------------------------------------- + if err := k.validateRequeue(); err != nil { + return nil, err + } + return k, nil } diff --git a/pkg/katalog/validate_admission.go b/pkg/katalog/validate_admission.go index 55830d4f6..0c01d20af 100644 --- a/pkg/katalog/validate_admission.go +++ b/pkg/katalog/validate_admission.go @@ -7,7 +7,7 @@ import ( ) // validateAdmissionOperators rejects any validation.rules or mutation.rules -// entry — including their own when:/anyOf: gating conditions — that declares +// entry — including their own when:/or: gating conditions — that declares // an operator: string outside the known ConditionOperator set. An unknown // operator is otherwise silently skipped by the evaluator and the rule // always passes; this is exactly how operator: in went unimplemented in @@ -23,7 +23,7 @@ func (k *Katalog) validateAdmissionOperators() error { if err := checkConditionOperators(rule.When, crd.Name, rule.Field); err != nil { return err } - if err := checkConditionOperators(rule.AnyOf, crd.Name, rule.Field); err != nil { + if err := checkConditionOperators(rule.Or, crd.Name, rule.Field); err != nil { return err } } @@ -34,7 +34,7 @@ func (k *Katalog) validateAdmissionOperators() error { if err := checkConditionOperators(rule.When, crd.Name, rule.Field); err != nil { return err } - if err := checkConditionOperators(rule.AnyOf, crd.Name, rule.Field); err != nil { + if err := checkConditionOperators(rule.Or, crd.Name, rule.Field); err != nil { return err } } diff --git a/pkg/katalog/validate_admission_test.go b/pkg/katalog/validate_admission_test.go index c87d6f5a9..8119a24d5 100644 --- a/pkg/katalog/validate_admission_test.go +++ b/pkg/katalog/validate_admission_test.go @@ -70,10 +70,10 @@ func TestValidateAdmissionOperators_UnknownOperatorInWhen(t *testing.T) { assert.Contains(t, err.Error(), "greaterOrEqual") } -func TestValidateAdmissionOperators_UnknownOperatorInAnyOf(t *testing.T) { +func TestValidateAdmissionOperators_UnknownOperatorInOr(t *testing.T) { k := katalogWithValidationRule("app", orktypes.ValidationRule{ Field: "spec.replicas", Prefix: "x", Message: "msg", - AnyOf: []orktypes.Condition{{Field: "spec.tier", Operator: "notARealOp"}}, + Or: []orktypes.Condition{{Field: "spec.tier", Operator: "notARealOp"}}, }) err := k.validateAdmissionOperators() require.Error(t, err) diff --git a/pkg/katalog/validate_autoscale.go b/pkg/katalog/validate_autoscale.go index 9e9a4adb8..90e235aaa 100644 --- a/pkg/katalog/validate_autoscale.go +++ b/pkg/katalog/validate_autoscale.go @@ -99,7 +99,7 @@ func autoscaleIsProfileOnly(spec *orktypes.AutoscaleSpec) bool { // Any manual field invalidates profile usage if spec.HasCooldownDuration() || spec.HasIntervalDuration() || - spec.HasWhenConditions() || spec.HasAnyOfConditions() || + spec.HasWhenConditions() || spec.HasOrConditions() || spec.HasDoWorkers() || spec.HasDoQueueDepth() || spec.HasDoResync() { return false diff --git a/pkg/katalog/validate_cron_conditions.go b/pkg/katalog/validate_cron_conditions.go index ff337ecda..8866de635 100644 --- a/pkg/katalog/validate_cron_conditions.go +++ b/pkg/katalog/validate_cron_conditions.go @@ -23,39 +23,39 @@ func (k *Katalog) CronConditionWarnings() []string { return warnings } -// collectHookConditions gathers all when:/anyOf: conditions from every resource in a HookTemplates. +// collectHookConditions gathers all when:/or: conditions from every resource in a HookTemplates. func collectHookConditions(ht *orktypes.HookTemplates) []orktypes.Condition { var out []orktypes.Condition - add := func(when, anyOf []orktypes.Condition) { + add := func(when, or []orktypes.Condition) { out = append(out, when...) - out = append(out, anyOf...) + out = append(out, or...) } for _, r := range ht.Deployments { - add(r.Conditions, r.AnyOf) + add(r.Conditions, r.Or) } for _, r := range ht.StatefulSets { - add(r.Conditions, r.AnyOf) + add(r.Conditions, r.Or) } for _, r := range ht.ReplicaSets { - add(r.Conditions, r.AnyOf) + add(r.Conditions, r.Or) } for _, r := range ht.Services { - add(r.Conditions, r.AnyOf) + add(r.Conditions, r.Or) } for _, r := range ht.Jobs { - add(r.Conditions, r.AnyOf) + add(r.Conditions, r.Or) } for _, r := range ht.CronJobs { - add(r.Conditions, r.AnyOf) + add(r.Conditions, r.Or) } for _, r := range ht.ConfigMaps { - add(r.Conditions, r.AnyOf) + add(r.Conditions, r.Or) } for _, r := range ht.Secrets { - add(r.Conditions, r.AnyOf) + add(r.Conditions, r.Or) } for _, r := range ht.HorizontalPodAutoscalers { - add(r.Conditions, r.AnyOf) + add(r.Conditions, r.Or) } return out } diff --git a/pkg/katalog/validate_requeue.go b/pkg/katalog/validate_requeue.go new file mode 100644 index 000000000..3084f206c --- /dev/null +++ b/pkg/katalog/validate_requeue.go @@ -0,0 +1,25 @@ +package katalog + +import "fmt" + +// validateRequeue checks that requeue.after is either a template expression +// or a valid duration string accepted by utils.ParseTimeDuration. +// Empty string is allowed — it means no requeue. +func (k *Katalog) validateRequeue() error { + for crdName, crd := range k.enabledCRDs { + rc := crd.OperatorBox.Reconciler + if rc == nil || rc.Requeue == nil { + continue + } + after := rc.Requeue.After + if after == "" || isTemplate(after) { + continue + } + if _, err := parseTimeDuration(after); err != nil { + return fmt.Errorf("%s crd %q: requeue.after %q is not a valid duration or template expression. "+ + "Use a Go duration string (e.g. \"30s\", \"5m\") or a template (e.g. '{{ .spec.interval | default \"60s\" }}')", + failureMark(), crdName, after) + } + } + return nil +} diff --git a/pkg/katalog/validate_requeue_test.go b/pkg/katalog/validate_requeue_test.go new file mode 100644 index 000000000..ce88ac82f --- /dev/null +++ b/pkg/katalog/validate_requeue_test.go @@ -0,0 +1,58 @@ +package katalog + +import ( + "testing" + + orktypes "github.com/orkspace/orkestra/pkg/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func katalogWithRequeue(crdName string, rq *orktypes.RequeueConfig) *Katalog { + return &Katalog{ + enabledCRDs: map[string]orktypes.CRDEntry{ + crdName: {OperatorBox: orktypes.OperatorBoxConfig{ + Reconciler: &orktypes.ReconcilerConfig{Requeue: rq}, + }}, + }, + } +} + +func TestValidateRequeue_NoConfig(t *testing.T) { + k := katalogWithRequeue("myapp", nil) + assert.NoError(t, k.validateRequeue()) +} + +func TestValidateRequeue_EmptyAfter(t *testing.T) { + k := katalogWithRequeue("myapp", &orktypes.RequeueConfig{After: ""}) + assert.NoError(t, k.validateRequeue()) +} + +func TestValidateRequeue_ValidDuration(t *testing.T) { + for _, after := range []string{"30s", "5m", "1h", "500ms"} { + k := katalogWithRequeue("myapp", &orktypes.RequeueConfig{After: after}) + assert.NoError(t, k.validateRequeue(), "after=%q", after) + } +} + +func TestValidateRequeue_ValidTemplate(t *testing.T) { + k := katalogWithRequeue("myapp", &orktypes.RequeueConfig{ + After: `{{ .spec.checkInterval | default "60s" }}`, + }) + assert.NoError(t, k.validateRequeue()) +} + +func TestValidateRequeue_InvalidAfter(t *testing.T) { + k := katalogWithRequeue("myapp", &orktypes.RequeueConfig{After: "not-a-duration"}) + err := k.validateRequeue() + require.Error(t, err) + assert.Contains(t, err.Error(), "requeue.after") + assert.Contains(t, err.Error(), "not-a-duration") +} + +func TestValidateRequeue_InvalidAfter_Bare_Number(t *testing.T) { + k := katalogWithRequeue("myapp", &orktypes.RequeueConfig{After: "60"}) + err := k.validateRequeue() + require.Error(t, err) + assert.Contains(t, err.Error(), "requeue.after") +} diff --git a/pkg/katalog/validate_watch.go b/pkg/katalog/validate_watch.go index b017ca867..9dd2c48c2 100644 --- a/pkg/katalog/validate_watch.go +++ b/pkg/katalog/validate_watch.go @@ -30,10 +30,75 @@ func (k *Katalog) validateWatchEntries() error { if err := k.validatePreReconcileGateTemplates(crdName, crd); err != nil { return err } + if err := k.validateGateFailPolicies(crdName); err != nil { + return err + } } return nil } +func (k *Katalog) validateGateFailPolicies(crdName string) error { + crd, ok := k.enabledCRDs[crdName] + if !ok { + return nil + } + pr := crd.OperatorBox.PreReconcile + if pr == nil { + return nil + } + type gateInfo struct { + gate *orktypes.GateConditions + location string + } + changed := false + for _, gi := range []gateInfo{ + {pr.EnqueueGate, "preReconcile.enqueueGate"}, + {pr.ReconcileGate, "preReconcile.reconcileGate"}, + } { + g := gi.gate + if g == nil { + continue + } + if g.FailPolicy != "" && !orktypes.IsValidFailPolicy(string(g.FailPolicy)) { + return fmt.Errorf("%s crd %q: %s.failPolicy %q is not valid. Valid values: %s", + failureMark(), crdName, gi.location, g.FailPolicy, orktypes.FailPolicyJoined()) + } + if len(g.ExternalCalls()) > 0 && g.FailPolicy == "" { + crd.Warnings.AddWarning(fmt.Sprintf( + "%s has external: calls but no failPolicy declared. "+ + "Default is open — evaluation failure will pass the gate. "+ + "Set failPolicy: closed if unknown state should hold back reconciliation.", + gi.location)) + changed = true + } + if g.FailPolicy == orktypes.FailPolicyClosed && allContinueOnError(g.ExternalCalls()) { + crd.Warnings.AddWarning(fmt.Sprintf( + "%s has failPolicy: closed but all external: calls have continueOnError: true. "+ + "continueOnError suppresses call errors before they reach the gate — "+ + "failPolicy: closed will never trigger. "+ + "Use when: conditions on external.*.error to gate on suppressed failures instead.", + gi.location)) + changed = true + } + } + if changed { + k.enabledCRDs[crdName] = crd + } + return nil +} + +func allContinueOnError(calls []orktypes.ExternalCallSpec) bool { + if len(calls) == 0 { + return false + } + for _, c := range calls { + if !c.ContinueOnError { + return false + } + } + return true +} + func validateCRDWatchEntries(crdName string, crd orktypes.CRDEntry) error { entries := crd.WatchEntries() if len(entries) == 0 { @@ -127,11 +192,11 @@ func (k *Katalog) validatePreReconcileGateTemplates(crdName string, crd orktypes return err } } - for i, cond := range gate.AnyOfConditions() { - if err := parseGateTemplate(crdName, location, fmt.Sprintf("anyOf[%d].field", i), cond.Field, funcMap); err != nil { + for i, cond := range gate.OrConditions() { + if err := parseGateTemplate(crdName, location, fmt.Sprintf("or[%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 { + if err := parseGateTemplate(crdName, location, fmt.Sprintf("or[%d].equals", i), cond.Equals, funcMap); err != nil { return err } } diff --git a/pkg/katalog/validation_methods.go b/pkg/katalog/validation_methods.go index 6e1827e25..740a450e0 100644 --- a/pkg/katalog/validation_methods.go +++ b/pkg/katalog/validation_methods.go @@ -402,8 +402,8 @@ func (k *Katalog) validateAutoscalerMetrics() error { conds := crd.OperatorBox.Autoscale.Conditions - // Validate anyOf - for _, c := range conds.AnyOf { + // Validate or + for _, c := range conds.Or { if strings.HasPrefix(c.Field, "metrics.") { if err := crd.ValidateMetricField(c.Field); err != nil { k.handleValidationErrors(err) diff --git a/pkg/kubeclient/ctrlclient.go b/pkg/kubeclient/ctrlclient.go index 4f11554ea..bdf4774b4 100644 --- a/pkg/kubeclient/ctrlclient.go +++ b/pkg/kubeclient/ctrlclient.go @@ -3,10 +3,14 @@ package kubeclient import ( "context" "fmt" + "strings" + "github.com/orkspace/orkestra/pkg/logger" + "github.com/orkspace/orkestra/pkg/utils" 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/labels" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" sigs "sigs.k8s.io/controller-runtime/pkg/client" @@ -33,9 +37,62 @@ var _ sigs.Client = (*ctrlClientAdapter)(nil) // ── Reader ──────────────────────────────────────────────────────────────────── func (a *ctrlClientAdapter) Get(ctx context.Context, key sigs.ObjectKey, obj sigs.Object, _ ...sigs.GetOption) error { + if u, ok, reason := a.getFromStore(obj, key); ok { + return runtime.DefaultUnstructuredConverter.FromUnstructured(u.Object, obj) + } else { + logger.Debug(). + Str("type", fmt.Sprintf("%T", obj)). + Str("key", key.Namespace+"/"+key.Name). + Str("reason", reason). + Msg("ctrlclient.Get: cache miss — live API call") + } return a.k.Get(ctx, key.Namespace, key.Name, obj) } +// getFromStore attempts a cache-backed read. Returns (result, true, "") on hit; +// (nil, false, reason) on miss so the caller can log the reason without branching. +func (a *ctrlClientAdapter) getFromStore(obj runtime.Object, key sigs.ObjectKey) (*unstructured.Unstructured, bool, string) { + fn := a.k.GetStoreFor() + if fn == nil { + return nil, false, "storeFor not wired" + } + gvks, _, err := a.k.Scheme().ObjectKinds(obj) + if err != nil || len(gvks) == 0 { + return nil, false, fmt.Sprintf("scheme cannot resolve GVK: %v", err) + } + gvk := gvks[0] + store := fn(gvk) + if store == nil { + return nil, false, "no informer store for " + gvk.String() + } + storeKey := key.Name + if key.Namespace != "" { + storeKey = key.Namespace + "/" + key.Name + } + raw, exists, err := store.GetByKey(storeKey) + if err != nil { + return nil, false, "store.GetByKey error: " + err.Error() + } + if !exists || raw == nil { + return nil, false, "key not in store" + } + if u, ok := raw.(*unstructured.Unstructured); ok { + return u, true, "" + } + // Typed informers store the concrete Go type. Convert to unstructured so the + // caller can FromUnstructured it into the target — same roundtrip, avoids a + // direct type assertion that would only work for one specific type. + rObj, ok := raw.(runtime.Object) + if !ok { + return nil, false, fmt.Sprintf("store item is %T, not runtime.Object", raw) + } + rawMap, err := runtime.DefaultUnstructuredConverter.ToUnstructured(rObj) + if err != nil { + return nil, false, "ToUnstructured: " + err.Error() + } + return &unstructured.Unstructured{Object: rawMap}, true, "" +} + func (a *ctrlClientAdapter) List(ctx context.Context, list sigs.ObjectList, opts ...sigs.ListOption) error { lo := &sigs.ListOptions{} for _, opt := range opts { @@ -46,8 +103,19 @@ func (a *ctrlClientAdapter) List(ctx context.Context, list sigs.ObjectList, opts 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 + // List types have a "List" suffix in the kind; strip it to get the item GVK. gvk := gvks[0] + + if items, ok, reason := a.listFromStore(gvk, lo); ok { + return a.assembleList(list, items) + } else { + logger.Debug(). + Str("gvk", gvk.String()). + Str("namespace", lo.Namespace). + Str("reason", reason). + Msg("ctrlclient.List: cache miss — live API call") + } + 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) @@ -98,6 +166,115 @@ func (a *ctrlClientAdapter) List(ctx context.Context, list sigs.ObjectList, opts return runtime.DefaultUnstructuredConverter.FromUnstructured(listMap, list) } +// listFromStore attempts a cache-backed list for the given list GVK. Returns +// (items, true) when the store is registered and all matching items are found. +// Falls back to false so the caller goes live. Label selector and namespace +// filtering match controller-runtime's delegating client behaviour. +// +// When a field selector is present, listFromStore tries a ByIndex lookup using +// the registered indexer for the GVK. If the field key matches a registered index +// name the query is served from the index. If no matching index is registered the +// call falls back to the live API (field-selector filtering in-process would return +// incorrect results for fields not covered by an index). +func (a *ctrlClientAdapter) listFromStore(listGVK schema.GroupVersionKind, lo *sigs.ListOptions) ([]*unstructured.Unstructured, bool, string) { + fn := a.k.GetStoreFor() + if fn == nil { + return nil, false, "storeFor not wired" + } + elemKind := strings.TrimSuffix(listGVK.Kind, "List") + if elemKind == listGVK.Kind { + return nil, false, "not a list type" + } + elemGVK := schema.GroupVersionKind{Group: listGVK.Group, Version: listGVK.Version, Kind: elemKind} + store := fn(elemGVK) + if store == nil { + return nil, false, "no informer store for " + elemGVK.String() + } + + // Field selector — try index before full scan. + if lo.FieldSelector != nil && !lo.FieldSelector.Empty() { + reqs := lo.FieldSelector.Requirements() + indexFn := a.k.GetIndexerFor() + if indexFn == nil { + return nil, false, "field selector present but indexerFor not wired" + } + indexer := indexFn(elemGVK) + if indexer == nil { + return nil, false, "field selector present but no indexer for " + elemGVK.String() + } + // Serve the first matchable requirement from the index; remaining + // requirements are applied as post-filters. + registeredIndexers := indexer.GetIndexers() + for i, req := range reqs { + if _, ok := registeredIndexers[req.Field]; !ok { + continue + } + raws, err := indexer.ByIndex(req.Field, req.Value) + if err != nil { + return nil, false, "ByIndex error: " + err.Error() + } + remaining := append(reqs[:i:i], reqs[i+1:]...) + var result []*unstructured.Unstructured + for _, raw := range raws { + u, ok := raw.(*unstructured.Unstructured) + if !ok { + return nil, false, fmt.Sprintf("index item is %T, not *Unstructured", raw) + } + if lo.Namespace != "" && u.GetNamespace() != lo.Namespace { + continue + } + if lo.LabelSelector != nil && !lo.LabelSelector.Matches(labels.Set(u.GetLabels())) { + continue + } + if !utils.MatchesFieldRequirements(u, remaining) { + continue + } + result = append(result, u) + } + return result, true, "" + } + // No registered index covers any requirement — go live so the API applies the filter. + return nil, false, "no registered index covers field selector " + lo.FieldSelector.String() + } + + var sel labels.Selector + if lo.LabelSelector != nil { + sel = lo.LabelSelector + } else { + sel = labels.Everything() + } + + var result []*unstructured.Unstructured + for _, raw := range store.List() { + u, ok := raw.(*unstructured.Unstructured) + if !ok { + return nil, false, fmt.Sprintf("store item is %T, not *Unstructured", raw) + } + if lo.Namespace != "" && u.GetNamespace() != lo.Namespace { + continue + } + if !sel.Matches(labels.Set(u.GetLabels())) { + continue + } + result = append(result, u) + } + return result, true, "" +} + +// assembleList converts a slice of unstructured items into the typed list obj. +func (a *ctrlClientAdapter) assembleList(list sigs.ObjectList, items []*unstructured.Unstructured) error { + rawItems := make([]interface{}, len(items)) + for i, u := range items { + rawItems[i] = u.Object + } + listMap, err := runtime.DefaultUnstructuredConverter.ToUnstructured(list) + if err != nil { + return fmt.Errorf("ctrlclient List (cache): marshal list shell: %w", err) + } + listMap["items"] = rawItems + return runtime.DefaultUnstructuredConverter.FromUnstructured(listMap, list) +} + // ── Writer ──────────────────────────────────────────────────────────────────── func (a *ctrlClientAdapter) Create(ctx context.Context, obj sigs.Object, _ ...sigs.CreateOption) error { diff --git a/pkg/kubeclient/fixture/01-hooks/e2e.yaml b/pkg/kubeclient/fixture/01-hooks/e2e.yaml index 02fc305a3..96bd70aaa 100644 --- a/pkg/kubeclient/fixture/01-hooks/e2e.yaml +++ b/pkg/kubeclient/fixture/01-hooks/e2e.yaml @@ -55,7 +55,7 @@ spec: - name: Feature flag annotation false (outside hours) after: cr-applied timeout: 30s - anyOf: + or: - field: '{{ inBusinessHours }}' equals: "false" kubectl: diff --git a/pkg/kubeclient/fixture/01-hooks/katalog.yaml b/pkg/kubeclient/fixture/01-hooks/katalog.yaml index 29d7616ac..dffa57fc8 100644 --- a/pkg/kubeclient/fixture/01-hooks/katalog.yaml +++ b/pkg/kubeclient/fixture/01-hooks/katalog.yaml @@ -40,7 +40,7 @@ spec: location: github.com/orkspace/orkestra-args-hooks/hooks function: BlockchainAppHooks alias: bchooks - resources: + managedResources: - kind: Deployment external: - name: flags diff --git a/pkg/kubeclient/fixture/02-constructor/constructor/blockchainnode_reconciler.go b/pkg/kubeclient/fixture/02-constructor/constructor/blockchainnode_reconciler.go index f75ddf963..3ca576d53 100644 --- a/pkg/kubeclient/fixture/02-constructor/constructor/blockchainnode_reconciler.go +++ b/pkg/kubeclient/fixture/02-constructor/constructor/blockchainnode_reconciler.go @@ -25,28 +25,29 @@ func NewBlockchainNodeReconciler(kube kubeclient.Interface) domain.Reconciler { return &BlockchainNodeReconciler{kube: kube} } -func (r *BlockchainNodeReconciler) Reconcile(ctx context.Context, key string) error { +func (r *BlockchainNodeReconciler) Reconcile(ctx context.Context, req domain.Request) (domain.Result, error) { + key := req.Key raw, exists, err := r.kube.GetInformer().GetIndexer().GetByKey(key) if err != nil { - return fmt.Errorf("cache lookup %q: %w", key, err) + return domain.Result{}, fmt.Errorf("cache lookup %q: %w", key, err) } if !exists { - return nil + return domain.Result{}, nil } node, ok := raw.(*apiv1.BlockchainNode) if !ok { - return fmt.Errorf("unexpected type %T for key %q", raw, key) + return domain.Result{}, fmt.Errorf("unexpected type %T for key %q", raw, key) } node = node.DeepCopyObject().(*apiv1.BlockchainNode) if node.DeletionTimestamp != nil { - return nil + return domain.Result{}, nil } resolver, err := orktmpl.NewResolver(ctx, node) if err != nil { - return fmt.Errorf("building resolver: %w", err) + return domain.Result{}, fmt.Errorf("building resolver: %w", err) } kube := r.kube.ScopedFor(resolver.TemplateEvaluator()) @@ -80,10 +81,10 @@ func (r *BlockchainNodeReconciler) Reconcile(ctx context.Context, key string) er }, } if err := orkdeploy.Apply(ctx, kube, node, spec); err != nil { - return fmt.Errorf("blockchainnode deployment: %w", err) + return domain.Result{}, fmt.Errorf("blockchainnode deployment: %w", err) } - return kube.PatchStatus(ctx, node, map[string]any{ + return domain.Result{}, kube.PatchStatus(ctx, node, map[string]any{ "phase": "Running", "network": node.Spec.Network, "featureEnabled": annotation, diff --git a/pkg/kubeclient/fixture/02-constructor/e2e.yaml b/pkg/kubeclient/fixture/02-constructor/e2e.yaml index 8b532ce21..b566f5dd3 100644 --- a/pkg/kubeclient/fixture/02-constructor/e2e.yaml +++ b/pkg/kubeclient/fixture/02-constructor/e2e.yaml @@ -60,7 +60,7 @@ spec: - name: Feature flag annotation false (outside hours) after: cr-applied timeout: 30s - anyOf: + or: - field: '{{ inBusinessHours }}' equals: "false" kubectl: diff --git a/pkg/kubeclient/fixture/02-constructor/katalog.yaml b/pkg/kubeclient/fixture/02-constructor/katalog.yaml index 8a1570455..acb40e5ba 100644 --- a/pkg/kubeclient/fixture/02-constructor/katalog.yaml +++ b/pkg/kubeclient/fixture/02-constructor/katalog.yaml @@ -34,7 +34,7 @@ spec: location: github.com/orkspace/orkestra-args-constructor/constructor function: NewBlockchainNodeReconciler alias: bnconstructor - resources: + managedResources: - kind: Deployment args: flagUrl: '{{ .spec.serviceUrl }}/flags/{{ .metadata.name }}/v2Enabled' 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 c591b82b7..6057d5ec0 100644 --- a/pkg/kubeclient/fixture/03-hooks-targets/constructor/blockchainappwithtargets_reconciler.go +++ b/pkg/kubeclient/fixture/03-hooks-targets/constructor/blockchainappwithtargets_reconciler.go @@ -25,28 +25,29 @@ func NewBlockchainAppWithTargetsReconciler(kube kubeclient.Interface) domain.Rec return &BlockchainAppWithTargetsReconciler{kube: kube} } -func (r *BlockchainAppWithTargetsReconciler) Reconcile(ctx context.Context, key string) error { +func (r *BlockchainAppWithTargetsReconciler) Reconcile(ctx context.Context, req domain.Request) (domain.Result, error) { + key := req.Key raw, exists, err := r.kube.GetInformer().GetIndexer().GetByKey(key) if err != nil { - return fmt.Errorf("cache lookup %q: %w", key, err) + return domain.Result{}, fmt.Errorf("cache lookup %q: %w", key, err) } if !exists { - return nil + return domain.Result{}, nil } app, ok := raw.(*apiv1.BlockchainAppWithTargets) if !ok { - return fmt.Errorf("unexpected type %T for key %q", raw, key) + return domain.Result{}, fmt.Errorf("unexpected type %T for key %q", raw, key) } app = app.DeepCopyObject().(*apiv1.BlockchainAppWithTargets) if app.DeletionTimestamp != nil { - return nil + return domain.Result{}, nil } resolver, err := orktmpl.NewResolver(ctx, app) if err != nil { - return fmt.Errorf("building resolver: %w", err) + return domain.Result{}, fmt.Errorf("building resolver: %w", err) } kube := r.kube.ScopedFor(resolver.TemplateEvaluator()) @@ -73,10 +74,10 @@ func (r *BlockchainAppWithTargetsReconciler) Reconcile(ctx context.Context, key }, } if err := orkdeploy.Apply(ctx, kube, app, spec); err != nil { - return fmt.Errorf("blockchainappwithtargets deployment: %w", err) + return domain.Result{}, fmt.Errorf("blockchainappwithtargets deployment: %w", err) } - return kube.PatchStatus(ctx, app, map[string]any{ + return domain.Result{}, kube.PatchStatus(ctx, app, map[string]any{ "phase": "Running", "network": app.Spec.Network, "featureEnabled": annotation, diff --git a/pkg/kubeclient/fixture/03-hooks-targets/katalog.yaml b/pkg/kubeclient/fixture/03-hooks-targets/katalog.yaml index c3d0bad73..8838f4ca6 100644 --- a/pkg/kubeclient/fixture/03-hooks-targets/katalog.yaml +++ b/pkg/kubeclient/fixture/03-hooks-targets/katalog.yaml @@ -89,7 +89,7 @@ spec: location: github.com/orkspace/orkestra-args-hooks-targets/hooks function: BlockchainAppHooks alias: bchooks - resources: + managedResources: - kind: Deployment args: featureEnabled: "true" @@ -102,7 +102,7 @@ spec: location: github.com/orkspace/orkestra-args-hooks-targets/hooks function: BlockchainAppHooks alias: bchooks - resources: + managedResources: - kind: Deployment args: featureEnabled: "false" diff --git a/pkg/kubeclient/fixture/04-ctrlruntime/cmd/orkestra/main.go b/pkg/kubeclient/fixture/04-ctrlruntime/cmd/orkestra/main.go index 6231bbffa..16b7774a3 100644 --- a/pkg/kubeclient/fixture/04-ctrlruntime/cmd/orkestra/main.go +++ b/pkg/kubeclient/fixture/04-ctrlruntime/cmd/orkestra/main.go @@ -1,4 +1,4 @@ -// Code generated by "ork generate registry" on 2026-08-20T13:32:48Z. DO NOT EDIT. +// Code generated by "ork generate registry" on 2026-08-21T14:56:19Z. DO NOT EDIT. // Re-generate by running: ork generate registry --file package main diff --git a/pkg/kubeclient/fixture/04-ctrlruntime/constructor/webapp_reconciler.go b/pkg/kubeclient/fixture/04-ctrlruntime/constructor/webapp_reconciler.go index 8cbd938f2..c6066fb62 100644 --- a/pkg/kubeclient/fixture/04-ctrlruntime/constructor/webapp_reconciler.go +++ b/pkg/kubeclient/fixture/04-ctrlruntime/constructor/webapp_reconciler.go @@ -6,6 +6,7 @@ import ( appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/equality" "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ctrl "sigs.k8s.io/controller-runtime" @@ -51,6 +52,20 @@ func (r *WebAppReconciler) Reconcile(ctx context.Context, req ctrl.Request) (rec return ctrl.Result{}, err } + if err := r.reconcileConfigMap(ctx, webapp); err != nil { + log.Error(err, "reconcileConfigMap failed") + return ctrl.Result{}, err + } + + // List ConfigMaps by field index — served from cache when the watch informer + // has synced; falls back to live API during the first reconcile cycle. + cmList := &corev1.ConfigMapList{} + if err := r.client.List(ctx, cmList, client.MatchingFields{"metadata.labels.app": webapp.Name}); err != nil { + log.Error(err, "list ConfigMaps by index failed") + } else { + log.Info("configmaps found via index", "count", len(cmList.Items)) + } + base := webapp.DeepCopyObject().(client.Object) webapp.Status.Phase = "Running" webapp.Status.Endpoint = fmt.Sprintf("%s.%s.svc.cluster.local", webapp.Name, webapp.Namespace) @@ -63,6 +78,39 @@ func (r *WebAppReconciler) Reconcile(ctx context.Context, req ctrl.Request) (rec return ctrl.Result{}, nil } +func (r *WebAppReconciler) reconcileConfigMap(ctx context.Context, webapp *webappv1.WebApp) error { + log := ctrl.LoggerFrom(ctx).WithValues("webapp", webapp.Name) + desired := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: webapp.Name + "-config", + Namespace: webapp.Namespace, + Labels: map[string]string{"app": webapp.Name}, + OwnerReferences: []metav1.OwnerReference{ + *metav1.NewControllerRef(webapp, webappv1.GroupVersionKind), + }, + }, + Data: map[string]string{ + "image": webapp.Spec.Image, + }, + } + existing := &corev1.ConfigMap{} + err := r.client.Get(ctx, client.ObjectKey{Name: desired.Name, Namespace: desired.Namespace}, existing) + if errors.IsNotFound(err) { + log.Info("creating configmap") + return r.client.Create(ctx, desired) + } + if err != nil { + return err + } + if equality.Semantic.DeepEqual(existing.Data, desired.Data) { + return nil + } + log.Info("patching configmap") + patch := client.MergeFrom(existing.DeepCopy()) + existing.Data = desired.Data + return r.client.Patch(ctx, existing, patch) +} + func (r *WebAppReconciler) reconcileDeployment(ctx context.Context, webapp *webappv1.WebApp) error { log := ctrl.LoggerFrom(ctx).WithValues("webapp", webapp.Name, "namespace", webapp.Namespace) @@ -113,8 +161,26 @@ func (r *WebAppReconciler) reconcileDeployment(ctx context.Context, webapp *weba return err } + // Compare only the fields our reconciler controls. Comparing the whole Spec + // fails because the API server adds defaults (Strategy, ProgressDeadlineSeconds, + // etc.) that our desired struct omits, causing an endless patch loop. + currentReplicas := int32(1) + if existing.Spec.Replicas != nil { + currentReplicas = *existing.Spec.Replicas + } + currentImage := "" + if len(existing.Spec.Template.Spec.Containers) > 0 { + currentImage = existing.Spec.Template.Spec.Containers[0].Image + } + if currentReplicas == replicas && currentImage == webapp.Spec.Image { + return nil + } log.Info("patching deployment", "image", webapp.Spec.Image, "replicas", replicas) patch := client.MergeFrom(existing.DeepCopy()) - existing.Spec = desired.Spec + existing.Spec.Replicas = desired.Spec.Replicas + if len(existing.Spec.Template.Spec.Containers) > 0 { + existing.Spec.Template.Spec.Containers[0].Image = webapp.Spec.Image + existing.Spec.Template.Spec.Containers[0].Ports = desired.Spec.Template.Spec.Containers[0].Ports + } return r.client.Patch(ctx, existing, patch) } diff --git a/pkg/kubeclient/fixture/04-ctrlruntime/katalog.yaml b/pkg/kubeclient/fixture/04-ctrlruntime/katalog.yaml index 833f4b862..0ba249a41 100644 --- a/pkg/kubeclient/fixture/04-ctrlruntime/katalog.yaml +++ b/pkg/kubeclient/fixture/04-ctrlruntime/katalog.yaml @@ -27,6 +27,13 @@ spec: alias: webappv1 operatorBox: + watch: + - apiVersion: v1 + kind: ConfigMap + index: + - name: metadata.labels.app + field: "metadata.labels.app" + reconciler: default: false constructor: 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 index c534fff5c..495a79c7b 100644 --- a/pkg/kubeclient/fixture/04-ctrlruntime/pkg/typeregistry/zz_generated_typeregistry.go +++ b/pkg/kubeclient/fixture/04-ctrlruntime/pkg/typeregistry/zz_generated_typeregistry.go @@ -1,5 +1,5 @@ // pkg/typeregistry/zz_generated_typeregistry.go -// Code generated by "ork generate registry" on 2026-08-20T13:32:48Z. DO NOT EDIT. +// Code generated by "ork generate registry" on 2026-08-21T14:56:19Z. DO NOT EDIT. // Re-generate by running: ork generate registry --file // // This file registers compiled Go types and external functions. diff --git a/pkg/kubeclient/interface.go b/pkg/kubeclient/interface.go index 7a9574875..9565145e8 100644 --- a/pkg/kubeclient/interface.go +++ b/pkg/kubeclient/interface.go @@ -6,6 +6,7 @@ import ( "github.com/orkspace/orkestra/domain" "k8s.io/apimachinery/pkg/api/meta" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/client-go/dynamic" "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" @@ -42,6 +43,22 @@ type Interface interface { // attached. Called by the runtime before invoking a constructor function. WithInformer(inf cache.SharedIndexInformer) Interface + // WithStoreFor attaches a closure that returns the informer store for a GVK. + // Called by the runtime so ToClient can serve cached reads for registered types. + // fn is evaluated lazily at client.Get/List time — it always reflects the live + // factory state, including informers registered after this call. + WithStoreFor(fn func(schema.GroupVersionKind) cache.Store) Interface + + // GetStoreFor returns the store-lookup closure, or nil if none was attached. + GetStoreFor() func(schema.GroupVersionKind) cache.Store + + // WithIndexerFor attaches a closure that returns the cache.Indexer for a GVK. + // Called by the runtime so ToClient can use ByIndex for field-selector queries. + WithIndexerFor(fn func(schema.GroupVersionKind) cache.Indexer) Interface + + // GetIndexerFor returns the indexer-lookup closure, or nil if none was attached. + GetIndexerFor() func(schema.GroupVersionKind) cache.Indexer + // 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 diff --git a/pkg/kubeclient/kubeclient.go b/pkg/kubeclient/kubeclient.go index 50bcea271..e33ffc1c3 100644 --- a/pkg/kubeclient/kubeclient.go +++ b/pkg/kubeclient/kubeclient.go @@ -14,6 +14,7 @@ import ( apiextclientset "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset" "k8s.io/apimachinery/pkg/api/meta" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/client-go/discovery" "k8s.io/client-go/discovery/cached/memory" "k8s.io/client-go/dynamic" @@ -48,11 +49,17 @@ type Kubeclient struct { args Args // informer is the primary CRD's SharedIndexInformer, injected by the runtime - // before the constructor is called. Accessible via Informer(). + // before the constructor is called. Accessible via GetInformer(). 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 + // storeFor is a closure that returns the informer store for a GVK, injected by + // the runtime so ToClient can serve cached reads. Nil when not wired. + storeFor func(schema.GroupVersionKind) cache.Store + // indexerFor is a closure that returns the cache.Indexer for a GVK, injected by + // the runtime so ToClient can use ByIndex for field-selector queries. Nil when not wired. + indexerFor func(schema.GroupVersionKind) cache.Indexer // Testing FakeClientset kubernetes.Interface @@ -276,3 +283,31 @@ func (k *Kubeclient) GetInformer() cache.SharedIndexInformer { func (k *Kubeclient) GetEventRecorder() EventRecorder { return k.eventRecorder } + +// WithStoreFor returns a copy of this Interface with the given store-lookup +// closure attached. Called by the runtime before invoking a constructor so that +// ToClient can serve cached reads for informer-backed types. +func (k *Kubeclient) WithStoreFor(fn func(schema.GroupVersionKind) cache.Store) Interface { + cp := *k + cp.storeFor = fn + return &cp +} + +// GetStoreFor returns the store-lookup closure, or nil if none was attached. +func (k *Kubeclient) GetStoreFor() func(schema.GroupVersionKind) cache.Store { + return k.storeFor +} + +// WithIndexerFor returns a copy of this Interface with the given indexer-lookup +// closure attached. Called by the runtime before invoking a constructor so that +// ToClient can use ByIndex for field-selector queries. +func (k *Kubeclient) WithIndexerFor(fn func(schema.GroupVersionKind) cache.Indexer) Interface { + cp := *k + cp.indexerFor = fn + return &cp +} + +// GetIndexerFor returns the indexer-lookup closure, or nil if none was attached. +func (k *Kubeclient) GetIndexerFor() func(schema.GroupVersionKind) cache.Indexer { + return k.indexerFor +} diff --git a/pkg/note/README.md b/pkg/note/README.md index 78fb594b0..b1a37fb75 100644 --- a/pkg/note/README.md +++ b/pkg/note/README.md @@ -79,6 +79,7 @@ Complete documentation is in [docs/](docs/README.md). | Inspect node topology | [22 — Node Notes](docs/22-node.md) | | Read StatefulSet revision state | [23 — StatefulSet Notes](docs/23-statefulset.md) | | Navigate ReplicaSet ownership | [24 — ReplicaSet Notes](docs/24-replicaset.md) | +| Work with timestamps and durations | [26 — Time Notes](docs/26-time.md) | | Gate on Kubernetes label/annotation format | [31 — Kubernetes Validation Notes](docs/31-kube-validation.md) | | Check emails, git URLs, images, JSON, ports | [32 — Validation Notes](docs/32-validation.md) | diff --git a/pkg/note/catalog_generated.go b/pkg/note/catalog_generated.go index 39d3233d6..1e998f00b 100644 --- a/pkg/note/catalog_generated.go +++ b/pkg/note/catalog_generated.go @@ -1740,6 +1740,13 @@ var BuiltinNotes = []NoteInfo{ Example: "# Gate rotation on time elapsed (30 days = 2592000 seconds)\nwhen:\n - field: \"{{ timeSince (index .metadata.annotations \\\"myorg.io/last-rotated\\\") }}\"\n operator: gte\n value: \"2592000\"", Keywords: []string{"time", "since", "seconds", "elapsed", "duration", "int", "compare"}, }, + { + Name: "timeUntil", + Domain: "time", + Description: "Return a Go duration string representing the time remaining until a timestamp. Returns `\"0s\"` when the timestamp is in the past or unparseable. The primary use is `requeue.after:` — schedule the next reconcile at exactly the right moment for this specific CR.", + Example: "operatorBox:\n reconciler:\n requeue:\n after: \"{{ timeUntil .status.certExpiry }}\"\n when:\n - field: status.certExpiry\n operator: exists\n\nstatus:\n fields:\n - path: timeUntilExpiry\n value: \"{{ timeUntil .status.certExpiry }}\"\n # → \"719h43m12s\" (about 30 days)\n # → \"0s\" (already expired)", + Keywords: []string{"time", "until", "remaining", "duration", "expiry", "cert", "requeue", "schedule"}, + }, { Name: "weekday", Domain: "time", diff --git a/pkg/note/docs/26-time.md b/pkg/note/docs/26-time.md index 90c0dcea5..4950717c6 100644 --- a/pkg/note/docs/26-time.md +++ b/pkg/note/docs/26-time.md @@ -41,6 +41,31 @@ when: --- +### `timeUntil` + +Return a Go duration string representing the time remaining until a timestamp. Returns `"0s"` when the timestamp is in the past or unparseable. The primary use is `requeue.after:` — schedule the next reconcile at exactly the right moment for this specific CR. + +Keywords: time, until, remaining, duration, expiry, cert, requeue, schedule + +```yaml +operatorBox: + reconciler: + requeue: + after: "{{ timeUntil .status.certExpiry }}" + when: + - field: status.certExpiry + operator: exists + +status: + fields: + - path: timeUntilExpiry + value: "{{ timeUntil .status.certExpiry }}" + # → "719h43m12s" (about 30 days) + # → "0s" (already expired) +``` + +--- + ### `isExpired` Return `true` when a timestamp plus a duration is in the past. The canonical way to drive rotation logic in `when:` conditions. Duration is an extended duration string — Go's units (`"30m"`, `"24h"`) plus `d`/`w`/`mo`/`y` (`"30d"`, `"1y"`). @@ -239,6 +264,7 @@ status: |------|---------|---------|-------| | `timeAgo` | `timestamp string` | `string` | `"Xs ago"` / `"Xm ago"` / `"Xh ago"` / `"Xd ago"` | | `timeSince` | `timestamp string` | `int64` | seconds since timestamp | +| `timeUntil` | `timestamp string` | `string` | Go duration string until timestamp; `"0s"` if past | | `isExpired` | `timestamp string, duration string` | `bool` | `true` when `timestamp + duration` is in the past | | `timeFormat` | `timestamp, layout string` | `string` | Go time layout | | `durationSeconds` | `duration string` | `int64` | Go duration → seconds | diff --git a/pkg/note/time.go b/pkg/note/time.go index a8fe59daf..e3a990267 100644 --- a/pkg/note/time.go +++ b/pkg/note/time.go @@ -13,6 +13,7 @@ import ( // Time notes // - timeAgo // - timeSince +// - timeUntil // - isExpired // - timeFormat // - weekday / weekend @@ -23,6 +24,7 @@ func timeNotes() template.FuncMap { return template.FuncMap{ "timeAgo": noteTimeAgo, "timeSince": noteTimeSince, + "timeUntil": noteTimeUntil, "isExpired": noteIsExpired, "timeFormat": noteTimeFormat, "durationSeconds": noteDurationSeconds, @@ -90,6 +92,25 @@ func noteTimeSince(s string) int64 { return int64(math.Round(time.Since(t).Seconds())) } +// noteTimeUntil returns a Go duration string representing the time remaining +// until the given timestamp. Returns "0s" when the timestamp is in the past +// or unparseable. Intended for use in requeue.after: to schedule the next +// reconcile at exactly the right moment. +// +// {{ timeUntil .status.certExpiry }} → "719h43m12s" +// {{ timeUntil .status.certExpiry }} → "0s" (already expired) +func noteTimeUntil(s string) string { + t, ok := parseTime(fmt.Sprint(s)) + if !ok { + return "0s" + } + d := time.Until(t) + if d <= 0 { + return "0s" + } + return d.Truncate(time.Second).String() +} + // noteIsExpired returns true when the timestamp plus the given duration is in the past. // The duration string follows utils.ParseTimeDuration format — Go's units // (s, m, h) plus d/w/mo/y (e.g. "30m", "24h", "7d", "1y"). diff --git a/pkg/note/time_test.go b/pkg/note/time_test.go index 9ba392d56..b5f195d33 100644 --- a/pkg/note/time_test.go +++ b/pkg/note/time_test.go @@ -6,6 +6,24 @@ import ( "time" ) +func TestTimeUntil(t *testing.T) { + future := time.Now().UTC().Add(2 * time.Hour).Format(time.RFC3339) + past := time.Now().UTC().Add(-1 * time.Hour).Format(time.RFC3339) + + if got := noteTimeUntil(future); got == "0s" { + t.Errorf("timeUntil(future) = %q, want non-zero duration", got) + } + if got := noteTimeUntil(past); got != "0s" { + t.Errorf("timeUntil(past) = %q, want \"0s\"", got) + } + if got := noteTimeUntil(""); got != "0s" { + t.Errorf("timeUntil(\"\") = %q, want \"0s\"", got) + } + if got := noteTimeUntil("not-a-time"); got != "0s" { + t.Errorf("timeUntil(invalid) = %q, want \"0s\"", got) + } +} + func TestWeekdayWeekend(t *testing.T) { // These are time-dependent; just assert they are mutually exclusive. w := noteWeekday() diff --git a/pkg/profiles/autoscaler.go b/pkg/profiles/autoscaler.go index 94e787a2f..d74d16258 100644 --- a/pkg/profiles/autoscaler.go +++ b/pkg/profiles/autoscaler.go @@ -138,7 +138,7 @@ func expandBatch(b orktypes.AutoscaleBaseline, cfg profileConfig) *orktypes.Auto Interval: orktypes.Duration{Duration: cfg.interval}, Cooldown: orktypes.Duration{Duration: cfg.cooldown}, Conditions: orktypes.AutoscaleConditions{ - AnyOf: []orktypes.Condition{{Cron: "0 23 * * *", Duration: orktypes.Duration{Duration: 3 * time.Hour}}}, + Or: []orktypes.Condition{{Cron: "0 23 * * *", Duration: orktypes.Duration{Duration: 3 * time.Hour}}}, }, Do: orktypes.AutoscaleAction{Workers: intPtr(workers), QueueDepth: intPtr(queue)}, } diff --git a/pkg/registry/e2e/runner.go b/pkg/registry/e2e/runner.go index 57d277364..eb39ead55 100644 --- a/pkg/registry/e2e/runner.go +++ b/pkg/registry/e2e/runner.go @@ -434,7 +434,7 @@ func (r *Runner) Run(ctx context.Context) (*Result, error) { return nil, err } - // Build a template evaluator from spec.notes so when:/anyOf: expressions + // Build a template evaluator from spec.notes so when:/or: expressions // on expect blocks can reference user-defined note functions. noteEval := orktmpl.NewResolverFromMap(nil). WithUserNotes(r.e2e.Spec.Notes). diff --git a/pkg/registry/e2e/verify.go b/pkg/registry/e2e/verify.go index 3fe6b489d..79baf637f 100644 --- a/pkg/registry/e2e/verify.go +++ b/pkg/registry/e2e/verify.go @@ -30,12 +30,12 @@ const portForwardTimeout = 15 * time.Second // verifyExpectation polls until all conditions pass or timeout expires. // workDir is the working directory for command checks — relative paths in // commands and resource file refs resolve from there. -// errSkipped is a sentinel returned when a when:/anyOf: gate is not met. +// errSkipped is a sentinel returned when a when:/or: gate is not met. // The runner detects it to record a skipped case rather than a failure. var errSkipped = fmt.Errorf("skipped") func verifyExpectation(ctx context.Context, exp orktypes.E2EExpectation, workDir string, cs kubernetes.Interface, cfg *rest.Config, noteEval orktypes.TemplateEvaluator) error { - if !orktypes.EvaluateConditions(nil, exp.When, exp.AnyOf, noteEval) { + if !orktypes.EvaluateConditions(nil, exp.When, exp.Or, noteEval) { return errSkipped } if exp.Wait != "" { @@ -228,7 +228,7 @@ type assertions struct { // // Each check builds a single-field orktypes.Condition around the trimmed // output and evaluates it with the same EvaluateOneCond the reconciler and -// webhook use for when:/anyOf: — comparison logic (numeric parsing, regex, +// webhook use for when:/or: — comparison logic (numeric parsing, regex, // ranges) lives in exactly one place instead of being reimplemented here, // and gains every operator pkg/types supports for free. func applyAssertions(output string, a assertions) error { diff --git a/pkg/registry/motif/expander.go b/pkg/registry/motif/expander.go index d6600a18c..bfc42ba13 100644 --- a/pkg/registry/motif/expander.go +++ b/pkg/registry/motif/expander.go @@ -381,16 +381,16 @@ func evalMotifCondition(cond orktypes.Condition) bool { // passesMotifConditions reports whether all conditions pass. // Empty condition slice → unconditional (true). -func passesMotifConditions(conditions []orktypes.Condition, anyOf []orktypes.Condition) bool { +func passesMotifConditions(conditions []orktypes.Condition, or []orktypes.Condition) bool { // AND conditions for _, c := range conditions { if !evalMotifCondition(c) { return false } } - // anyOf (OR) — if any pass, the block passes - if len(anyOf) > 0 { - for _, c := range anyOf { + // or — if any pass, the block passes + if len(or) > 0 { + for _, c := range or { if evalMotifCondition(c) { return true } @@ -404,10 +404,10 @@ func passesMotifConditions(conditions []orktypes.Condition, anyOf []orktypes.Con // Static conditions (no {{ }}, already input-substituted) gate the resource at // expansion time. Runtime conditions (still contain {{ }}) are preserved on the // resource for the reconciler to evaluate against live CR state. -func motifConditionFilter(conditions, anyOf []orktypes.Condition) (bool, []orktypes.Condition, []orktypes.Condition) { +func motifConditionFilter(conditions, or []orktypes.Condition) (bool, []orktypes.Condition, []orktypes.Condition) { static, runtime := splitConditions(conditions) - staticAnyOf, runtimeAnyOf := splitConditions(anyOf) - return passesMotifConditions(static, staticAnyOf), runtime, runtimeAnyOf + staticOr, runtimeOr := splitConditions(or) + return passesMotifConditions(static, staticOr), runtime, runtimeOr } // filterExpandedResources applies motif condition filtering to ht using HookTemplates.FilterResources. diff --git a/pkg/registry/simulate/docs/04-internals.md b/pkg/registry/simulate/docs/04-internals.md index 5bd2a5b90..b157d2cf7 100644 --- a/pkg/registry/simulate/docs/04-internals.md +++ b/pkg/registry/simulate/docs/04-internals.md @@ -18,7 +18,7 @@ The CR is pre-populated with Orkestra's managed labels and annotations before in ## Same-kind pre-existing instances -A CR file can hold more than one document of the CRD-under-test's own kind, not just sibling kinds. `resolveCRInputs` (in `cmd/cli/simulate.go`) treats the FIRST document of the target kind as the CR that's actually reconciled — unchanged from a single-doc file. Any FURTHER documents of that same kind are seeded into the fake dynamic client only — never reconciled, never added to the informer — so reconcile-time checks that list other instances of the CRD (`operator: unique` in `validation.rules` or `when:`/`anyOf:`) see them as real pre-existing state instead of an empty list. +A CR file can hold more than one document of the CRD-under-test's own kind, not just sibling kinds. `resolveCRInputs` (in `cmd/cli/simulate.go`) treats the FIRST document of the target kind as the CR that's actually reconciled — unchanged from a single-doc file. Any FURTHER documents of that same kind are seeded into the fake dynamic client only — never reconciled, never added to the informer — so reconcile-time checks that list other instances of the CRD (`operator: unique` in `validation.rules` or `when:`/`or:`) see them as real pre-existing state instead of an empty list. ```yaml # cr.yaml — first doc reconciled, second is pre-existing (same domain → denied) diff --git a/pkg/registry/simulate/fixture/unique/README.md b/pkg/registry/simulate/fixture/unique/README.md index 69cf6b7a6..05bbe1857 100644 --- a/pkg/registry/simulate/fixture/unique/README.md +++ b/pkg/registry/simulate/fixture/unique/README.md @@ -2,7 +2,7 @@ Demonstrates `operator: unique` — field value must be unique across all existing instances of a CRD. Works the same way in `validation.rules` and in -`when:`/`anyOf:` (see `status.fields.domainUnique` below), enforced only at +`when:`/`or:` (see `status.fields.domainUnique` below), enforced only at reconcile time via a live checker the reconciler injects into the resolver. ## Run diff --git a/pkg/registry/simulate/fixture/unique/katalog.yaml b/pkg/registry/simulate/fixture/unique/katalog.yaml index b183d9658..b4690cee4 100644 --- a/pkg/registry/simulate/fixture/unique/katalog.yaml +++ b/pkg/registry/simulate/fixture/unique/katalog.yaml @@ -8,7 +8,7 @@ metadata: name: simulate-fixture-unique-operator author: orkspace version: 0.1.0 - description: "Fixture for operator: unique in validation.rules and when/anyOf." + description: "Fixture for operator: unique in validation.rules and when/or." spec: crds: diff --git a/pkg/registry/simulate/gate.go b/pkg/registry/simulate/gate.go index fb7be1b67..ef4970cf3 100644 --- a/pkg/registry/simulate/gate.go +++ b/pkg/registry/simulate/gate.go @@ -19,39 +19,39 @@ type gatedReconciler struct { notes orktypes.NoteRegistry } -func (g *gatedReconciler) Reconcile(ctx context.Context, key string) error { +func (g *gatedReconciler) Reconcile(ctx context.Context, req domain.Request) (domain.Result, error) { if g.gate == nil { - return g.inner.Reconcile(ctx, key) + return g.inner.Reconcile(ctx, req) } obj := g.getObj() if obj == nil { - return g.inner.Reconcile(ctx, key) + return g.inner.Reconcile(ctx, req) } resolver, err := orktmpl.NewResolver(ctx, obj) if err != nil { - return g.inner.Reconcile(ctx, key) + return g.inner.Reconcile(ctx, req) } eval := resolver.WithUserNotes(g.notes).TemplateEvaluator() // Evaluate preReconcile.enqueueGate first — mirrors informer-level drop in live path. if g.gate.EnqueueGate.HasConditions() { - if !orktypes.EvaluateConditions(resolver.Data(), g.gate.EnqueueGate.WhenConditions(), g.gate.EnqueueGate.AnyOfConditions(), eval) { - return nil // filtered — skip, no error + if !orktypes.EvaluateConditions(resolver.Data(), g.gate.EnqueueGate.WhenConditions(), g.gate.EnqueueGate.OrConditions(), eval) { + return domain.Result{}, nil // filtered — skip, no error } } - // Evaluate preReconcile.when/anyOf — mirrors kordinator gate in live path. + // Evaluate preReconcile.when/or — mirrors kordinator gate in live path. if g.gate.ReconcileGate.HasConditions() { - if !orktypes.EvaluateConditions(resolver.Data(), g.gate.WhenConditions(), g.gate.AnyOfConditions(), eval) { - return nil // gated — skip, no error + if !orktypes.EvaluateConditions(resolver.Data(), g.gate.WhenConditions(), g.gate.OrConditions(), eval) { + return domain.Result{}, nil // gated — skip, no error } } - return g.inner.Reconcile(ctx, key) + return g.inner.Reconcile(ctx, req) } // wrapWithGate returns r wrapped with a preReconcile gate check if the CRD -// declares any filter or when/anyOf conditions; otherwise returns r unchanged. +// declares any filter or when/or conditions; otherwise returns r unchanged. func wrapWithGate(r domain.Reconciler, gate *orktypes.PreReconcileConfig, notes orktypes.NoteRegistry, getObj func() *unstructured.Unstructured) domain.Reconciler { if gate == nil || (!gate.ReconcileGate.HasConditions() && !gate.EnqueueGate.HasConditions()) { return r diff --git a/pkg/registry/simulate/kubeclient.go b/pkg/registry/simulate/kubeclient.go index c6f74f1cb..4cf724c06 100644 --- a/pkg/registry/simulate/kubeclient.go +++ b/pkg/registry/simulate/kubeclient.go @@ -56,6 +56,8 @@ type FakeKubeclient struct { args kubeclient.Args informer cache.SharedIndexInformer eventRecorder kubeclient.EventRecorder + storeFor func(schema.GroupVersionKind) cache.Store + indexerFor func(schema.GroupVersionKind) cache.Indexer } // dynamicObjects seeds the fake dynamic client's tracker at construction — @@ -162,6 +164,24 @@ func (f *FakeKubeclient) WithEventRecorder(ev kubeclient.EventRecorder) kubeclie func (f *FakeKubeclient) GetInformer() cache.SharedIndexInformer { return f.informer } func (f *FakeKubeclient) GetEventRecorder() kubeclient.EventRecorder { return f.eventRecorder } +func (f *FakeKubeclient) WithStoreFor(fn func(schema.GroupVersionKind) cache.Store) kubeclient.Interface { + cp := *f + cp.storeFor = fn + return &cp +} + +func (f *FakeKubeclient) GetStoreFor() func(schema.GroupVersionKind) cache.Store { return f.storeFor } + +func (f *FakeKubeclient) WithIndexerFor(fn func(schema.GroupVersionKind) cache.Indexer) kubeclient.Interface { + cp := *f + cp.indexerFor = fn + return &cp +} + +func (f *FakeKubeclient) GetIndexerFor() func(schema.GroupVersionKind) cache.Indexer { + return f.indexerFor +} + // AdvanceCycle increments the cycle counter. Call between simulated reconciles. func (f *FakeKubeclient) AdvanceCycle() { f.shared.mu.Lock() diff --git a/pkg/registry/simulate/loop.go b/pkg/registry/simulate/loop.go index f887827ed..54baccf8a 100644 --- a/pkg/registry/simulate/loop.go +++ b/pkg/registry/simulate/loop.go @@ -5,6 +5,7 @@ import ( "github.com/orkspace/orkestra/domain" "github.com/orkspace/orkestra/pkg/kubeclient" + apitypes "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/tools/cache" ) @@ -29,7 +30,11 @@ func runLoop(ctx context.Context, r domain.Reconciler, kube loopKube, key string kube.AdvanceCycle() cycleResult := CycleResult{Cycle: cycle} - cycleResult.Error = r.Reconcile(ctx, key) + ns, name, _ := cache.SplitMetaNamespaceKey(key) + _, cycleResult.Error = r.Reconcile(ctx, domain.Request{ + Key: key, + NamespacedName: apitypes.NamespacedName{Namespace: ns, Name: name}, + }) cycleResult.Ops = kube.OpsForCycle(cycle) result.Cycles = append(result.Cycles, cycleResult) diff --git a/pkg/resources/README.md b/pkg/resources/README.md index 5b1b5c338..3376b72a4 100644 --- a/pkg/resources/README.md +++ b/pkg/resources/README.md @@ -46,7 +46,7 @@ All functions receive a `kubeclient.KubeClient` and a `domain.Object` (the owner comment — these are the source of truth for the published schema docs ([`documentation/reference/schema/06-resources/`](../../documentation/reference/schema/06-resources/index.md)), generated by `make generate-resource-docs`. Cross-cutting fields shared by every - resource kind (`labels`, `annotations`, `when`, `reconcile`, `anyOf`, + resource kind (`labels`, `annotations`, `when`, `reconcile`, `or`, `forEach`, `sleep`) already have established wording in the other `types_*.go` files — copy that text verbatim rather than rewriting it, so the same field reads identically across every resource's doc page. Any diff --git a/pkg/resources/template/resolve_customresources.go b/pkg/resources/template/resolve_customresources.go index a02b7c9fa..7e80480ae 100644 --- a/pkg/resources/template/resolve_customresources.go +++ b/pkg/resources/template/resolve_customresources.go @@ -29,7 +29,7 @@ func (r *Resolver) ResolveCustomResourceTemplate(src orktypes.CustomResourceTemp Sleep: src.Sleep, ForEach: src.ForEach, Conditions: src.Conditions, - AnyOf: src.AnyOf, + Or: src.Or, } var err error diff --git a/pkg/resources/template/resolver_data.go b/pkg/resources/template/resolver_data.go index d3cd571be..224950ec7 100644 --- a/pkg/resources/template/resolver_data.go +++ b/pkg/resources/template/resolver_data.go @@ -358,7 +358,7 @@ func (r *Resolver) WithHealth(health map[string]interface{}) *Resolver { // injected under the "_uniquenessChecker" key — the string must match // pkg/types' uniquenessCheckerKey constant (same convention as _cronWindows, // see when.go). Not template-visible; read internally by operator: unique in -// both validation.rules and when:/anyOf: (see orktypes.UniquenessChecker). +// both validation.rules and when:/or: (see orktypes.UniquenessChecker). // Only the reconciler calls this, so the operator is enforced at reconcile // time only. func (r *Resolver) WithUniquenessChecker(checker orktypes.UniquenessChecker) *Resolver { diff --git a/pkg/runtime/autoscaler/README.md b/pkg/runtime/autoscaler/README.md index 84f9c8c76..d611d9ff1 100644 --- a/pkg/runtime/autoscaler/README.md +++ b/pkg/runtime/autoscaler/README.md @@ -21,7 +21,7 @@ Full step-by-step documentation is in [docs/](docs/README.md). | I want to… | Go to | |-----------|-------| | Understand the evaluation loop and override lifecycle | [01 — Overview](docs/01-overview.md) | -| Write `anyOf:` / `when:` conditions | [02 — Conditions](docs/02-conditions.md) | +| Write `or:` / `when:` conditions | [02 — Conditions](docs/02-conditions.md) | | Reference another CRD's metrics with `cross..metrics.*` | [03 — Cross-Metrics](docs/03-cross-metrics.md) | | Understand the WorkerInfo API response | [04 — Worker Info](docs/04-worker-info.md) | diff --git a/pkg/runtime/autoscaler/autoscaler.go b/pkg/runtime/autoscaler/autoscaler.go index 45adb1d1a..7e8f5f655 100644 --- a/pkg/runtime/autoscaler/autoscaler.go +++ b/pkg/runtime/autoscaler/autoscaler.go @@ -138,12 +138,12 @@ func (a *Autoscaler) evaluate(ctx context.Context) { // conditionsMet builds a data map from live metrics and delegates to EvaluateConditions — // the same general condition evaluator used by the reconciler for template -// when:/anyOf: conditions. Time-based conditions (time:, dayOfWeek:, cron:) are +// when:/or: conditions. Time-based conditions (time:, dayOfWeek:, cron:) are // handled inside EvaluateOneCond. Metric conditions are pre-populated into the // data map so NavigateDotPath resolves them as normal dot-paths. func (a *Autoscaler) conditionsMet(_ context.Context) bool { data := a.buildConditionData() - return orktypes.EvaluateConditions(data, a.spec.Conditions.When, a.spec.Conditions.AnyOf, nil) + return orktypes.EvaluateConditions(data, a.spec.Conditions.When, a.spec.Conditions.Or, nil) } // buildConditionData returns the data map passed to EvaluateConditions. @@ -157,7 +157,7 @@ func (a *Autoscaler) buildConditionData() map[string]interface{} { "metrics": a.metrics.AsMap(), } - all := append(a.spec.Conditions.AnyOf, a.spec.Conditions.When...) + all := append(a.spec.Conditions.Or, a.spec.Conditions.When...) for _, cond := range all { // Cross-metric resolution if orktypes.IsCrossMetricField(cond.Field) { diff --git a/pkg/runtime/autoscaler/docs/02-conditions.md b/pkg/runtime/autoscaler/docs/02-conditions.md index 96a93f719..eaa4b1290 100644 --- a/pkg/runtime/autoscaler/docs/02-conditions.md +++ b/pkg/runtime/autoscaler/docs/02-conditions.md @@ -6,7 +6,7 @@ operatorBox: autoscale: conditions: - anyOf: # OR — at least one must match + or: # OR — at least one must match - time: ... - dayOfWeek: ... - cron: ... @@ -18,14 +18,14 @@ operatorBox: workers: 8 ``` -**`anyOf` is OR, `when` is AND.** +**`or` is OR, `when` is AND.** -Full condition expression: `(anyOf passes OR anyOf is empty) AND (all when entries pass OR when is empty)`. +Full condition expression: `(or passes OR or is empty) AND (all when entries pass OR when is empty)`. -## anyOf — time window +## or — time window ```yaml -anyOf: +or: - time: after: "08:00" before: "20:00" @@ -33,26 +33,26 @@ anyOf: Both `after` and `before` are optional. Either alone is valid. Times are in 24-hour `HH:MM` format, evaluated against the local clock of the operator process. -## anyOf — day of week +## or — day of week ```yaml -anyOf: +or: - dayOfWeek: in: [Monday, Tuesday, Wednesday, Thursday, Friday] ``` ```yaml -anyOf: +or: - dayOfWeek: notIn: [Saturday, Sunday] ``` `in` and `notIn` are mutually exclusive. Day names are case-insensitive. -## anyOf — cron +## or — cron ```yaml -anyOf: +or: - cron: "0 9 * * 1-5" # 09:00 every weekday duration: 9h # window stays open for 9 hours ``` @@ -61,15 +61,15 @@ The cron expression uses standard five-field syntax (via `robfig/cron`). When th Window state is tracked per cron expression string in `state.CronWindowsOpenAt` via `types.TickCronWindow`. This is stateful across evaluation ticks: a cron fire that occurs between two ticks is not missed — the window remains open until `duration` elapses. `TickCronWindow` is a general-purpose function; any Orkestra component (future job runner, etc.) can bring its own `map[string]time.Time` and call it on each evaluation cycle. -## anyOf — inline metric +## or — inline metric ```yaml -anyOf: +or: - field: metrics.workersBusyPercent greaterThan: "90" ``` -An inline metric in `anyOf` participates in the OR — if the metric threshold is met, the whole `anyOf` block passes even if time/day conditions do not. +An inline metric in `or` participates in the OR — if the metric threshold is met, the whole `or` block passes even if time/day conditions do not. ## when — metric conditions @@ -102,7 +102,7 @@ For cross-CRD metrics see [03 — Cross-Metrics](03-cross-metrics.md). - An unknown `field` value returns `""` and evaluates as **false** (conservative — does not trigger override). - Both `greaterThan` and `lessThan` compare as floating-point numbers. Non-numeric values are always false. -- Empty `anyOf` and empty `when` both evaluate as **pass** — omitting them means "always apply the override". +- Empty `or` and empty `when` both evaluate as **pass** — omitting them means "always apply the override". --- diff --git a/pkg/runtime/autoscaler/docs/README.md b/pkg/runtime/autoscaler/docs/README.md index cc1e30a58..7492c4e62 100644 --- a/pkg/runtime/autoscaler/docs/README.md +++ b/pkg/runtime/autoscaler/docs/README.md @@ -7,7 +7,7 @@ This directory explains how the `pkg/runtime/autoscaler` package works and how t | File | What it covers | |------|----------------| | [01-overview.md](01-overview.md) | The evaluation loop, override lifecycle, and baseline restore | -| [02-conditions.md](02-conditions.md) | `anyOf:` (time/cron/day) and `when:` (metric) condition evaluation | +| [02-conditions.md](02-conditions.md) | `or:` (time/cron/day) and `when:` (metric) condition evaluation | | [03-cross-metrics.md](03-cross-metrics.md) | Observing another CRD's runtime metrics via `cross..metrics.*` | | [04-worker-info.md](04-worker-info.md) | `WorkerInfo` — the CRD endpoint's worker snapshot and what each field means | diff --git a/pkg/runtime/informer/factory.go b/pkg/runtime/informer/factory.go index 7d5327c64..cc3f47dba 100644 --- a/pkg/runtime/informer/factory.go +++ b/pkg/runtime/informer/factory.go @@ -5,11 +5,72 @@ import ( "context" "github.com/orkspace/orkestra/pkg/logger" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/client-go/tools/cache" ) +// OwnerNameIndex is the name of the auto-registered owner-reference index on every +// informer created by this factory. Use it with indexer.ByIndex(OwnerNameIndex, ownerName). +const OwnerNameIndex = "orkestra.io/owner-name" + +// OwnerNameIndexFunc indexes an unstructured object by the names of its owner references. +func OwnerNameIndexFunc(obj interface{}) ([]string, error) { + u, ok := obj.(*unstructured.Unstructured) + if !ok { + return nil, nil + } + refs := u.GetOwnerReferences() + names := make([]string, 0, len(refs)) + for _, ref := range refs { + names = append(names, ref.Name) + } + return names, nil +} + +// StoreFor returns the informer store for the given GVK, or nil if no informer +// is registered for that type. Used by kubeclient.ToClient to serve cached reads. +func (f *Factory) StoreFor(gvk schema.GroupVersionKind) cache.Store { + f.mu.RLock() + defer f.mu.RUnlock() + entry, ok := f.informers[gvk.String()] + if !ok || entry.Informer == nil { + return nil + } + return entry.Informer.GetStore() +} + +// IndexerFor returns the cache.Indexer for the given GVK, or nil if no informer +// is registered. The indexer supports ByIndex(OwnerNameIndex, ownerName) out of +// the box; additional indexes can be registered via AddIndexers on the informer. +func (f *Factory) IndexerFor(gvk schema.GroupVersionKind) cache.Indexer { + f.mu.RLock() + defer f.mu.RUnlock() + entry, ok := f.informers[gvk.String()] + if !ok || entry.Informer == nil { + return nil + } + return entry.Informer.GetIndexer() +} + +// RegisterInformer records an already-running informer under the given GVK so +// that StoreFor and IndexerFor can serve it. Used by the kordinator to expose +// watch-entry informers (which it owns and starts itself) to the kubeclient +// cache layer without going through the full ForListerWatcher path. +func (f *Factory) RegisterInformer(gvk schema.GroupVersionKind, inf cache.SharedIndexInformer) { + f.mu.Lock() + defer f.mu.Unlock() + key := gvk.String() + if _, exists := f.informers[key]; exists { + return // already registered — first registration wins + } + f.informers[key] = &InformerEntry{ + Informer: inf, + GVK: &gvk, + } +} + // For creates or returns a SharedIndexInformer for the given object type. // Uses the client provider to build the ListerWatcher via newListWatch. // Each type gets exactly one informer — subsequent calls return the cached one. @@ -63,7 +124,9 @@ func (f *Factory) getOrCreate( resync = f.defaultResync } - inf := cache.NewSharedIndexInformer(lw, obj, resync, cache.Indexers{}) + inf := cache.NewSharedIndexInformer(lw, obj, resync, cache.Indexers{ + OwnerNameIndex: OwnerNameIndexFunc, + }) gvkStr := gvk.String() // Ensure GVK is normalized for all CRDs diff --git a/pkg/runtime/kordinator/pre_reconcile.go b/pkg/runtime/kordinator/pre_reconcile.go index 14afbbf3f..c649c39ee 100644 --- a/pkg/runtime/kordinator/pre_reconcile.go +++ b/pkg/runtime/kordinator/pre_reconcile.go @@ -24,7 +24,7 @@ func (k *Kontroller) objectFromCache(entry RegistryEntry, key string) *unstructu return obj } -// evaluatePreReconcileCheck evaluates the preReconcile.when/anyOf gate for the +// evaluatePreReconcileCheck evaluates the preReconcile.when/or gate for the // given CR. Returns (true, reason) when gated — reconciler must not be called. // Returns (false, "") when conditions pass. // diff --git a/pkg/runtime/kordinator/watch_informer.go b/pkg/runtime/kordinator/watch_informer.go index 516dac574..5bf44f48f 100644 --- a/pkg/runtime/kordinator/watch_informer.go +++ b/pkg/runtime/kordinator/watch_informer.go @@ -1,11 +1,17 @@ // pkg/runtime/kordinator/watch_informer.go // -// Secondary watch informers for operatorBox.watch entries. +// Secondary watch informers for operatorBox.watch entries and managed resources. // -// 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. +// Two sources produce watch informers: +// +// 1. operatorBox.watch — explicit entries declared by the operator author. Full +// control: on:, enqueueGate:, keyFrom:, index:. +// +// 2. constructor.resources / hooks.resources — owned resource types. Treated as +// implicit watch entries: all events, owner-reference key resolution, no index. +// Mirrors what Owns() does in controller-runtime — cache-backed reads and +// re-enqueue when an owned resource changes. Explicit watch: entries take +// priority when the same type appears in both lists. // // Key resolution order (first match wins): // 1. keyFrom.label — the watched object has a label whose value is the primary CR key. @@ -17,6 +23,7 @@ package kordinator import ( "context" + "strings" "github.com/orkspace/orkestra/pkg/kubeclient" "github.com/orkspace/orkestra/pkg/logger" @@ -28,11 +35,15 @@ import ( "k8s.io/client-go/tools/cache" ) -// startWatchInformers creates one dynamic informer per watch: entry on the CRD. +// startWatchInformers creates one dynamic informer per watch: entry on the CRD, +// plus one implicit informer per managed resource (constructor.resources / +// hooks.resources) that is not already covered by an explicit watch: entry. // 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() { + hasWatch := crd.WithWatchEntries() + hasResources := crd.WithAnyManagedResources() + if !hasWatch && !hasResources { return } @@ -43,9 +54,19 @@ func (k *DependencyKordinator) startWatchInformers(ctx context.Context, crd orkt return } + // Track GVRs covered by explicit watch: entries so managed resources don't + // register a second informer for the same type. Explicit watch: takes priority. + covered := map[string]bool{} + for _, w := range crd.WatchEntries() { + gvr, ok := k.kat.ResolveGVR(w.ToManagedResource()) + if ok { + covered[gvr.String()] = true + } + } + + // Explicit watch: entries — full control over events, enqueueGate, keyFrom, index. for _, watchEntry := range crd.WatchEntries() { watchEntry := watchEntry - gvr, ok := k.kat.ResolveGVR(watchEntry.ToManagedResource()) if !ok { logger.Warn(). @@ -55,51 +76,106 @@ func (k *DependencyKordinator) startWatchInformers(ctx context.Context, crd orkt Msg("watch: cannot resolve GVR — entry skipped") continue } + k.startOneWatchInformer(ctx, watchEntry, gvr, crd, primaryGVK, wq, true) + } - 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") + // Implicit watch entries from resources: — all events, owner-reference + // key resolution only. Explicit watch: entries take priority. + // broadcastAllowed=false: owned resources with no ownerReference mean nothing to enqueue. + for _, r := range crd.AllManagedResources() { + gvr, ok := k.kat.ResolveGVR(r) + if !ok || covered[gvr.String()] { + continue + } + covered[gvr.String()] = true // deduplicate within the resources list itself + synth := orktypes.WatchEntry{ + APIVersion: gvr.Group + "/" + gvr.Version, + Kind: r.Kind, + } + if synth.APIVersion == "/" { + synth.APIVersion = "v1" // core group + } + k.startOneWatchInformer(ctx, synth, gvr, crd, primaryGVK, wq, false) } } +// startOneWatchInformer creates, wires, registers, and starts a single watch +// informer for the given entry + resolved GVR. +func (k *DependencyKordinator) startOneWatchInformer( + ctx context.Context, + watchEntry orktypes.WatchEntry, + gvr schema.GroupVersionResource, + crd orktypes.CRDEntry, + primaryGVK string, + wq *queue.Workqueue, + broadcastAllowed bool, +) { + lw := k.kube.NewDynamicListerWatcher(watchEntryToCRDInfo(watchEntry, gvr), kubeclient.ListOptions{}) + + // Build indexers: always include namespace; add any user-declared index: entries. + indexers := cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc} + for _, wi := range watchEntry.Index { + parts := splitWatchField(wi.Field) // pre-split; capture by value + indexers[wi.Name] = func(obj interface{}) ([]string, error) { + u, ok := obj.(*unstructured.Unstructured) + if !ok { + return nil, nil + } + val, found, err := unstructured.NestedString(u.Object, parts...) + if err != nil || !found || val == "" { + return nil, err + } + return []string{val}, nil + } + } + + inf := cache.NewSharedIndexInformer( + lw, + &unstructured.Unstructured{}, + 0, // no resync — primary CRD resync handles re-queuing + indexers, + ) + + // 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, broadcastAllowed) + }, + UpdateFunc: func(_, newObj interface{}) { + if !localInf.HasSynced() || !watchEntry.WatchesOn(string(orktypes.WatchEventUpdate)) { + return + } + k.resolveAndEnqueue(newObj, watchEntry, crd, primaryGVK, wq, broadcastAllowed) + }, + 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, broadcastAllowed) + }, + }) + + // Register in the shared factory so IndexerFor/StoreFor can serve this + // informer's cache to the kubeclient layer (MatchingFields, cached Get/List). + watchGVK := schema.GroupVersionKind{Group: gvr.Group, Version: gvr.Version, Kind: watchEntry.Kind} + k.informerFactory.RegisterInformer(watchGVK, inf) + + 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. // @@ -108,7 +184,7 @@ func (k *DependencyKordinator) startWatchInformers(ctx context.Context, crd orkt // 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) { +func (k *DependencyKordinator) resolveAndEnqueue(obj interface{}, w orktypes.WatchEntry, crd orktypes.CRDEntry, primaryGVK string, wq *queue.Workqueue, broadcastAllowed bool) { u, ok := watchedToUnstructured(obj) if !ok { return @@ -162,6 +238,11 @@ func (k *DependencyKordinator) resolveAndEnqueue(obj interface{}, w orktypes.Wat } // 4. Broadcast — no specific match; enqueue all known primary CRs. + // Skipped for implicit informers from resources: (broadcastAllowed=false) — an + // owned resource with no ownerReference has no CR to enqueue. + if !broadcastAllowed { + return + } registered := k.informerFactory.Registered() entry, ok := registered[primaryGVK] if !ok || entry == nil { @@ -193,6 +274,16 @@ func watchedToUnstructured(obj interface{}) (*unstructured.Unstructured, bool) { return u, ok } +// splitWatchField converts a dot-separated field path to path segments for +// unstructured.NestedString. Accepts both "spec.owner" and ".spec.owner". +func splitWatchField(field string) []string { + field = strings.TrimPrefix(field, ".") + if field == "" { + return nil + } + return strings.Split(field, ".") +} + // 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 diff --git a/pkg/runtime/kordinator/worker.go b/pkg/runtime/kordinator/worker.go index bb07a1ca6..217c36b44 100644 --- a/pkg/runtime/kordinator/worker.go +++ b/pkg/runtime/kordinator/worker.go @@ -10,6 +10,9 @@ import ( "github.com/orkspace/orkestra/pkg/logger" "github.com/orkspace/orkestra/pkg/metrics" "github.com/orkspace/orkestra/pkg/runtime/queue" + apitypes "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/cache" + ctrllog "sigs.k8s.io/controller-runtime/pkg/log" ) // queueDepthReporter is a local interface satisfied by GenericReconciler so the @@ -132,7 +135,7 @@ func (k *Kontroller) processItemForGVK(ctx context.Context, gvk string, item que return } - // Pre-reconcile gate: evaluate operatorBox.reconcile.when/anyOf conditions. + // Pre-reconcile gate: evaluate operatorBox.reconcile.when/or conditions. // The reconciler is never called when conditions are not met — gated state // is idle, not failure; error rate and health state are unaffected. if entry, ok := k.katalog.Get(gvk); ok { @@ -151,7 +154,8 @@ func (k *Kontroller) processItemForGVK(ctx context.Context, gvk string, item que } // safeReconcile catches panics - if err := k.safeReconcile(rec, k.crdHealthMap[gvk], ctx, item.Key, gvk); err != nil { + result, err := k.safeReconcile(rec, k.crdHealthMap[gvk], ctx, item.Key, gvk) + if err != nil { logger.Error().Err(err).Str("gvk", gvk).Str("key", item.Key).Msg("reconcile failed") wq.Queue.AddRateLimited(item) k.failedReconcile(gvk) @@ -159,6 +163,17 @@ func (k *Kontroller) processItemForGVK(ctx context.Context, gvk string, item que } wq.Queue.Forget(item) + + requeueAfter := result.RequeueAfter + if requeueAfter == 0 { + if entry, ok := k.katalog.Get(gvk); ok { + obj := k.objectFromCache(entry, item.Key) + requeueAfter = k.kat.EvaluateRequeue(ctx, entry.CRD.Name, obj) + } + } + if requeueAfter > 0 { + wq.Queue.AddAfter(item, requeueAfter) + } } // safeReconcile wraps a Reconciler's Reconcile() call in a fully isolated, @@ -181,7 +196,7 @@ func (k *Kontroller) safeReconcile( ctx context.Context, key string, gvk string, -) (err error) { +) (result domain.Result, err error) { // Track how long this reconcile took. // The defer ensures duration is recorded even if a panic occurs. @@ -212,19 +227,27 @@ func (k *Kontroller) safeReconcile( } }() + // Inject per-request fields into the logr context so ctrl.LoggerFrom(ctx) + // automatically carries the resource key on every reconciler log line. + ctx = ctrllog.IntoContext(ctx, ctrllog.FromContext(ctx).WithValues("resource", key, "gvk", gvk)) + // Execute the operator's reconcile logic. // Any returned error is treated as a reconcile failure. - err = rec.Reconcile(ctx, key) + ns, name, _ := cache.SplitMetaNamespaceKey(key) + result, err = rec.Reconcile(ctx, domain.Request{ + Key: key, + NamespacedName: apitypes.NamespacedName{Namespace: ns, Name: name}, + }) if err != nil { // Update CRD health state and metrics. health.RecordFailure(err, k.failureThreshold[gvk]) metrics.RecordReconcile(gvk, "error") - return err + return result, err } // Successful reconcile path. health.RecordSuccess() k.successReconcile(gvk) metrics.RecordReconcile(gvk, "success") - return nil + return result, nil } diff --git a/pkg/runtime/reconciler/conditions_test.go b/pkg/runtime/reconciler/conditions_test.go index 94477c7cc..53ec8b7cc 100644 --- a/pkg/runtime/reconciler/conditions_test.go +++ b/pkg/runtime/reconciler/conditions_test.go @@ -138,23 +138,23 @@ func TestEvaluateConditions_NestedField(t *testing.T) { } } -func TestEvaluateConditions_AnyOf(t *testing.T) { +func TestEvaluateConditions_Or(t *testing.T) { data := buildData(map[string]interface{}{"phase": "Failed"}) - // anyOf — OR semantics - anyOf := []orktypes.Condition{ + // or — OR semantics + or := []orktypes.Condition{ {Field: "spec.phase", Equals: "Failed"}, {Field: "spec.phase", Equals: "Succeeded"}, } - if !orktypes.EvaluateConditions(data, nil, anyOf, nil) { - t.Error("anyOf should pass when at least one condition matches") + if !orktypes.EvaluateConditions(data, nil, or, nil) { + t.Error("or should pass when at least one condition matches") } - anyOf = []orktypes.Condition{ + or = []orktypes.Condition{ {Field: "spec.phase", Equals: "Pending"}, {Field: "spec.phase", Equals: "Running"}, } - if orktypes.EvaluateConditions(data, nil, anyOf, nil) { - t.Error("anyOf should fail when no conditions match") + if orktypes.EvaluateConditions(data, nil, or, nil) { + t.Error("or should fail when no conditions match") } } diff --git a/pkg/runtime/reconciler/docs/02-run-pattern.md b/pkg/runtime/reconciler/docs/02-run-pattern.md index 3426b322e..798f6f7bf 100644 --- a/pkg/runtime/reconciler/docs/02-run-pattern.md +++ b/pkg/runtime/reconciler/docs/02-run-pattern.md @@ -20,7 +20,7 @@ func runWidgets( // ── Section A: activeNames pre-pass ────────────────────────────────────── activeNames := make(map[string]bool, len(srcs)) for _, s := range srcs { - if !orktypes.EvaluateConditions(resolver.Data(), s.Conditions, s.AnyOf) { + if !orktypes.EvaluateConditions(resolver.Data(), s.Conditions, s.Or) { continue } n, _ := resolver.Resolve(s.Name) @@ -35,7 +35,7 @@ func runWidgets( for i, src := range srcs { // B1. Evaluate conditions - conditionPassed := orktypes.EvaluateConditions(resolver.Data(), src.Conditions, src.AnyOf) + conditionPassed := orktypes.EvaluateConditions(resolver.Data(), src.Conditions, src.Or) // B2. Early name/namespace resolution name, _ := resolver.Resolve(src.Name) @@ -103,11 +103,11 @@ func runWidgets( ## Section B1 — Condition evaluation -Always call `orktypes.EvaluateConditions(resolver.Data(), src.Conditions, src.AnyOf)`. +Always call `orktypes.EvaluateConditions(resolver.Data(), src.Conditions, src.Or)`. - `resolver.Data()` — the full CR data map including `.children.*`, `.external.*`, `.cross.*`. Do NOT pass the `owner` object directly — it does not have these injected fields. - `src.Conditions` — the `when:` block (AND semantics). -- `src.AnyOf` — the `anyOf:` block (OR semantics). Both must be present on the type struct. +- `src.Or` — the `or:` block (OR semantics). Both must be present on the type struct. ## Section B2 — Early name/namespace resolution diff --git a/pkg/runtime/reconciler/docs/04-conditions.md b/pkg/runtime/reconciler/docs/04-conditions.md index 0b6cb01bf..23c1ed38b 100644 --- a/pkg/runtime/reconciler/docs/04-conditions.md +++ b/pkg/runtime/reconciler/docs/04-conditions.md @@ -1,4 +1,4 @@ -# 04 — Conditions, when:, anyOf:, and the activeNames Guard +# 04 — Conditions, when:, or:, and the activeNames Guard ## How conditions are declared in YAML @@ -16,19 +16,19 @@ onCreate: - field: spec.enabled equals: "true" # OR: at least one must pass - anyOf: + or: - field: spec.tier equals: premium - field: spec.tier equals: enterprise ``` -`when:` conditions are AND'd. `anyOf:` conditions are OR'd. When both are declared, both must pass. +`when:` conditions are AND'd. `or:` conditions are OR'd. When both are declared, both must pass. ## EvaluateConditions ```go -orktypes.EvaluateConditions(data map[string]interface{}, allOf []Condition, anyOf []Condition) bool +orktypes.EvaluateConditions(data map[string]interface{}, allOf []Condition, or []Condition) bool ``` `data` is `resolver.Data()` — the full CR data map including injected fields: @@ -117,7 +117,7 @@ Before the main loop, build a set of every `(ns/name)` that has **at least one p ```go activeNames := make(map[string]bool, len(srcs)) for _, s := range srcs { - if !orktypes.EvaluateConditions(resolver.Data(), s.Conditions, s.AnyOf) { + if !orktypes.EvaluateConditions(resolver.Data(), s.Conditions, s.Or) { continue } n, _ := resolver.Resolve(s.Name) @@ -152,15 +152,15 @@ In all other cases, include the pre-pass. The cost is one extra loop over the so | Field | Used by | Notes | |-------|---------|-------| -| `time:` | Autoscale `anyOf:` | Clock window (`after:` / `before:` in HH:MM) | -| `dayOfWeek:` | Autoscale `anyOf:` | Day filter (`in:` / `notIn:`) | -| `cron:` + `duration:` | Autoscale `anyOf:` | Cron-gated window, tracked statefully across ticks | +| `time:` | Autoscale `or:` | Clock window (`after:` / `before:` in HH:MM) | +| `dayOfWeek:` | Autoscale `or:` | Day filter (`in:` / `notIn:`) | +| `cron:` + `duration:` | Autoscale `or:` | Cron-gated window, tracked statefully across ticks | | `notify:` | Template `when:`, autoscale `when:` | Alert teams when the condition passes | | `source:` | Autoscale `when:` on cross-metrics | HTTP fallback for cross-binary `cross..metrics.*` | -These fields are valid on any `Condition` struct (including template `when:`/`anyOf:`) and are evaluated by the shared `EvaluateOneCond` path. Time-based conditions on template sources evaluate against the operator process's wall clock at reconcile time — they do not inject cron window state into the resolver, so stateful `TickCronWindow` tracking is the autoscaler's responsibility. +These fields are valid on any `Condition` struct (including template `when:`/`or:`) and are evaluated by the shared `EvaluateOneCond` path. Time-based conditions on template sources evaluate against the operator process's wall clock at reconcile time — they do not inject cron window state into the resolver, so stateful `TickCronWindow` tracking is the autoscaler's responsibility. -## The Conditions and AnyOf fields on template source types +## The Conditions and Or fields on template source types Every `XxxTemplateSource` struct in `pkg/types/` must carry: @@ -169,14 +169,14 @@ type IngressTemplateSource struct { Name string `yaml:"name"` Namespace string `yaml:"namespace,omitempty"` Conditions []Condition `yaml:"when,omitempty"` - AnyOf []Condition `yaml:"anyOf,omitempty"` + Or []Condition `yaml:"or,omitempty"` Reconcile bool `yaml:"reconcile,omitempty"` ForEach *ForEachSpec `yaml:"forEach,omitempty"` // ... resource-specific fields } ``` -The `Conditions` field maps to `when:` and the `AnyOf` field maps to `anyOf:`. Both are optional — empty slices always pass. +The `Conditions` field maps to `when:` and the `Or` field maps to `or:`. Both are optional — empty slices always pass. --- diff --git a/pkg/runtime/reconciler/docs/05-foreach.md b/pkg/runtime/reconciler/docs/05-foreach.md index ddcd09cab..3f280c537 100644 --- a/pkg/runtime/reconciler/docs/05-foreach.md +++ b/pkg/runtime/reconciler/docs/05-foreach.md @@ -132,7 +132,7 @@ The helper `itemResolver(base, fi, as, i)` in `run_foreach.go` picks automatical `WithItemAndValue` additionally injects: - `.value` — the map value (object or string). Access nested fields as `.value.replicas`. -`when:` and `anyOf:` conditions on a `forEach` source are evaluated per-item — each expanded copy may pass or fail independently. +`when:` and `or:` conditions on a `forEach` source are evaluated per-item — each expanded copy may pass or fail independently. ## Calling expandForEach from runResourceGroup diff --git a/pkg/runtime/reconciler/docs/07-adding-a-resource.md b/pkg/runtime/reconciler/docs/07-adding-a-resource.md index df5d691b2..0943ebde9 100644 --- a/pkg/runtime/reconciler/docs/07-adding-a-resource.md +++ b/pkg/runtime/reconciler/docs/07-adding-a-resource.md @@ -50,14 +50,14 @@ type IngressTemplateSource struct { Labels []KV `yaml:"labels,omitempty"` Annotations []KV `yaml:"annotations,omitempty"` Conditions []Condition `yaml:"when,omitempty"` - AnyOf []Condition `yaml:"anyOf,omitempty"` + Or []Condition `yaml:"or,omitempty"` Reconcile bool `yaml:"reconcile,omitempty"` ForEach *ForEachSpec `yaml:"forEach,omitempty"` } ``` Required fields: -- `Conditions` / `AnyOf` — conditions support, maps to `when:` / `anyOf:`. +- `Conditions` / `Or` — conditions support, maps to `when:` / `or:`. - `Reconcile` — the `reconcile: true` shorthand. - `ForEach` — forEach support (list field: `.item` = element; map field: `.item` = key, `.value` = map value). - `Name`, `Namespace` — always present. @@ -371,7 +371,7 @@ func RunIngresses( ) error { activeNames := make(map[string]bool, len(srcs)) for _, s := range srcs { - if !orktypes.EvaluateConditions(resolver.Data(), s.Conditions, s.AnyOf, resolver.TemplateEvaluator()) { + if !orktypes.EvaluateConditions(resolver.Data(), s.Conditions, s.Or, resolver.TemplateEvaluator()) { continue } n, _ := resolver.Resolve(s.Name) @@ -383,7 +383,7 @@ func RunIngresses( } for i, src := range srcs { - conditionPassed := orktypes.EvaluateConditions(resolver.Data(), src.Conditions, src.AnyOf, resolver.TemplateEvaluator()) + conditionPassed := orktypes.EvaluateConditions(resolver.Data(), src.Conditions, src.Or, resolver.TemplateEvaluator()) name, _ := resolver.Resolve(src.Name) ns, _ := resolver.Resolve(src.Namespace) @@ -503,7 +503,7 @@ A clean build is the acceptance criterion. No new tests are required for the run ## Checklist -- [ ] `pkg/types/ingress.go` — `IngressTemplateSource` with `Conditions`, `AnyOf`, `Reconcile`, `ForEach` +- [ ] `pkg/types/ingress.go` — `IngressTemplateSource` with `Conditions`, `Or`, `Reconcile`, `ForEach` - [ ] `pkg/types/katalog_spec_hooks.go` — `Ingresses []IngressTemplateSource` field on `HookTemplates` - [ ] `pkg/resources/ingresses/types.go` — `ResolvedIngressSpec` - [ ] `pkg/resources/ingresses/ingress.go` — `Create`, `Update`, `DeleteIfOwned`, `Resolve` diff --git a/pkg/runtime/reconciler/docs/README.md b/pkg/runtime/reconciler/docs/README.md index 6712845d0..6e0965040 100644 --- a/pkg/runtime/reconciler/docs/README.md +++ b/pkg/runtime/reconciler/docs/README.md @@ -9,7 +9,7 @@ This directory explains how the `pkg/runtime/reconciler` package works and how t | [01-architecture.md](01-architecture.md) | The full reconcile pipeline from CR event to Kubernetes API call | | [02-run-pattern.md](02-run-pattern.md) | The `run_*.go` function contract — what every resource runner must do | | [03-registry-layer.md](03-registry-layer.md) | How `pkg/resources/` packages work | -| [04-conditions.md](04-conditions.md) | `when:` / `anyOf:` evaluation, operators, and the `activeNames` guard | +| [04-conditions.md](04-conditions.md) | `when:` / `or:` evaluation, operators, and the `activeNames` guard | | [05-foreach.md](05-foreach.md) | How `forEach:` expansion works and what it requires from a runner | | [06-normalize.md](06-normalize.md) | The `normalize:` phase — collapsing multiple input shapes before reconcile | | [07-adding-a-resource.md](07-adding-a-resource.md) | Step-by-step guide — using `run_ingress.go` as the worked example | diff --git a/pkg/runtime/reconciler/generic.go b/pkg/runtime/reconciler/generic.go index 8484f5820..848eb08e9 100644 --- a/pkg/runtime/reconciler/generic.go +++ b/pkg/runtime/reconciler/generic.go @@ -265,15 +265,15 @@ var _ domain.Reconciler = (*GenericReconciler[domain.Object])(nil) // // The semaphore gates concurrent execution — when an autoscaler is active it // can reduce effective concurrency below the goroutine count without stopping goroutines. -func (r *GenericReconciler[PTR]) Reconcile(ctx context.Context, key string) error { +func (r *GenericReconciler[PTR]) Reconcile(ctx context.Context, req domain.Request) (domain.Result, error) { if err := r.workerSem.Acquire(ctx); err != nil { - return err // context cancelled while waiting for a concurrency slot + return domain.Result{}, err // context cancelled while waiting for a concurrency slot } start := time.Now() - err := r.reconcileCore(ctx, key) + err := r.reconcileCore(ctx, req.Key) r.workerSem.Release() r.autoMetrics.RecordReconcile(time.Since(start), err != nil) - return err + return domain.Result{}, err } func (r *GenericReconciler[PTR]) reconcileCore(ctx context.Context, key string) error { @@ -340,7 +340,7 @@ func (r *GenericReconciler[PTR]) reconcileCore(ctx context.Context, key string) resolver = resolver.WithRequest(intent) } // Gives operator: unique live CRD access for the rest of this reconcile - // pass — validation.rules and any when:/anyOf: block evaluated against + // pass — validation.rules and any when:/or: block evaluated against // this resolver (mutation rules, template sources) all share it. resolver = resolver.WithUniquenessChecker(newUniquenessChecker(ctx, r.kube, r.crd.GVR(), r.crd.IsNamespaced())) // Run hook-declared external calls before ScopedFor so their results are diff --git a/pkg/runtime/reconciler/run_customresource.go b/pkg/runtime/reconciler/run_customresource.go index 66810903f..1ce4785a8 100644 --- a/pkg/runtime/reconciler/run_customresource.go +++ b/pkg/runtime/reconciler/run_customresource.go @@ -28,7 +28,7 @@ func runCustomResources( // Track active names for conditional cleanup when resources are no longer desired. activeNames := make(map[string]bool, len(srcs)) for _, s := range srcs { - if !orktypes.EvaluateConditions(resolver.Data(), s.Conditions, s.AnyOf, resolver.TemplateEvaluator()) { + if !orktypes.EvaluateConditions(resolver.Data(), s.Conditions, s.Or, resolver.TemplateEvaluator()) { continue } n, _ := resolver.Resolve(s.Metadata.Name) @@ -40,7 +40,7 @@ func runCustomResources( } for i, src := range srcs { - conditionPassed := orktypes.EvaluateConditions(resolver.Data(), src.Conditions, src.AnyOf, resolver.TemplateEvaluator()) + conditionPassed := orktypes.EvaluateConditions(resolver.Data(), src.Conditions, src.Or, resolver.TemplateEvaluator()) // Resolve name/namespace for guard/cleanup decisions name, _ := resolver.Resolve(src.Metadata.Name) diff --git a/pkg/runtime/reconciler/run_mutations.go b/pkg/runtime/reconciler/run_mutations.go index 499828317..a086bece6 100644 --- a/pkg/runtime/reconciler/run_mutations.go +++ b/pkg/runtime/reconciler/run_mutations.go @@ -79,7 +79,7 @@ func runMutation( if !rule.Fires.FiresAtReconcile() { continue } - if !orktypes.EvaluateConditions(data, rule.When, rule.AnyOf, resolver.TemplateEvaluator()) { + if !orktypes.EvaluateConditions(data, rule.When, rule.Or, resolver.TemplateEvaluator()) { continue } diff --git a/pkg/runtime/reconciler/run_providers.go b/pkg/runtime/reconciler/run_providers.go index d010ec525..54fe8439e 100644 --- a/pkg/runtime/reconciler/run_providers.go +++ b/pkg/runtime/reconciler/run_providers.go @@ -209,7 +209,7 @@ type resolvedDeclaration struct { Kind string Fields map[string]string Conditions []orktypes.Condition // when: AND conditions - AnyOf []orktypes.Condition // anyOf: OR conditions + Or []orktypes.Condition // or: OR conditions } // resolveProviderBlock resolves template expressions in all field values. @@ -224,7 +224,7 @@ func resolveProviderBlock( Kind: raw.Kind, Fields: make(map[string]string, len(raw.Fields)), Conditions: raw.Conditions, - AnyOf: raw.AnyOf, + Or: raw.Or, } for key, tmplVal := range raw.Fields { val, err := resolver.Resolve(tmplVal) @@ -240,7 +240,7 @@ func resolveProviderBlock( } // filterProviderDeclarations removes declarations whose conditions fail. -// Uses EvaluateConditions — handles when: (AND), anyOf: (OR), and all operators +// Uses EvaluateConditions — handles when: (AND), or: (OR), and all operators // including typeOf. Takes resolver data (not domain.Object) so that // template-resolved .spec.*, .status.*, .cross.* fields are visible. func filterProviderDeclarations( @@ -250,7 +250,7 @@ func filterProviderDeclarations( ) []orktypes.ProviderDeclaration { result := make([]orktypes.ProviderDeclaration, 0, len(declarations)) for _, decl := range declarations { - if !orktypes.EvaluateConditions(data, decl.Conditions, decl.AnyOf, eval) { + if !orktypes.EvaluateConditions(data, decl.Conditions, decl.Or, eval) { continue } result = append(result, orktypes.ProviderDeclaration{ diff --git a/pkg/runtime/reconciler/run_status.go b/pkg/runtime/reconciler/run_status.go index 68e6d15a7..b45232ac1 100644 --- a/pkg/runtime/reconciler/run_status.go +++ b/pkg/runtime/reconciler/run_status.go @@ -115,7 +115,7 @@ func runStatusPatch[PTR domain.Object]( } // ── Layer 2: Declared status fields ─────────────────────────────────── - // Conditional fields (with when:/anyOf:) are always evaluated so that + // Conditional fields (with when:/or:) are always evaluated so that // status can reflect why reconcile failed (e.g. external health check result). // Unconditional fields are only written on success — on error they would // write stale or misleading values (e.g. phase: Active while denied). @@ -132,7 +132,7 @@ func runStatusPatch[PTR domain.Object]( if reconcileErr != nil { var conditional []orktypes.StatusFieldSpec for _, f := range fields { - if len(f.When) > 0 || len(f.AnyOf) > 0 { + if len(f.When) > 0 || len(f.Or) > 0 { conditional = append(conditional, f) } } diff --git a/pkg/runtime/reconciler/run_validations.go b/pkg/runtime/reconciler/run_validations.go index 1c1ea21c4..72d9177d2 100644 --- a/pkg/runtime/reconciler/run_validations.go +++ b/pkg/runtime/reconciler/run_validations.go @@ -108,7 +108,7 @@ func runValidation(data map[string]interface{}, resolver *orktmpl.Resolver, cfg eval = resolver.TemplateEvaluator() tr = resolver } - if !orktypes.EvaluateConditions(data, rule.When, rule.AnyOf, eval) { + if !orktypes.EvaluateConditions(data, rule.When, rule.Or, eval) { continue } ruleViolation := orktypes.EvaluateValidationRule(data, tr, rule) diff --git a/pkg/runtime/reconciler/uniqueness.go b/pkg/runtime/reconciler/uniqueness.go index b94ed9fac..9e5749fef 100644 --- a/pkg/runtime/reconciler/uniqueness.go +++ b/pkg/runtime/reconciler/uniqueness.go @@ -33,7 +33,7 @@ type liveUniquenessChecker struct { // newUniquenessChecker builds the checker injected into every reconcile via // template.Resolver.WithUniquenessChecker, so operator: unique has live CRD -// access in both validation.rules and when:/anyOf:. +// access in both validation.rules and when:/or:. func newUniquenessChecker(ctx context.Context, kube dynamicClientProvider, gvr schema.GroupVersionResource, namespaced bool) orktypes.UniquenessChecker { return &liveUniquenessChecker{ctx: ctx, kube: kube, gvr: gvr, namespaced: namespaced} } diff --git a/pkg/runtime/runners/clusterrolebindings.go b/pkg/runtime/runners/clusterrolebindings.go index ab05e78cd..add0fa4b4 100644 --- a/pkg/runtime/runners/clusterrolebindings.go +++ b/pkg/runtime/runners/clusterrolebindings.go @@ -28,7 +28,7 @@ func RunClusterRoleBindings( ) error { activeNames := make(map[string]bool, len(srcs)) for _, s := range srcs { - if !orktypes.EvaluateConditions(resolver.Data(), s.Conditions, s.AnyOf, resolver.TemplateEvaluator()) { + if !orktypes.EvaluateConditions(resolver.Data(), s.Conditions, s.Or, resolver.TemplateEvaluator()) { continue } n, _ := resolver.Resolve(s.Name) @@ -36,7 +36,7 @@ func RunClusterRoleBindings( } for i, src := range srcs { - conditionPassed := orktypes.EvaluateConditions(resolver.Data(), src.Conditions, src.AnyOf, resolver.TemplateEvaluator()) + conditionPassed := orktypes.EvaluateConditions(resolver.Data(), src.Conditions, src.Or, resolver.TemplateEvaluator()) name, _ := resolver.Resolve(src.Name) diff --git a/pkg/runtime/runners/clusterroles.go b/pkg/runtime/runners/clusterroles.go index 0765c1dfe..40779964a 100644 --- a/pkg/runtime/runners/clusterroles.go +++ b/pkg/runtime/runners/clusterroles.go @@ -28,7 +28,7 @@ func RunClusterRoles( ) error { activeNames := make(map[string]bool, len(srcs)) for _, s := range srcs { - if !orktypes.EvaluateConditions(resolver.Data(), s.Conditions, s.AnyOf, resolver.TemplateEvaluator()) { + if !orktypes.EvaluateConditions(resolver.Data(), s.Conditions, s.Or, resolver.TemplateEvaluator()) { continue } n, _ := resolver.Resolve(s.Name) @@ -36,7 +36,7 @@ func RunClusterRoles( } for i, src := range srcs { - conditionPassed := orktypes.EvaluateConditions(resolver.Data(), src.Conditions, src.AnyOf, resolver.TemplateEvaluator()) + conditionPassed := orktypes.EvaluateConditions(resolver.Data(), src.Conditions, src.Or, resolver.TemplateEvaluator()) name, _ := resolver.Resolve(src.Name) diff --git a/pkg/runtime/runners/configmaps.go b/pkg/runtime/runners/configmaps.go index 269e6ce2e..4e4101057 100644 --- a/pkg/runtime/runners/configmaps.go +++ b/pkg/runtime/runners/configmaps.go @@ -37,7 +37,7 @@ func RunConfigMaps( ) error { activeNames := make(map[string]bool, len(srcs)) for _, s := range srcs { - if !orktypes.EvaluateConditions(resolver.Data(), s.Conditions, s.AnyOf, resolver.TemplateEvaluator()) { + if !orktypes.EvaluateConditions(resolver.Data(), s.Conditions, s.Or, resolver.TemplateEvaluator()) { continue } n, _ := resolver.Resolve(s.Name) @@ -50,7 +50,7 @@ func RunConfigMaps( for i, src := range srcs { // 1. Evaluate conditions BEFORE resolving templates - conditionPassed := orktypes.EvaluateConditions(resolver.Data(), src.Conditions, src.AnyOf, resolver.TemplateEvaluator()) + conditionPassed := orktypes.EvaluateConditions(resolver.Data(), src.Conditions, src.Or, resolver.TemplateEvaluator()) // Early name/ns resolution — needed for guard check and DeleteIfOwned cleanup. // ResolveConfigMapTemplate resolves these again internally — intentional, cheap. diff --git a/pkg/runtime/runners/cronjobs.go b/pkg/runtime/runners/cronjobs.go index 35c1aa0e3..ff0ccd1b4 100644 --- a/pkg/runtime/runners/cronjobs.go +++ b/pkg/runtime/runners/cronjobs.go @@ -44,7 +44,7 @@ func RunCronJobs( // typeOf conditions both targeting {{ .metadata.name }}. activeNames := make(map[string]bool, len(srcs)) for _, s := range srcs { - if !orktypes.EvaluateConditions(resolver.Data(), s.Conditions, s.AnyOf, resolver.TemplateEvaluator()) { + if !orktypes.EvaluateConditions(resolver.Data(), s.Conditions, s.Or, resolver.TemplateEvaluator()) { continue } n, _ := resolver.Resolve(s.Name) @@ -57,7 +57,7 @@ func RunCronJobs( for i, src := range srcs { // 1. Evaluate conditions BEFORE resolving templates - conditionPassed := orktypes.EvaluateConditions(resolver.Data(), src.Conditions, src.AnyOf, resolver.TemplateEvaluator()) + conditionPassed := orktypes.EvaluateConditions(resolver.Data(), src.Conditions, src.Or, resolver.TemplateEvaluator()) // Early name/ns resolution — needed for guard check and DeleteIfOwned cleanup. name, _ := resolver.Resolve(src.Name) diff --git a/pkg/runtime/runners/deployments.go b/pkg/runtime/runners/deployments.go index 6cc7a88e8..78eab4bc9 100644 --- a/pkg/runtime/runners/deployments.go +++ b/pkg/runtime/runners/deployments.go @@ -32,7 +32,7 @@ func RunDeployments( ) error { activeNames := make(map[string]bool, len(srcs)) for _, s := range srcs { - if !orktypes.EvaluateConditions(resolver.Data(), s.Conditions, s.AnyOf, resolver.TemplateEvaluator()) { + if !orktypes.EvaluateConditions(resolver.Data(), s.Conditions, s.Or, resolver.TemplateEvaluator()) { continue } n, _ := resolver.Resolve(s.Name) @@ -46,7 +46,7 @@ func RunDeployments( for i, src := range srcs { // 1. Evaluate conditions BEFORE resolving templates - conditionPassed := orktypes.EvaluateConditions(resolver.Data(), src.Conditions, src.AnyOf, resolver.TemplateEvaluator()) + conditionPassed := orktypes.EvaluateConditions(resolver.Data(), src.Conditions, src.Or, resolver.TemplateEvaluator()) // Early name/ns resolution — needed for guard check and DeleteIfOwned cleanup. // ResolveDeploymentTemplate resolves these again internally — intentional, cheap. diff --git a/pkg/runtime/runners/docs/01-runner-contract.md b/pkg/runtime/runners/docs/01-runner-contract.md index 8dbe457c4..e2794dfdf 100644 --- a/pkg/runtime/runners/docs/01-runner-contract.md +++ b/pkg/runtime/runners/docs/01-runner-contract.md @@ -33,7 +33,7 @@ func RunWidgets( // ── Section A: activeNames pre-pass ────────────────────────────────────── activeNames := make(map[string]bool, len(srcs)) for _, s := range srcs { - if !orktypes.EvaluateConditions(resolver.Data(), s.Conditions, s.AnyOf, resolver.TemplateEvaluator()) { + if !orktypes.EvaluateConditions(resolver.Data(), s.Conditions, s.Or, resolver.TemplateEvaluator()) { continue } n, _ := resolver.Resolve(s.Name) @@ -48,7 +48,7 @@ func RunWidgets( for i, src := range srcs { // B1. Evaluate conditions - conditionPassed := orktypes.EvaluateConditions(resolver.Data(), src.Conditions, src.AnyOf, resolver.TemplateEvaluator()) + conditionPassed := orktypes.EvaluateConditions(resolver.Data(), src.Conditions, src.Or, resolver.TemplateEvaluator()) // B2. Early name/namespace resolution name, _ := resolver.Resolve(src.Name) @@ -118,11 +118,11 @@ func RunWidgets( ## Section B1 — Condition evaluation -Always call `orktypes.EvaluateConditions(resolver.Data(), src.Conditions, src.AnyOf, resolver.TemplateEvaluator())`. +Always call `orktypes.EvaluateConditions(resolver.Data(), src.Conditions, src.Or, resolver.TemplateEvaluator())`. - `resolver.Data()` — the full CR data map including `.children.*`, `.external.*`, `.cross.*`. Do NOT pass the `owner` object directly — it does not have these injected fields. - `src.Conditions` — the `when:` block (AND semantics). -- `src.AnyOf` — the `anyOf:` block (OR semantics). +- `src.Or` — the `or:` block (OR semantics). --- diff --git a/pkg/runtime/runners/hpas.go b/pkg/runtime/runners/hpas.go index 9f7859575..9b48a1bac 100644 --- a/pkg/runtime/runners/hpas.go +++ b/pkg/runtime/runners/hpas.go @@ -25,7 +25,7 @@ func RunHPAs( ) error { activeNames := make(map[string]bool, len(srcs)) for _, s := range srcs { - if !orktypes.EvaluateConditions(resolver.Data(), s.Conditions, s.AnyOf, resolver.TemplateEvaluator()) { + if !orktypes.EvaluateConditions(resolver.Data(), s.Conditions, s.Or, resolver.TemplateEvaluator()) { continue } n, _ := resolver.Resolve(s.Name) @@ -37,7 +37,7 @@ func RunHPAs( } for i, src := range srcs { - conditionPassed := orktypes.EvaluateConditions(resolver.Data(), src.Conditions, src.AnyOf, resolver.TemplateEvaluator()) + conditionPassed := orktypes.EvaluateConditions(resolver.Data(), src.Conditions, src.Or, resolver.TemplateEvaluator()) name, _ := resolver.Resolve(src.Name) ns, _ := resolver.Resolve(src.Namespace) diff --git a/pkg/runtime/runners/ingresses.go b/pkg/runtime/runners/ingresses.go index bd5a1741e..f3b5a5a01 100644 --- a/pkg/runtime/runners/ingresses.go +++ b/pkg/runtime/runners/ingresses.go @@ -29,7 +29,7 @@ func RunIngresses( ) error { activeNames := make(map[string]bool, len(srcs)) for _, s := range srcs { - if !orktypes.EvaluateConditions(resolver.Data(), s.Conditions, s.AnyOf, resolver.TemplateEvaluator()) { + if !orktypes.EvaluateConditions(resolver.Data(), s.Conditions, s.Or, resolver.TemplateEvaluator()) { continue } n, _ := resolver.Resolve(s.Name) @@ -41,7 +41,7 @@ func RunIngresses( } for i, src := range srcs { - conditionPassed := orktypes.EvaluateConditions(resolver.Data(), src.Conditions, src.AnyOf, resolver.TemplateEvaluator()) + conditionPassed := orktypes.EvaluateConditions(resolver.Data(), src.Conditions, src.Or, resolver.TemplateEvaluator()) name, _ := resolver.Resolve(src.Name) ns, _ := resolver.Resolve(src.Namespace) diff --git a/pkg/runtime/runners/jobs.go b/pkg/runtime/runners/jobs.go index 94cf6620d..76946b3af 100644 --- a/pkg/runtime/runners/jobs.go +++ b/pkg/runtime/runners/jobs.go @@ -39,7 +39,7 @@ func RunJobs( ) error { for i, src := range srcs { // 1. Evaluate conditions BEFORE resolving templates - conditionPassed := orktypes.EvaluateConditions(resolver.Data(), src.Conditions, src.AnyOf, resolver.TemplateEvaluator()) + conditionPassed := orktypes.EvaluateConditions(resolver.Data(), src.Conditions, src.Or, resolver.TemplateEvaluator()) // Early name/ns resolution — needed for guard check. // Jobs are terminal (no DeleteIfOwned on condition fail), but guard diff --git a/pkg/runtime/runners/limitranges.go b/pkg/runtime/runners/limitranges.go index 379696947..a7e2877c8 100644 --- a/pkg/runtime/runners/limitranges.go +++ b/pkg/runtime/runners/limitranges.go @@ -30,7 +30,7 @@ func RunLimitRanges( ) error { activeNames := make(map[string]bool, len(srcs)) for _, s := range srcs { - if !orktypes.EvaluateConditions(resolver.Data(), s.Conditions, s.AnyOf, resolver.TemplateEvaluator()) { + if !orktypes.EvaluateConditions(resolver.Data(), s.Conditions, s.Or, resolver.TemplateEvaluator()) { continue } n, _ := resolver.Resolve(s.Name) @@ -42,7 +42,7 @@ func RunLimitRanges( } for i, src := range srcs { - conditionPassed := orktypes.EvaluateConditions(resolver.Data(), src.Conditions, src.AnyOf, resolver.TemplateEvaluator()) + conditionPassed := orktypes.EvaluateConditions(resolver.Data(), src.Conditions, src.Or, resolver.TemplateEvaluator()) name, _ := resolver.Resolve(src.Name) ns, _ := resolver.Resolve(src.Namespace) diff --git a/pkg/runtime/runners/namespaces.go b/pkg/runtime/runners/namespaces.go index f6bc3c28d..11e55bda4 100644 --- a/pkg/runtime/runners/namespaces.go +++ b/pkg/runtime/runners/namespaces.go @@ -31,7 +31,7 @@ func RunNamespaces( ) error { activeNames := make(map[string]bool, len(srcs)) for _, s := range srcs { - if !orktypes.EvaluateConditions(resolver.Data(), s.Conditions, s.AnyOf, resolver.TemplateEvaluator()) { + if !orktypes.EvaluateConditions(resolver.Data(), s.Conditions, s.Or, resolver.TemplateEvaluator()) { continue } n, _ := resolver.Resolve(s.Name) @@ -40,7 +40,7 @@ func RunNamespaces( for i, src := range srcs { // 1. Evaluate conditions BEFORE resolving templates - conditionPassed := orktypes.EvaluateConditions(resolver.Data(), src.Conditions, src.AnyOf, resolver.TemplateEvaluator()) + conditionPassed := orktypes.EvaluateConditions(resolver.Data(), src.Conditions, src.Or, resolver.TemplateEvaluator()) // Early name resolution — needed for DeleteIfOwned cleanup. name, _ := resolver.Resolve(src.Name) diff --git a/pkg/runtime/runners/networkpolicies.go b/pkg/runtime/runners/networkpolicies.go index 1c9da3610..5a40bbb1e 100644 --- a/pkg/runtime/runners/networkpolicies.go +++ b/pkg/runtime/runners/networkpolicies.go @@ -30,7 +30,7 @@ func RunNetworkPolicies( ) error { activeNames := make(map[string]bool, len(srcs)) for _, s := range srcs { - if !orktypes.EvaluateConditions(resolver.Data(), s.Conditions, s.AnyOf, resolver.TemplateEvaluator()) { + if !orktypes.EvaluateConditions(resolver.Data(), s.Conditions, s.Or, resolver.TemplateEvaluator()) { continue } n, _ := resolver.Resolve(s.Name) @@ -42,7 +42,7 @@ func RunNetworkPolicies( } for i, src := range srcs { - conditionPassed := orktypes.EvaluateConditions(resolver.Data(), src.Conditions, src.AnyOf, resolver.TemplateEvaluator()) + conditionPassed := orktypes.EvaluateConditions(resolver.Data(), src.Conditions, src.Or, resolver.TemplateEvaluator()) name, _ := resolver.Resolve(src.Name) ns, _ := resolver.Resolve(src.Namespace) diff --git a/pkg/runtime/runners/pdbs.go b/pkg/runtime/runners/pdbs.go index d87bd6ad2..1c61cfda0 100644 --- a/pkg/runtime/runners/pdbs.go +++ b/pkg/runtime/runners/pdbs.go @@ -25,7 +25,7 @@ func RunPDBs( ) error { activeNames := make(map[string]bool, len(srcs)) for _, s := range srcs { - if !orktypes.EvaluateConditions(resolver.Data(), s.Conditions, s.AnyOf, resolver.TemplateEvaluator()) { + if !orktypes.EvaluateConditions(resolver.Data(), s.Conditions, s.Or, resolver.TemplateEvaluator()) { continue } n, _ := resolver.Resolve(s.Name) @@ -37,7 +37,7 @@ func RunPDBs( } for i, src := range srcs { - conditionPassed := orktypes.EvaluateConditions(resolver.Data(), src.Conditions, src.AnyOf, resolver.TemplateEvaluator()) + conditionPassed := orktypes.EvaluateConditions(resolver.Data(), src.Conditions, src.Or, resolver.TemplateEvaluator()) name, _ := resolver.Resolve(src.Name) ns, _ := resolver.Resolve(src.Namespace) diff --git a/pkg/runtime/runners/pods.go b/pkg/runtime/runners/pods.go index dd7dcc4e5..a17e1e328 100644 --- a/pkg/runtime/runners/pods.go +++ b/pkg/runtime/runners/pods.go @@ -33,7 +33,7 @@ func RunPods( activeNames := make(map[string]bool, len(srcs)) for _, s := range srcs { - if !orktypes.EvaluateConditions(resolver.Data(), s.Conditions, s.AnyOf, resolver.TemplateEvaluator()) { + if !orktypes.EvaluateConditions(resolver.Data(), s.Conditions, s.Or, resolver.TemplateEvaluator()) { continue } n, _ := resolver.Resolve(s.Name) @@ -46,7 +46,7 @@ func RunPods( for i, src := range srcs { - conditionPassed := orktypes.EvaluateConditions(resolver.Data(), src.Conditions, src.AnyOf, resolver.TemplateEvaluator()) + conditionPassed := orktypes.EvaluateConditions(resolver.Data(), src.Conditions, src.Or, resolver.TemplateEvaluator()) name, _ := resolver.Resolve(src.Name) ns, _ := resolver.Resolve(src.Namespace) diff --git a/pkg/runtime/runners/pvcs.go b/pkg/runtime/runners/pvcs.go index 1f10eb860..0d5186b52 100644 --- a/pkg/runtime/runners/pvcs.go +++ b/pkg/runtime/runners/pvcs.go @@ -25,7 +25,7 @@ func RunPVCs( ) error { activeNames := make(map[string]bool, len(srcs)) for _, s := range srcs { - if !orktypes.EvaluateConditions(resolver.Data(), s.Conditions, s.AnyOf, resolver.TemplateEvaluator()) { + if !orktypes.EvaluateConditions(resolver.Data(), s.Conditions, s.Or, resolver.TemplateEvaluator()) { continue } n, _ := resolver.Resolve(s.Name) @@ -37,7 +37,7 @@ func RunPVCs( } for i, src := range srcs { - conditionPassed := orktypes.EvaluateConditions(resolver.Data(), src.Conditions, src.AnyOf, resolver.TemplateEvaluator()) + conditionPassed := orktypes.EvaluateConditions(resolver.Data(), src.Conditions, src.Or, resolver.TemplateEvaluator()) name, _ := resolver.Resolve(src.Name) ns, _ := resolver.Resolve(src.Namespace) diff --git a/pkg/runtime/runners/pvs.go b/pkg/runtime/runners/pvs.go index 647153763..7ed3e5f12 100644 --- a/pkg/runtime/runners/pvs.go +++ b/pkg/runtime/runners/pvs.go @@ -25,7 +25,7 @@ func RunPVs( ) error { activeNames := make(map[string]bool, len(srcs)) for _, s := range srcs { - if !orktypes.EvaluateConditions(resolver.Data(), s.Conditions, s.AnyOf, resolver.TemplateEvaluator()) { + if !orktypes.EvaluateConditions(resolver.Data(), s.Conditions, s.Or, resolver.TemplateEvaluator()) { continue } n, _ := resolver.Resolve(s.Name) @@ -33,7 +33,7 @@ func RunPVs( } for i, src := range srcs { - conditionPassed := orktypes.EvaluateConditions(resolver.Data(), src.Conditions, src.AnyOf, resolver.TemplateEvaluator()) + conditionPassed := orktypes.EvaluateConditions(resolver.Data(), src.Conditions, src.Or, resolver.TemplateEvaluator()) name, _ := resolver.Resolve(src.Name) diff --git a/pkg/runtime/runners/replicasets.go b/pkg/runtime/runners/replicasets.go index 419e97ad9..f58650abf 100644 --- a/pkg/runtime/runners/replicasets.go +++ b/pkg/runtime/runners/replicasets.go @@ -34,7 +34,7 @@ func RunReplicaSets( // Track active names for conditional cleanup activeNames := make(map[string]bool, len(srcs)) for _, s := range srcs { - if !orktypes.EvaluateConditions(resolver.Data(), s.Conditions, s.AnyOf, resolver.TemplateEvaluator()) { + if !orktypes.EvaluateConditions(resolver.Data(), s.Conditions, s.Or, resolver.TemplateEvaluator()) { continue } n, _ := resolver.Resolve(s.Name) @@ -48,7 +48,7 @@ func RunReplicaSets( for i, src := range srcs { // 1. Evaluate conditions BEFORE resolving templates - conditionPassed := orktypes.EvaluateConditions(resolver.Data(), src.Conditions, src.AnyOf, resolver.TemplateEvaluator()) + conditionPassed := orktypes.EvaluateConditions(resolver.Data(), src.Conditions, src.Or, resolver.TemplateEvaluator()) // Early name/ns resolution — needed for guard check and DeleteIfOwned cleanup. name, _ := resolver.Resolve(src.Name) diff --git a/pkg/runtime/runners/resourcequotas.go b/pkg/runtime/runners/resourcequotas.go index 11f4967ea..cb22108ac 100644 --- a/pkg/runtime/runners/resourcequotas.go +++ b/pkg/runtime/runners/resourcequotas.go @@ -30,7 +30,7 @@ func RunResourceQuotas( ) error { activeNames := make(map[string]bool, len(srcs)) for _, s := range srcs { - if !orktypes.EvaluateConditions(resolver.Data(), s.Conditions, s.AnyOf, resolver.TemplateEvaluator()) { + if !orktypes.EvaluateConditions(resolver.Data(), s.Conditions, s.Or, resolver.TemplateEvaluator()) { continue } n, _ := resolver.Resolve(s.Name) @@ -42,7 +42,7 @@ func RunResourceQuotas( } for i, src := range srcs { - conditionPassed := orktypes.EvaluateConditions(resolver.Data(), src.Conditions, src.AnyOf, resolver.TemplateEvaluator()) + conditionPassed := orktypes.EvaluateConditions(resolver.Data(), src.Conditions, src.Or, resolver.TemplateEvaluator()) name, _ := resolver.Resolve(src.Name) ns, _ := resolver.Resolve(src.Namespace) diff --git a/pkg/runtime/runners/rolebindings.go b/pkg/runtime/runners/rolebindings.go index d7fea2c12..5b555498c 100644 --- a/pkg/runtime/runners/rolebindings.go +++ b/pkg/runtime/runners/rolebindings.go @@ -29,7 +29,7 @@ func RunRoleBindings( ) error { activeNames := make(map[string]bool, len(srcs)) for _, s := range srcs { - if !orktypes.EvaluateConditions(resolver.Data(), s.Conditions, s.AnyOf, resolver.TemplateEvaluator()) { + if !orktypes.EvaluateConditions(resolver.Data(), s.Conditions, s.Or, resolver.TemplateEvaluator()) { continue } n, _ := resolver.Resolve(s.Name) @@ -41,7 +41,7 @@ func RunRoleBindings( } for i, src := range srcs { - conditionPassed := orktypes.EvaluateConditions(resolver.Data(), src.Conditions, src.AnyOf, resolver.TemplateEvaluator()) + conditionPassed := orktypes.EvaluateConditions(resolver.Data(), src.Conditions, src.Or, resolver.TemplateEvaluator()) name, _ := resolver.Resolve(src.Name) ns, _ := resolver.Resolve(src.Namespace) diff --git a/pkg/runtime/runners/roles.go b/pkg/runtime/runners/roles.go index 79166e469..18f7b5c60 100644 --- a/pkg/runtime/runners/roles.go +++ b/pkg/runtime/runners/roles.go @@ -29,7 +29,7 @@ func RunRoles( ) error { activeNames := make(map[string]bool, len(srcs)) for _, s := range srcs { - if !orktypes.EvaluateConditions(resolver.Data(), s.Conditions, s.AnyOf, resolver.TemplateEvaluator()) { + if !orktypes.EvaluateConditions(resolver.Data(), s.Conditions, s.Or, resolver.TemplateEvaluator()) { continue } n, _ := resolver.Resolve(s.Name) @@ -41,7 +41,7 @@ func RunRoles( } for i, src := range srcs { - conditionPassed := orktypes.EvaluateConditions(resolver.Data(), src.Conditions, src.AnyOf, resolver.TemplateEvaluator()) + conditionPassed := orktypes.EvaluateConditions(resolver.Data(), src.Conditions, src.Or, resolver.TemplateEvaluator()) name, _ := resolver.Resolve(src.Name) ns, _ := resolver.Resolve(src.Namespace) diff --git a/pkg/runtime/runners/secrets.go b/pkg/runtime/runners/secrets.go index 1d8799c10..a5b13dff9 100644 --- a/pkg/runtime/runners/secrets.go +++ b/pkg/runtime/runners/secrets.go @@ -1,13 +1,13 @@ // pkg/runners/secrets.go // // Adds to the previous version: -// - orktypes.EvaluateConditions instead of evaluateConditions (fixes anyOf: being ignored) +// - orktypes.EvaluateConditions instead of evaluateConditions (fixes or: being ignored) // - rotateAfter: support — time-based credential rotation // - tls: {...} support — self-signed CA + signed certificate generation // // Execution order per secret declaration: // -// 1. EvaluateConditions(when:, anyOf:) — skip if conditions fail +// 1. EvaluateConditions(when:, or:) — skip if conditions fail // 2. Once/rotation check // a. once: true, rotateAfter set — check annotation, delete if expired // b. once: true, no rotateAfter — skip if exists @@ -43,7 +43,7 @@ func RunSecrets( ) error { activeNames := make(map[string]bool, len(srcs)) for _, s := range srcs { - if !orktypes.EvaluateConditions(resolver.Data(), s.Conditions, s.AnyOf, resolver.TemplateEvaluator()) { + if !orktypes.EvaluateConditions(resolver.Data(), s.Conditions, s.Or, resolver.TemplateEvaluator()) { continue } n, _ := resolver.Resolve(s.Name) @@ -59,11 +59,11 @@ func RunSecrets( for i, src := range srcs { // ── Step 1: condition evaluation ──────────────────────────────────────── - // EvaluateConditions checks both when: (AND) and anyOf: (OR). + // EvaluateConditions checks both when: (AND) and or: (OR). // IMPORTANT: must use resolver.Data() not the owner object directly. // resolver.Data() includes .children.*, .external.*, .cross.* — the owner // object alone does not have these injected fields. - conditionPassed := orktypes.EvaluateConditions(resolver.Data(), src.Conditions, src.AnyOf, resolver.TemplateEvaluator()) + conditionPassed := orktypes.EvaluateConditions(resolver.Data(), src.Conditions, src.Or, resolver.TemplateEvaluator()) // Resolve name and namespace early — needed for guard check, once: checks, // and DeleteIfOwned cleanup. ResolveSecretTemplate resolves these again diff --git a/pkg/runtime/runners/serviceaccounts.go b/pkg/runtime/runners/serviceaccounts.go index bb968a688..e9d2ea5a4 100644 --- a/pkg/runtime/runners/serviceaccounts.go +++ b/pkg/runtime/runners/serviceaccounts.go @@ -32,7 +32,7 @@ func RunServiceAccounts( ) error { activeNames := make(map[string]bool, len(srcs)) for _, s := range srcs { - if !orktypes.EvaluateConditions(resolver.Data(), s.Conditions, s.AnyOf, resolver.TemplateEvaluator()) { + if !orktypes.EvaluateConditions(resolver.Data(), s.Conditions, s.Or, resolver.TemplateEvaluator()) { continue } n, _ := resolver.Resolve(s.Name) @@ -45,7 +45,7 @@ func RunServiceAccounts( for i, src := range srcs { // 1. Evaluate conditions BEFORE resolving templates - conditionPassed := orktypes.EvaluateConditions(resolver.Data(), src.Conditions, src.AnyOf, resolver.TemplateEvaluator()) + conditionPassed := orktypes.EvaluateConditions(resolver.Data(), src.Conditions, src.Or, resolver.TemplateEvaluator()) // Early name/ns resolution — needed for guard check and DeleteIfOwned cleanup. name, _ := resolver.Resolve(src.Name) diff --git a/pkg/runtime/runners/services.go b/pkg/runtime/runners/services.go index 5f76300d7..06e8be5e6 100644 --- a/pkg/runtime/runners/services.go +++ b/pkg/runtime/runners/services.go @@ -26,7 +26,7 @@ func RunServices( ) error { activeNames := make(map[string]bool, len(srcs)) for _, s := range srcs { - if !orktypes.EvaluateConditions(resolver.Data(), s.Conditions, s.AnyOf, resolver.TemplateEvaluator()) { + if !orktypes.EvaluateConditions(resolver.Data(), s.Conditions, s.Or, resolver.TemplateEvaluator()) { continue } n, _ := resolver.Resolve(s.Name) @@ -39,7 +39,7 @@ func RunServices( for i, src := range srcs { // 1. Evaluate conditions BEFORE resolving templates - conditionPassed := orktypes.EvaluateConditions(resolver.Data(), src.Conditions, src.AnyOf, resolver.TemplateEvaluator()) + conditionPassed := orktypes.EvaluateConditions(resolver.Data(), src.Conditions, src.Or, resolver.TemplateEvaluator()) name, _ := resolver.Resolve(src.Name) ns, _ := resolver.Resolve(src.Namespace) diff --git a/pkg/runtime/runners/statefulsets.go b/pkg/runtime/runners/statefulsets.go index 01cbda33b..5aad0a95f 100644 --- a/pkg/runtime/runners/statefulsets.go +++ b/pkg/runtime/runners/statefulsets.go @@ -25,7 +25,7 @@ func RunStatefulSets( ) error { activeNames := make(map[string]bool, len(srcs)) for _, s := range srcs { - if !orktypes.EvaluateConditions(resolver.Data(), s.Conditions, s.AnyOf, resolver.TemplateEvaluator()) { + if !orktypes.EvaluateConditions(resolver.Data(), s.Conditions, s.Or, resolver.TemplateEvaluator()) { continue } n, _ := resolver.Resolve(s.Name) @@ -37,7 +37,7 @@ func RunStatefulSets( } for i, src := range srcs { - conditionPassed := orktypes.EvaluateConditions(resolver.Data(), src.Conditions, src.AnyOf, resolver.TemplateEvaluator()) + conditionPassed := orktypes.EvaluateConditions(resolver.Data(), src.Conditions, src.Or, resolver.TemplateEvaluator()) name, _ := resolver.Resolve(src.Name) ns, _ := resolver.Resolve(src.Namespace) diff --git a/pkg/runtime/runners/workload_autoscale.go b/pkg/runtime/runners/workload_autoscale.go index 80a1dbbd3..1f51ba17b 100644 --- a/pkg/runtime/runners/workload_autoscale.go +++ b/pkg/runtime/runners/workload_autoscale.go @@ -123,7 +123,7 @@ func evaluateWorkloadAutoscaleForKind( // ── Evaluate scale-up ──────────────────────────────────────────────────── if cfg.ScaleUp != nil && current < max { c := cfg.ScaleUp - if orktypes.EvaluateConditions(data, c.Conditions.When, c.Conditions.AnyOf, eval) { + if orktypes.EvaluateConditions(data, c.Conditions.When, c.Conditions.Or, eval) { target := resolveTarget(current, max, c, true) if target != current { log.Info().Int32("from", current).Int32("to", target).Msg("autoscale: scaling up") @@ -141,7 +141,7 @@ func evaluateWorkloadAutoscaleForKind( // ── Evaluate scale-down ────────────────────────────────────────────────── if cfg.ScaleDown != nil && current > min { c := cfg.ScaleDown - if orktypes.EvaluateConditions(data, c.Conditions.When, c.Conditions.AnyOf, eval) { + if orktypes.EvaluateConditions(data, c.Conditions.When, c.Conditions.Or, eval) { target := resolveTarget(current, min, c, false) if target != current { log.Info().Int32("from", current).Int32("to", target).Msg("autoscale: scaling down") diff --git a/pkg/tools/migrate/README.md b/pkg/tools/migrate/README.md index 9fd9edfd2..1206d53e1 100644 --- a/pkg/tools/migrate/README.md +++ b/pkg/tools/migrate/README.md @@ -36,12 +36,14 @@ 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` | -| `req.NamespacedName` | `client.ObjectKey{Namespace: namespace, Name: name}` | +| `Reconcile(ctx, req ctrl.Request) (ctrl.Result, error)` | `Reconcile(ctx context.Context, req domain.Request) (domain.Result, error)` | +| `return ctrl.Result{}, err` | `return domain.Result{}, err` | +| `return ctrl.Result{}, nil` | `return domain.Result{}, nil` | +| `return ctrl.Result{RequeueAfter: X}, nil` | `return domain.Result{RequeueAfter: X}, nil` | +| `req.String()` | `req.String()` (preserved — `domain.Request` implements `Stringer`) | +| `req.NamespacedName` | `req.NamespacedName` (available directly on `domain.Request`) | | `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 | More invasive; produces fully idiomatic Orkestra code. diff --git a/pkg/tools/migrate/docs/01-output.md b/pkg/tools/migrate/docs/01-output.md index fa1ee8084..7b59295ab 100644 --- a/pkg/tools/migrate/docs/01-output.md +++ b/pkg/tools/migrate/docs/01-output.md @@ -2,11 +2,19 @@ ## toclient mode (default) -`ork migrate` (without `--mode`) produces the minimum change needed to run your reconciler inside Orkestra. Only two things change: +`ork migrate` (without `--mode`) produces the minimum change needed to run your reconciler inside Orkestra. Three things happen: ### SetupWithManager is removed -Replaced with a comment: +Before removal, `ork migrate` scans `SetupWithManager` and extracts: + +- **`For(&pkg.Kind{})`** → `apiTypes.kind`, `object`, `objectList`, `version`, `location`, `alias` in `katalog.yaml` +- **`Owns(&pkg.Kind{})`** → `constructor.managedResources:` entries (kind + apiVersion for standard k8s types) +- **`Watches(&pkg.Kind{}, …)`** → `operatorBox.watch:` entries + +Only `group` and `plural` cannot be determined from Go source — they remain as TODOs. + +The method itself is replaced with a comment: ```go // SetupWithManager removed — Orkestra provides the informer, workqueue, @@ -26,6 +34,15 @@ func NewWebAppReconciler(kube kubeclient.Interface) domain.Reconciler { `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. +### Orkestra imports are injected + +```go +"github.com/orkspace/orkestra/domain" +"github.com/orkspace/orkestra/pkg/kubeclient" +``` + +The `ctrl` import is **kept** — the Reconcile signature and body are unchanged, so `ctrl.Request`, `ctrl.Result`, and `ctrl.LoggerFrom` still compile. + --- ## native mode (`--mode native`) @@ -39,45 +56,45 @@ Full mechanical rewrite to idiomatic Orkestra style. 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, req domain.Request) (domain.Result, 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. +`req.String()` returns `namespace/name` — same as before. `req.NamespacedName` is available directly on `domain.Request`. The `ctrl` import is removed. ### Return statements -Every `ctrl.Result` is collapsed: +Every `ctrl.Result` is rewritten: ```go // Before return ctrl.Result{}, err return ctrl.Result{}, nil +return ctrl.Result{RequeueAfter: 30 * time.Second}, nil // After -return err -return nil +return domain.Result{}, err +return domain.Result{}, nil +return domain.Result{RequeueAfter: 30 * time.Second}, nil ``` -`ctrl.Result{RequeueAfter: X}` cannot be collapsed mechanically — it is flagged: +`RequeueAfter` is preserved through `domain.Result` — no information is lost. + +### req.NamespacedName + +`req.NamespacedName` is available directly on `domain.Request` — no injection needed. Call sites that pass it to `r.Get` receive a TODO comment when the tool cannot decompose it automatically: ```go -// TODO(ork migrate): RequeueAfter removed — Orkestra retries on non-nil error -return nil +r.kube.Get(ctx, namespace, name, obj /* TODO(ork migrate): extract namespace+name from: req.NamespacedName */) ``` -Return an error to trigger a retry. Orkestra's backoff policy applies automatically. - -### req.NamespacedName +### Struct simplified -When the body uses `req.NamespacedName`, the tool injects a key split at the top of `Reconcile` and replaces usages: +The embedded `client.Client` (and any other ctrl-runtime fields) are replaced with a single `kube kubeclient.Interface` field. The constructor becomes: ```go -// Injected at top of Reconcile body -parts := strings.SplitN(key, "/", 2) -namespace, name := parts[0], parts[1] - -// Usage replaced -r.Get(ctx, client.ObjectKey{Namespace: namespace, Name: name}, webapp) +func NewWebAppReconciler(kube kubeclient.Interface) domain.Reconciler { + return &WebAppReconciler{kube: kube} +} ``` ### r.Status().Update() @@ -85,7 +102,7 @@ r.Get(ctx, client.ObjectKey{Namespace: namespace, Name: name}, webapp) Flagged inline — Orkestra uses a different status API: ```go -nil /* TODO(ork migrate): replace with r.kube.PatchStatus(ctx, obj, GroupVersionResource, map[string]interface{}{...}) */ +nil /* TODO(ork migrate): replace with r.kube.PatchStatus(ctx, obj, map[string]interface{}{...}) */ ``` ### TODO markers diff --git a/pkg/tools/migrate/docs/02-generated-files.md b/pkg/tools/migrate/docs/02-generated-files.md index a37ca75fe..9e3d5e1c3 100644 --- a/pkg/tools/migrate/docs/02-generated-files.md +++ b/pkg/tools/migrate/docs/02-generated-files.md @@ -1,38 +1,61 @@ # Generated files -`ork migrate -o ./my-operator` writes five files. All have `TODO(ork migrate):` markers where the tool could not infer values from the source. +`ork migrate -o ./my-operator` writes five files. Fields marked `TODO(ork migrate):` could not be inferred from the source and need manual review. ## katalog.yaml -The constructor Katalog stub. Fields you must fill in: +The constructor Katalog stub. `ork migrate` extracts what it can from `SetupWithManager`: + +| Field | Source | Detected? | +|-------|--------|-----------| +| `kind` | `For(&pkg.Kind{})` struct name | ✓ | +| `object` / `objectList` | same struct name + `List` suffix | ✓ | +| `version` | import path last segment (`v1alpha1`, `v1`) | ✓ | +| `location` | full import path of the type package | ✓ | +| `alias` | import alias in source | ✓ | +| `managedResources:` | each `Owns(&pkg.Kind{})` call | ✓ | +| `watch:` | each `Watches(&pkg.Kind{}, …)` call | ✓ | +| `group` | not in source (CRD marker or YAML) | ✗ TODO | +| `plural` | not in source | ✗ TODO | + +Example output for a reconciler with `For(&demov1alpha1.WebApp{})`, `Owns(&appsv1.Deployment{})`, `Watches(&demov1alpha1.Config{}, …)`: ```yaml metadata: - name: webapp-reconciler # derived from receiver type - author: myorg # TODO: set your org + name: web-app-reconciler # derived from receiver type spec: crds: - webapp-reconciler: + web-app: apiTypes: - group: TODO # TODO: your CRD group (e.g. apps.myorg.io) - version: v1alpha1 - kind: TODO # TODO: your CRD kind (e.g. WebApp) - plural: TODO - object: TODO - objectList: TODOList - location: github.com/myorg/my-operator/api/v1alpha1 # adjust package path + group: TODO # TODO(ork migrate): your CRD group + version: v1alpha1 # from import path + kind: WebApp # from For() + plural: TODO # TODO(ork migrate) + object: WebApp # from For() + objectList: WebAppList + location: github.com/example/webapp-operator/api/v1alpha1 + alias: demov1alpha1 operatorBox: - default: false # constructor owns the full loop - - constructor: - location: github.com/myorg/my-operator/controller # adjust - function: NewWebAppReconciler # derived from receiver type - resources: - - kind: TODO # TODO: list the resource kinds you manage + watch: + - apiVersion: TODO # TODO(ork migrate): verify (custom package) + kind: Config + on: [create, update, delete] + + reconciler: + default: false + + constructor: + location: github.com/example/webapp-operator/controller + function: NewWebAppReconciler + managedResources: + - kind: Deployment + apiVersion: apps/v1 ``` +Only `group` and `plural` remain as TODOs for standard k8s `Owns()` types. + ## simulate.yaml Simulation stub. Fill in the resource kinds your operator creates in cycle 1: diff --git a/pkg/tools/migrate/docs/03-limitations.md b/pkg/tools/migrate/docs/03-limitations.md index 27eb29dd9..c619611eb 100644 --- a/pkg/tools/migrate/docs/03-limitations.md +++ b/pkg/tools/migrate/docs/03-limitations.md @@ -51,13 +51,13 @@ r.kube.PatchStatus(ctx, webapp, apiv1.GroupVersionResource, map[string]interface ### ctrl.Result{RequeueAfter: X} -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. +`RequeueAfter` is preserved through `domain.Result{RequeueAfter: X}` — no information is lost and no TODO is added. --- ## Out of scope in both modes -- **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`. +- **kubebuilder RBAC markers** — `// +kubebuilder:rbac:groups=...` are left as-is. They have no effect in Orkestra — declare resources in the Katalog's `managedResources:` 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. diff --git a/pkg/tools/migrate/generator.go b/pkg/tools/migrate/generator.go index 8ced7ceae..359875612 100644 --- a/pkg/tools/migrate/generator.go +++ b/pkg/tools/migrate/generator.go @@ -63,6 +63,29 @@ func Generate(res *Result, opts Options) Files { } func generateKatalog(res *Result, opts Options, crdName, constructorFn string) string { + resources := buildResourcesBlock(res.Owns) + watchBlock := buildWatchBlock(res.Watches) + p := res.Primary + + kind := todoField(p.Kind, "set your CRD kind (e.g. WebApp)") + version := p.Version + if version == "" { + version = "v1alpha1" + } + object := todoField(p.Object, "set the Go type name (e.g. WebApp)") + objectList := p.ObjectList + if objectList == "" { + objectList = "TODO # TODO(ork migrate): set the Go list type (e.g. WebAppList)" + } + location := p.Location + if location == "" { + location = opts.ModulePath + "/api/" + version + " # TODO(ork migrate): adjust to your API types package" + } + alias := p.Alias + if alias == "" { + alias = "apiv1alpha1" + } + return fmt.Sprintf(`# Schema reference: https://orkestra.sh/docs/reference/schema/katalog/ apiVersion: orkestra.orkspace.io/v1 kind: Katalog @@ -84,19 +107,19 @@ spec: %s: apiTypes: group: TODO # TODO(ork migrate): set your CRD group (e.g. apps.myorg.io) - version: v1alpha1 - kind: TODO # TODO(ork migrate): set your CRD kind (e.g. WebApp) + version: %s + kind: %s plural: TODO # TODO(ork migrate): set the plural (e.g. webapps) - object: TODO - objectList: TODOList - location: %s/api/v1alpha1 # TODO(ork migrate): adjust to your API types package - alias: apiv1alpha1 + object: %s + objectList: %s + location: %s + alias: %s allowedNamespaces: - default operatorBox: - reconciler: +%s reconciler: # default: false — the GenericReconciler is not used. # Your constructor owns the full reconcile loop. default: false @@ -104,9 +127,59 @@ spec: constructor: location: %s/%s # TODO(ork migrate): adjust to your reconciler package function: %s - resources: - - kind: TODO # TODO(ork migrate): list every resource kind your operator manages -`, opts.OperatorName, res.PkgName, crdName, opts.ModulePath, opts.ModulePath, res.PkgName, constructorFn) + managedResources: +%s`, opts.OperatorName, res.PkgName, crdName, + version, kind, object, objectList, location, alias, + watchBlock, opts.ModulePath, res.PkgName, constructorFn, resources) +} + +// todoField returns the value if non-empty, or a TODO placeholder with the given hint. +func todoField(value, hint string) string { + if value != "" { + return value + } + return "TODO # TODO(ork migrate): " + hint +} + +// buildResourcesBlock renders the managedResources: list under constructor: from Owns() detections. +func buildResourcesBlock(owns []DetectedType) string { + if len(owns) == 0 { + return " - kind: TODO # TODO(ork migrate): list every resource kind your operator manages\n" + } + var b strings.Builder + for _, o := range owns { + fmt.Fprintf(&b, " - kind: %s\n", o.Kind) + if o.APIVersion != "" && !strings.HasPrefix(o.APIVersion, "TODO") { + fmt.Fprintf(&b, " apiVersion: %s\n", o.APIVersion) + } else if strings.HasPrefix(o.APIVersion, "TODO:") { + fmt.Fprintf(&b, " # TODO(ork migrate): apiVersion for %s (%s)\n", + o.Kind, strings.TrimPrefix(o.APIVersion, "TODO: ")) + } + } + return b.String() +} + +// buildWatchBlock renders the watch: block under operatorBox: from Watches() detections. +// Returns an empty string when no watch entries were detected. +func buildWatchBlock(watches []DetectedType) string { + if len(watches) == 0 { + return "" + } + var b strings.Builder + b.WriteString(" watch:\n") + for _, w := range watches { + apiVer := w.APIVersion + suffix := "" + if strings.HasPrefix(apiVer, "TODO:") { + // Emit the raw import path as a comment so the user knows where to look. + suffix = " # TODO(ork migrate): verify apiVersion (" + strings.TrimPrefix(apiVer, "TODO: ") + ")" + apiVer = "TODO" + } + fmt.Fprintf(&b, " - apiVersion: %s%s\n", apiVer, suffix) + fmt.Fprintf(&b, " kind: %s\n", w.Kind) + fmt.Fprintf(&b, " on: [create, update, delete]\n") + } + return b.String() } func generateSimulate(opts Options, crdName string) string { @@ -340,12 +413,8 @@ grep -rn "TODO(ork migrate)" . Work through each marker in order: -- [ ] Set ` + "`" + `group` + "`" + `, ` + "`" + `kind` + "`" + `, ` + "`" + `plural` + "`" + `, ` + "`" + `location` + "`" + ` in ` + "`" + `katalog.yaml` + "`" + ` -- [ ] Replace the embedded ` + "`" + `client.Client` + "`" + ` struct field with ` + "`" + `kube kubeclient.Interface` + "`" + ` -- [ ] Update your constructor to accept ` + "`" + `(kube kubeclient.Interface, 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, map[string]interface{}{...})` + "`" + ` -- [ ] Add ` + "`" + `github.com/orkspace/orkestra/domain` + "`" + ` and ` + "`" + `pkg/kubeclient` + "`" + ` imports +- [ ] Update ` + "`" + `group` + "`" + `, ` + "`" + `kind` + "`" + `, ` + "`" + `plural` + "`" + `, ` + "`" + `location` + "`" + ` in ` + "`" + `katalog.yaml` + "`" + ` +- [ ] Review ` + "`" + `managedResources:` + "`" + ` in ` + "`" + `katalog.yaml` + "`" + ` — add or correct the resource kinds your operator manages - [ ] Fill in resource assertions in ` + "`" + `simulate.yaml` + "`" + ` and ` + "`" + `e2e.yaml` + "`" + ` - [ ] Delete ` + "`" + `main.go` + "`" + `, scheme registration, and manager setup @@ -441,19 +510,12 @@ kubectl apply -f bundle.yaml ## What to know -**` + "`" + `r.client.Patch` + "`" + ` lines compile unchanged.** -` + "`" + `kubeclient.Patch` + "`" + ` is a type alias for ` + "`" + `sigs.k8s.io/controller-runtime/pkg/client.Patch` + "`" + `, so -` + "`" + `MergeFrom` + "`" + `, ` + "`" + `StrategicMergeFrom` + "`" + `, and ` + "`" + `Apply` + "`" + ` from controller-runtime satisfy it directly. -Only the receiver changes: ` + "`" + `r.client` + "`" + ` → ` + "`" + `r.kube` + "`" + `. - -**` + "`" + `r.Status().Update()` + "`" + ` must be replaced manually.** -The tool flags it but cannot rewrite it — the status fields and GVR are -specific to your CRD. Replace with ` + "`" + `r.kube.PatchStatus(ctx, obj, fields)` + "`" + `. +**No changes to ` + "`" + `Reconcile` + "`" + `, struct fields, or call sites.** +The injected constructor calls ` + "`" + `kubeclient.ToClient(kube)` + "`" + ` to wrap the interface as +` + "`" + `client.Client` + "`" + ` — your existing field and all ` + "`" + `r.client.*` + "`" + ` calls compile unchanged. -**` + "`" + `ctrl.Result{RequeueAfter: X}` + "`" + ` has no direct equivalent.** -A non-nil error requeues with exponential backoff. To requeue without -signalling failure, return a sentinel error or use a named error type. -The TODO comment in the output explains this. +**` + "`" + `ctrl.Result{RequeueAfter: X}` + "`" + ` is preserved.** +The bridge propagates ` + "`" + `RequeueAfter` + "`" + ` to Orkestra's work queue — no changes needed. **` + "`" + `SetupWithManager` + "`" + `, ` + "`" + `main.go` + "`" + `, and scheme registration are gone.** Orkestra owns the informer, workqueue, and manager. Delete them — do not diff --git a/pkg/tools/migrate/migrate.go b/pkg/tools/migrate/migrate.go index be96b201f..313e0fcc5 100644 --- a/pkg/tools/migrate/migrate.go +++ b/pkg/tools/migrate/migrate.go @@ -21,8 +21,8 @@ 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. + // native style: Reconcile(ctx context.Context, req domain.Request) (domain.Result, 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, @@ -45,6 +45,31 @@ type Result struct { Warnings []string // Mode is the migration mode used to produce this result. Mode Mode + // Owns lists types detected in Owns() calls inside SetupWithManager. + // Each entry is a resource the operator owns and should appear in constructor.managedResources:. + Owns []DetectedType + // Watches lists types detected in Watches() calls inside SetupWithManager. + // Each entry should appear in operatorBox.watch:. + Watches []DetectedType + // Primary holds the type information extracted from the For() call in SetupWithManager. + Primary PrimaryType +} + +// DetectedType is a resource type extracted from an Owns() or Watches() call. +type DetectedType struct { + Kind string + APIVersion string // best-effort from import path; may be TODO if not resolvable +} + +// PrimaryType holds the type information extracted from the For() call in SetupWithManager. +// All fields are best-effort; unresolvable fields are left empty. +type PrimaryType struct { + Kind string // struct name from For(&pkg.Kind{}) + Object string // same as Kind + ObjectList string // Kind + "List" by convention + Version string // last path segment of import when it looks like a version (e.g. v1alpha1) + Location string // full import path of the package (e.g. github.com/org/project/api/v1alpha1) + Alias string // import alias used in source (e.g. demov1alpha1) } // replacement is a byte-range substitution to apply to source text. @@ -89,35 +114,32 @@ func rewriteNative(src []byte) (*Result, error) { } } - ctxParam, reqParam := "ctx", "req" + res.Primary, res.Owns, res.Watches = extractOwnsWatches(f) + + ctxParam := "ctx" params := fn.Type.Params.List if len(params) > 0 && len(params[0].Names) > 0 { ctxParam = params[0].Names[0].Name } - if len(params) > 1 && len(params[1].Names) > 0 { - reqParam = params[1].Names[0].Name - } var reps []replacement - // Change params to (ctx context.Context, key string) + // Change params to (ctx context.Context, req domain.Request) reps = append(reps, replacement{ start: off(fset, fn.Type.Params.Opening), end: off(fset, fn.Type.Params.Closing) + 1, - text: fmt.Sprintf("(%s context.Context, key string)", ctxParam), + text: fmt.Sprintf("(%s context.Context, req domain.Request)", ctxParam), }) - // Change return type to error + // Change return type to (domain.Result, error) if fn.Type.Results != nil { reps = append(reps, replacement{ start: off(fset, fn.Type.Results.Opening), end: off(fset, fn.Type.Results.Closing) + 1, - text: "error", + text: "(domain.Result, error)", }) } - usesNamespacedName := false - ast.Inspect(fn.Body, func(n ast.Node) bool { switch x := n.(type) { case *ast.ReturnStmt: @@ -127,10 +149,11 @@ func rewriteNative(src []byte) (*Result, error) { secondText := sliceSrc(src, fset, x.Results[1]) var text string if hasRequeueAfter(x.Results[0]) { - text = "// TODO(ork migrate): RequeueAfter removed — equivalent is `return err`.\n\t\t// A non-nil error requeues with exponential backoff. To requeue without\n\t\t// signalling failure, return a sentinel error or use a named error type.\n\t\treturn " + secondText - res.Warnings = append(res.Warnings, "ctrl.Result{RequeueAfter:} found — equivalent is `return err`; non-nil error requeues with backoff") + // Preserve RequeueAfter through domain.Result + afterExpr := requeueAfterExpr(x.Results[0]) + text = "return domain.Result{RequeueAfter: " + afterExpr + "}, " + secondText } else { - text = "return " + secondText + text = "return domain.Result{}, " + secondText } reps = append(reps, replacement{ start: off(fset, x.Pos()), @@ -138,34 +161,6 @@ func rewriteNative(src []byte) (*Result, error) { text: text, }) - case *ast.SelectorExpr: - ident, ok := x.X.(*ast.Ident) - if !ok || ident.Name != reqParam { - return true - } - if x.Sel.Name == "NamespacedName" { - usesNamespacedName = true - reps = append(reps, replacement{ - start: off(fset, x.Pos()), - end: off(fset, x.End()), - text: "client.ObjectKey{Namespace: namespace, Name: name}", - }) - } - - case *ast.CallExpr: - // req.String() → key - sel, ok := x.Fun.(*ast.SelectorExpr) - if !ok { - return true - } - ident, ok := sel.X.(*ast.Ident) - if ok && ident.Name == reqParam && sel.Sel.Name == "String" && len(x.Args) == 0 { - reps = append(reps, replacement{ - start: off(fset, x.Pos()), - end: off(fset, x.End()), - text: "key", - }) - } } return true }) @@ -211,16 +206,6 @@ func rewriteNative(src []byte) (*Result, error) { res.Warnings = append(res.Warnings, "SetupWithManager removed — delete main.go and scheme registration") } - // Inject key split at top of Reconcile body when req.NamespacedName was used - if usesNamespacedName { - bodyOpen := off(fset, fn.Body.Lbrace) + 1 - reps = append(reps, replacement{ - start: bodyOpen, - end: bodyOpen, - text: "\n\tparts := strings.SplitN(key, \"/\", 2)\n\tnamespace, name := parts[0], parts[1]\n", - }) - } - result := applyReplacements(src, reps) // Rewrite r.Get/Create/Patch → r.kube.* with kubeclient signatures. @@ -232,7 +217,7 @@ func rewriteNative(src []byte) (*Result, error) { res.Warnings = append(res.Warnings, "reconciler struct not found — update struct fields and add constructor manually") } - result = rewriteImports(result, usesNamespacedName) + result = rewriteImports(result, false) formatted, fmtErr := format.Source(result) if fmtErr != nil { @@ -266,6 +251,8 @@ func rewriteToClient(src []byte) (*Result, error) { res.ReceiverType = typeName(fn.Recv.List[0].Type) } + res.Primary, res.Owns, res.Watches = extractOwnsWatches(f) + var reps []replacement // Remove SetupWithManager. @@ -326,8 +313,9 @@ func %s(kube kubeclient.Interface) domain.Reconciler { return res, nil } -// rewriteImportsToClient removes the ctrl import (no longer needed for -// SetupWithManager) and injects ToClient/ReconcilerFrom import hints. +// rewriteImportsToClient injects the domain and kubeclient imports needed by +// the generated constructor. The ctrl import is kept — toclient mode leaves +// the Reconcile signature and body unchanged, so ctrl.Request/ctrl.Result stay. func rewriteImportsToClient(src []byte) []byte { fset := token.NewFileSet() f, err := parser.ParseFile(fset, "", src, parser.ParseComments) @@ -335,24 +323,24 @@ func rewriteImportsToClient(src []byte) []byte { return src } - var reps []replacement + hasDomain, hasKubeclient := false, false 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: ""}) + switch path { + case "github.com/orkspace/orkestra/domain": + hasDomain = true + case "github.com/orkspace/orkestra/pkg/kubeclient": + hasKubeclient = true } } - 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\"") + result := src + if !hasDomain { + result = injectImport(result, `"github.com/orkspace/orkestra/domain"`) + } + if !hasKubeclient { + result = injectImport(result, `"github.com/orkspace/orkestra/pkg/kubeclient"`) + } return result } @@ -526,9 +514,7 @@ func rewriteStruct(src []byte, receiverType string) ([]byte, bool) { reps = append(reps, replacement{ start: off(fset, st.Fields.Opening), end: off(fset, st.Fields.Closing) + 1, - text: "{\n\tinformer cache.SharedIndexInformer\n" + - "\tkube kubeclient.Interface\n" + - "\tev event.Recorder\n}", + text: "{\n\tkube kubeclient.Interface\n}", }) } case *ast.FuncDecl: @@ -547,16 +533,8 @@ func rewriteStruct(src []byte, receiverType string) ([]byte, bool) { if !hasConstructor { constructor := fmt.Sprintf(` // %s is the constructor function registered in the Katalog. -func %s( - kube kubeclient.Interface, - informer cache.SharedIndexInformer, - ev event.Recorder, -) domain.Reconciler { - return &%s{ - kube: kube, - informer: informer, - ev: ev, - } +func %s(kube kubeclient.Interface) domain.Reconciler { + return &%s{kube: kube} } `, constructorName, constructorName, receiverType) result = append(result, []byte(constructor)...) @@ -613,6 +591,28 @@ func hasRequeueAfter(expr ast.Expr) bool { return false } +// requeueAfterExpr extracts the RequeueAfter value expression text from a ctrl.Result literal. +// Returns "0" if not found. +func requeueAfterExpr(expr ast.Expr) string { + lit, ok := expr.(*ast.CompositeLit) + if !ok { + return "0" + } + fset := token.NewFileSet() + for _, elt := range lit.Elts { + kv, ok := elt.(*ast.KeyValueExpr) + if !ok { + continue + } + if ident, ok := kv.Key.(*ast.Ident); ok && ident.Name == "RequeueAfter" { + var buf strings.Builder + _ = format.Node(&buf, fset, kv.Value) + return buf.String() + } + } + return "0" +} + // typeName extracts the base type name from a receiver type expression. // Handles *T and T. func typeName(expr ast.Expr) string { @@ -688,13 +688,167 @@ func rewriteImports(src []byte, addStrings bool) []byte { result = injectImport(result, "// TODO(ork migrate): add these imports:\n"+ "// \"github.com/orkspace/orkestra/domain\"\n"+ - "// \"github.com/orkspace/orkestra/pkg/event\"\n"+ - "// \"github.com/orkspace/orkestra/pkg/kubeclient\"\n"+ - "// \"k8s.io/client-go/tools/cache\"") + "// \"github.com/orkspace/orkestra/pkg/kubeclient\"") return result } +// extractOwnsWatches scans SetupWithManager for For(), Owns(), and Watches() call +// chains. Import aliases are resolved to apiVersion strings and import paths using +// the file's import declarations (best-effort; emits TODO when unresolvable). +func extractOwnsWatches(f *ast.File) (primary PrimaryType, owns, watches []DetectedType) { + imports := buildImportMaps(f) + + for _, decl := range f.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || fn.Name.Name != "SetupWithManager" || fn.Recv == nil { + continue + } + ast.Inspect(fn.Body, func(n ast.Node) bool { + call, ok := n.(*ast.CallExpr) + if !ok { + return true + } + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok { + return true + } + method := sel.Sel.Name + if len(call.Args) == 0 { + return true + } + switch method { + case "For": + kind, pkgAlias := detectKindAndAlias(call.Args[0]) + if kind == "" { + return true + } + info := imports[pkgAlias] + primary = PrimaryType{ + Kind: kind, + Object: kind, + ObjectList: kind + "List", + Version: importPathVersion(info.Path), + Location: info.Path, + Alias: pkgAlias, + } + case "Owns": + dt, ok := detectTypeArg(call.Args[0], imports) + if !ok { + return true + } + owns = append(owns, dt) + case "Watches": + dt, ok := detectTypeArg(call.Args[0], imports) + if !ok { + return true + } + watches = append(watches, dt) + } + return true + }) + } + return +} + +// detectPkgAlias returns the package alias used in a &pkg.Kind{} expression. +// importPathVersion extracts the version segment from an import path when the +// last segment looks like a Go module version tag (e.g. v1, v1alpha1, v2beta2). +func importPathVersion(path string) string { + parts := strings.Split(path, "/") + if len(parts) == 0 { + return "" + } + last := parts[len(parts)-1] + if len(last) > 1 && last[0] == 'v' && last[1] >= '0' && last[1] <= '9' { + return last + } + return "" +} + +// detectKindAndAlias extracts the struct name and package alias from a +// &pkg.Kind{} or &Kind{} expression. alias is empty when there is no qualifier. +func detectKindAndAlias(arg ast.Expr) (kind, alias string) { + if unary, ok := arg.(*ast.UnaryExpr); ok { + arg = unary.X + } + lit, ok := arg.(*ast.CompositeLit) + if !ok { + return "", "" + } + switch t := lit.Type.(type) { + case *ast.SelectorExpr: + if ident, ok := t.X.(*ast.Ident); ok { + return t.Sel.Name, ident.Name + } + case *ast.Ident: + return t.Name, "" + } + return "", "" +} + +// detectTypeArg extracts Kind and APIVersion from a &pkg.Kind{} argument. +func detectTypeArg(arg ast.Expr, imports map[string]importInfo) (DetectedType, bool) { + kind, alias := detectKindAndAlias(arg) + if kind == "" { + return DetectedType{}, false + } + apiVersion := "TODO" + if alias != "" { + apiVersion = imports[alias].APIVersion + } + return DetectedType{Kind: kind, APIVersion: apiVersion}, true +} + +// importInfo holds the resolved values for one import declaration. +type importInfo struct { + Path string // full import path + APIVersion string // best-effort Kubernetes apiVersion +} + +// buildImportMaps returns alias → importInfo for all imports in one pass, +// replacing the two separate buildImportPathMap / buildImportAliasMap functions. +func buildImportMaps(f *ast.File) map[string]importInfo { + m := make(map[string]importInfo) + for _, imp := range f.Imports { + path := strings.Trim(imp.Path.Value, `"`) + var alias string + if imp.Name != nil && imp.Name.Name != "_" && imp.Name.Name != "." { + alias = imp.Name.Name + } else { + parts := strings.Split(path, "/") + alias = parts[len(parts)-1] + } + m[alias] = importInfo{Path: path, APIVersion: importPathToAPIVersion(path)} + } + return m +} + +// importPathToAPIVersion converts a Go import path to a Kubernetes apiVersion. +// Examples: +// +// k8s.io/api/apps/v1 → apps/v1 +// k8s.io/api/core/v1 → v1 +// k8s.io/api/networking/v1 → networking/v1 +// github.com/org/project/api/v1alpha1 → TODO: github.com/org/project/api/v1alpha1 +func importPathToAPIVersion(path string) string { + parts := strings.Split(path, "/") + if len(parts) < 2 { + return "TODO" + } + // k8s.io/api// + if strings.HasPrefix(path, "k8s.io/api/") { + group := parts[len(parts)-2] + version := parts[len(parts)-1] + if group == "core" { + return version + } + return group + "/" + version + } + // For custom API packages, emit a TODO with the path so the user can fill it in. + return "TODO: " + path +} + // injectImport inserts a line into the first import block found in src. func injectImport(src []byte, line string) []byte { fset := token.NewFileSet() diff --git a/pkg/tools/migrate/migrate_test.go b/pkg/tools/migrate/migrate_test.go index cbe1e9758..e02ab8098 100644 --- a/pkg/tools/migrate/migrate_test.go +++ b/pkg/tools/migrate/migrate_test.go @@ -63,8 +63,8 @@ func TestRewrite_SignatureChange(t *testing.T) { src := string(res.Source) - if !strings.Contains(src, "Reconcile(ctx context.Context, key string) error") { - t.Error("expected Orkestra signature: Reconcile(ctx context.Context, key string) error") + if !strings.Contains(src, "Reconcile(ctx context.Context, req domain.Request) (domain.Result, error)") { + t.Error("expected Orkestra signature: Reconcile(ctx context.Context, req domain.Request) (domain.Result, error)") } if strings.Contains(src, "ctrl.Request") { t.Error("ctrl.Request should be removed") @@ -83,14 +83,14 @@ func TestRewrite_ReturnCollapse(t *testing.T) { src := string(res.Source) if strings.Contains(src, "ctrl.Result{}") { - t.Error("ctrl.Result{} should be collapsed") + t.Error("ctrl.Result{} should be replaced with domain.Result{}") } - // Error returns become `return err`, nil returns become `return nil`. - if !strings.Contains(src, "return err") { - t.Error("expected collapsed return err") + // Error returns become `return domain.Result{}, err`, nil returns become `return domain.Result{}, nil`. + if !strings.Contains(src, "return domain.Result{}, err") { + t.Error("expected return domain.Result{}, err") } - if !strings.Contains(src, "return nil") { - t.Error("expected collapsed return nil") + if !strings.Contains(src, "return domain.Result{}, nil") { + t.Error("expected return domain.Result{}, nil") } } @@ -102,15 +102,14 @@ func TestRewrite_ReqNamespacedName(t *testing.T) { src := string(res.Source) - // key split injected - if !strings.Contains(src, `strings.SplitN(key, "/", 2)`) { - t.Error("expected key split injection") + // No key-split injection — domain.Request carries NamespacedName directly. + if strings.Contains(src, `strings.SplitN`) { + t.Error("key-split injection should not be emitted") } - if strings.Contains(src, "req.NamespacedName") { - t.Error("req.NamespacedName should be replaced") - } - if !strings.Contains(src, "r.kube.Get(ctx, namespace, name,") { - t.Error("expected r.kube.Get with extracted namespace and name args") + // rewriteKubeCalls rewrites the call site with a TODO comment since + // req.NamespacedName is not a composite literal it can decompose automatically. + if !strings.Contains(src, "r.kube.Get") { + t.Error("expected r.kube.Get rewrite") } } @@ -122,8 +121,9 @@ func TestRewrite_ReqString(t *testing.T) { src := string(res.Source) - if strings.Contains(src, "req.String()") { - t.Error("req.String() should be replaced with key") + // req.String() is preserved — domain.Request implements Stringer + if !strings.Contains(src, "req.String()") { + t.Error("req.String() should be preserved — domain.Request has String()") } } @@ -155,14 +155,14 @@ func TestRewrite_StructRewritten(t *testing.T) { if strings.Contains(src, "client.Client") { t.Error("embedded client.Client should be removed from struct") } - if !strings.Contains(src, "kube kubeclient.Interface") { + if !strings.Contains(src, "kube kubeclient.Interface") { t.Error("expected kube kubeclient.Interface field in struct") } - if !strings.Contains(src, "informer cache.SharedIndexInformer") { - t.Error("expected informer cache.SharedIndexInformer field in struct") + if strings.Contains(src, "informer cache.SharedIndexInformer") { + t.Error("informer field should not be in struct — use kube.GetInformer()") } - if !strings.Contains(src, "ev event.Recorder") { - t.Error("expected ev event.Recorder field in struct") + if strings.Contains(src, "ev event.Recorder") { + t.Error("ev field should not be in struct — use kube.GetEventRecorder()") } } @@ -251,17 +251,12 @@ func (r *MyReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Re } out := string(res.Source) - if !strings.Contains(out, "TODO(ork migrate): RequeueAfter removed") { - t.Error("expected RequeueAfter TODO comment") + // RequeueAfter is now preserved: ctrl.Result{RequeueAfter: X} → domain.Result{RequeueAfter: X} + if !strings.Contains(out, "domain.Result{RequeueAfter:") { + t.Error("expected domain.Result{RequeueAfter: ...} in output") } - hasWarning := false - for _, w := range res.Warnings { - if strings.Contains(w, "RequeueAfter") { - hasWarning = true - } - } - if !hasWarning { - t.Error("expected RequeueAfter warning") + if strings.Contains(out, "ctrl.Result") { + t.Error("ctrl.Result should be replaced") } } @@ -316,6 +311,19 @@ func (r *WebAppReconciler) SetupWithManager(mgr ctrl.Manager) error { t.Error("expected SetupWithManager to be removed or replaced with comment") } + // ctrl import must be kept — signature and body are unchanged. + if !strings.Contains(out, `"sigs.k8s.io/controller-runtime"`) { + t.Error("expected ctrl import to be kept in toclient mode") + } + + // Orkestra imports must be injected (not just TODO comments). + if !strings.Contains(out, `"github.com/orkspace/orkestra/domain"`) { + t.Error("expected domain import to be injected") + } + if !strings.Contains(out, `"github.com/orkspace/orkestra/pkg/kubeclient"`) { + t.Error("expected kubeclient import to be injected") + } + // Mode recorded. if res.Mode != ModeToClient { t.Errorf("expected Mode ModeToClient, got %q", res.Mode) @@ -357,6 +365,164 @@ func TestGenerate_KatalogContainsConstructor(t *testing.T) { } } +func TestExtractPrimaryType(t *testing.T) { + const src = `package controller + +import ( + "context" + ctrl "sigs.k8s.io/controller-runtime" + demov1alpha1 "github.com/example/operator/api/v1alpha1" +) + +type WebAppReconciler struct{} + +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(&demov1alpha1.WebApp{}). + Complete(r) +} +` + res, err := Rewrite([]byte(src), ModeToClient) + if err != nil { + t.Fatalf("Rewrite: %v", err) + } + + p := res.Primary + if p.Kind != "WebApp" { + t.Errorf("Primary.Kind = %q, want WebApp", p.Kind) + } + if p.Object != "WebApp" { + t.Errorf("Primary.Object = %q, want WebApp", p.Object) + } + if p.ObjectList != "WebAppList" { + t.Errorf("Primary.ObjectList = %q, want WebAppList", p.ObjectList) + } + if p.Version != "v1alpha1" { + t.Errorf("Primary.Version = %q, want v1alpha1", p.Version) + } + if p.Location != "github.com/example/operator/api/v1alpha1" { + t.Errorf("Primary.Location = %q, want github.com/example/operator/api/v1alpha1", p.Location) + } + if p.Alias != "demov1alpha1" { + t.Errorf("Primary.Alias = %q, want demov1alpha1", p.Alias) + } + + // katalog.yaml should use the detected values + files := Generate(res, Options{ + ModulePath: "github.com/example/webapp-operator", + OperatorName: "webapp-operator", + }) + if !strings.Contains(files.Katalog, "kind: WebApp") { + t.Error("katalog should contain kind: WebApp from For()") + } + if !strings.Contains(files.Katalog, "object: WebApp") { + t.Error("katalog should contain object: WebApp") + } + if !strings.Contains(files.Katalog, "objectList: WebAppList") { + t.Error("katalog should contain objectList: WebAppList") + } + if !strings.Contains(files.Katalog, "version: v1alpha1") { + t.Error("katalog should contain version: v1alpha1") + } + if !strings.Contains(files.Katalog, "location: github.com/example/operator/api/v1alpha1") { + t.Error("katalog should contain the detected location") + } + if !strings.Contains(files.Katalog, "alias: demov1alpha1") { + t.Error("katalog should contain alias: demov1alpha1") + } +} + +func TestExtractOwnsWatches(t *testing.T) { + const src = `package controller + +import ( + "context" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + ctrl "sigs.k8s.io/controller-runtime" + demov1alpha1 "github.com/example/operator/api/v1alpha1" +) + +type WebAppReconciler struct{} + +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(&demov1alpha1.WebApp{}). + Owns(&appsv1.Deployment{}). + Owns(&corev1.Service{}). + Watches(&demov1alpha1.Config{}, nil). + Complete(r) +} +` + res, err := Rewrite([]byte(src), ModeToClient) + if err != nil { + t.Fatalf("Rewrite: %v", err) + } + + if len(res.Owns) != 2 { + t.Fatalf("expected 2 Owns entries, got %d: %+v", len(res.Owns), res.Owns) + } + ownsKinds := map[string]string{} + for _, o := range res.Owns { + ownsKinds[o.Kind] = o.APIVersion + } + if ownsKinds["Deployment"] != "apps/v1" { + t.Errorf("Owns: Deployment APIVersion = %q, want apps/v1", ownsKinds["Deployment"]) + } + if ownsKinds["Service"] != "v1" { + t.Errorf("Owns: Service APIVersion = %q, want v1", ownsKinds["Service"]) + } + + if len(res.Watches) != 1 { + t.Fatalf("expected 1 Watches entry, got %d: %+v", len(res.Watches), res.Watches) + } + if res.Watches[0].Kind != "Config" { + t.Errorf("Watches[0].Kind = %q, want Config", res.Watches[0].Kind) + } + if !strings.Contains(res.Watches[0].APIVersion, "TODO") { + t.Errorf("Watches[0].APIVersion = %q, want TODO (custom package)", res.Watches[0].APIVersion) + } +} + +func TestGenerate_KatalogWithOwnsWatches(t *testing.T) { + res := &Result{ + ReceiverType: "WebAppReconciler", + PkgName: "controller", + Owns: []DetectedType{ + {Kind: "Deployment", APIVersion: "apps/v1"}, + {Kind: "Service", APIVersion: "v1"}, + }, + Watches: []DetectedType{ + {Kind: "Config", APIVersion: "TODO: github.com/example/operator/api/v1alpha1"}, + }, + } + files := Generate(res, Options{ + ModulePath: "github.com/example/webapp-operator", + OperatorName: "webapp-operator", + }) + + if !strings.Contains(files.Katalog, "kind: Deployment") { + t.Error("katalog should contain Deployment from Owns()") + } + if !strings.Contains(files.Katalog, "kind: Service") { + t.Error("katalog should contain Service from Owns()") + } + if !strings.Contains(files.Katalog, "watch:") { + t.Error("katalog should contain watch: block from Watches()") + } + if !strings.Contains(files.Katalog, "kind: Config") { + t.Error("katalog should contain Config in watch: block") + } +} + func TestToKebab(t *testing.T) { cases := []struct { in string diff --git a/pkg/types/admission.go b/pkg/types/admission.go index be0a5348a..4789fed74 100644 --- a/pkg/types/admission.go +++ b/pkg/types/admission.go @@ -185,9 +185,9 @@ type ValidationRule struct { // Empty means unconditional. When []Condition `yaml:"when,omitempty" json:"when,omitempty"` - // AnyOf — at least one condition must pass for this rule to be evaluated (OR). - // When both When and AnyOf are declared, both blocks must pass. - AnyOf []Condition `yaml:"anyOf,omitempty" json:"anyOf,omitempty"` + // Or — at least one condition must pass for this rule to be evaluated (OR). + // When both When and Or are declared, both blocks must pass. + Or []Condition `yaml:"or,omitempty" json:"or,omitempty"` // Fires controls at which lifecycle points this rule is evaluated. // Absent: fires at both admission and reconcile time. @@ -348,8 +348,8 @@ type MutationRule struct { // When — all conditions must pass for this rule to be applied (AND). When []Condition `yaml:"when,omitempty" json:"when,omitempty"` - // AnyOf — at least one condition must pass for this rule to be applied (OR). - AnyOf []Condition `yaml:"anyOf,omitempty" json:"anyOf,omitempty"` + // Or — at least one condition must pass for this rule to be applied (OR). + Or []Condition `yaml:"or,omitempty" json:"or,omitempty"` // Fires controls at which lifecycle points this rule is applied. // Absent: fires at both admission and reconcile time. diff --git a/pkg/types/applyapi_test.go b/pkg/types/applyapi_test.go index 766bd44b2..7b9dc378b 100644 --- a/pkg/types/applyapi_test.go +++ b/pkg/types/applyapi_test.go @@ -203,7 +203,7 @@ serve: } } -func TestServeFieldAnyOfAndDisabled(t *testing.T) { +func TestServeFieldOrAndDisabled(t *testing.T) { input := ` serve: enabled: true @@ -213,7 +213,7 @@ serve: order: 1 prodDeploy: label: "Production Deploy" - anyOf: + or: - time: after: "08:00" before: "18:00" @@ -228,14 +228,14 @@ serve: t.Fatalf("unmarshal: %v", err) } pd := entry.Serve.Fields["prodDeploy"] - if len(pd.AnyOf) != 2 { - t.Fatalf("prodDeploy.AnyOf len = %d, want 2", len(pd.AnyOf)) + if len(pd.Or) != 2 { + t.Fatalf("prodDeploy.Or len = %d, want 2", len(pd.Or)) } - if pd.AnyOf[0].Time == nil || pd.AnyOf[0].Time.After != "08:00" { - t.Errorf("prodDeploy.AnyOf[0].Time = %+v", pd.AnyOf[0].Time) + if pd.Or[0].Time == nil || pd.Or[0].Time.After != "08:00" { + t.Errorf("prodDeploy.Or[0].Time = %+v", pd.Or[0].Time) } - if pd.AnyOf[1].DayOfWeek == nil || pd.AnyOf[1].DayOfWeek.Weekday == nil { - t.Errorf("prodDeploy.AnyOf[1].DayOfWeek = %+v", pd.AnyOf[1].DayOfWeek) + if pd.Or[1].DayOfWeek == nil || pd.Or[1].DayOfWeek.Weekday == nil { + t.Errorf("prodDeploy.Or[1].DayOfWeek = %+v", pd.Or[1].DayOfWeek) } lf := entry.Serve.Fields["legacyFeature"] diff --git a/pkg/types/autoscale.go b/pkg/types/autoscale.go index 9b19314a9..fc8c8ff71 100644 --- a/pkg/types/autoscale.go +++ b/pkg/types/autoscale.go @@ -14,7 +14,7 @@ // interval: 15s // cooldown: 2m // conditions: -// anyOf: +// or: // - time: // after: "08:00" // before: "17:00" @@ -51,7 +51,7 @@ type AutoscaleSpec struct { Cooldown Duration `yaml:"cooldown,omitempty" json:"cooldown,omitempty"` // Conditions declares the trigger conditions. Both blocks must pass - // when both are declared (AND between blocks, OR within anyOf). + // when both are declared (AND between blocks, OR within or). Conditions AutoscaleConditions `yaml:"conditions" json:"conditions"` // Do declares the override values applied when conditions are met. @@ -66,9 +66,9 @@ type AutoscaleSpec struct { // AutoscaleConditions holds the condition blocks for autoscale evaluation. type AutoscaleConditions struct { - // AnyOf — OR semantics. At least one condition in this list must be true. + // Or — OR semantics. At least one condition in this list must be true. // Supports all Condition kinds: time, dayOfWeek, cron, and metric fields. - AnyOf []Condition `yaml:"anyOf,omitempty" json:"anyOf,omitempty"` + Or []Condition `yaml:"or,omitempty" json:"or,omitempty"` // When — AND semantics. All conditions in this list must be true. // Supports: metric conditions (metrics.*, cross..metrics.*). @@ -181,9 +181,9 @@ func (a *AutoscaleSpec) HasWhenConditions() bool { return len(a.Conditions.When) > 0 } -// HasAnyOfConditions returns whether anyOf conditions are declared. -func (a *AutoscaleSpec) HasAnyOfConditions() bool { - return len(a.Conditions.AnyOf) > 0 +// HasOrConditions returns whether or: conditions are declared. +func (a *AutoscaleSpec) HasOrConditions() bool { + return len(a.Conditions.Or) > 0 } // HasDoWorkers returns whether do.workers is set. diff --git a/pkg/types/conditions.go b/pkg/types/conditions.go index 208bb7a0d..7560ed4c0 100644 --- a/pkg/types/conditions.go +++ b/pkg/types/conditions.go @@ -21,8 +21,8 @@ package types // Fields reference CR paths using dot notation: spec.environment, metadata.name. // // The same type is used in: -// - when: / anyOf: on template sources (resource conditions) -// - operatorBox.autoscale.conditions.anyOf and when: (autoscale conditions) +// - when: / or: on template sources (resource conditions) +// - operatorBox.autoscale.conditions.or and when: (autoscale conditions) // - operatorBox.rollback.trigger (rollback conditions) // - notification condition blocks type Condition struct { @@ -105,18 +105,18 @@ type Condition struct { // NotIn is a shorthand for operator: notIn. Comma-separated list. NotIn string `yaml:"notIn,omitempty" json:"notIn,omitempty"` - // ── Time-based fields (anyOf in autoscale conditions) ──────────────────── + // ── Time-based fields (or in autoscale conditions) ──────────────────── // Time — active when the current time is within the declared window. // After and Before are both optional; omit one for a half-open range. - // anyOf: + // or: // - time: // after: "08:00" // before: "20:00" Time *TimeWindow `yaml:"time,omitempty" json:"time,omitempty"` // DayOfWeek — active on the specified days of the week. - // anyOf: + // or: // - dayOfWeek: // in: [Monday, Tuesday, Wednesday, Thursday, Friday] DayOfWeek *DayOfWeekCondition `yaml:"dayOfWeek,omitempty" json:"dayOfWeek,omitempty"` @@ -257,7 +257,7 @@ const ( // ConditionUnique — field value must not match any other existing // instance of this CRD (the CR being evaluated is excluded from its own - // check). Works the same way in validation.rules and in when:/anyOf: — + // check). Works the same way in validation.rules and in when:/or: — // e.g. gating a template source or mutation rule on whether a field is // still available. // diff --git a/pkg/types/custom_resource.go b/pkg/types/custom_resource.go index 0abb88b65..1c8ac9453 100644 --- a/pkg/types/custom_resource.go +++ b/pkg/types/custom_resource.go @@ -109,15 +109,15 @@ type CustomResourceTemplateSource struct { // behavior, and conditional provisioning without writing Go code. Conditions []Condition `yaml:"when,omitempty" json:"when,omitempty"` - // AnyOf holds OR conditions — at least one must pass for this resource to be created. + // Or holds OR conditions — at least one must pass for this resource to be created. // Works alongside the existing Conditions (when:) field which uses AND semantics. // - // anyOf: + // or: // - field: spec.tier // equals: pro // - field: spec.tier // equals: enterprise - AnyOf []Condition `yaml:"anyOf,omitempty" json:"anyOf,omitempty"` + Or []Condition `yaml:"or,omitempty" json:"or,omitempty"` // ForEach declares dynamic expansion over a list field. // When set, one source declaration becomes N declarations — one per list element. diff --git a/pkg/types/docker.go b/pkg/types/docker.go index 0fb01fb67..62c739af7 100644 --- a/pkg/types/docker.go +++ b/pkg/types/docker.go @@ -116,7 +116,7 @@ type DockerHookSpec struct { // .spec.image, .children.job.status.succeeded are all accessible. When []Condition `yaml:"when,omitempty"` - AnyOf []Condition `yaml:"anyOf,omitempty"` + Or []Condition `yaml:"or,omitempty"` // Sleep injects an artificial delay into the reconcile of this resource. // Useful for autoscale testing, latency simulation, and chaos engineering. diff --git a/pkg/types/e2e.go b/pkg/types/e2e.go index 6871ab5d5..abe5dea7d 100644 --- a/pkg/types/e2e.go +++ b/pkg/types/e2e.go @@ -306,7 +306,7 @@ type E2ESpec struct { // runs automatically during ork push. ValuesFiles []string `yaml:"valuesFiles,omitempty"` - // Notes declares user-defined note functions available in when:/anyOf: expressions. + // Notes declares user-defined note functions available in when:/or: expressions. // Same syntax as a Katalog notes block — same FuncMap registration, same functions. Notes NoteRegistry `yaml:"notes,omitempty"` @@ -416,13 +416,13 @@ type E2EExpectation struct { // Use commands: for anything that doesn't fit a subcommand. Kubectl *E2EKubectl `yaml:"kubectl,omitempty"` - // When / AnyOf gate this expectation using the same []Condition type as - // katalog when:/anyOf:. An expectation whose conditions do not pass is + // When / Or gate this expectation using the same []Condition type as + // katalog when:/or:. An expectation whose conditions do not pass is // skipped — not failed. Notes declared in spec.notes are available as // template expressions: field: '{{ inBusinessHours }}'. // Empty blocks always pass. - When []Condition `yaml:"when,omitempty"` - AnyOf []Condition `yaml:"anyOf,omitempty"` + When []Condition `yaml:"when,omitempty"` + Or []Condition `yaml:"or,omitempty"` // OnFailure declares diagnostic operations to run immediately when this // specific expectation fails. Runs before moving to the next expectation. diff --git a/pkg/types/external.go b/pkg/types/external.go index 85bc311d5..d98ee411a 100644 --- a/pkg/types/external.go +++ b/pkg/types/external.go @@ -178,7 +178,7 @@ type ExternalCallSpec struct { // When conditions gate this call — skipped if conditions fail. Conditions []Condition `yaml:"when,omitempty" json:"when,omitempty"` - AnyOf []Condition `yaml:"anyOf,omitempty" json:"anyOf,omitempty"` + Or []Condition `yaml:"or,omitempty" json:"or,omitempty"` // Sleep injects an artificial delay. Useful for testing. // Accepts extended duration units (s, m, h, d, w, mo, y). diff --git a/pkg/types/foreach.go b/pkg/types/foreach.go index 9dd130fa6..cdfdb91e4 100644 --- a/pkg/types/foreach.go +++ b/pkg/types/foreach.go @@ -28,7 +28,7 @@ // my-app-eu-west-1 // my-app-ap-southeast-1 // -// Each declaration is fully independent — when:, anyOf:, labels, and all +// Each declaration is fully independent — when:, or:, labels, and all // other fields are evaluated per-item with .item in context. // // forEach works on all resource types: deployments, services, secrets, diff --git a/pkg/types/git.go b/pkg/types/git.go index b833f9b88..43305cae0 100644 --- a/pkg/types/git.go +++ b/pkg/types/git.go @@ -62,7 +62,7 @@ type GitHookSpec struct { // .spec.image, .children.job.status.succeeded are all accessible. When []Condition `yaml:"when,omitempty"` - AnyOf []Condition `yaml:"anyOf,omitempty"` + Or []Condition `yaml:"or,omitempty"` // Sleep injects an artificial delay into the reconcile of this resource. // Useful for autoscale testing, latency simulation, and chaos engineering. diff --git a/pkg/types/hooks_conditions.go b/pkg/types/hooks_conditions.go index 8f7711665..3aa21b06f 100644 --- a/pkg/types/hooks_conditions.go +++ b/pkg/types/hooks_conditions.go @@ -3,160 +3,160 @@ package types // FilterResources returns a new HookTemplates containing only the resources // that pass fn, with their conditions updated to whatever fn returns. // -// fn receives the current (conditions, anyOf) for a resource and returns: +// fn receives the current (conditions, or) for a resource and returns: // - keep: whether to include the resource in the output // - conditions: the conditions to set on the kept resource -// - anyOf: the anyOf conditions to set on the kept resource +// - or: the or conditions to set on the kept resource // // Callers use this to separate motif-time static conditions (evaluated now) // from runtime conditions (preserved on the resource for the reconciler). // // External calls are copied unchanged — their when: conditions are always // runtime conditions evaluated by the reconciler, never by the expander. -func (h HookTemplates) FilterResources(fn func(conditions, anyOf []Condition) (keep bool, newConditions, newAnyOf []Condition)) HookTemplates { +func (h HookTemplates) FilterResources(fn func(conditions, or []Condition) (keep bool, newConditions, newOr []Condition)) HookTemplates { var out HookTemplates for _, s := range h.Deployments { - if keep, c, a := fn(s.Conditions, s.AnyOf); keep { - s.Conditions, s.AnyOf = c, a + if keep, c, a := fn(s.Conditions, s.Or); keep { + s.Conditions, s.Or = c, a out.Deployments = append(out.Deployments, s) } } for _, s := range h.ReplicaSets { - if keep, c, a := fn(s.Conditions, s.AnyOf); keep { - s.Conditions, s.AnyOf = c, a + if keep, c, a := fn(s.Conditions, s.Or); keep { + s.Conditions, s.Or = c, a out.ReplicaSets = append(out.ReplicaSets, s) } } for _, s := range h.StatefulSets { - if keep, c, a := fn(s.Conditions, s.AnyOf); keep { - s.Conditions, s.AnyOf = c, a + if keep, c, a := fn(s.Conditions, s.Or); keep { + s.Conditions, s.Or = c, a out.StatefulSets = append(out.StatefulSets, s) } } for _, s := range h.Services { - if keep, c, a := fn(s.Conditions, s.AnyOf); keep { - s.Conditions, s.AnyOf = c, a + if keep, c, a := fn(s.Conditions, s.Or); keep { + s.Conditions, s.Or = c, a out.Services = append(out.Services, s) } } for _, s := range h.Pods { - if keep, c, a := fn(s.Conditions, s.AnyOf); keep { - s.Conditions, s.AnyOf = c, a + if keep, c, a := fn(s.Conditions, s.Or); keep { + s.Conditions, s.Or = c, a out.Pods = append(out.Pods, s) } } for _, s := range h.Jobs { - if keep, c, a := fn(s.Conditions, s.AnyOf); keep { - s.Conditions, s.AnyOf = c, a + if keep, c, a := fn(s.Conditions, s.Or); keep { + s.Conditions, s.Or = c, a out.Jobs = append(out.Jobs, s) } } for _, s := range h.CronJobs { - if keep, c, a := fn(s.Conditions, s.AnyOf); keep { - s.Conditions, s.AnyOf = c, a + if keep, c, a := fn(s.Conditions, s.Or); keep { + s.Conditions, s.Or = c, a out.CronJobs = append(out.CronJobs, s) } } for _, s := range h.Secrets { - if keep, c, a := fn(s.Conditions, s.AnyOf); keep { - s.Conditions, s.AnyOf = c, a + if keep, c, a := fn(s.Conditions, s.Or); keep { + s.Conditions, s.Or = c, a out.Secrets = append(out.Secrets, s) } } for _, s := range h.ConfigMaps { - if keep, c, a := fn(s.Conditions, s.AnyOf); keep { - s.Conditions, s.AnyOf = c, a + if keep, c, a := fn(s.Conditions, s.Or); keep { + s.Conditions, s.Or = c, a out.ConfigMaps = append(out.ConfigMaps, s) } } for _, s := range h.ServiceAccounts { - if keep, c, a := fn(s.Conditions, s.AnyOf); keep { - s.Conditions, s.AnyOf = c, a + if keep, c, a := fn(s.Conditions, s.Or); keep { + s.Conditions, s.Or = c, a out.ServiceAccounts = append(out.ServiceAccounts, s) } } for _, s := range h.Ingresses { - if keep, c, a := fn(s.Conditions, s.AnyOf); keep { - s.Conditions, s.AnyOf = c, a + if keep, c, a := fn(s.Conditions, s.Or); keep { + s.Conditions, s.Or = c, a out.Ingresses = append(out.Ingresses, s) } } for _, s := range h.PersistentVolumes { - if keep, c, a := fn(s.Conditions, s.AnyOf); keep { - s.Conditions, s.AnyOf = c, a + if keep, c, a := fn(s.Conditions, s.Or); keep { + s.Conditions, s.Or = c, a out.PersistentVolumes = append(out.PersistentVolumes, s) } } for _, s := range h.PersistentVolumeClaims { - if keep, c, a := fn(s.Conditions, s.AnyOf); keep { - s.Conditions, s.AnyOf = c, a + if keep, c, a := fn(s.Conditions, s.Or); keep { + s.Conditions, s.Or = c, a out.PersistentVolumeClaims = append(out.PersistentVolumeClaims, s) } } for _, s := range h.HorizontalPodAutoscalers { - if keep, c, a := fn(s.Conditions, s.AnyOf); keep { - s.Conditions, s.AnyOf = c, a + if keep, c, a := fn(s.Conditions, s.Or); keep { + s.Conditions, s.Or = c, a out.HorizontalPodAutoscalers = append(out.HorizontalPodAutoscalers, s) } } for _, s := range h.PodDisruptionBudgets { - if keep, c, a := fn(s.Conditions, s.AnyOf); keep { - s.Conditions, s.AnyOf = c, a + if keep, c, a := fn(s.Conditions, s.Or); keep { + s.Conditions, s.Or = c, a out.PodDisruptionBudgets = append(out.PodDisruptionBudgets, s) } } for _, s := range h.Namespaces { - if keep, c, a := fn(s.Conditions, s.AnyOf); keep { - s.Conditions, s.AnyOf = c, a + if keep, c, a := fn(s.Conditions, s.Or); keep { + s.Conditions, s.Or = c, a out.Namespaces = append(out.Namespaces, s) } } for _, s := range h.Roles { - if keep, c, a := fn(s.Conditions, s.AnyOf); keep { - s.Conditions, s.AnyOf = c, a + if keep, c, a := fn(s.Conditions, s.Or); keep { + s.Conditions, s.Or = c, a out.Roles = append(out.Roles, s) } } for _, s := range h.RoleBindings { - if keep, c, a := fn(s.Conditions, s.AnyOf); keep { - s.Conditions, s.AnyOf = c, a + if keep, c, a := fn(s.Conditions, s.Or); keep { + s.Conditions, s.Or = c, a out.RoleBindings = append(out.RoleBindings, s) } } for _, s := range h.ClusterRoles { - if keep, c, a := fn(s.Conditions, s.AnyOf); keep { - s.Conditions, s.AnyOf = c, a + if keep, c, a := fn(s.Conditions, s.Or); keep { + s.Conditions, s.Or = c, a out.ClusterRoles = append(out.ClusterRoles, s) } } for _, s := range h.ClusterRoleBindings { - if keep, c, a := fn(s.Conditions, s.AnyOf); keep { - s.Conditions, s.AnyOf = c, a + if keep, c, a := fn(s.Conditions, s.Or); keep { + s.Conditions, s.Or = c, a out.ClusterRoleBindings = append(out.ClusterRoleBindings, s) } } for _, s := range h.NetworkPolicies { - if keep, c, a := fn(s.Conditions, s.AnyOf); keep { - s.Conditions, s.AnyOf = c, a + if keep, c, a := fn(s.Conditions, s.Or); keep { + s.Conditions, s.Or = c, a out.NetworkPolicies = append(out.NetworkPolicies, s) } } for _, s := range h.ResourceQuotas { - if keep, c, a := fn(s.Conditions, s.AnyOf); keep { - s.Conditions, s.AnyOf = c, a + if keep, c, a := fn(s.Conditions, s.Or); keep { + s.Conditions, s.Or = c, a out.ResourceQuotas = append(out.ResourceQuotas, s) } } for _, s := range h.LimitRanges { - if keep, c, a := fn(s.Conditions, s.AnyOf); keep { - s.Conditions, s.AnyOf = c, a + if keep, c, a := fn(s.Conditions, s.Or); keep { + s.Conditions, s.Or = c, a out.LimitRanges = append(out.LimitRanges, s) } } for _, s := range h.CustomResource { - if keep, c, a := fn(s.Conditions, s.AnyOf); keep { - s.Conditions, s.AnyOf = c, a + if keep, c, a := fn(s.Conditions, s.Or); keep { + s.Conditions, s.Or = c, a out.CustomResource = append(out.CustomResource, s) } } diff --git a/pkg/types/hooks_required_serve.go b/pkg/types/hooks_required_serve.go index c9f1c5710..1d0992ae6 100644 --- a/pkg/types/hooks_required_serve.go +++ b/pkg/types/hooks_required_serve.go @@ -118,7 +118,7 @@ func serveFieldLabel(cfg ServeFieldConfig, name string) string { // Center form. See "Required fields are enforced automatically" at // https://orkestra.sh/docs/reference/schema/katalog/validation#required-fields-are-enforced-automatically // for the full rationale, including why inheriting the field's own -// When/AnyOf matters for discriminator-routed CRDs. +// When/Or matters for discriminator-routed CRDs. func (c *CRDEntry) RequiredServeFieldRules() []ValidationRule { var rules []ValidationRule for _, ref := range c.allServeFieldRefs() { @@ -132,7 +132,7 @@ func (c *CRDEntry) RequiredServeFieldRules() []ValidationRule { Message: ref.label + " is required", Action: ValidationActionDeny, When: ref.cfg.When, - AnyOf: ref.cfg.AnyOf, + Or: ref.cfg.Or, }) } return rules @@ -164,7 +164,7 @@ func (c *CRDEntry) EnumServeFieldRules() []ValidationRule { Message: ref.label + " must be one of: " + strings.Join(ref.cfg.Enum, ", "), Action: ValidationActionDeny, When: when, - AnyOf: ref.cfg.AnyOf, + Or: ref.cfg.Or, }) } return rules diff --git a/pkg/types/hooks_required_serve_test.go b/pkg/types/hooks_required_serve_test.go index 18789ecbd..2af03b362 100644 --- a/pkg/types/hooks_required_serve_test.go +++ b/pkg/types/hooks_required_serve_test.go @@ -66,7 +66,7 @@ func TestRequiredServeFieldRules_SpecField(t *testing.T) { } } -func TestRequiredServeFieldRules_InheritsWhenAndAnyOf(t *testing.T) { +func TestRequiredServeFieldRules_InheritsWhenAndOr(t *testing.T) { appOnly := []Condition{{Field: "spec.workloadType", Equals: "app"}} certOrApp := []Condition{ {Field: "spec.workloadType", Equals: "cert"}, @@ -79,8 +79,8 @@ func TestRequiredServeFieldRules_InheritsWhenAndAnyOf(t *testing.T) { // when workloadType: app — the same When already used to hide it // from the form for other workload types. "repoURL": {Label: "Repository URL", Required: true, When: appOnly}, - // AnyOf carries through the same way. - "domain": {Label: "Domain", Required: true, AnyOf: certOrApp}, + // Or carries through the same way. + "domain": {Label: "Domain", Required: true, Or: certOrApp}, // A universal field with no condition at all. "team": {Label: "Team", Required: true}, }, @@ -95,18 +95,18 @@ func TestRequiredServeFieldRules_InheritsWhenAndAnyOf(t *testing.T) { if len(repoRule.When) != 1 || repoRule.When[0].Field != "spec.workloadType" || repoRule.When[0].Equals != "app" { t.Errorf("repoURL rule.When = %+v, want the field's own When condition carried through", repoRule.When) } - if len(repoRule.AnyOf) != 0 { - t.Errorf("repoURL rule.AnyOf = %+v, want empty — the field declared When, not AnyOf", repoRule.AnyOf) + if len(repoRule.Or) != 0 { + t.Errorf("repoURL rule.Or = %+v, want empty — the field declared When, not Or", repoRule.Or) } domainRule := findRule(t, rules, "spec.domain") - if len(domainRule.AnyOf) != 2 { - t.Errorf("domain rule.AnyOf = %+v, want the field's own AnyOf conditions carried through", domainRule.AnyOf) + if len(domainRule.Or) != 2 { + t.Errorf("domain rule.Or = %+v, want the field's own Or conditions carried through", domainRule.Or) } teamRule := findRule(t, rules, "spec.team") - if len(teamRule.When) != 0 || len(teamRule.AnyOf) != 0 { - t.Errorf("team rule When/AnyOf = %+v/%+v, want both empty — the field declared neither", teamRule.When, teamRule.AnyOf) + if len(teamRule.When) != 0 || len(teamRule.Or) != 0 { + t.Errorf("team rule When/Or = %+v/%+v, want both empty — the field declared neither", teamRule.When, teamRule.Or) } } diff --git a/pkg/types/includes.go b/pkg/types/includes.go index 82eaffe04..c43856efc 100644 --- a/pkg/types/includes.go +++ b/pkg/types/includes.go @@ -181,6 +181,86 @@ func ExpandProfileInclude(r *ProfileRegistry, baseDir string) error { return nil } +// ExpandWatchEntries resolves include entries in a []WatchEntry list. +// An entry with include: set is replaced in-place by the "watch:" list from the +// referenced file. Entries without include: are kept as-is. +// The include path is resolved relative to baseDir. +func ExpandWatchEntries(entries []WatchEntry, baseDir string) ([]WatchEntry, error) { + var expanded []WatchEntry + for _, entry := range entries { + if entry.Include == "" { + expanded = append(expanded, entry) + continue + } + path := entry.Include + if !filepath.IsAbs(path) { + path = filepath.Join(baseDir, path) + } + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading watch include %q: %w", entry.Include, err) + } + var f struct { + Watch []WatchEntry `yaml:"watch"` + } + if err := orkutils.StrictUnmarshal(data, &f); err != nil { + return nil, fmt.Errorf("parsing watch include %q: %w", entry.Include, err) + } + expanded = append(expanded, f.Watch...) + } + return expanded, nil +} + +// ExpandReconcilerInclude resolves the reconciler.include field by reading the +// referenced file, unmarshaling its "reconciler:" block, and merging it under +// the inline config. Inline fields take precedence. Cleared after expansion. +func ExpandReconcilerInclude(r *ReconcilerConfig, baseDir string) error { + if r == nil || r.Include == "" { + return nil + } + path := r.Include + if !filepath.IsAbs(path) { + path = filepath.Join(baseDir, path) + } + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("reading reconciler.include %q: %w", r.Include, err) + } + var f struct { + Reconciler ReconcilerConfig `yaml:"reconciler"` + } + if err := orkutils.StrictUnmarshal(data, &f); err != nil { + return fmt.Errorf("parsing reconciler.include %q: %w", r.Include, err) + } + inc := f.Reconciler + if r.Default == nil && inc.Default != nil { + r.Default = inc.Default + } + if r.Hooks == nil && inc.Hooks != nil { + r.Hooks = inc.Hooks + } + if r.ConstructorDecl == nil && inc.ConstructorDecl != nil { + r.ConstructorDecl = inc.ConstructorDecl + } + if r.Profile == "" && inc.Profile != "" { + r.Profile = inc.Profile + } + if r.Workers == 0 && inc.Workers != 0 { + r.Workers = inc.Workers + } + if r.Resync.Duration == 0 && inc.Resync.Duration != 0 { + r.Resync = inc.Resync + } + if r.Queue.IsEmpty() && !inc.Queue.IsEmpty() { + r.Queue = inc.Queue + } + if r.Requeue == nil && inc.Requeue != nil { + r.Requeue = inc.Requeue + } + r.Include = "" + return nil +} + // ExpandExternalCalls resolves include entries in a []ExternalCallSpec list. // An entry with include: set is replaced in-place by the "calls:" list from the // referenced file. Entries without include: are kept as-is. diff --git a/pkg/types/katalog_spec_providers.go b/pkg/types/katalog_spec_providers.go index bc845f60f..85df42da2 100644 --- a/pkg/types/katalog_spec_providers.go +++ b/pkg/types/katalog_spec_providers.go @@ -69,17 +69,17 @@ func ParseProviderBlocks(raw map[string][]map[string]interface{}) ([]ProviderBlo // parseOneDeclaration parses one map entry from a provider block list. // // Each entry is a single-key map where the key is the resource kind -// and the value is the fields map plus optional "when" / "anyOf" keys. +// and the value is the fields map plus optional "when" / "or" keys. // -// {"s3": {"bucket": "my-bucket", "region": "us-east-1", "when": [...], "anyOf": [...]}} +// {"s3": {"bucket": "my-bucket", "region": "us-east-1", "when": [...], "or": [...]}} func parseOneDeclaration(raw map[string]interface{}) (RawProviderDeclaration, error) { // The declaration must have exactly one "kind" key. - // "when" and "anyOf" are special — they hold conditions, not fields. + // "when" and "or" are special — they hold conditions, not fields. var kind string var fieldsRaw map[string]interface{} for k, v := range raw { - if k == "when" || k == "anyOf" { + if k == "when" || k == "or" { continue } if kind != "" { @@ -109,13 +109,13 @@ func parseOneDeclaration(raw map[string]interface{}) (RawProviderDeclaration, er decl.Conditions = conditions } - // Parse anyOf: conditions (OR) - if anyOfRaw, ok := raw["anyOf"]; ok { - conditions, err := parseConditions(anyOfRaw) + // Parse or: conditions (OR) + if orRaw, ok := raw["or"]; ok { + conditions, err := parseConditions(orRaw) if err != nil { - return decl, fmt.Errorf("parsing anyOf: conditions: %w", err) + return decl, fmt.Errorf("parsing or: conditions: %w", err) } - decl.AnyOf = conditions + decl.Or = conditions } return decl, nil diff --git a/pkg/types/methods.go b/pkg/types/methods.go index abd7423bd..c5e092f51 100644 --- a/pkg/types/methods.go +++ b/pkg/types/methods.go @@ -85,7 +85,7 @@ func (c *CRDEntry) SkipObservedGeneration() bool { // ShouldEnrich returns true when the given enrichment target is enabled — // either via EnrichAll: true or an explicit entry in Enrich. -// Condition gates (when:/anyOf:) are not evaluated here — they are handled +// Condition gates (when:/or:) are not evaluated here — they are handled // higher up by ActiveEnrichTargets before the CRDEntry reaches each enricher. func (c *CRDEntry) ShouldEnrich(target string) bool { if c.EnrichAll { @@ -99,9 +99,9 @@ func (c *CRDEntry) ShouldEnrich(target string) bool { return false } -// ActiveEnrichTargets returns the subset of Enrich entries whose when:/anyOf: +// ActiveEnrichTargets returns the subset of Enrich entries whose when:/or: // conditions pass for the given data map and evaluator. Unconditional entries -// (no when: or anyOf:) always pass. Called from ReadChildren to pre-filter +// (no when: or or:) always pass. Called from ReadChildren to pre-filter // crd.Enrich before dispatching to individual enricher functions. func (c *CRDEntry) ActiveEnrichTargets(data map[string]interface{}, eval TemplateEvaluator) []EnrichTarget { if c.EnrichAll { @@ -109,24 +109,24 @@ func (c *CRDEntry) ActiveEnrichTargets(data map[string]interface{}, eval Templat } result := make([]EnrichTarget, 0, len(c.Enrich)) for _, t := range c.Enrich { - if len(t.When) == 0 && len(t.AnyOf) == 0 { + if len(t.When) == 0 && len(t.Or) == 0 { result = append(result, t) continue } - if EvaluateConditions(data, t.When, t.AnyOf, eval) { + if EvaluateConditions(data, t.When, t.Or, eval) { result = append(result, t) } } return result } -// UnconditionalEnrichTargets returns entries with no when:/anyOf: conditions. +// UnconditionalEnrichTargets returns entries with no when:/or: conditions. // These run in phase 1 of ReadChildren so that .children.* is populated before // conditional gates are evaluated. func (c *CRDEntry) UnconditionalEnrichTargets() []EnrichTarget { result := make([]EnrichTarget, 0, len(c.Enrich)) for _, t := range c.Enrich { - if len(t.When) == 0 && len(t.AnyOf) == 0 { + if len(t.When) == 0 && len(t.Or) == 0 { result = append(result, t) } } @@ -140,10 +140,10 @@ func (c *CRDEntry) UnconditionalEnrichTargets() []EnrichTarget { func (c *CRDEntry) ConditionalActiveEnrichTargets(data map[string]interface{}, eval TemplateEvaluator) []EnrichTarget { result := make([]EnrichTarget, 0) for _, t := range c.Enrich { - if len(t.When) == 0 && len(t.AnyOf) == 0 { + if len(t.When) == 0 && len(t.Or) == 0 { continue // already ran in phase 1 } - if EvaluateConditions(data, t.When, t.AnyOf, eval) { + if EvaluateConditions(data, t.When, t.Or, eval) { result = append(result, t) } } @@ -211,19 +211,31 @@ func (c *CRDEntry) WithConstructorDecl() bool { // managed resources for RBAC generation. func (c *CRDEntry) WithHookManagedResources() bool { r := c.OperatorBox.Reconciler - return c.WithHooksDecl() && r != nil && len(r.Hooks.Resources) > 0 + return c.WithHooksDecl() && r != nil && len(r.Hooks.ManagedResources) > 0 } // WithConstructorManagedResources reports whether this CRD has a constructor // that declares managed resources for RBAC generation. func (c *CRDEntry) WithConstructorManagedResources() bool { r := c.OperatorBox.Reconciler - return c.WithConstructorDecl() && r != nil && len(r.ConstructorDecl.Resources) > 0 + return c.WithConstructorDecl() && r != nil && len(r.ConstructorDecl.ManagedResources) > 0 } -// WithAnyManagedResources reports whether hooks or constructor declare resources. +// WithAnyManagedResources reports whether hooks or constructor declare resources, +// including per-target operatorBox declarations. func (c *CRDEntry) WithAnyManagedResources() bool { - return c.WithHookManagedResources() || c.WithConstructorManagedResources() + if c.WithHookManagedResources() || c.WithConstructorManagedResources() { + return true + } + if c.Serve == nil || c.Serve.Target.Entries == nil { + return false + } + for _, entry := range c.Serve.Target.Entries { + if len(targetManagedResources(entry.OperatorBox)) > 0 { + return true + } + } + return false } // HookManagedResources returns the list of managed resources declared under @@ -232,7 +244,7 @@ func (c *CRDEntry) HookManagedResources() []ManagedResource { if !c.WithHooksDecl() { return nil } - return c.OperatorBox.Reconciler.Hooks.Resources + return c.OperatorBox.Reconciler.Hooks.ManagedResources } // ConstructorManagedResources returns the list of managed resources declared @@ -242,12 +254,58 @@ func (c *CRDEntry) ConstructorManagedResources() []ManagedResource { if !c.WithConstructorDecl() { return nil } - return c.OperatorBox.Reconciler.ConstructorDecl.Resources + return c.OperatorBox.Reconciler.ConstructorDecl.ManagedResources +} + +// AllManagedResources returns the combined list of managed resources from hooks, +// constructor, and per-target operatorBox declarations. Duplicates across targets +// are fine — startWatchInformers deduplicates by GVR via the covered set. +func (c *CRDEntry) AllManagedResources() []ManagedResource { + hooks := c.HookManagedResources() + ctor := c.ConstructorManagedResources() + out := make([]ManagedResource, 0, len(hooks)+len(ctor)) + out = append(out, hooks...) + out = append(out, ctor...) + if c.Serve != nil { + for _, entry := range c.Serve.Target.Entries { + out = append(out, targetManagedResources(entry.OperatorBox)...) + } + } + return out } -// WithWatchEntries reports whether this CRD declares any secondary watch entries. +// targetManagedResources extracts hook + constructor resources from a per-target +// operatorBox pointer. Returns nil when the box is nil or has no resources. +func targetManagedResources(box *OperatorBoxConfig) []ManagedResource { + if box.IsEmpty() || box.Reconciler.IsEmpty() { + return nil + } + rec := box.Reconciler + var out []ManagedResource + if rec.HasHooksDecl() { + out = append(out, rec.Hooks.ManagedResources...) + } + if rec.HasConstructorDecl() { + out = append(out, rec.ConstructorDecl.ManagedResources...) + } + return out +} + +// WithWatchEntries reports whether this CRD or any per-target operatorBox declares +// secondary watch entries. func (c *CRDEntry) WithWatchEntries() bool { - return len(c.OperatorBox.Watch) > 0 + if len(c.OperatorBox.Watch) > 0 { + return true + } + if c.Serve == nil { + return false + } + for _, entry := range c.Serve.Target.Entries { + if entry.OperatorBox != nil && len(entry.OperatorBox.Watch) > 0 { + return true + } + } + return false } // WithSentinels reports whether this CRD declares any preReconcile sentinels. @@ -255,10 +313,22 @@ 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. +// WatchEntries returns the combined secondary watch entries from the base +// operatorBox.watch and all per-target operatorBox.watch declarations. +// Duplicates across targets are deduplicated by startWatchInformers via covered set. func (c *CRDEntry) WatchEntries() []WatchEntry { - return c.OperatorBox.Watch + base := c.OperatorBox.Watch + if c.Serve == nil { + return base + } + out := make([]WatchEntry, 0, len(base)) + out = append(out, base...) + for _, entry := range c.Serve.Target.Entries { + if entry.OperatorBox != nil { + out = append(out, entry.OperatorBox.Watch...) + } + } + return out } // HasTemplates reports whether this CRD declares any declarative hook templates. diff --git a/pkg/types/provider_katalog.go b/pkg/types/provider_katalog.go index 3c45599e9..acee3b16a 100644 --- a/pkg/types/provider_katalog.go +++ b/pkg/types/provider_katalog.go @@ -186,6 +186,6 @@ type RawProviderDeclaration struct { // whose conditions fail are removed from the list before dispatch. Conditions []Condition `yaml:"when,omitempty" json:"when,omitempty"` - // AnyOf holds OR conditions — at least one must pass. - AnyOf []Condition `yaml:"anyOf,omitempty" json:"anyOf,omitempty"` + // Or holds OR conditions — at least one must pass. + Or []Condition `yaml:"or,omitempty" json:"or,omitempty"` } diff --git a/pkg/types/status.go b/pkg/types/status.go index 3181ed48f..f6ba528d8 100644 --- a/pkg/types/status.go +++ b/pkg/types/status.go @@ -97,15 +97,15 @@ type StatusFieldSpec struct { // .spec.image, .children.job.status.succeeded are all accessible. When []Condition `yaml:"when,omitempty"` - // AnyOf — optional OR-conditions. If any condition passes, the field is written. + // Or — optional OR-conditions. If any condition passes, the field is written. // Useful for multi-branch declarative state machines. - AnyOf []Condition `yaml:"anyOf,omitempty"` + Or []Condition `yaml:"or,omitempty"` - // ClearOnFalse — when true and the when:/anyOf: condition evaluates to false, + // ClearOnFalse — when true and the when:/or: condition evaluates to false, // the field is explicitly written as "" rather than left untouched. // Use this for transient fields (e.g. crashReason) that should disappear // once the condition that produced them is no longer true. - // Has no effect when no when:/anyOf: conditions are declared. + // Has no effect when no when:/or: conditions are declared. ClearOnFalse bool `yaml:"clearOnFalse,omitempty" json:"clearOnFalse,omitempty"` } diff --git a/pkg/types/types_configmap.go b/pkg/types/types_configmap.go index 7f262a875..77ae23bf4 100644 --- a/pkg/types/types_configmap.go +++ b/pkg/types/types_configmap.go @@ -74,9 +74,9 @@ type ConfigMapTemplateSource struct { // .item and . are available in template expressions within this declaration. ForEach *ForEachSpec `yaml:"forEach,omitempty" json:"forEach,omitempty"` - // AnyOf holds OR conditions — at least one must pass for this resource to be created. + // Or holds OR conditions — at least one must pass for this resource to be created. // Works alongside the existing Conditions (when:) field which uses AND semantics. - AnyOf []Condition `yaml:"anyOf,omitempty" json:"anyOf,omitempty"` + Or []Condition `yaml:"or,omitempty" json:"or,omitempty"` // Sleep injects an artificial delay into the reconcile of this resource. // Useful for autoscale testing, latency simulation, and chaos engineering. diff --git a/pkg/types/types_crd_entry.go b/pkg/types/types_crd_entry.go index da744689a..928614926 100644 --- a/pkg/types/types_crd_entry.go +++ b/pkg/types/types_crd_entry.go @@ -354,8 +354,8 @@ func mergeReconcilerConfig(base, target *ReconcilerConfig) *ReconcilerConfig { if target.Hooks.Alias != "" { h.Alias = target.Hooks.Alias } - if len(target.Hooks.Resources) > 0 { - h.Resources = target.Hooks.Resources + if len(target.Hooks.ManagedResources) > 0 { + h.ManagedResources = target.Hooks.ManagedResources } // Args: merge key-by-key; target overrides CRD-level per key. if len(target.Hooks.Args) > 0 { diff --git a/pkg/types/types_deployment.go b/pkg/types/types_deployment.go index ad01816a0..e3e2debbd 100644 --- a/pkg/types/types_deployment.go +++ b/pkg/types/types_deployment.go @@ -163,15 +163,15 @@ type DeploymentTemplateSource struct { // # name: "{{ .metadata.name }}-{{ .region }}" ForEach *ForEachSpec `yaml:"forEach,omitempty" json:"forEach,omitempty"` - // AnyOf holds OR conditions — at least one must pass for this resource to be created. + // Or holds OR conditions — at least one must pass for this resource to be created. // Works alongside the existing Conditions (when:) field which uses AND semantics. // - // anyOf: + // or: // - field: spec.tier // equals: pro // - field: spec.tier // equals: enterprise - AnyOf []Condition `yaml:"anyOf,omitempty" json:"anyOf,omitempty"` + Or []Condition `yaml:"or,omitempty" json:"or,omitempty"` // WorkingDirectory sets the container's working directory (container.WorkingDir). // Useful for Git-backed pipelines where build/test commands must run inside diff --git a/pkg/types/types_enrich_target.go b/pkg/types/types_enrich_target.go index 6e735f893..f4739ef3e 100644 --- a/pkg/types/types_enrich_target.go +++ b/pkg/types/types_enrich_target.go @@ -21,7 +21,7 @@ import ( // - field: "{{ replicasReady .children.deployment }}" // equals: "false" // - replicasets: -// anyOf: +// or: // - field: spec.debug // equals: "true" type EnrichTarget struct { @@ -32,8 +32,8 @@ type EnrichTarget struct { // in field: values are evaluated against the resolver's full data map // so all note functions (replicasReady, hasCrashingPod, …) are available. // Empty: always enriches (same as shorthand). - When []Condition `yaml:"when,omitempty"` - AnyOf []Condition `yaml:"anyOf,omitempty"` + When []Condition `yaml:"when,omitempty"` + Or []Condition `yaml:"or,omitempty"` } // UnmarshalYAML handles both shorthand ("pods") and struct form: @@ -55,14 +55,14 @@ func (e *EnrichTarget) UnmarshalYAML(value *yaml.Node) error { } e.Key = value.Content[0].Value var body struct { - When []Condition `yaml:"when"` - AnyOf []Condition `yaml:"anyOf"` + When []Condition `yaml:"when"` + Or []Condition `yaml:"or"` } if err := value.Content[1].Decode(&body); err != nil { return fmt.Errorf("enrich target %q: %w", e.Key, err) } e.When = body.When - e.AnyOf = body.AnyOf + e.Or = body.Or return nil } @@ -72,14 +72,14 @@ func (e *EnrichTarget) UnmarshalYAML(value *yaml.Node) error { // MarshalYAML serializes back to the shorthand form when there are no // conditions, and to the struct form when conditions are present. func (e EnrichTarget) MarshalYAML() (interface{}, error) { - if len(e.When) == 0 && len(e.AnyOf) == 0 { + if len(e.When) == 0 && len(e.Or) == 0 { return e.Key, nil } type body struct { - When []Condition `yaml:"when,omitempty"` - AnyOf []Condition `yaml:"anyOf,omitempty"` + When []Condition `yaml:"when,omitempty"` + Or []Condition `yaml:"or,omitempty"` } return map[string]body{ - e.Key: {When: e.When, AnyOf: e.AnyOf}, + e.Key: {When: e.When, Or: e.Or}, }, nil } diff --git a/pkg/types/types_hpa.go b/pkg/types/types_hpa.go index 51b56673d..a9824cce9 100644 --- a/pkg/types/types_hpa.go +++ b/pkg/types/types_hpa.go @@ -94,15 +94,15 @@ type HPATemplateSource struct { // behavior, and conditional provisioning without writing Go code. Conditions []Condition `yaml:"when,omitempty" json:"when,omitempty"` - // AnyOf holds OR conditions — at least one must pass for this resource to be created. + // Or holds OR conditions — at least one must pass for this resource to be created. // Works alongside the existing Conditions (when:) field which uses AND semantics. // - // anyOf: + // or: // - field: spec.tier // equals: pro // - field: spec.tier // equals: enterprise - AnyOf []Condition `yaml:"anyOf,omitempty" json:"anyOf,omitempty"` + Or []Condition `yaml:"or,omitempty" json:"or,omitempty"` // ForEach declares dynamic expansion over a list field. // When set, one source declaration becomes N declarations — one per list element. diff --git a/pkg/types/types_ingress.go b/pkg/types/types_ingress.go index 2a059d958..cb3b3f6c9 100644 --- a/pkg/types/types_ingress.go +++ b/pkg/types/types_ingress.go @@ -90,15 +90,15 @@ type IngressTemplateSource struct { // behavior, and conditional provisioning without writing Go code. Conditions []Condition `yaml:"when,omitempty" json:"when,omitempty"` - // AnyOf holds OR conditions — at least one must pass for this resource to be created. + // Or holds OR conditions — at least one must pass for this resource to be created. // Works alongside the existing Conditions (when:) field which uses AND semantics. // - // anyOf: + // or: // - field: spec.tier // equals: pro // - field: spec.tier // equals: enterprise - AnyOf []Condition `yaml:"anyOf,omitempty" json:"anyOf,omitempty"` + Or []Condition `yaml:"or,omitempty" json:"or,omitempty"` // ForEach declares dynamic expansion over a list field. // When set, one source declaration becomes N declarations — one per list element. diff --git a/pkg/types/types_job.go b/pkg/types/types_job.go index f9a239fb8..428022a7d 100644 --- a/pkg/types/types_job.go +++ b/pkg/types/types_job.go @@ -104,15 +104,15 @@ type JobTemplateSource struct { // as: region ForEach *ForEachSpec `yaml:"forEach,omitempty" json:"forEach,omitempty"` - // AnyOf holds OR conditions — at least one must pass for this resource to be created. + // Or holds OR conditions — at least one must pass for this resource to be created. // Works alongside the existing Conditions (when:) field which uses AND semantics. // - // anyOf: + // or: // - field: spec.tier // equals: pro // - field: spec.tier // equals: enterprise - AnyOf []Condition `yaml:"anyOf,omitempty" json:"anyOf,omitempty"` + Or []Condition `yaml:"or,omitempty" json:"or,omitempty"` // WorkingDirectory sets the container's working directory (container.WorkingDir). // Useful for Git-backed pipelines where build/test commands must run inside @@ -285,15 +285,15 @@ type CronJobTemplateSource struct { // as: region ForEach *ForEachSpec `yaml:"forEach,omitempty" json:"forEach,omitempty"` - // AnyOf holds OR conditions — at least one must pass for this resource to be created. + // Or holds OR conditions — at least one must pass for this resource to be created. // Works alongside the existing Conditions (when:) field which uses AND semantics. // - // anyOf: + // or: // - field: spec.tier // equals: pro // - field: spec.tier // equals: enterprise - AnyOf []Condition `yaml:"anyOf,omitempty" json:"anyOf,omitempty"` + Or []Condition `yaml:"or,omitempty" json:"or,omitempty"` // WorkingDirectory sets the container's working directory (container.WorkingDir). // Useful for Git-backed pipelines where build/test commands must run inside diff --git a/pkg/types/types_limitrange.go b/pkg/types/types_limitrange.go index 68dc8c4c5..d5a376567 100644 --- a/pkg/types/types_limitrange.go +++ b/pkg/types/types_limitrange.go @@ -83,8 +83,8 @@ type LimitRangeTemplateSource struct { // Conditions (when:) — all must pass for this resource to be applied. Conditions []Condition `yaml:"when,omitempty" json:"when,omitempty"` - // AnyOf — at least one must pass. - AnyOf []Condition `yaml:"anyOf,omitempty" json:"anyOf,omitempty"` + // Or — at least one must pass. + Or []Condition `yaml:"or,omitempty" json:"or,omitempty"` // Reconcile: true — sync on every reconcile (drift correction). Reconcile bool `yaml:"reconcile,omitempty" json:"reconcile,omitempty"` diff --git a/pkg/types/types_networkpolicy.go b/pkg/types/types_networkpolicy.go index e157d220d..2a6c207b0 100644 --- a/pkg/types/types_networkpolicy.go +++ b/pkg/types/types_networkpolicy.go @@ -83,8 +83,8 @@ type NetworkPolicyTemplateSource struct { // Conditions (when:) — all must pass for this resource to be applied. Conditions []Condition `yaml:"when,omitempty" json:"when,omitempty"` - // AnyOf — at least one must pass. - AnyOf []Condition `yaml:"anyOf,omitempty" json:"anyOf,omitempty"` + // Or — at least one must pass. + Or []Condition `yaml:"or,omitempty" json:"or,omitempty"` // Profile — named NetworkPolicy preset. Expands into ingress/egress rules and policy types. // Allowed values: deny-all, deny-all-ingress, deny-all-egress, allow-same-namespace, allow-dns-egress. diff --git a/pkg/types/types_operatorbox.go b/pkg/types/types_operatorbox.go index fc8471eb6..f90e5f647 100644 --- a/pkg/types/types_operatorbox.go +++ b/pkg/types/types_operatorbox.go @@ -2,36 +2,78 @@ package types import ( + "strings" + "github.com/orkspace/orkestra/domain" "github.com/orkspace/orkestra/pkg/runtime/sentinel" ) +// ── FailPolicy ──────────────────────────────────────────────────────────────────── + +// FailPolicy controls what a gate does when it cannot evaluate its conditions — +// for example when an external: call fails or times out. +type FailPolicy string + +const ( + // FailPolicyOpen passes the gate on evaluation failure. + // The object is enqueued / reconciled as if the gate was not declared. + // This is the default when failPolicy is omitted. + FailPolicyOpen FailPolicy = "open" + + // FailPolicyClosed holds the gate on evaluation failure. + // The object is dropped from the queue / held back from the reconciler. + // Use on reconcileGate when unknown state is worse than a missed reconcile. + FailPolicyClosed FailPolicy = "closed" +) + +// ValidFailPolicies returns all known failPolicy values in declaration order. +func ValidFailPolicies() []string { + return []string{string(FailPolicyOpen), string(FailPolicyClosed)} +} + +// IsValidFailPolicy reports whether s is a known FailPolicy value. +func IsValidFailPolicy(s string) bool { + switch FailPolicy(s) { + case FailPolicyOpen, FailPolicyClosed: + return true + } + return false +} + +// FailPolicyJoined returns a comma-separated list of valid failPolicy values for error messages. +func FailPolicyJoined() string { return strings.Join(ValidFailPolicies(), ", ") } + // ── PreReconcileConfig ──────────────────────────────────────────────────────────── -// GateConditions declares when/anyOf conditions and optional external calls +// GateConditions declares when/or conditions and optional external calls // shared by both preReconcile gates. type GateConditions struct { // External declares HTTP or gRPC calls made before conditions are evaluated. // Results are injected into the resolver under .external..* and are - // available in when:/anyOf: field expressions. + // available in when:/or: field expressions. External []ExternalCallSpec `yaml:"external,omitempty" json:"external,omitempty"` // When declares AND conditions. All must be true for the gate to pass. When []Condition `yaml:"when,omitempty" json:"when,omitempty"` - // AnyOf declares OR conditions. At least one must be true. - // When both When and AnyOf are declared, both must pass. - AnyOf []Condition `yaml:"anyOf,omitempty" json:"anyOf,omitempty"` + // Or declares OR conditions. At least one must be true. + // When both When and Or are declared, both must pass. + Or []Condition `yaml:"or,omitempty" json:"or,omitempty"` + + // FailPolicy controls what the gate does when it cannot evaluate its conditions — + // for example when an external: call fails or times out. + // Defaults to open when omitted. + FailPolicy FailPolicy `yaml:"failPolicy,omitempty" json:"failPolicy,omitempty"` } -// HasConditions reports whether any when/anyOf conditions are declared. +// HasConditions reports whether any when/or conditions are declared. func (g *GateConditions) HasConditions() bool { - return g != nil && (len(g.When) > 0 || len(g.AnyOf) > 0) + return g != nil && (len(g.When) > 0 || len(g.Or) > 0) } // HasGate reports whether the gate has anything to evaluate — conditions or external calls. func (g *GateConditions) HasGate() bool { - return g != nil && (len(g.When) > 0 || len(g.AnyOf) > 0 || len(g.External) > 0) + return g != nil && (len(g.When) > 0 || len(g.Or) > 0 || len(g.External) > 0) } // WhenConditions returns the AND conditions, safe on nil receiver. @@ -42,12 +84,12 @@ func (g *GateConditions) WhenConditions() []Condition { return g.When } -// AnyOfConditions returns the OR conditions, safe on nil receiver. -func (g *GateConditions) AnyOfConditions() []Condition { +// OrConditions returns the OR conditions, safe on nil receiver. +func (g *GateConditions) OrConditions() []Condition { if g == nil { return nil } - return g.AnyOf + return g.Or } // ExternalCalls returns the external calls declared on this gate, or nil when @@ -167,6 +209,33 @@ type WatchEntry struct { // 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"` + + // Index declares field-path indexers that Orkestra registers on this watch's + // informer. Each entry makes client.List(ctx, &list, client.MatchingFields{name: value}) + // serve from the cache instead of making a live API call. + // + // watch: + // - apiVersion: v1 + // kind: ConfigMap + // index: + // - name: metadata.ownerRef + // field: ".metadata.ownerReferences[0].name" + Index []WatchIndex `yaml:"index,omitempty" json:"index,omitempty"` + + // Include is a path (relative to the katalog file) to a YAML file whose + // "watch:" list replaces this entry in-place. When set all other fields on + // this entry are ignored. Cleared after expansion. + Include string `yaml:"include,omitempty" json:"include,omitempty"` +} + +// WatchIndex declares one field-path indexer on a watch: entry informer. +// Name is used as the index key — it must match the key passed to client.MatchingFields. +// Field is a dot-separated JSON path into the watched object (e.g. ".spec.owner"). +type WatchIndex struct { + // Name is the index name. Must match the key in client.MatchingFields. + Name string `yaml:"name" json:"name"` + // Field is the JSON path to index on (e.g. ".spec.owner", ".metadata.labels.app"). + Field string `yaml:"field" json:"field"` } // WatchKeyFrom overrides the default ownerReference → broadcast key resolution @@ -252,7 +321,7 @@ func (w WatchEntry) ToManagedResource() ManagedResource { // when: // - field: "{{ .spec.enabled }}" // equals: "true" -// anyOf: +// or: // - field: "{{ .status.phase }}" // equals: "Ready" type PreReconcileConfig struct { @@ -309,7 +378,7 @@ func (r *PreReconcileConfig) InvalidSentinels() []string { return invalid } -// HasPreReconcileConditions reports whether reconcileGate has any when/anyOf conditions declared. +// HasPreReconcileConditions reports whether reconcileGate has any when/or conditions declared. func (r *PreReconcileConfig) HasPreReconcileConditions() bool { return r != nil && (r.ReconcileGate.HasConditions() || r.EnqueueGate.HasConditions()) } @@ -363,12 +432,12 @@ func (r *PreReconcileConfig) WhenConditions() []Condition { return r.ReconcileGate.WhenConditions() } -// AnyOfConditions returns the reconcileGate OR conditions, safe on nil receiver. -func (r *PreReconcileConfig) AnyOfConditions() []Condition { +// OrConditions returns the reconcileGate OR conditions, safe on nil receiver. +func (r *PreReconcileConfig) OrConditions() []Condition { if r == nil { return nil } - return r.ReconcileGate.AnyOfConditions() + return r.ReconcileGate.OrConditions() } // ── OperatorBoxConfig ────────────────────────────────────────────────────────── @@ -421,6 +490,33 @@ type ReconcilerConfig struct { // Queue — work queue tuning for this CRD. Queue Queue `yaml:"queue,omitempty" json:"queue,omitempty"` + + // Requeue declares per-object requeue behavior after successful reconciliation. + Requeue *RequeueConfig `yaml:"requeue,omitempty"` + + // Include is a path (relative to the katalog file) to a YAML file whose + // "reconciler:" block is loaded and merged under this config. Inline fields + // take precedence over included ones. Cleared after expansion. + Include string `yaml:"include,omitempty" json:"include,omitempty"` +} + +// RequeueConfig declares per-object requeue behavior after a successful reconcile. +// Evaluated after every reconcile cycle that does not return an error. +// Errors are handled by queue.retryBackoff, not by requeue. +type RequeueConfig struct { + // After is a template expression resolving to a Go duration string. + // Evaluated against the reconciled CR after each successful cycle. + // "0s" or empty means no requeue — wait for the next informer event. + // Example: '{{ .spec.checkInterval | default "60s" }}' + After string `yaml:"after,omitempty"` + + // When declares AND conditions — requeue only fires when all are true. + // When absent, requeue fires unconditionally after every reconcile. + When []Condition `yaml:"when,omitempty"` + + // Or declares OR conditions — requeue fires when any one is true. + // When both When and Or are present, both must pass. + Or []Condition `yaml:"or,omitempty"` } // IsDefault returns true when the reconciler should use the GenericReconciler. @@ -456,6 +552,31 @@ func (r *ReconcilerConfig) HasConstructorDecl() bool { return r.ConstructorDecl != nil } +// HasRequeueDecl reports whether a requeue configuration exists. +func (r *ReconcilerConfig) HasRequeueDecl() bool { + if r == nil { + return false + } + return r.Requeue != nil +} + +// IsRequeueEmpty reports whether the requeue configuration is effectively empty. +func (r *ReconcilerConfig) IsRequeueEmpty() bool { + if r == nil || r.Requeue == nil { + return true + } + rc := r.Requeue + return rc.After == "" && len(rc.When) == 0 && len(rc.Or) == 0 +} + +// IsEmpty reports whether this requeue configuration has no effective behavior. +func (rc *RequeueConfig) IsEmpty() bool { + if rc == nil { + return true + } + return rc.After == "" && len(rc.When) == 0 && len(rc.Or) == 0 +} + // IsEmpty reports whether the reconciler config has no meaningful settings. // Used to skip unnecessary config blocks in the Katalog. func (r *ReconcilerConfig) IsEmpty() bool { @@ -483,6 +604,9 @@ func (r *ReconcilerConfig) IsEmpty() bool { if !r.Queue.IsEmpty() { return false } + if r.Requeue.IsEmpty() { + return false + } return true } @@ -499,7 +623,7 @@ type OperatorBoxConfig struct { Reconciler *ReconcilerConfig `yaml:"reconciler,omitempty" json:"reconciler,omitempty"` // PreReconcile declares pre-reconcile gate conditions. When declared, the kordinator - // evaluates when/anyOf before calling the reconciler. If conditions are not met + // evaluates when/or before calling the reconciler. If conditions are not met // the reconciler is never called — the item is discarded and re-evaluated on the // next informer tick. // nil → no gate; reconciler is always called (default behavior). @@ -613,7 +737,7 @@ type OperatorBoxConfig struct { // .spec.image, .children.job.status.succeeded are all accessible. When []Condition `yaml:"when,omitempty"` - AnyOf []Condition `yaml:"anyOf,omitempty"` + Or []Condition `yaml:"or,omitempty"` } // IsEmpty reports true when this operatorBox is empty @@ -644,8 +768,8 @@ type HookDeclaration struct { // e.g. "projecthooks" Alias string `yaml:"alias,omitempty" json:"alias,omitempty" validate:"omitempty"` - // Resources — Kubernetes resource types this hook manages (used for RBAC generation). - Resources []ManagedResource `json:"resources,omitempty" yaml:"resources,omitempty"` + // ManagedResources — Kubernetes resource types this hook manages (used for RBAC generation). + ManagedResources []ManagedResource `json:"managedResources,omitempty" yaml:"managedResources,omitempty"` // RunHooksFirst — when true, the hook runs before declarative templates. // When false (default), declarative templates run first and the hook is @@ -694,8 +818,8 @@ type ConstructorDeclaration struct { // Alias — Go import alias. Optional, auto-derived from Location if omitted. Alias string `yaml:"alias,omitempty" json:"alias,omitempty" validate:"omitempty"` - // Resources — Kubernetes resource types this constructor manages (used for RBAC generation). - Resources []ManagedResource `json:"resources,omitempty" yaml:"resources,omitempty"` + // ManagedResources — Kubernetes resource types this constructor manages (used for RBAC generation). + ManagedResources []ManagedResource `json:"managedResources,omitempty" yaml:"managedResources,omitempty"` // Args — arbitrary key/value pairs passed to the constructor at startup. // Read via kube.Args().String("key"), .Bool("key"), etc. @@ -706,9 +830,21 @@ type ConstructorDeclaration struct { // ManagedResource describes a Kubernetes resource type that a typed extension // (either a hook or a constructor) will manage. // -// Orkestra uses this information to generate RBAC rules for the operator -// ServiceAccount. Each declared resource results in permissions to -// get/list/watch/create/update/patch/delete that resource type. +// Orkestra uses this information for two purposes: +// +// 1. RBAC generation — each declared resource results in permissions to +// get/list/watch/create/update/patch/delete that resource type. +// +// 2. Implicit watch informer — Orkestra automatically starts a watch informer +// for each declared resource, identical to declaring a watch: entry with +// all events and owner-reference key resolution. This means: +// +// - r.client.Get / r.client.List for that type are served from cache +// - when an owned resource changes, Orkestra enqueues the primary CR +// +// If you need finer control (custom on:, enqueueGate:, keyFrom:, or index:), +// declare an explicit watch: entry for that type — it takes priority over +// the implicit informer from resources:. // // For built‑in Kubernetes resources, Kind alone is sufficient because Orkestra // resolves the full GroupVersionResource from its internal registry. diff --git a/pkg/types/types_pdb.go b/pkg/types/types_pdb.go index 672a394bd..00cacee83 100644 --- a/pkg/types/types_pdb.go +++ b/pkg/types/types_pdb.go @@ -75,15 +75,15 @@ type PDBTemplateSource struct { // behavior, and conditional provisioning without writing Go code. Conditions []Condition `yaml:"when,omitempty" json:"when,omitempty"` - // AnyOf holds OR conditions — at least one must pass for this resource to be created. + // Or holds OR conditions — at least one must pass for this resource to be created. // Works alongside the existing Conditions (when:) field which uses AND semantics. // - // anyOf: + // or: // - field: spec.tier // equals: pro // - field: spec.tier // equals: enterprise - AnyOf []Condition `yaml:"anyOf,omitempty" json:"anyOf,omitempty"` + Or []Condition `yaml:"or,omitempty" json:"or,omitempty"` // ForEach declares dynamic expansion over a list field. // When set, one source declaration becomes N declarations — one per list element. diff --git a/pkg/types/types_pod.go b/pkg/types/types_pod.go index c34191177..5b552ffa6 100644 --- a/pkg/types/types_pod.go +++ b/pkg/types/types_pod.go @@ -104,15 +104,15 @@ type PodTemplateSource struct { // as: region ForEach *ForEachSpec `yaml:"forEach,omitempty" json:"forEach,omitempty"` - // AnyOf holds OR conditions — at least one must pass for this resource to be created. + // Or holds OR conditions — at least one must pass for this resource to be created. // Works alongside the existing Conditions (when:) field which uses AND semantics. // - // anyOf: + // or: // - field: spec.tier // equals: pro // - field: spec.tier // equals: enterprise - AnyOf []Condition `yaml:"anyOf,omitempty" json:"anyOf,omitempty"` + Or []Condition `yaml:"or,omitempty" json:"or,omitempty"` // Probes — startup, liveness, and readiness probe configuration. // diff --git a/pkg/types/types_pvc.go b/pkg/types/types_pvc.go index daba511a2..b0d35b79e 100644 --- a/pkg/types/types_pvc.go +++ b/pkg/types/types_pvc.go @@ -63,15 +63,15 @@ type PVCTemplateSource struct { // behavior, and conditional provisioning without writing Go code. Conditions []Condition `yaml:"when,omitempty" json:"when,omitempty"` - // AnyOf holds OR conditions — at least one must pass for this resource to be created. + // Or holds OR conditions — at least one must pass for this resource to be created. // Works alongside the existing Conditions (when:) field which uses AND semantics. // - // anyOf: + // or: // - field: spec.tier // equals: pro // - field: spec.tier // equals: enterprise - AnyOf []Condition `yaml:"anyOf,omitempty" json:"anyOf,omitempty"` + Or []Condition `yaml:"or,omitempty" json:"or,omitempty"` // ForEach declares dynamic expansion over a list field. // When set, one source declaration becomes N declarations — one per list element. @@ -167,15 +167,15 @@ type PVTemplateSource struct { // behavior, and conditional provisioning without writing Go code. Conditions []Condition `yaml:"when,omitempty" json:"when,omitempty"` - // AnyOf holds OR conditions — at least one must pass for this resource to be created. + // Or holds OR conditions — at least one must pass for this resource to be created. // Works alongside the existing Conditions (when:) field which uses AND semantics. // - // anyOf: + // or: // - field: spec.tier // equals: pro // - field: spec.tier // equals: enterprise - AnyOf []Condition `yaml:"anyOf,omitempty" json:"anyOf,omitempty"` + Or []Condition `yaml:"or,omitempty" json:"or,omitempty"` // ForEach declares dynamic expansion over a list field. // When set, one source declaration becomes N declarations — one per list element. diff --git a/pkg/types/types_rbac.go b/pkg/types/types_rbac.go index 52c7738e9..112584ae8 100644 --- a/pkg/types/types_rbac.go +++ b/pkg/types/types_rbac.go @@ -98,15 +98,15 @@ type RoleTemplateSource struct { // as: region ForEach *ForEachSpec `yaml:"forEach,omitempty" json:"forEach,omitempty"` - // AnyOf holds OR conditions — at least one must pass for this resource to be created. + // Or holds OR conditions — at least one must pass for this resource to be created. // Works alongside the existing Conditions (when:) field which uses AND semantics. // - // anyOf: + // or: // - field: spec.tier // equals: pro // - field: spec.tier // equals: enterprise - AnyOf []Condition `yaml:"anyOf,omitempty" json:"anyOf,omitempty"` + Or []Condition `yaml:"or,omitempty" json:"or,omitempty"` // Sleep injects an artificial delay into the reconcile of this resource. // Useful for autoscale testing, latency simulation, and chaos engineering. @@ -194,15 +194,15 @@ type RoleBindingTemplateSource struct { // as: region ForEach *ForEachSpec `yaml:"forEach,omitempty" json:"forEach,omitempty"` - // AnyOf holds OR conditions — at least one must pass for this resource to be created. + // Or holds OR conditions — at least one must pass for this resource to be created. // Works alongside the existing Conditions (when:) field which uses AND semantics. // - // anyOf: + // or: // - field: spec.tier // equals: pro // - field: spec.tier // equals: enterprise - AnyOf []Condition `yaml:"anyOf,omitempty" json:"anyOf,omitempty"` + Or []Condition `yaml:"or,omitempty" json:"or,omitempty"` // Sleep injects an artificial delay into the reconcile of this resource. // Useful for autoscale testing, latency simulation, and chaos engineering. @@ -270,15 +270,15 @@ type ClusterRoleTemplateSource struct { // onReconcile. When false (default), only runs on onCreate (idempotent create). Reconcile bool `yaml:"reconcile,omitempty" json:"reconcile,omitempty"` - // AnyOf holds OR conditions — at least one must pass for this resource to be created. + // Or holds OR conditions — at least one must pass for this resource to be created. // Works alongside the existing Conditions (when:) field which uses AND semantics. // - // anyOf: + // or: // - field: spec.tier // equals: pro // - field: spec.tier // equals: enterprise - AnyOf []Condition `yaml:"anyOf,omitempty" json:"anyOf,omitempty"` + Or []Condition `yaml:"or,omitempty" json:"or,omitempty"` // ForEach declares dynamic expansion over a list field. // When set, one source declaration becomes N declarations — one per list element. @@ -364,15 +364,15 @@ type ClusterRoleBindingTemplateSource struct { // onReconcile. When false (default), only runs on onCreate (idempotent create). Reconcile bool `yaml:"reconcile,omitempty" json:"reconcile,omitempty"` - // AnyOf holds OR conditions — at least one must pass for this resource to be created. + // Or holds OR conditions — at least one must pass for this resource to be created. // Works alongside the existing Conditions (when:) field which uses AND semantics. // - // anyOf: + // or: // - field: spec.tier // equals: pro // - field: spec.tier // equals: enterprise - AnyOf []Condition `yaml:"anyOf,omitempty" json:"anyOf,omitempty"` + Or []Condition `yaml:"or,omitempty" json:"or,omitempty"` // ForEach declares dynamic expansion over a list field. // When set, one source declaration becomes N declarations — one per list element. diff --git a/pkg/types/types_replicaset.go b/pkg/types/types_replicaset.go index 34a051a55..c72c8686a 100644 --- a/pkg/types/types_replicaset.go +++ b/pkg/types/types_replicaset.go @@ -139,8 +139,8 @@ type ReplicaSetTemplateSource struct { // Autoscale declares workload autoscaling behaviour for this ReplicaSet. Autoscale *WorkloadAutoscale `yaml:"autoscale,omitempty" json:"autoscale,omitempty"` - // AnyOf holds OR conditions — at least one must pass for this resource. - AnyOf []Condition `yaml:"anyOf,omitempty" json:"anyOf,omitempty"` + // Or holds OR conditions — at least one must pass for this resource. + Or []Condition `yaml:"or,omitempty" json:"or,omitempty"` // WorkingDirectory sets the container's working directory (container.WorkingDir). WorkingDirectory string `yaml:"workingDirectory,omitempty" json:"workingDirectory,omitempty"` diff --git a/pkg/types/types_resourcequota.go b/pkg/types/types_resourcequota.go index 92ba15202..7a09a893a 100644 --- a/pkg/types/types_resourcequota.go +++ b/pkg/types/types_resourcequota.go @@ -74,8 +74,8 @@ type ResourceQuotaTemplateSource struct { // Conditions (when:) — all must pass for this resource to be applied. Conditions []Condition `yaml:"when,omitempty" json:"when,omitempty"` - // AnyOf — at least one must pass. - AnyOf []Condition `yaml:"anyOf,omitempty" json:"anyOf,omitempty"` + // Or — at least one must pass. + Or []Condition `yaml:"or,omitempty" json:"or,omitempty"` // Reconcile: true — sync on every reconcile (drift correction). Reconcile bool `yaml:"reconcile,omitempty" json:"reconcile,omitempty"` diff --git a/pkg/types/types_secret.go b/pkg/types/types_secret.go index cb4f13928..6e89022e9 100644 --- a/pkg/types/types_secret.go +++ b/pkg/types/types_secret.go @@ -85,8 +85,8 @@ type SecretTemplateSource struct { // ForEach declares dynamic expansion (same as other resource types) ForEach *ForEachSpec `yaml:"forEach,omitempty" json:"forEach,omitempty"` - // AnyOf holds OR conditions (same as other resource types) - AnyOf []Condition `yaml:"anyOf,omitempty" json:"anyOf,omitempty"` + // Or holds OR conditions (same as other resource types) + Or []Condition `yaml:"or,omitempty" json:"or,omitempty"` // Once — when true, evaluates templates and creates the Secret only if it // does not already exist; every subsequent reconcile is a no-op. Use this diff --git a/pkg/types/types_serve.go b/pkg/types/types_serve.go index 4fce5bbd7..7244647ec 100644 --- a/pkg/types/types_serve.go +++ b/pkg/types/types_serve.go @@ -264,9 +264,9 @@ type ServeFieldConfig struct { // template source when: blocks. When []Condition `yaml:"when,omitempty" json:"when,omitempty"` - // AnyOf is a list of conditions where at least ONE must be true for the + // Or is a list of conditions where at least ONE must be true for the // field to be visible. OR counterpart to When (AND). - AnyOf []Condition `yaml:"anyOf,omitempty" json:"anyOf,omitempty"` + Or []Condition `yaml:"or,omitempty" json:"or,omitempty"` // Required, when true, marks the field as mandatory in the serve form — // the browser enforces this natively (asterisk on the label, form cannot diff --git a/pkg/types/types_service.go b/pkg/types/types_service.go index ae5c90814..b11470931 100644 --- a/pkg/types/types_service.go +++ b/pkg/types/types_service.go @@ -97,15 +97,15 @@ type ServiceTemplateSource struct { // as: region ForEach *ForEachSpec `yaml:"forEach,omitempty" json:"forEach,omitempty"` - // AnyOf holds OR conditions — at least one must pass for this resource to be created. + // Or holds OR conditions — at least one must pass for this resource to be created. // Works alongside the existing Conditions (when:) field which uses AND semantics. // - // anyOf: + // or: // - field: spec.tier // equals: pro // - field: spec.tier // equals: enterprise - AnyOf []Condition `yaml:"anyOf,omitempty" json:"anyOf,omitempty"` + Or []Condition `yaml:"or,omitempty" json:"or,omitempty"` // Sleep injects an artificial delay into the reconcile of this resource. // Useful for autoscale testing, latency simulation, and chaos engineering. diff --git a/pkg/types/types_serviceaccount.go b/pkg/types/types_serviceaccount.go index 6b3c885b8..968fe2df0 100644 --- a/pkg/types/types_serviceaccount.go +++ b/pkg/types/types_serviceaccount.go @@ -61,15 +61,15 @@ type ServiceAccountTemplateSource struct { // as: region ForEach *ForEachSpec `yaml:"forEach,omitempty" json:"forEach,omitempty"` - // AnyOf holds OR conditions — at least one must pass for this resource to be created. + // Or holds OR conditions — at least one must pass for this resource to be created. // Works alongside the existing Conditions (when:) field which uses AND semantics. // - // anyOf: + // or: // - field: spec.tier // equals: pro // - field: spec.tier // equals: enterprise - AnyOf []Condition `yaml:"anyOf,omitempty" json:"anyOf,omitempty"` + Or []Condition `yaml:"or,omitempty" json:"or,omitempty"` // Sleep injects an artificial delay into the reconcile of this resource. // Useful for autoscale testing, latency simulation, and chaos engineering. @@ -142,15 +142,15 @@ type NamespaceTemplateSource struct { // as: region ForEach *ForEachSpec `yaml:"forEach,omitempty" json:"forEach,omitempty"` - // AnyOf holds OR conditions — at least one must pass for this resource to be created. + // Or holds OR conditions — at least one must pass for this resource to be created. // Works alongside the existing Conditions (when:) field which uses AND semantics. // - // anyOf: + // or: // - field: spec.tier // equals: pro // - field: spec.tier // equals: enterprise - AnyOf []Condition `yaml:"anyOf,omitempty" json:"anyOf,omitempty"` + Or []Condition `yaml:"or,omitempty" json:"or,omitempty"` // Sleep injects an artificial delay into the reconcile of this resource. // Useful for autoscale testing, latency simulation, and chaos engineering. diff --git a/pkg/types/types_statefulset.go b/pkg/types/types_statefulset.go index e865a5ae5..192981f3f 100644 --- a/pkg/types/types_statefulset.go +++ b/pkg/types/types_statefulset.go @@ -155,15 +155,15 @@ type StatefulSetTemplateSource struct { // behavior, and conditional provisioning without writing Go code. Conditions []Condition `yaml:"when,omitempty" json:"when,omitempty"` - // AnyOf holds OR conditions — at least one must pass for this resource to be created. + // Or holds OR conditions — at least one must pass for this resource to be created. // Works alongside the existing Conditions (when:) field which uses AND semantics. // - // anyOf: + // or: // - field: spec.tier // equals: pro // - field: spec.tier // equals: enterprise - AnyOf []Condition `yaml:"anyOf,omitempty" json:"anyOf,omitempty"` + Or []Condition `yaml:"or,omitempty" json:"or,omitempty"` // ForEach declares dynamic expansion over a list field. // When set, one source declaration becomes N declarations — one per list element. diff --git a/pkg/types/types_workload_autoscale.go b/pkg/types/types_workload_autoscale.go index eb1ecf8c7..3c47eea16 100644 --- a/pkg/types/types_workload_autoscale.go +++ b/pkg/types/types_workload_autoscale.go @@ -66,8 +66,8 @@ type WorkloadScaleConditions struct { // When — AND semantics. All conditions must be true. When []Condition `yaml:"when,omitempty" json:"when,omitempty"` - // AnyOf — OR semantics. At least one condition must be true. - AnyOf []Condition `yaml:"anyOf,omitempty" json:"anyOf,omitempty"` + // Or — OR semantics. At least one condition must be true. + Or []Condition `yaml:"or,omitempty" json:"or,omitempty"` } // EffectiveCooldown returns the cooldown duration, applying the default of 1m when absent. diff --git a/pkg/types/validation_eval.go b/pkg/types/validation_eval.go index 8ade813b8..4ce3b4eb0 100644 --- a/pkg/types/validation_eval.go +++ b/pkg/types/validation_eval.go @@ -3,7 +3,7 @@ // // This used to be two independently hand-maintained copies of the same // switch statement, one in pkg/runtime/reconciler, one in -// pkg/gateway/webhook. That's how operator: in — defined for when:/anyOf: +// pkg/gateway/webhook. That's how operator: in — defined for when:/or: // conditions — went unimplemented in validation.rules in both places at // once, silently, without either side noticing: a rule using it always // passed. One implementation means one place to fix, and no way for the two @@ -51,7 +51,7 @@ type UniquenessChecker interface { // reconciler wires in a live-List() checker on every reconcile, the // admission webhook wires in an HTTP-backed one on every admission request, // so operator: unique is enforced identically in both validation.rules and -// when:/anyOf: at both points. Always passes elsewhere (e2e, template-only +// when:/or: at both points. Always passes elsewhere (e2e, template-only // contexts, simulate without a seeded fixture) where there's no live CRD to // check against. const uniquenessCheckerKey = "_uniquenessChecker" diff --git a/pkg/types/validation_eval_test.go b/pkg/types/validation_eval_test.go index 03949a846..956974445 100644 --- a/pkg/types/validation_eval_test.go +++ b/pkg/types/validation_eval_test.go @@ -186,7 +186,7 @@ func TestEvaluateValidationRule_Operators(t *testing.T) { // ── in ────────────────────────────────────────────────────────────── // The exact regression this suite guards: operator: in was defined - // for when:/anyOf: conditions but missing from the validation-rule + // for when:/or: conditions but missing from the validation-rule // evaluation switch, so a rule using it always passed silently, in // both the reconciler and the webhook, at once. {"in: value in list — passes", specData(map[string]interface{}{"workloadType": "app"}), ValidationRule{Field: "spec.workloadType", Operator: ConditionIn, Value: "app,cert,monitoring,infra", Message: "invalid"}, true}, diff --git a/pkg/types/when.go b/pkg/types/when.go index 97aba0b54..a331b0a2c 100644 --- a/pkg/types/when.go +++ b/pkg/types/when.go @@ -3,7 +3,7 @@ // EvaluateConditions — extended condition evaluation with OR logic. // // The when: field ([]Condition) uses AND semantics. -// anyOf: is a new parallel field on template sources with OR semantics. +// or: is a parallel field on template sources with OR semantics. // // # AND only // when: @@ -11,7 +11,7 @@ // equals: "Ready" // // # OR -// anyOf: +// or: // - field: status.phase // equals: "Failed" // - field: status.phase @@ -21,7 +21,7 @@ // when: // - field: spec.enabled // equals: "true" -// anyOf: +// or: // - field: status.phase // equals: "Failed" // - field: status.phase @@ -48,21 +48,21 @@ type TemplateEvaluator func(tmpl string) (string, bool) // IsTemplate reports whether s contains a Go template expression. func IsTemplate(s string) bool { return strings.Contains(s, "{{") } -// EvaluateConditions evaluates when: (allOf, AND) and anyOf: (OR) conditions. +// EvaluateConditions evaluates when: (allOf, AND) and or: (OR) conditions. // data is resolver.Data() — full CR map including children, external, cross. // eval is optional — pass nil to disable template evaluation (backward compatible). // // Both blocks must pass when both are declared. // Empty blocks always pass. -func EvaluateConditions(data map[string]interface{}, allOf []Condition, anyOf []Condition, eval TemplateEvaluator) bool { +func EvaluateConditions(data map[string]interface{}, allOf []Condition, or []Condition, eval TemplateEvaluator) bool { for _, cond := range allOf { if !EvaluateOneCond(data, cond, eval) { return false } } - if len(anyOf) > 0 { + if len(or) > 0 { passed := false - for _, cond := range anyOf { + for _, cond := range or { if EvaluateOneCond(data, cond, eval) { passed = true break diff --git a/pkg/types/when_test.go b/pkg/types/when_test.go index 333912152..2c72d2219 100644 --- a/pkg/types/when_test.go +++ b/pkg/types/when_test.go @@ -475,7 +475,7 @@ func TestEvaluateOneCond_Unique_AlwaysTrue(t *testing.T) { assert.True(t, orktypes.EvaluateOneCond(d, c, nil)) } -// ── EvaluateConditions — allOf / anyOf ────────────────────────────────────────────── +// ── EvaluateConditions — allOf / or ────────────────────────────────────────────── func TestEvaluateConditions_EmptyBothPasses(t *testing.T) { assert.True(t, orktypes.EvaluateConditions(nil, nil, nil, nil)) @@ -499,42 +499,42 @@ func TestEvaluateConditions_AllOfOneFails(t *testing.T) { assert.False(t, orktypes.EvaluateConditions(d, allOf, nil, nil)) } -func TestEvaluateConditions_AnyOfOneMatches(t *testing.T) { +func TestEvaluateConditions_OrOneMatches(t *testing.T) { d := data("phase", "Failed") - anyOf := []orktypes.Condition{ + or := []orktypes.Condition{ {Field: "phase", Equals: "Failed"}, {Field: "phase", Equals: "Succeeded"}, } - assert.True(t, orktypes.EvaluateConditions(d, nil, anyOf, nil)) + assert.True(t, orktypes.EvaluateConditions(d, nil, or, nil)) } -func TestEvaluateConditions_AnyOfNoneMatch(t *testing.T) { +func TestEvaluateConditions_OrNoneMatch(t *testing.T) { d := data("phase", "Running") - anyOf := []orktypes.Condition{ + or := []orktypes.Condition{ {Field: "phase", Equals: "Failed"}, {Field: "phase", Equals: "Succeeded"}, } - assert.False(t, orktypes.EvaluateConditions(d, nil, anyOf, nil)) + assert.False(t, orktypes.EvaluateConditions(d, nil, or, nil)) } func TestEvaluateConditions_BothMustPass(t *testing.T) { d := data("env", "prod", "phase", "Failed") allOf := []orktypes.Condition{{Field: "env", Equals: "prod"}} - anyOf := []orktypes.Condition{ + or := []orktypes.Condition{ {Field: "phase", Equals: "Failed"}, {Field: "phase", Equals: "Succeeded"}, } - assert.True(t, orktypes.EvaluateConditions(d, allOf, anyOf, nil)) + assert.True(t, orktypes.EvaluateConditions(d, allOf, or, nil)) } -func TestEvaluateConditions_AllOfPassAnyOfFails(t *testing.T) { +func TestEvaluateConditions_AllOfPassOrFails(t *testing.T) { d := data("env", "prod", "phase", "Running") allOf := []orktypes.Condition{{Field: "env", Equals: "prod"}} - anyOf := []orktypes.Condition{ + or := []orktypes.Condition{ {Field: "phase", Equals: "Failed"}, {Field: "phase", Equals: "Succeeded"}, } - assert.False(t, orktypes.EvaluateConditions(d, allOf, anyOf, nil)) + assert.False(t, orktypes.EvaluateConditions(d, allOf, or, nil)) } // ── EvaluateOneCond — cron window injection via _cronWindows ───────────────── diff --git a/pkg/utils/split.go b/pkg/utils/split.go index 3d4bc60b0..cf1c9f1dd 100644 --- a/pkg/utils/split.go +++ b/pkg/utils/split.go @@ -83,3 +83,13 @@ func SplitColonSeparated(s string) []string { func SplitSemicolonSeparated(s string) []string { return SplitBySeparator(s, ";") } + +// SplitField converts a dot-separated field path to a slice of path segments. +// Accepts both "spec.owner" and ".spec.owner" — the leading dot is stripped. +func SplitField(field string) []string { + field = strings.TrimPrefix(field, ".") + if field == "" { + return nil + } + return strings.Split(field, ".") +} diff --git a/pkg/utils/unstructured.go b/pkg/utils/unstructured.go new file mode 100644 index 000000000..1f78a3c29 --- /dev/null +++ b/pkg/utils/unstructured.go @@ -0,0 +1,23 @@ +package utils + +import ( + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/fields" +) + +// MatchesFieldRequirements checks that u satisfies all requirements using +// simple string equality on unstructured field values. Used as a post-filter +// after an index lookup to handle residual requirements the index did not cover. +func MatchesFieldRequirements(u *unstructured.Unstructured, reqs fields.Requirements) bool { + for _, req := range reqs { + parts := SplitField(req.Field) + if len(parts) == 0 { + continue + } + val, _, _ := unstructured.NestedString(u.Object, parts...) + if val != req.Value { + return false + } + } + return true +}