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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,44 @@ Both gates use the full resolver chain (`.spec`, `.metadata`, serve intent, prof

`crdFiles` / `crFiles` added to `E2ESpec`. `tests/simulate-envtest/04-conditional-reconciliation/` covers gate-pass and gate-discard via envtest simulate. `examples/intermediate/05-when-conditions/conditional-reconciliation/` — App (reconcileGate) + Route (unconditional) pack.

### Per-target `operatorBox` — surface-specific reconciliation

`serve.target.<name>.operatorBox` overrides the CRD-level `operatorBox` for CRs routed through that surface. The gateway stamps `orkestra.orkspace.io/serve-target` on every applied CR; the runtime reads that annotation at reconcile time and uses the matching target's templates instead of the shared CRD-level ones. CRs applied via `kubectl apply` (no annotation) fall back to the CRD-level `operatorBox`.

```yaml
operatorBox:
onCreate:
deployments:
- name: "{{ .metadata.name }}"
services:
- name: "{{ .metadata.name }}-svc"

serve:
enabled: true
target:
web:
primary: true
operatorBox:
onCreate:
deployments:
- name: "{{ .metadata.name }}-web"
apifixture:
operatorBox:
onCreate:
deployments:
- name: "{{ .metadata.name }}-apifixture"
```

`preReconcile` and `status` follow the same pattern — a target may declare its own gate conditions or status fields, with the CRD-level config as the fallback when absent. Reconciler-level settings (`reconciler.workers`, `reconciler.resync`, `reconciler.queue`, `reconciler.profile`, `autoscale`, `rollback`) are rejected by `ork validate` on target entries — the worker pool is fixed at CRD level.

Cleanup on target change is handled automatically: `DeleteIfOwned` removes resources declared by the previous target's operatorBox that are no longer present in the new one.

**`ork simulate --target <name>`** — simulates a specific target's operatorBox. Also declarable in `simulate.yaml` via `spec.target:`. CLI flag takes precedence over the spec field.

**`simulate.yaml` `spec.target:`** — new field. Pins the simulated reconciliation to a named target's operatorBox, equivalent to passing `--target` on the CLI.

**`ork simulate` refactored** — CLI simulate helpers now take a `cliSimulateOptions` struct instead of a flat parameter list, reducing signature length across `runSimulate`, `runSimulateFromSpec`, `runSimulateDiscovery`, and `simulateOne`.

### Serve modes, apply-time controls, and field selectors

Three new blocks under `serve` and per target give platform teams granular control over the Gateway API surface, override behaviour, and full CR routing.
Expand Down
4 changes: 2 additions & 2 deletions cmd/cli/play_chain.go
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ func playRunSimulate(ctx context.Context, katalogFile string, obj *unstructured.
if simulateConfig != "" {
return runSimulateWithCR(ctx, simulateConfig, tmp.Name())
}
return runSimulate(ctx, katalogFile, tmp.Name(), "", 10, simulate.RunOptions{SkipExternal: true}, false, false, "")
return runSimulate(ctx, katalogFile, tmp.Name(), cliSimulateOptions{MaxCycles: 10, SkipExternal: true})
}

// runSimulateWithCR runs simulate using the spec file for katalog/cycles/expect
Expand Down Expand Up @@ -252,7 +252,7 @@ func runSimulateWithCR(ctx context.Context, specPath, crFile string) error {
crdOpts.Peers = in.peers
crdOpts.ExistingInstances = in.existing
expect := simulate.ExpectForCRD(doc.Spec.Expect, name)
if err := simulateOne(ctx, kat, name, in.cr, cycles, crdOpts, false, false, nil, "", expect); err != nil {
if err := simulateOne(ctx, kat, name, in.cr, cycles, crdOpts, cliSimulateOptions{}, nil, expect); err != nil {
failed = append(failed, name)
}
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/cli/push.go
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ var pushCmd = &cobra.Command{
} else {
fmt.Printf("\nRunning simulate gate (%s)...\n", registry.FileSimulate)
start := time.Now()
if err := runSimulateFromSpec(cmd.Context(), simFile, "", 10, false, false, ""); err != nil {
if err := runSimulateFromSpec(cmd.Context(), simFile, cliSimulateOptions{MaxCycles: 10}); err != nil {
return fmt.Errorf("✗ Simulate gate failed — push blocked\n Run 'ork simulate' to see the failures\n Use --force to override (recorded in the artifact)\n\n%w", err)
}
dur := time.Since(start).Round(time.Millisecond).String()
Expand Down
94 changes: 59 additions & 35 deletions cmd/cli/simulate.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,17 @@ import (
sigsyaml "sigs.k8s.io/yaml"
)

// cliSimulateOptions groups the CLI-level flags that flow through all simulate helpers.
type cliSimulateOptions struct {
CRDName string
MaxCycles int
Target string
SkipExternal bool
DebugOps bool
UseEnvtest bool
K8sVersion string
}

var simulateCmd = &cobra.Command{
Use: "simulate",
Short: "Simulate operator reconciliation in memory — no cluster required",
Expand All @@ -40,18 +51,16 @@ should produce so the run is repeatable and verifiable:
ork simulate -f katalog.yaml --cr cr.yaml # direct flags; op-print only
ork simulate ./... # discovers all simulate.yaml files recursively`,
RunE: func(cmd *cobra.Command, args []string) error {
crdName, _ := cmd.Flags().GetString("crd")
maxCycles, _ := cmd.Flags().GetInt("cycles")
target, _ := cmd.Flags().GetString("target")

skipExternal, _ := cmd.Flags().GetBool("skip-external")
debugOps, _ := cmd.Flags().GetBool("debug-ops")
devServer, _ := cmd.Flags().GetBool("dev-server")
useEnvtest, _ := cmd.Flags().GetBool("envtest")
k8sVersion, _ := cmd.Flags().GetString("k8s-version")
opts := simulate.RunOptions{SkipExternal: skipExternal, Target: target}

if devServer {
cliOpts := cliSimulateOptions{}
cliOpts.CRDName, _ = cmd.Flags().GetString("crd")
cliOpts.MaxCycles, _ = cmd.Flags().GetInt("cycles")
cliOpts.Target, _ = cmd.Flags().GetString("target")
cliOpts.SkipExternal, _ = cmd.Flags().GetBool("skip-external")
cliOpts.DebugOps, _ = cmd.Flags().GetBool("debug-ops")
cliOpts.UseEnvtest, _ = cmd.Flags().GetBool("envtest")
cliOpts.K8sVersion, _ = cmd.Flags().GetString("k8s-version")

if devServer, _ := cmd.Flags().GetBool("dev-server"); devServer {
devServerPort, _ := cmd.Flags().GetInt("dev-server-port")
if err := devserver.Start(devServerPort); err != nil {
return fmt.Errorf("starting dev server: %w", err)
Expand All @@ -61,8 +70,7 @@ should produce so the run is repeatable and verifiable:
// Discovery mode: ork simulate ./...
if len(args) > 0 && args[0] == "./..." {
skipRaw, _ := cmd.Flags().GetStringSlice("skip")
root := "."
return runSimulateDiscovery(cmd.Context(), root, crdName, maxCycles, skipRaw, debugOps, useEnvtest, k8sVersion)
return runSimulateDiscovery(cmd.Context(), ".", skipRaw, cliOpts)
}

katalogFile, _ := cmd.Flags().GetString("file")
Expand All @@ -83,7 +91,7 @@ should produce so the run is repeatable and verifiable:

// Simulate kind: assert mode
if isSimulateDoc(katalogFile) {
return runSimulateFromSpec(cmd.Context(), katalogFile, crdName, maxCycles, debugOps, useEnvtest, k8sVersion)
return runSimulateFromSpec(cmd.Context(), katalogFile, cliOpts)
}

// Reject E2E files with a clear message
Expand All @@ -99,11 +107,12 @@ should produce so the run is repeatable and verifiable:
return fmt.Errorf("--cr is required")
}

return runSimulate(cmd.Context(), katalogFile, crFile, crdName, maxCycles, opts, debugOps, useEnvtest, k8sVersion)
return runSimulate(cmd.Context(), katalogFile, crFile, cliOpts)
},
}

func runSimulate(ctx context.Context, katalogFile, crFile, crdName string, maxCycles int, opts simulate.RunOptions, debugOps, useEnvtest bool, k8sVersion string) error {
func runSimulate(ctx context.Context, katalogFile, crFile string, cliOpts cliSimulateOptions) error {
maxCycles := cliOpts.MaxCycles
if maxCycles <= 0 {
maxCycles = 10
}
Expand Down Expand Up @@ -134,12 +143,14 @@ func runSimulate(ctx context.Context, katalogFile, crFile, crdName string, maxCy

// If --crd is given, simulate that CRD only. Otherwise simulate all.
var targets []string
if crdName != "" {
targets = []string{crdName}
if cliOpts.CRDName != "" {
targets = []string{cliOpts.CRDName}
} else {
targets = kat.CRDNames()
}

baseOpts := simulate.RunOptions{SkipExternal: cliOpts.SkipExternal, Target: cliOpts.Target}

for _, name := range targets {
crdEntry, ok := kat.CRDEntry(name)
if !ok {
Expand All @@ -153,17 +164,17 @@ func runSimulate(ctx context.Context, katalogFile, crFile, crdName string, maxCy
}
return fmt.Errorf("no CR found for CRD %q (kind: %s) in %s", name, crdEntry.APITypes.Kind, crFile)
}
crdOpts := opts
crdOpts := baseOpts
crdOpts.Peers = in.peers
crdOpts.ExistingInstances = in.existing
if err := simulateOne(ctx, kat, name, in.cr, maxCycles, crdOpts, debugOps, useEnvtest, nil, k8sVersion, nil); err != nil {
if err := simulateOne(ctx, kat, name, in.cr, maxCycles, crdOpts, cliOpts, nil, nil); err != nil {
return err
}
}
return nil
}

func simulateOne(ctx context.Context, kat *katalog.Katalog, crdName string, cr *unstructured.Unstructured, maxCycles int, opts simulate.RunOptions, debugOps, useEnvtest bool, crdPaths []string, k8sVersion string, expect *orktypes.SimulateExpect) error {
func simulateOne(ctx context.Context, kat *katalog.Katalog, crdName string, cr *unstructured.Unstructured, maxCycles int, opts simulate.RunOptions, cliOpts cliSimulateOptions, crdPaths []string, expect *orktypes.SimulateExpect) error {
fmt.Printf("Simulating %s/%s\n", crdName, cr.GetName())

// Emit notes for operatorBox blocks that cannot execute in the fake cluster.
Expand All @@ -185,11 +196,11 @@ func simulateOne(ctx context.Context, kat *katalog.Katalog, crdName string, cr *
start := time.Now()
var result *simulate.Result
var err error
if useEnvtest {
if cliOpts.UseEnvtest {
if len(crdPaths) == 0 {
return fmt.Errorf("%s --envtest requires spec.crd or spec.crdFiles to be set", failureMark())
}
result, err = simulate.RunWithEnvtest(ctx, kat, crdName, cr, maxCycles, opts, crdPaths, k8sVersion)
result, err = simulate.RunWithEnvtest(ctx, kat, crdName, cr, maxCycles, opts, crdPaths, cliOpts.K8sVersion)
} else {
result, err = simulate.Run(ctx, kat, crdName, cr, maxCycles, opts)
}
Expand All @@ -201,7 +212,7 @@ func simulateOne(ctx context.Context, kat *katalog.Katalog, crdName string, cr *
spin.Stop()
elapsed := time.Since(start)

if debugOps {
if cliOpts.DebugOps {
fmt.Printf(" [debug-ops] %d total ops recorded across all cycles:\n", len(result.AllOps))
for _, op := range result.AllOps {
fmt.Printf(" [debug-ops] cycle=%-2d verb=%-8s resource=%-20s name=%s\n",
Expand Down Expand Up @@ -551,7 +562,7 @@ func isSimulateDoc(path string) bool {

// runSimulateFromSpec loads a simulate.yaml and runs it in assert mode.
// Aggregator form (imports, no spec) expands each imported file in order.
func runSimulateFromSpec(ctx context.Context, path string, crdName string, maxCycles int, debugOps, useEnvtest bool, k8sVersion string) error {
func runSimulateFromSpec(ctx context.Context, path string, cliOpts cliSimulateOptions) error {
if abs, err := filepath.Abs(path); err == nil {
path = abs
}
Expand All @@ -574,7 +585,7 @@ func runSimulateFromSpec(ctx context.Context, path string, crdName string, maxCy
if !filepath.IsAbs(impPath) {
impPath = filepath.Join(dir, impPath)
}
if err := runSimulateFromSpec(ctx, impPath, crdName, maxCycles, debugOps, useEnvtest, k8sVersion); err != nil {
if err := runSimulateFromSpec(ctx, impPath, cliOpts); err != nil {
return err
}
}
Expand All @@ -595,10 +606,18 @@ func runSimulateFromSpec(ctx context.Context, path string, crdName string, maxCy

cycles := doc.Spec.Cycles
if cycles <= 0 {
cycles = maxCycles
cycles = cliOpts.MaxCycles
}

opts := simulate.RunOptions{SkipExternal: doc.Spec.SkipExternal}
// CLI flag wins over spec field for both target and skipExternal.
effectiveTarget := cliOpts.Target
if effectiveTarget == "" {
effectiveTarget = doc.Spec.Target
}
opts := simulate.RunOptions{
SkipExternal: cliOpts.SkipExternal || doc.Spec.SkipExternal,
Target: effectiveTarget,
}

katalogPath := filepath.Join(dir, doc.Spec.Katalog)

Expand Down Expand Up @@ -651,8 +670,8 @@ func runSimulateFromSpec(ctx context.Context, path string, crdName string, maxCy
}

var targets []string
if crdName != "" {
targets = []string{crdName}
if cliOpts.CRDName != "" {
targets = []string{cliOpts.CRDName}
} else {
targets = kat.CRDNames()
}
Expand All @@ -674,7 +693,7 @@ func runSimulateFromSpec(ctx context.Context, path string, crdName string, maxCy
crdOpts.Peers = in.peers
crdOpts.ExistingInstances = in.existing
expect := simulate.ExpectForCRD(doc.Spec.Expect, name)
if err := simulateOne(ctx, kat, name, in.cr, cycles, crdOpts, debugOps, useEnvtest, crdPaths, k8sVersion, expect); err != nil {
if err := simulateOne(ctx, kat, name, in.cr, cycles, crdOpts, cliOpts, crdPaths, expect); err != nil {
failed = append(failed, name)
}
}
Expand Down Expand Up @@ -709,9 +728,9 @@ type simulateFileResult struct {
cycleErrs bool
}

// runSimulateDiscovery finds all e2e.yaml files under root, simulates each,
// runSimulateDiscovery finds all simulate.yaml files under root, simulates each,
// and prints an aggregate summary.
func runSimulateDiscovery(ctx context.Context, root, crdName string, maxCycles int, skip []string, debugOps, useEnvtest bool, k8sVersion string) error {
func runSimulateDiscovery(ctx context.Context, root string, skip []string, cliOpts cliSimulateOptions) error {
var patterns []string
for _, s := range skip {
patterns = append(patterns, s)
Expand All @@ -729,12 +748,17 @@ func runSimulateDiscovery(ctx context.Context, root, crdName string, maxCycles i

absRoot, _ := filepath.Abs(root)

// In discovery mode each file declares its own target; don't let a CLI
// --target flag override every file in the suite.
fileOpts := cliOpts
fileOpts.Target = ""

var results []simulateFileResult
for _, p := range paths {
rel, _ := filepath.Rel(absRoot, p)

start := time.Now()
err := runSimulateFromSpec(ctx, p, crdName, maxCycles, debugOps, useEnvtest, k8sVersion)
err := runSimulateFromSpec(ctx, p, fileOpts)
elapsed := time.Since(start)

var res simulateFileResult
Expand Down
6 changes: 3 additions & 3 deletions cmd/internal/runtime_konstructor.go
Original file line number Diff line number Diff line change
Expand Up @@ -316,9 +316,9 @@ func konstructRuntime(kfg *konfig.Konfig, m *merger.Merger, ctx context.Context)
}

// ── Enqueue filter — Tier 2b (pre-enqueue condition gate) ─────────────
// Register when the CRD declares operatorBox.preReconcile.enqueueGate or
// preReconcile.external conditions.
if rc := crd.PreReconcileCheck(); rc.HasEnqueueGate() {
// Register when any operatorBox (CRD-level or per-target) declares an
// enqueueGate. EvaluateEnqueueFilter resolves the effective box at runtime.
if crd.HasAnyEnqueueGate() {
crdNameForFilter := crd.Name
katForFilter := kat
cs := kube.Clientset()
Expand Down
45 changes: 45 additions & 0 deletions documentation/concepts/self-service/02-target-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,51 @@ At apply time, `.status` is not yet available. Callers should poll `pollUrl` to

---

!!! tip "The one-sentence version"
The Katalog declares operators; targets declare operational profiles; OperatorBoxes execute those profiles; the Gateway selects the profile; the Runtime provides the shared machinery.

## Target as a unit of runtime execution

A target is not only a routing identifier. Each named target in `serve.target` can carry its own `operatorBox` — a complete set of lifecycle hooks, resource templates, and `preReconcile` gates. When the CR is reconciled, the reconciler selects the `operatorBox` that matches the active target.

This means the same CRD can provision different infrastructure depending on which surface submitted the intent:

```yaml
serve:
target:
web:
primary: true
operatorBox:
preReconcile:
enqueueGate:
when:
- field: "{{ .spec.image }}"
notEquals: ""
onCreate:
deployments:
- name: "{{ .metadata.name }}-web"

regional:
operatorBox:
preReconcile:
reconcileGate:
when:
- field: "{{ len .spec.regions }}"
notEquals: "0"
onCreate:
deployments:
- name: "{{ .metadata.name }}-{{ .item }}"
forEach:
field: spec.regions
as: item
```

When a CR switches from one target to another (re-submitted via a different surface), the reconciler detects the change and cleans up resources from the previous target before applying the new box.

→ [Per-target operatorBox schema](../../reference/schema/02-katalog/26-serve-target-operatorbox.md) — gates, surface cleanup, `keepPreviousSurface`

---

## See also

→ [`serve.target` schema reference](../../reference/schema/02-katalog/20-serve.md#servetarget)
Expand Down
19 changes: 15 additions & 4 deletions documentation/concepts/self-service/10-multi-cluster-routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,10 +111,21 @@ the local cluster — the one the gateway runs on. This is unchanged behaviour.

## Read path behaviour

When the gateway reads resources (GET requests), cluster templates are not
resolved — the intent fields are not available on the read path. The gateway
falls back to the local cluster for reads and lists. Writes (POST, PATCH,
DELETE) resolve the cluster expression against the submitted fields.
GET requests for resources and schema support a `?cluster=<name>` query parameter
that routes the request to the named registered cluster:

```bash
# Read a resource from a specific cluster
curl /api/v1/resources/AppRequest/default/payments-api?cluster=prod \
-H "Authorization: Bearer $TOKEN"

# Get the schema for a target on a specific cluster
curl /api/v1/schema?target=app&cluster=staging \
-H "Authorization: Bearer $TOKEN"
```

When `?cluster` is omitted, the gateway reads from the local cluster. When the
cluster name is not registered, the gateway returns a 404.

## Onboarding a new cluster

Expand Down
Loading
Loading